From 60d5d198984beec51942330532bce86b9a34b8f6 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 9 Sep 2026 19:41:02 -0400 Subject: [PATCH 1/2] refactor(api-v3): split the 10,469-line blueprint into a package web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181 functions in one module -- 9% of the core by line count and three times the next largest file. It becomes a package of nine route modules grouped by path segment, plus __init__.py for the shared imports, constants, Blueprint and helpers. Every route module decorates the SAME api_v3 Blueprint object, so endpoint names stay api_v3., the URL map is unchanged and app.py is untouched. Verified: 111 routes before, 111 after, byte-identical rules, endpoints and methods, and every endpoint still on the one blueprint. plugins 3,867 config 1,178 starlark 692 system 619 fonts 452 misc 398 wifi 361 display 326 backup 212 __init__ 1,787 (imports, constants, Blueprint, 56 helpers) Two things the URL-map check could not catch, both found by running the suite: 1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one directory deeper made that resolve to web_interface/ instead of the project root. Nothing failed at import; it surfaced as ~110 tests failing with 404s and "installation script not found", because every path built from it was one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts PROJECT_ROOT/run.py exists so the next move cannot repeat it. 2. Module-attribute patching. Tests do monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route module that binds such a name by value never sees the patch. The shared code therefore stays in __init__.py rather than moving to a _common submodule -- it has to live on the module the tests patch -- and the eleven names tests patch are read back through the package (_pkg.X) instead of bound by value. Those eleven were found by AST-scanning every setattr in the test tree, not by guessing; "time" is among them, used to drive a fake clock through the second-resolution credential-backup filenames. Test changes are confined to what genuinely moved: patch targets that now name the owning route module, imports of helpers, and six tests that scan the api_v3 source as a file and now read the package directory. Full suite: 4,278 passed, 68 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- test/fixtures/api_v3_url_map.json | 947 ++ test/test_api_v3_optional_body.py | 11 +- test/test_api_v3_url_map.py | 136 + test/test_config_main_redacts_secrets.py | 3 +- test/test_vegas_density.py | 5 +- test/test_web_api.py | 8 +- test/test_web_error_detail.py | 6 +- .../test_api_v3_backup_restore.py | 2 +- .../test_calendar_oauth_endpoints.py | 5 +- .../test_config_logging_omits_secrets.py | 6 +- .../test_secret_separation_parity.py | 18 +- .../test_starlark_pixlet_routes.py | 8 +- .../test_systemctl_sudoers_alignment.py | 11 +- web_interface/blueprints/api_v3.py | 9961 ----------------- web_interface/blueprints/api_v3/__init__.py | 1787 +++ web_interface/blueprints/api_v3/backup.py | 212 + web_interface/blueprints/api_v3/config.py | 1178 ++ web_interface/blueprints/api_v3/display.py | 326 + web_interface/blueprints/api_v3/fonts.py | 452 + web_interface/blueprints/api_v3/misc.py | 398 + web_interface/blueprints/api_v3/plugins.py | 3867 +++++++ web_interface/blueprints/api_v3/starlark.py | 692 ++ web_interface/blueprints/api_v3/system.py | 619 + web_interface/blueprints/api_v3/wifi.py | 361 + 24 files changed, 11032 insertions(+), 9987 deletions(-) create mode 100644 test/fixtures/api_v3_url_map.json create mode 100644 test/test_api_v3_url_map.py delete mode 100644 web_interface/blueprints/api_v3.py create mode 100644 web_interface/blueprints/api_v3/__init__.py create mode 100644 web_interface/blueprints/api_v3/backup.py create mode 100644 web_interface/blueprints/api_v3/config.py create mode 100644 web_interface/blueprints/api_v3/display.py create mode 100644 web_interface/blueprints/api_v3/fonts.py create mode 100644 web_interface/blueprints/api_v3/misc.py create mode 100644 web_interface/blueprints/api_v3/plugins.py create mode 100644 web_interface/blueprints/api_v3/starlark.py create mode 100644 web_interface/blueprints/api_v3/system.py create mode 100644 web_interface/blueprints/api_v3/wifi.py diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json new file mode 100644 index 000000000..f2557fd92 --- /dev/null +++ b/test/fixtures/api_v3_url_map.json @@ -0,0 +1,947 @@ +[ + [ + "/api/v3/backup/", + "api_v3.backup_delete", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/download/", + "api_v3.backup_download", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/export", + "api_v3.backup_export", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/backup/list", + "api_v3.backup_list", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/preview", + "api_v3.backup_preview", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/restore", + "api_v3.backup_restore", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/backup/validate", + "api_v3.backup_validate", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/cache/delete", + "api_v3.delete_cache_file", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/cache/list", + "api_v3.list_cache_files", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/dim-schedule", + "api_v3.get_dim_schedule_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/dim-schedule", + "api_v3.save_dim_schedule_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/main", + "api_v3.get_main_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/main", + "api_v3.save_main_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/raw/main", + "api_v3.save_raw_main_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/raw/secrets", + "api_v3.save_raw_secrets_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/schedule", + "api_v3.get_schedule_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/schedule", + "api_v3.save_schedule_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/secrets", + "api_v3.get_secrets_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/current", + "api_v3.get_display_current", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/current-status", + "api_v3.get_current_display_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/modes", + "api_v3.get_display_modes", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/on-demand/start", + "api_v3.start_on_demand_display", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/display/on-demand/status", + "api_v3.get_on_demand_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/on-demand/stop", + "api_v3.stop_on_demand_display", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/errors/clear", + "api_v3.clear_old_errors", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/errors/plugin/", + "api_v3.get_plugin_errors", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/errors/summary", + "api_v3.get_error_summary", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/", + "api_v3.delete_font", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/catalog", + "api_v3.get_fonts_catalog", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/overrides", + "api_v3.get_fonts_overrides", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/overrides", + "api_v3.save_fonts_overrides", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/fonts/overrides/", + "api_v3.delete_font_override", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/preview", + "api_v3.get_font_preview", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/tokens", + "api_v3.get_font_tokens", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/upload", + "api_v3.upload_font", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/hardware/status", + "api_v3.get_hardware_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/health", + "api_v3.get_health", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/logs", + "api_v3.get_logs", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins//static/", + "api_v3.serve_plugin_static", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/action", + "api_v3.execute_plugin_action", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/assets/delete", + "api_v3.delete_plugin_asset", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/assets/list", + "api_v3.list_plugin_assets", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/assets/upload", + "api_v3.upload_plugin_asset", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/authenticate/spotify", + "api_v3.authenticate_spotify", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/authenticate/ytm", + "api_v3.authenticate_ytm", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/calendar/authenticate", + "api_v3.authenticate_calendar", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/calendar/list-calendars", + "api_v3.list_calendar_calendars", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/calendar/upload-credentials", + "api_v3.upload_calendar_credentials", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/config", + "api_v3.get_plugin_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/config", + "api_v3.save_plugin_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/config/reset", + "api_v3.reset_plugin_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/health", + "api_v3.get_plugin_health", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/health/", + "api_v3.get_plugin_health_single", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/health//reset", + "api_v3.reset_plugin_health", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/install", + "api_v3.install_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/install-from-url", + "api_v3.install_plugin_from_url", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/installed", + "api_v3.get_installed_plugins", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/limits/", + "api_v3.manage_plugin_limits", + [ + "GET", + "HEAD", + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/metrics", + "api_v3.get_plugin_metrics", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/metrics/", + "api_v3.get_plugin_metrics_single", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/metrics//reset", + "api_v3.reset_plugin_metrics", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/of-the-day/json/delete", + "api_v3.delete_of_the_day_json", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/of-the-day/json/upload", + "api_v3.upload_of_the_day_json", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/operation/", + "api_v3.get_operation_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/operation/history", + "api_v3.clear_operation_history", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/operation/history", + "api_v3.get_operation_history", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/reconciliation-status", + "api_v3.get_reconciliation_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/registry-from-url", + "api_v3.get_registry_from_url", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.add_saved_repository", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.get_saved_repositories", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.remove_saved_repository", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/schema", + "api_v3.get_plugin_schema", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/state", + "api_v3.get_plugin_state", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/state/reconcile", + "api_v3.reconcile_plugin_state", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/store/github-status", + "api_v3.get_github_auth_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/store/list", + "api_v3.list_plugin_store", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/store/refresh", + "api_v3.refresh_plugin_store", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/toggle", + "api_v3.toggle_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/uninstall", + "api_v3.uninstall_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/update", + "api_v3.update_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/skins", + "api_v3.list_skins", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps", + "api_v3.get_starlark_apps", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps/", + "api_v3.get_starlark_app", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps/", + "api_v3.uninstall_starlark_app", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps//config", + "api_v3.get_starlark_app_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps//config", + "api_v3.update_starlark_app_config", + [ + "OPTIONS", + "PUT" + ] + ], + [ + "/api/v3/starlark/apps//render", + "api_v3.render_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/apps//toggle", + "api_v3.toggle_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/install-pixlet", + "api_v3.install_pixlet", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/repository/browse", + "api_v3.browse_tronbyte_repository", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/repository/categories", + "api_v3.get_tronbyte_categories", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/repository/install", + "api_v3.install_from_tronbyte_repository", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/status", + "api_v3.get_starlark_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/upload", + "api_v3.upload_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/sync/status", + "api_v3.get_sync_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/action", + "api_v3.execute_system_action", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/system/check-update", + "api_v3.check_for_update", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/git-branches", + "api_v3.get_git_branches", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/git-info", + "api_v3.get_git_info", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/status", + "api_v3.get_system_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/version", + "api_v3.get_system_version", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/ap/auto-enable", + "api_v3.get_auto_enable_ap_mode", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/ap/auto-enable", + "api_v3.set_auto_enable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/ap/disable", + "api_v3.disable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/ap/enable", + "api_v3.enable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/connect", + "api_v3.connect_wifi", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/disconnect", + "api_v3.disconnect_wifi", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/radio", + "api_v3.get_wifi_radio", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/radio", + "api_v3.set_wifi_radio", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/scan", + "api_v3.scan_wifi_networks", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/status", + "api_v3.get_wifi_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ] +] \ No newline at end of file diff --git a/test/test_api_v3_optional_body.py b/test/test_api_v3_optional_body.py index 4b7eb1338..fe1abc5e7 100644 --- a/test/test_api_v3_optional_body.py +++ b/test/test_api_v3_optional_body.py @@ -106,7 +106,12 @@ def test_malformed_json_gets_a_400_not_a_500(self, api_v3_client, api_v3_module, class TestNoBodyReadContradictsItsOwnGuard: - SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py" + PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3" + + @property + def _source(self) -> str: + """api_v3 is a package; read every module of it.""" + return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py"))) def test_no_or_default_read_is_unguarded(self): """`get_json() or ` is a contradiction without silent=True. @@ -115,7 +120,7 @@ def test_no_or_default_read_is_unguarded(self): means the call raises before the default can apply. """ offenders = [ - line.strip() for line in self.SOURCE.read_text().splitlines() + line.strip() for line in self._source.splitlines() if "request.get_json()" in line and " or " in line ] assert offenders == [], ( @@ -124,7 +129,7 @@ def test_no_or_default_read_is_unguarded(self): def test_no_not_data_guard_is_unreachable(self): """A `if not data:` guard needs a read that can actually return None.""" - lines = self.SOURCE.read_text().splitlines() + lines = self._source.splitlines() offenders = [] for i, line in enumerate(lines): if re.search(r"=\s*request\.get_json\(\)\s*$", line): diff --git a/test/test_api_v3_url_map.py b/test/test_api_v3_url_map.py new file mode 100644 index 000000000..157ab8a14 --- /dev/null +++ b/test/test_api_v3_url_map.py @@ -0,0 +1,136 @@ +"""The /api/v3 URL map is a contract, and the split must not have moved it. + +api_v3.py was one 10,469-line module and is now a package. Every route module +in it decorates the *same* Blueprint object, so this refactor was supposed to +be invisible from outside: same URLs, same endpoint names, same methods. + +"Supposed to be" is the problem. A route function silently dropped during a +move -- a module that never gets imported, a decorator left behind -- costs +nothing at import time and fails only when someone hits the URL. So the map is +pinned here. + +The snapshot is intentionally the *whole* map rather than a count. A count +passes when one route is deleted and another added, which is exactly the shape +a careless move produces. + +If you are adding a route, this test is meant to fail: add the entry to +EXPECTED. If you are moving one between modules, it is meant to pass unchanged +-- endpoint names are `api_v3.` regardless of which module the +function lives in, and that is the property that makes the package safe. +""" +import json +import os + +import pytest +from flask import Flask + +from web_interface.blueprints.api_v3 import api_v3 + +SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures", "api_v3_url_map.json") + + +def _current_map(): + app = Flask(__name__) + app.register_blueprint(api_v3, url_prefix="/api/v3") + return sorted( + [r.rule, r.endpoint, sorted(r.methods)] + for r in app.url_map.iter_rules() + if r.endpoint != "static" + ) + + +def test_the_url_map_matches_the_snapshot(): + current = _current_map() + with open(SNAPSHOT, encoding="utf-8") as fh: + expected = json.load(fh) + + cur = {(r, e) for r, e, _ in current} + exp = {(r, e) for r, e, _ in expected} + + lost = sorted(exp - cur) + added = sorted(cur - exp) + assert not lost, ( + f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. " + "A route lost in a module move costs nothing at import time and fails " + "only when someone hits the URL.") + assert not added, ( + f"{len(added)} new route(s): {added[:5]}. If that is intended, " + f"regenerate {os.path.relpath(SNAPSHOT)}.") + + # Methods too: a route that quietly loses POST is still a broken route. + cur_methods = {(r, e): m for r, e, m in current} + for rule, endpoint, methods in expected: + assert cur_methods[(rule, endpoint)] == methods, ( + f"{rule} ({endpoint}) methods changed: " + f"{methods} -> {cur_methods[(rule, endpoint)]}") + + +def test_every_endpoint_is_on_the_one_blueprint(): + """The package must not fragment into several blueprints. + + Splitting into per-domain *blueprints* would rename every endpoint from + `api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names + one. Keeping a single Blueprint object across the modules is what makes + the split a pure code move, so assert it rather than trusting it. + """ + for rule, endpoint, _ in _current_map(): + assert endpoint.startswith("api_v3."), ( + f"{rule} is registered as {endpoint}, not on the api_v3 blueprint") + + +def test_the_snapshot_is_not_empty(): + """Guards the failure mode this file exists to prevent. + + An empty or truncated snapshot would make every assertion above pass + vacuously -- the same trap as a route map that imports no modules. + """ + with open(SNAPSHOT, encoding="utf-8") as fh: + expected = json.load(fh) + assert len(expected) > 100, ( + f"snapshot has only {len(expected)} routes; it should have the whole " + "/api/v3 surface") + + +@pytest.mark.parametrize("module", [ + "backup", "config", "display", "fonts", "misc", + "plugins", "starlark", "system", "wifi", +]) +def test_every_route_module_contributes(module): + """Each module must actually register something. + + A module that fails to import, or that is left out of __init__, takes its + routes with it silently -- the package still imports and the app still + starts. + """ + import importlib + mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}") + routes = [n for n in dir(mod) + if callable(getattr(mod, n, None)) + and getattr(getattr(mod, n), "__module__", "") == mod.__name__] + assert routes, f"{module}.py defines no view functions" + + +def test_project_root_points_at_the_project(): + """PROJECT_ROOT is derived from __file__, so moving the file breaks it. + + The split moved this code from web_interface/blueprints/api_v3.py to + web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and + `Path(__file__).parent.parent.parent` quietly began resolving to + web_interface/ instead of the project root. Nothing failed at import. It + surfaced as routes returning 404 and "installation script not found", + because every path built from it pointed one level too shallow. + + A URL-map check cannot catch that: the routes were all registered, they + just could not find anything. + """ + from pathlib import Path + + from web_interface.blueprints.api_v3 import PROJECT_ROOT + + # The project root is the directory holding run.py and web_interface/. + assert (PROJECT_ROOT / "run.py").is_file(), ( + f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the " + "project root") + assert (PROJECT_ROOT / "web_interface").is_dir() + assert PROJECT_ROOT == Path(__file__).resolve().parents[1] diff --git a/test/test_config_main_redacts_secrets.py b/test/test_config_main_redacts_secrets.py index 15172df24..c33ab9627 100644 --- a/test/test_config_main_redacts_secrets.py +++ b/test/test_config_main_redacts_secrets.py @@ -130,7 +130,8 @@ def test_the_endpoint_itself_redacts(): app = flask.Flask(__name__) try: with app.test_request_context("/config/main"): - response = mod.get_main_config() + # get_main_config is a route; it lives in the config module now. + response = mod.config.get_main_config() payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data) finally: mod.api_v3.config_manager = previous diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index cd6db2bc5..2338421f6 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -470,7 +470,10 @@ def _api_numeric_fields(self): """Extract the numeric_fields map from api_v3 without importing Flask.""" import ast import pathlib - src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text() + # api_v3 is a package; the routes are spread across its modules. + src = '\n'.join( + p.read_text() for p in + sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py'))) tree = ast.parse(src) for node in ast.walk(tree): if not isinstance(node, ast.Assign): diff --git a/test/test_web_api.py b/test/test_web_api.py index 8cadf9225..679bfd846 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -390,7 +390,7 @@ def test_save_schedule_config(self, client, mock_config_manager): class TestSystemAPI: """Test system API endpoints.""" - @patch('web_interface.blueprints.api_v3.subprocess') + @patch('web_interface.blueprints.api_v3.system.subprocess') def test_get_system_status(self, mock_subprocess, client): """Test getting system status.""" # The endpoint returns 503 without psutil, which is an optional @@ -407,7 +407,7 @@ def test_get_system_status(self, mock_subprocess, client): data = json.loads(response.data) assert 'service' in data or 'status' in data or 'active' in data - @patch('web_interface.blueprints.api_v3.subprocess') + @patch('web_interface.blueprints.api_v3.system.subprocess') def test_get_system_version(self, mock_subprocess, client): """Test getting system version.""" mock_result = MagicMock() @@ -421,7 +421,7 @@ def test_get_system_version(self, mock_subprocess, client): data = json.loads(response.data) assert 'version' in data.get('data', {}) or 'version' in data - @patch('web_interface.blueprints.api_v3.subprocess') + @patch('web_interface.blueprints.api_v3.system.subprocess') def test_execute_system_action(self, mock_subprocess, client): """Test executing system action.""" mock_result = MagicMock() @@ -502,7 +502,7 @@ def test_start_on_demand_display(self, client): if response.status_code in [200, 201]: assert api_v3.cache_manager.set.called - @patch('web_interface.blueprints.api_v3._ensure_cache_manager') + @patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') def test_stop_on_demand_display(self, mock_ensure_cache, client): """Test stopping on-demand display.""" diff --git a/test/test_web_error_detail.py b/test/test_web_error_detail.py index 55f81db8b..6324bc018 100644 --- a/test/test_web_error_detail.py +++ b/test/test_web_error_detail.py @@ -114,7 +114,11 @@ def test_no_api_v3_handler_discards_its_exception(self): """ import ast - src = open("web_interface/blueprints/api_v3.py").read() + # api_v3 is a package; the routes are spread across its modules. + import pathlib + src = "\n".join( + p.read_text() for p in + sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py"))) tree = ast.parse(src) # This used to match one exact message string, so a handler that wrote diff --git a/test/web_interface/test_api_v3_backup_restore.py b/test/web_interface/test_api_v3_backup_restore.py index 7b67ca16b..1ea8d4cc1 100644 --- a/test/web_interface/test_api_v3_backup_restore.py +++ b/test/web_interface/test_api_v3_backup_restore.py @@ -230,7 +230,7 @@ def test_install_exception_is_recorded_without_leaking_details( def test_missing_store_manager_is_reported_per_plugin(self, client, restore): restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}]) api_v3.plugin_store_manager = None - with patch("web_interface.blueprints.api_v3.plugin_store_manager", None): + with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None): body = post(client).get_json() assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable" diff --git a/test/web_interface/test_calendar_oauth_endpoints.py b/test/web_interface/test_calendar_oauth_endpoints.py index 9520be5ff..b0293a28a 100644 --- a/test/web_interface/test_calendar_oauth_endpoints.py +++ b/test/web_interface/test_calendar_oauth_endpoints.py @@ -77,7 +77,10 @@ def test_both_urls_match_what_the_widgets_request(self): oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js' assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8') assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8') - source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8') + # api_v3 is a package now, so the route strings are spread across its + # modules; read the whole directory rather than one file. + pkg = Path(project_root) / 'web_interface/blueprints/api_v3' + source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py'))) assert "'/plugins/calendar/list-calendars'" in source assert "'/plugins/calendar/authenticate'" in source diff --git a/test/web_interface/test_config_logging_omits_secrets.py b/test/web_interface/test_config_logging_omits_secrets.py index 031eb4f13..250b215c7 100644 --- a/test/web_interface/test_config_logging_omits_secrets.py +++ b/test/web_interface/test_config_logging_omits_secrets.py @@ -10,8 +10,8 @@ import pytest -SOURCE = (Path(__file__).resolve().parents[2] - / "web_interface" / "blueprints" / "api_v3.py") +API_V3_PKG = (Path(__file__).resolve().parents[2] + / "web_interface" / "blueprints" / "api_v3") #: Objects that still hold submitted secret values at the point these log #: calls run. Interpolating one whole into a log message leaks credentials. @@ -19,7 +19,7 @@ def _logging_lines(): - for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1): + for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1): stripped = line.strip() if stripped.startswith("#"): continue diff --git a/test/web_interface/test_secret_separation_parity.py b/test/web_interface/test_secret_separation_parity.py index 91367234f..9486715e5 100644 --- a/test/web_interface/test_secret_separation_parity.py +++ b/test/web_interface/test_secret_separation_parity.py @@ -15,8 +15,18 @@ from src.web_interface.secret_helpers import find_secret_fields, separate_secrets -API_V3_PATH = (Path(__file__).resolve().parents[2] - / "web_interface" / "blueprints" / "api_v3.py") +API_V3_PKG = (Path(__file__).resolve().parents[2] + / "web_interface" / "blueprints" / "api_v3") + + +def _api_v3_source() -> str: + """Every module of the api_v3 package as one string. + + It used to be a single file; the inline copies this guards against could + now reappear in any module of the package. + """ + return "\n".join(p.read_text(encoding="utf-8") + for p in sorted(API_V3_PKG.glob("*.py"))) # The migration is complete: any inline reimplementation is a regression. EXPECTED_INLINE_COPIES = 0 @@ -24,7 +34,7 @@ class TestNoInlineCopies: def _count(self, name: str) -> int: - source = API_V3_PATH.read_text(encoding="utf-8") + source = _api_v3_source() return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE)) def test_no_inline_find_secret_fields(self): @@ -46,7 +56,7 @@ def test_no_inline_separate_secrets(self): def test_canonical_import_present(self): # Tripwire: the endpoints still need the helpers, so removing the # import means either dead secret handling or a new local copy. - source = API_V3_PATH.read_text(encoding="utf-8") + source = _api_v3_source() assert re.search( r"from src\.web_interface\.secret_helpers import .*find_secret_fields", source, diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 24fab51e9..55f53f9b4 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -63,14 +63,14 @@ def test_route_exists(self, rule, method): class TestInstallPixlet: def test_it_does_not_404(self, client): - with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run: run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") resp = client.post('/api/v3/starlark/install-pixlet') assert resp.status_code != 404, "the route is still missing" assert resp.get_json().get('message') != 'Resource not found' def test_success_is_reported_in_the_shape_the_button_reads(self, client): - with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run: run.return_value = MagicMock(returncode=0, stdout="done", stderr="") resp = client.post('/api/v3/starlark/install-pixlet') body = resp.get_json() @@ -78,7 +78,7 @@ def test_success_is_reported_in_the_shape_the_button_reads(self, client): assert 'message' in body, "the JS shows data.message on success" def test_a_failed_download_says_why(self, client): - with patch('web_interface.blueprints.api_v3.subprocess.run') as run: + with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run: run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release") resp = client.post('/api/v3/starlark/install-pixlet') body = resp.get_json() @@ -88,7 +88,7 @@ def test_a_failed_download_says_why(self, client): def test_a_timeout_is_reported_rather_than_hanging(self, client): import subprocess as sp - with patch('web_interface.blueprints.api_v3.subprocess.run', + with patch('web_interface.blueprints.api_v3.starlark.subprocess.run', side_effect=sp.TimeoutExpired(cmd='x', timeout=300)): resp = client.post('/api/v3/starlark/install-pixlet') assert resp.get_json()['status'] == 'error' diff --git a/test/web_interface/test_systemctl_sudoers_alignment.py b/test/web_interface/test_systemctl_sudoers_alignment.py index f29691f0d..0013eccf9 100644 --- a/test/web_interface/test_systemctl_sudoers_alignment.py +++ b/test/web_interface/test_systemctl_sudoers_alignment.py @@ -19,7 +19,12 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] -API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py" +# api_v3 is a package; a sudo systemctl call can live in any of its modules. +API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3" + + +def _api_v3_source() -> str: + return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py"))) SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh" @@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]: def test_every_sudo_systemctl_call_is_granted() -> None: - calls = _sudo_systemctl_calls(API_V3.read_text()) + calls = _sudo_systemctl_calls(_api_v3_source()) rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text()) assert calls, "expected to find sudo systemctl calls in api_v3.py" @@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None: def test_units_are_fully_qualified() -> None: """Privileged systemctl calls must name the unit as .service so they match the sudoers grants, which use the fully-qualified unit name.""" - calls = _sudo_systemctl_calls(API_V3.read_text()) + calls = _sudo_systemctl_calls(_api_v3_source()) unqualified = {(v, u) for v, u in calls if not u.endswith(".service")} assert not unqualified, ( "sudo systemctl calls must use fully-qualified .service unit names: " diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py deleted file mode 100644 index 865789f06..000000000 --- a/web_interface/blueprints/api_v3.py +++ /dev/null @@ -1,9961 +0,0 @@ -from flask import Blueprint, request, jsonify, Response -import json -import os -import re -import stat -import sys -import shutil -import subprocess -import tempfile -import time -import hashlib -import uuid -import logging -from datetime import datetime -from pathlib import Path -from typing import Dict, Any, Optional, Tuple, Type -from urllib.parse import urlparse, urlunparse - -logger = logging.getLogger(__name__) - -# Import new infrastructure -from src.web_interface.api_helpers import success_response, error_response, validate_request_json -from src.web_interface.errors import ErrorCode -from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values, - merge_secrets, remove_empty_secrets, - separate_secrets, - strip_masked_values) -from src.web_interface.error_handler import describe_exception, redact_text -from src.plugin_system.operation_types import OperationType -from src.web_interface.validators import ( - validate_file_upload -) -from src.error_aggregator import get_error_aggregator -from src.common.permission_utils import install_requirements_file - -_SUDO = shutil.which('sudo') -_JOURNALCTL = shutil.which('journalctl') -_GIT = shutil.which('git') - -# Cap subprocess output returned to the browser — pip can produce MBs on build failures. -_MAX_OUTPUT_BYTES = 51_200 # 50 KB - - -def _truncate_output(stdout: str, stderr: str) -> str: - """Combine stdout+stderr and truncate to _MAX_OUTPUT_BYTES (keeping the tail).""" - combined = (stdout + stderr).strip() - if len(combined) > _MAX_OUTPUT_BYTES: - combined = '[...output truncated...]\n' + combined[-_MAX_OUTPUT_BYTES:] - return combined - - -def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess: - """Install a requirements.txt file, preferring the vetted sudo wrapper so - the packages are visible to root-run ledmatrix.service — not just to - whichever non-root user runs this web process. Falls back to installing - for the current process only if the wrapper isn't set up yet (i.e. the - admin hasn't run scripts/install/configure_web_sudo.sh since upgrading), - so the button still does *something* useful rather than hard-failing. - - Thin wrapper around the shared implementation in permission_utils so the - Plugin Store's own dependency installation (store_manager.py) follows the - exact same root-visible install path instead of a divergent one. - """ - return install_requirements_file(req_file, timeout=timeout) - - -def _scrub_git_remote_url(url: str) -> str: - """Strip embedded username/password from an HTTPS remote URL before returning it to the UI.""" - try: - p = urlparse(url) - if p.scheme in ('http', 'https') and (p.username or p.password): - netloc = p.hostname or '' - if p.port: - netloc += f':{p.port}' - return urlunparse(p._replace(netloc=netloc)) - except Exception: - pass - return url - -# Will be initialized when blueprint is registered -# NOTE: the managers live on the blueprint object (app.py sets -# api_v3.config_manager / api_v3.plugin_manager). Deliberately not -# mirrored as module globals: a bare `config_manager` used to resolve to -# a None that was never assigned, which silently disabled the /health -# checks and made /display/current fall back to a hardcoded 128x64. -plugin_store_manager = None -saved_repositories_manager = None -cache_manager = None -schema_manager = None -operation_queue = None -plugin_state_manager = None -operation_history = None -sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available) - -# Get project root directory (web_interface/../..) -PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent - -# System fonts that cannot be deleted (used by catalog API and delete endpoint) -SYSTEM_FONTS = frozenset([ - 'pressstart2p-regular', 'pressstart2p', - '4x6-font', '4x6', - '5by7.regular', '5by7', '5x7', - '5x8', '6x9', '6x10', '6x12', '6x13', '6x13b', '6x13o', - '7x13', '7x13b', '7x13o', '7x14', '7x14b', - '8x13', '8x13b', '8x13o', - '9x15', '9x15b', '9x18', '9x18b', - '10x20', - 'matrixchunky8', 'matrixlight6', 'tom-thumb', - 'clr6x12', 'helvr12', 'texgyre-27' -]) - -api_v3 = Blueprint('api_v3', __name__) - -def _get_plugin_version(plugin_id: str) -> str: - """Read the installed version from a plugin's manifest.json. - - Returns the version string on success, or '' if the manifest - cannot be read (missing, corrupt, permission denied, etc.). - """ - manifest_path = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id / "manifest.json" - try: - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - return manifest.get('version', '') - except (FileNotFoundError, PermissionError, OSError) as e: - logger.warning("[PluginVersion] Could not read manifest for %s at %s: %s", plugin_id, manifest_path, e) - except json.JSONDecodeError as e: - logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e) - return '' - -def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool: - """Return True when the registry's ``latest_version`` is strictly newer - than the installed version. - - Thin alias for the shared comparator in - `src.plugin_system.compatibility.is_update_available` — the store's - `update_plugin` uses the same function, so the UI badge and the actual - reinstall decision can never disagree. - """ - from src.plugin_system.compatibility import is_update_available - return is_update_available(installed_version, latest_version) - -def _ensure_cache_manager(): - """Ensure cache manager is initialized.""" - global cache_manager - if cache_manager is None: - from src.cache_manager import CacheManager - cache_manager = CacheManager() - return cache_manager - -def _save_config_atomic(config_manager, config_data, create_backup=True): - """ - Save configuration using atomic save if available, fallback to regular save. - - Returns: - tuple: (success: bool, error_message: str or None) - """ - if hasattr(config_manager, 'save_config_atomic'): - result = config_manager.save_config_atomic(config_data, create_backup=create_backup) - if result.status.value != 'success': - return False, result.message - return True, None - else: - try: - config_manager.save_config(config_data) - return True, None - except Exception as e: - return False, str(e) - -def _coerce_to_bool(value): - """ - Coerce a form value to a proper Python boolean. - - HTML checkboxes send string values like "true", "on", "1" when checked. - This ensures we store actual booleans in config JSON, not strings. - - Args: - value: The form value (string, bool, int, or None) - - Returns: - bool: True if value represents a truthy checkbox state, False otherwise - """ - if value is None: - return False - if isinstance(value, bool): - return value - if isinstance(value, int): - return value == 1 - if isinstance(value, str): - return value.lower() in ('true', 'on', '1', 'yes') - return False - -def _get_display_service_status(): - """Return status information about the ledmatrix service.""" - try: - result = subprocess.run( - ['systemctl', 'is-active', 'ledmatrix'], - capture_output=True, - text=True, - timeout=3 - ) - return { - 'active': result.stdout.strip() == 'active', - 'returncode': result.returncode, - 'stdout': result.stdout.strip(), - 'stderr': result.stderr.strip() - } - except subprocess.TimeoutExpired: - return { - 'active': False, - 'returncode': -1, - 'stdout': '', - 'stderr': 'timeout' - } - except Exception as err: - return { - 'active': False, - 'returncode': -1, - 'stdout': '', - 'stderr': str(err) - } - -def _run_systemctl_command(args): - """Run a systemctl command safely.""" - try: - result = subprocess.run( - args, - capture_output=True, - text=True, - timeout=15 - ) - return { - 'returncode': result.returncode, - 'stdout': result.stdout, - 'stderr': result.stderr - } - except subprocess.TimeoutExpired: - return { - 'returncode': -1, - 'stdout': '', - 'stderr': 'timeout' - } - except Exception as err: - return { - 'returncode': -1, - 'stdout': '', - 'stderr': str(err) - } - -def _ensure_display_service_running(): - """Ensure the ledmatrix display service is running.""" - status = _get_display_service_status() - if status.get('active'): - status['started'] = False - return status - result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']) - service_status = _get_display_service_status() - result['started'] = result.get('returncode') == 0 - result['active'] = service_status.get('active') - result['status'] = service_status - return result - -def _stop_display_service(): - """Stop the ledmatrix display service.""" - result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']) - status = _get_display_service_status() - result['active'] = status.get('active') - result['status'] = status - return result - -#: Field names whose value is a credential. Matched by name because this -#: endpoint returns the whole config, core keys included, and core config has -#: no schema to carry x-secret markers. -_CREDENTIAL_NAME_PARTS = ("password", "passwd", "secret", "token", "api_key", - "apikey", "access_key", "private_key", "client_secret") - - -def _looks_like_a_credential(name: str) -> bool: - lowered = name.lower() - return any(part in lowered for part in _CREDENTIAL_NAME_PARTS) - - -def _redact_credentials(value): - """A copy of `value` with credential-named fields blanked. - - /config/main returned the raw config to anyone who could reach the port, - and this interface has no authentication. On one rig that meant a 40-char - GitHub token, a 183-char Home Assistant token and five API keys were - readable by anything on the LAN. - - The x-secret masking used by the plugin config endpoints does not help - here: this endpoint never consults a schema, and core keys such as - github.api_token have no schema to mark. Matching on the field name is - blunt, but for a whole-config dump the right default is that anything - named like a credential does not leave the process. - - Blanked rather than removed, and safe to blank: POST /config/main merges - into the loaded config and only writes the keys it was given, so a client - that round-trips this response cannot erase a secret it never saw. - """ - if isinstance(value, dict): - return {k: ("" if _looks_like_a_credential(k) and not isinstance(v, (dict, list)) - else _redact_credentials(v)) - for k, v in value.items()} - if isinstance(value, list): - return [_redact_credentials(item) for item in value] - return value - - -@api_v3.route('/config/main', methods=['GET']) -def get_main_config(): - """Get main configuration, with credentials redacted.""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - config = api_v3.config_manager.load_config() - return jsonify({'status': 'success', 'data': _redact_credentials(config)}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/config/schedule', methods=['GET']) -def get_schedule_config(): - """Get current schedule configuration""" - try: - if not api_v3.config_manager: - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - 'Config manager not initialized', - status_code=500 - ) - - config = api_v3.config_manager.load_config() - schedule_config = config.get('schedule', {}) - - return success_response(data=schedule_config) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - "An error occurred; see logs for details", - details=describe_exception(e), - status_code=500 - ) - -def _validate_time_format(time_str): - """Validate time format is HH:MM""" - try: - datetime.strptime(time_str, '%H:%M') - return True, None - except (ValueError, TypeError): - return False, f"Invalid time format: {time_str}. Expected HH:MM format." - -def _validate_time_range(start_time_str, end_time_str, allow_overnight=True): - """Validate time range. Returns (is_valid, error_message)""" - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = datetime.strptime(end_time_str, '%H:%M').time() - - # Allow overnight schedules (start > end) or same-day schedules - if not allow_overnight and start_time >= end_time: - return False, f"Start time ({start_time_str}) must be before end time ({end_time_str}) for same-day schedules" - - return True, None - except (ValueError, TypeError) as e: - return False, f"Invalid time format: {str(e)}" - -@api_v3.route('/config/schedule', methods=['POST']) -def save_schedule_config(): - """Save schedule configuration""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Load current config - current_config = api_v3.config_manager.load_config() - - # Build schedule configuration - # Handle enabled checkbox - can be True, False, or 'on' - enabled_value = data.get('enabled', False) - if isinstance(enabled_value, str): - enabled_value = enabled_value.lower() in ('true', 'on', '1') - schedule_config = { - 'enabled': enabled_value - } - - mode = data.get('mode', 'global') - schedule_config['mode'] = mode - - if mode == 'global': - # Simple global schedule - start_time = data.get('start_time', '07:00') - end_time = data.get('end_time', '23:00') - - # Validate time formats - is_valid, error_msg = _validate_time_format(start_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - error_msg, - status_code=400 - ) - - is_valid, error_msg = _validate_time_format(end_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - error_msg, - status_code=400 - ) - - schedule_config['start_time'] = start_time - schedule_config['end_time'] = end_time - # Remove days config when switching to global mode - schedule_config.pop('days', None) - else: - # Per-day schedule - schedule_config['days'] = {} - # Remove global times when switching to per-day mode - schedule_config.pop('start_time', None) - schedule_config.pop('end_time', None) - days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] - enabled_days_count = 0 - - for day in days: - day_config = {} - enabled_key = f'{day}_enabled' - start_key = f'{day}_start' - end_key = f'{day}_end' - - # Check if day is enabled - if enabled_key in data: - enabled_val = data[enabled_key] - # Handle checkbox values that may come as 'on', True, or False - if isinstance(enabled_val, str): - day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') - else: - day_config['enabled'] = bool(enabled_val) - else: - # Default to enabled if not specified - day_config['enabled'] = True - - # Only add times if day is enabled - if day_config.get('enabled', True): - enabled_days_count += 1 - start_time = None - end_time = None - - if start_key in data and data[start_key]: - start_time = data[start_key] - else: - start_time = '07:00' - - if end_key in data and data[end_key]: - end_time = data[end_key] - else: - end_time = '23:00' - - # Validate time formats - is_valid, error_msg = _validate_time_format(start_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - f"Invalid start time for {day}: {error_msg}", - status_code=400 - ) - - is_valid, error_msg = _validate_time_format(end_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - f"Invalid end time for {day}: {error_msg}", - status_code=400 - ) - - day_config['start_time'] = start_time - day_config['end_time'] = end_time - - schedule_config['days'][day] = day_config - - # Validate that at least one day is enabled in per-day mode - if enabled_days_count == 0: - return error_response( - ErrorCode.VALIDATION_ERROR, - "At least one day must be enabled in per-day schedule mode", - status_code=400 - ) - - # Update and save config using atomic save - current_config['schedule'] = schedule_config - success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) - if not success: - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save schedule configuration: {error_msg}", - status_code=500 - ) - - # Invalidate cache on config change - try: - from web_interface.cache import invalidate_cache - invalidate_cache() - except ImportError: - pass - - return success_response(message='Schedule configuration saved successfully') - except Exception as e: - import logging - logger.error("Error saving schedule config", exc_info=True) - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - "An error occurred; see logs for details", - - status_code=500, details=describe_exception(e) - ) - -@api_v3.route('/config/dim-schedule', methods=['GET']) -def get_dim_schedule_config(): - """Get current dim schedule configuration""" - import logging - import json - - if not api_v3.config_manager: - logging.error("[DIM SCHEDULE] Config manager not initialized") - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - 'Config manager not initialized', - status_code=500 - ) - - try: - config = api_v3.config_manager.load_config() - dim_schedule_config = config.get('dim_schedule', { - 'enabled': False, - 'dim_brightness': 30, - 'mode': 'global', - 'start_time': '20:00', - 'end_time': '07:00', - 'days': {} - }) - - return success_response(data=dim_schedule_config) - except FileNotFoundError as e: - logging.error(f"[DIM SCHEDULE] Config file not found: {e}", exc_info=True) - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - "Configuration file not found", - status_code=500 - ) - except json.JSONDecodeError as e: - logging.error(f"[DIM SCHEDULE] Invalid JSON in config file: {e}", exc_info=True) - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - "Configuration file contains invalid JSON", - status_code=500 - ) - except (IOError, OSError) as e: - logging.error(f"[DIM SCHEDULE] Error reading config file: {e}", exc_info=True) - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - "An error occurred; see logs for details", - status_code=500, details=describe_exception(e) - ) - except Exception as e: - logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True) - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - "An error occurred; see logs for details", - status_code=500, details=describe_exception(e) - ) - -@api_v3.route('/config/dim-schedule', methods=['POST']) -def save_dim_schedule_config(): - """Save dim schedule configuration""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Load current config - current_config = api_v3.config_manager.load_config() - - # Build dim schedule configuration - enabled_value = data.get('enabled', False) - if isinstance(enabled_value, str): - enabled_value = enabled_value.lower() in ('true', 'on', '1') - - # Validate and get dim_brightness - dim_brightness_raw = data.get('dim_brightness', 30) - try: - # Handle empty string or None - if dim_brightness_raw is None or dim_brightness_raw == '': - dim_brightness = 30 - else: - dim_brightness = int(dim_brightness_raw) - except (ValueError, TypeError, OverflowError): - return error_response( - ErrorCode.VALIDATION_ERROR, - "dim_brightness must be an integer between 0 and 100", - status_code=400 - ) - - if not 0 <= dim_brightness <= 100: - return error_response( - ErrorCode.VALIDATION_ERROR, - "dim_brightness must be between 0 and 100", - status_code=400 - ) - - dim_schedule_config = { - 'enabled': enabled_value, - 'dim_brightness': dim_brightness - } - - mode = data.get('mode', 'global') - dim_schedule_config['mode'] = mode - - if mode == 'global': - # Simple global schedule - start_time = data.get('start_time', '20:00') - end_time = data.get('end_time', '07:00') - - # Validate time formats - is_valid, error_msg = _validate_time_format(start_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - error_msg, - status_code=400 - ) - - is_valid, error_msg = _validate_time_format(end_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - error_msg, - status_code=400 - ) - - dim_schedule_config['start_time'] = start_time - dim_schedule_config['end_time'] = end_time - # Remove days config when switching to global mode - dim_schedule_config.pop('days', None) - else: - # Per-day schedule - dim_schedule_config['days'] = {} - # Remove global times when switching to per-day mode - dim_schedule_config.pop('start_time', None) - dim_schedule_config.pop('end_time', None) - days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] - enabled_days_count = 0 - - for day in days: - day_config = {} - enabled_key = f'{day}_enabled' - start_key = f'{day}_start' - end_key = f'{day}_end' - - # Check if day is enabled - if enabled_key in data: - enabled_val = data[enabled_key] - if isinstance(enabled_val, str): - day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') - else: - day_config['enabled'] = bool(enabled_val) - else: - day_config['enabled'] = True - - # Only add times if day is enabled - if day_config.get('enabled', True): - enabled_days_count += 1 - start_time = data.get(start_key) or '20:00' - end_time = data.get(end_key) or '07:00' - - # Validate time formats - is_valid, error_msg = _validate_time_format(start_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - f"Invalid start time for {day}: {error_msg}", - status_code=400 - ) - - is_valid, error_msg = _validate_time_format(end_time) - if not is_valid: - return error_response( - ErrorCode.VALIDATION_ERROR, - f"Invalid end time for {day}: {error_msg}", - status_code=400 - ) - - day_config['start_time'] = start_time - day_config['end_time'] = end_time - - dim_schedule_config['days'][day] = day_config - - # Validate that at least one day is enabled in per-day mode - if enabled_days_count == 0: - return error_response( - ErrorCode.VALIDATION_ERROR, - "At least one day must be enabled in per-day dim schedule mode", - status_code=400 - ) - - # Update and save config using atomic save - current_config['dim_schedule'] = dim_schedule_config - success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) - if not success: - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save dim schedule configuration: {error_msg}", - status_code=500 - ) - - # Invalidate cache on config change - try: - from web_interface.cache import invalidate_cache - invalidate_cache() - except ImportError: - pass - - return success_response(message='Dim schedule configuration saved successfully') - except Exception as e: - import logging - logger.error("Error saving dim schedule config", exc_info=True) - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - "An error occurred; see logs for details", - - status_code=500, details=describe_exception(e) - ) - -@api_v3.route('/config/main', methods=['POST']) -def save_main_config(): - """Save main configuration""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - # Try to get JSON data first, fallback to form data - data = None - if request.content_type == 'application/json': - data = request.get_json() - else: - # Handle form data - data = request.form.to_dict() - # Convert checkbox values - for key in ['web_display_autostart']: - if key in data: - data[key] = data[key] == 'on' - - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # What arrives here is the config itself, and the headers carry the - # session cookie -- neither belongs in the journal, least of all at - # ERROR on every save. The shape of the request is the part with - # diagnostic value, so log that, at the level it deserves. - logger.debug("save_main_config: %s, %d top-level key(s)", - request.content_type or 'no content-type', len(data)) - - # Merge with existing config (similar to original implementation) - current_config = api_v3.config_manager.load_config() - - # Handle general settings - # Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings - # If any general setting is present, we're updating the general tab - is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart', - 'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory']) - - if is_general_update: - # For checkbox: if not present in data during general update, it means unchecked - current_config['web_display_autostart'] = _coerce_to_bool(data.get('web_display_autostart')) - - if 'timezone' in data: - current_config['timezone'] = data['timezone'] - - # Device-wide scroll frame rate, read by plugins via - # BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps, - # which clamps silently -- rejecting here instead means a value that - # would have been quietly altered is reported rather than appearing to - # save and then behaving differently. - if 'target_fps' in data and data['target_fps'] not in ('', None): - raw_target_fps = data['target_fps'] - # A JSON body can carry real floats and bools, where int() would - # silently truncate: 90.5 would save as 90, and true as 1. Reject - # them rather than storing a value the user did not ask for. Form - # posts arrive as strings, so '90.5' still fails in int() below. - if isinstance(raw_target_fps, (bool, float)): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for target_fps: must be an integer" - }), 400 - try: - target_fps = int(raw_target_fps) - except (ValueError, TypeError, OverflowError): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for target_fps: must be an integer" - }), 400 - if not (30 <= target_fps <= 200): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for target_fps: must be between 30 and 200" - }), 400 - current_config['target_fps'] = target_fps - - # Handle location settings - if 'city' in data or 'state' in data or 'country' in data: - if 'location' not in current_config: - current_config['location'] = {} - if 'city' in data: - current_config['location']['city'] = data['city'] - if 'state' in data: - current_config['location']['state'] = data['state'] - if 'country' in data: - current_config['location']['country'] = data['country'] - - # Handle plugin system settings - if 'auto_discover' in data or 'auto_load_enabled' in data or 'development_mode' in data or 'plugins_directory' in data: - if 'plugin_system' not in current_config: - current_config['plugin_system'] = {} - - # Handle plugin system checkboxes - always set to handle unchecked state - # HTML checkboxes omit the key when unchecked, so missing key = unchecked = False - for checkbox in ['auto_discover', 'auto_load_enabled', 'development_mode']: - current_config['plugin_system'][checkbox] = _coerce_to_bool(data.get(checkbox)) - - # Handle plugins_directory - if 'plugins_directory' in data: - current_config['plugin_system']['plugins_directory'] = data['plugins_directory'] - - # Handle display settings - display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', - 'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', - 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format', - 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type', - 'row_address_type', 'pixel_mapper_config', 'orientation'] - - if any(k in data for k in display_fields): - if 'display' not in current_config: - current_config['display'] = {} - if 'hardware' not in current_config['display']: - current_config['display']['hardware'] = {} - if 'runtime' not in current_config['display']: - current_config['display']['runtime'] = {} - - # Allowed values for validated string fields - LED_RGB_ALLOWED = {'RGB', 'RBG', 'GRB', 'GBR', 'BRG', 'BGR'} - PANEL_TYPE_ALLOWED = {'', 'FM6126A', 'FM6127'} - - # Validate led_rgb_sequence - if 'led_rgb_sequence' in data and data['led_rgb_sequence'] not in LED_RGB_ALLOWED: - return jsonify({'status': 'error', 'message': f"Invalid LED RGB sequence '{data['led_rgb_sequence']}'. Allowed values: {', '.join(sorted(LED_RGB_ALLOWED))}"}), 400 - - # Validate panel_type - if 'panel_type' in data and data['panel_type'] not in PANEL_TYPE_ALLOWED: - return jsonify({'status': 'error', 'message': f"Invalid panel type '{data['panel_type']}'. Allowed values: Standard (empty), FM6126A, FM6127"}), 400 - - # Validate multiplexing - if 'multiplexing' in data: - try: - mux_val = int(data['multiplexing']) - if mux_val < 0 or mux_val > 22: - return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 - - # Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90") - if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str): - return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400 - - # Validate orientation (physical mounting rotation; composed onto pixel_mapper_config at runtime) - ORIENTATION_ALLOWED = {'normal', '180'} - if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED: - return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400 - - # Validate row_address_type - if 'row_address_type' in data: - try: - rat_val = int(data['row_address_type']) - if rat_val < 0 or rat_val > 4: - return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 - - # Handle hardware settings - for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', - 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', - 'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type', - 'pixel_mapper_config', 'orientation']: - if field in data: - if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode', - 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', - 'multiplexing', 'row_address_type']: - current_config['display']['hardware'][field] = int(data[field]) - else: - current_config['display']['hardware'][field] = data[field] - - # Handle runtime settings - if 'gpio_slowdown' in data: - current_config['display']['runtime']['gpio_slowdown'] = int(data['gpio_slowdown']) - if 'rp1_rio' in data: - try: - rp1_val = int(data['rp1_rio']) - if rp1_val not in (0, 1): - return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400 - current_config['display']['runtime']['rp1_rio'] = rp1_val - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400 - - # Handle checkboxes - coerce to bool to ensure proper JSON types - for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']: - current_config['display']['hardware'][checkbox] = _coerce_to_bool(data.get(checkbox)) - - # Handle display-level checkboxes (always set to handle unchecked state) - current_config['display']['use_short_date_format'] = _coerce_to_bool(data.get('use_short_date_format')) - - # Handle dynamic duration settings - if 'max_dynamic_duration_seconds' in data: - if 'dynamic_duration' not in current_config['display']: - current_config['display']['dynamic_duration'] = {} - current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds']) - - # Handle double-sided display settings - double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis'] - if any(k in data for k in double_sided_fields): - if 'display' not in current_config: - current_config['display'] = {} - if 'double_sided' not in current_config['display']: - current_config['display']['double_sided'] = {} - ds_config = current_config['display']['double_sided'] - - # Enabled checkbox: omitted from the form when unchecked. - # The Display form posts copies/axis on every save regardless of this - # checkbox, so when the feature is off we accept the values without - # rejecting the whole save — otherwise a stale copies/chain_length - # mismatch locks the user out of every other display setting. - enabled = _coerce_to_bool(data.get('double_sided_enabled')) - ds_config['enabled'] = enabled - - def _copies_fits_hardware(copies: int) -> Optional[str]: - """Error message if copies doesn't divide the panel evenly, else None.""" - # Use axis from this request if provided, else from stored config. - hw = current_config.get('display', {}).get('hardware', {}) - effective_axis = (data.get('double_sided_axis') - or current_config.get('display', {}).get('double_sided', {}).get('axis', 'horizontal')) - if effective_axis == 'horizontal': - chain_length = int(hw.get('chain_length', 2) or 2) - if chain_length % copies != 0: - return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly" - elif effective_axis == 'vertical': - parallel = int(hw.get('parallel', 1) or 1) - if parallel % copies != 0: - return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly" - return None - - if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None): - copies = None - try: - copies = int(data['double_sided_copies']) - except (ValueError, TypeError, OverflowError): - if enabled: - return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 - if copies is not None and not (2 <= copies <= 8): - if enabled: - return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400 - # Disabled: leave the stored value alone rather than writing junk. - copies = None - if copies is not None: - # Divisibility is a hardware-relational check — only meaningful - # when the feature is actually on. - if enabled: - fit_error = _copies_fits_hardware(copies) - if fit_error: - return jsonify({'status': 'error', 'message': fit_error}), 400 - ds_config['copies'] = copies - - if 'double_sided_axis' in data: - axis = data['double_sided_axis'] - if axis not in ('horizontal', 'vertical'): - if enabled: - return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400 - else: - ds_config['axis'] = axis - - # Handle Vegas scroll mode settings - vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', - 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins', - 'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding', - 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', - 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', - 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', - 'vegas_intra_plugin_gap', 'vegas_render_width_pct', - 'vegas_min_content_separation', 'vegas_min_cut_gap', - 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', - 'vegas_smooth_scroll', 'vegas_overflow_mode'] - - if any(k in data for k in vegas_fields): - if 'display' not in current_config: - current_config['display'] = {} - if 'vegas_scroll' not in current_config['display']: - current_config['display']['vegas_scroll'] = {} - - vegas_config = current_config['display']['vegas_scroll'] - - # Handle enabled checkbox - # HTML checkboxes omit the key entirely when unchecked, so if the form - # was submitted (any vegas field present) but enabled key is missing, - # the checkbox was unchecked and we should set enabled=False - vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) - vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) - vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( - data.get('vegas_dynamic_duration_enabled')) - vegas_config['continuous_scroll'] = _coerce_to_bool( - data.get('vegas_continuous_scroll')) - vegas_config['smooth_scroll'] = _coerce_to_bool( - data.get('vegas_smooth_scroll')) - - # max_plugin_width_ratio is the one fractional setting, so it is - # handled outside the integer loop below. - if data.get('vegas_overflow_mode') not in ('', None): - mode = str(data['vegas_overflow_mode']).strip().lower() - if mode not in ('rotate', 'truncate'): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for vegas_overflow_mode: " - "must be 'rotate' or 'truncate'" - }), 400 - vegas_config['overflow_mode'] = mode - - if data.get('vegas_extend_threshold_screens') not in ('', None): - try: - screens = float(data['vegas_extend_threshold_screens']) - except (ValueError, TypeError, OverflowError): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for vegas_extend_threshold_screens: " - "must be a number" - }), 400 - if not (1.0 <= screens <= 10.0): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for vegas_extend_threshold_screens: " - "must be between 1.0 and 10.0" - }), 400 - vegas_config['extend_threshold_screens'] = screens - - if data.get('vegas_max_plugin_width_ratio') not in ('', None): - try: - ratio = float(data['vegas_max_plugin_width_ratio']) - except (ValueError, TypeError, OverflowError): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for vegas_max_plugin_width_ratio: " - "must be a number" - }), 400 - if not (0 <= ratio <= 20): - return jsonify({ - 'status': 'error', - 'message': "Invalid value for vegas_max_plugin_width_ratio: " - "must be between 0 and 20 (0 disables the cap)" - }), 400 - vegas_config['max_plugin_width_ratio'] = ratio - - # Handle numeric settings with validation. - # - # These bounds must match VegasModeConfig.validate(), which is what - # actually gates Vegas starting. Where they were looser, a value - # saved with a 200 and then made VegasModeCoordinator.start() bail - # out with only a log line, so the ticker silently never ran. - # Where they were tighter (scroll_speed capped at 100 against a - # slider that goes to 200), a legitimate value was rejected with a - # 400. See test_vegas_api_bounds_match_validate. - numeric_fields = { - 'vegas_scroll_speed': ('scroll_speed', 1, 200), - 'vegas_separator_width': ('separator_width', 0, 128), - 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), - 'vegas_render_width_pct': ('render_width_pct', 10, 100), - 'vegas_min_content_separation': ('min_content_separation', 0, 256), - 'vegas_min_cut_gap': ('min_cut_gap', 1, 128), - 'vegas_target_fps': ('target_fps', 30, 200), - 'vegas_buffer_ahead': ('buffer_ahead', 1, 5), - 'vegas_trim_threshold': ('trim_threshold', 0, 254), - 'vegas_content_padding': ('content_padding', 0, 128), - 'vegas_min_plugin_width': ('min_plugin_width', 0, 512), - 'vegas_lead_in_width': ('lead_in_width', 0, 2048), - 'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50), - 'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600), - 'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600), - } - for field_name, (config_key, min_val, max_val) in numeric_fields.items(): - if field_name in data: - raw_value = data[field_name] - # Skip empty strings (treat as "not provided") - if raw_value == '' or raw_value is None: - continue - try: - int_value = int(raw_value) - except (ValueError, TypeError, OverflowError): - return jsonify({ - 'status': 'error', - 'message': f"Invalid value for {field_name}: must be an integer" - }), 400 - if not (min_val <= int_value <= max_val): - return jsonify({ - 'status': 'error', - 'message': f"Invalid value for {field_name}: must be between {min_val} and {max_val}" - }), 400 - vegas_config[config_key] = int_value - - # Handle plugin order and exclusions (JSON arrays) - if 'vegas_plugin_order' in data: - try: - if isinstance(data['vegas_plugin_order'], str): - parsed = json.loads(data['vegas_plugin_order']) - else: - parsed = data['vegas_plugin_order'] - # Ensure result is a list - vegas_config['plugin_order'] = list(parsed) if isinstance(parsed, (list, tuple)) else [] - except (json.JSONDecodeError, TypeError, ValueError): - vegas_config['plugin_order'] = [] - - if 'vegas_excluded_plugins' in data: - try: - if isinstance(data['vegas_excluded_plugins'], str): - parsed = json.loads(data['vegas_excluded_plugins']) - else: - parsed = data['vegas_excluded_plugins'] - # Ensure result is a list - vegas_config['excluded_plugins'] = list(parsed) if isinstance(parsed, (list, tuple)) else [] - except (json.JSONDecodeError, TypeError, ValueError): - vegas_config['excluded_plugins'] = [] - - # Handle multi-display sync settings - sync_fields = ["sync_role", "sync_port", "sync_follower_position"] - if any(k in data for k in sync_fields): - if 'sync' not in current_config: - current_config['sync'] = {} - SYNC_ROLE_ALLOWED = {'standalone', 'leader', 'follower'} - if 'sync_role' in data: - role_val = str(data['sync_role']).lower() - if role_val not in SYNC_ROLE_ALLOWED: - return jsonify({'status': 'error', 'message': f"Invalid sync role '{role_val}'. Must be one of: standalone, leader, follower"}), 400 - current_config['sync']['role'] = role_val - if 'sync_port' in data: - try: - port_val = int(data['sync_port']) - if not (1024 <= port_val <= 65535): - return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400 - current_config['sync']['port'] = port_val - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400 - - if "sync_follower_position" in data: - pos_val = str(data["sync_follower_position"]).lower() - if pos_val not in {"left", "right"}: - return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400 - current_config["sync"]["follower_position"] = pos_val - - # Handle primary rotation order: must be a JSON array of plugin-id - # strings. Reject anything else with a 400 rather than silently - # coercing, so a buggy client can't clear or corrupt the saved order. - if 'plugin_rotation_order' in data: - raw_order = data.pop('plugin_rotation_order') - try: - parsed = json.loads(raw_order) if isinstance(raw_order, str) else raw_order - except (json.JSONDecodeError, TypeError, ValueError): - return jsonify({'status': 'error', - 'message': 'plugin_rotation_order must be valid JSON'}), 400 - if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed): - return jsonify({'status': 'error', - 'message': 'plugin_rotation_order must be a list of plugin-id strings'}), 400 - if 'display' not in current_config: - current_config['display'] = {} - current_config['display']['plugin_rotation_order'] = parsed - - # Handle display durations. Popped from `data` (not just read) so - # they can never also fall through to the generic "remaining keys" - # merge near the end of this function, which would otherwise write - # them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30 - # sitting at config root alongside the correct - # display.display_durations.clock_duration). - duration_fields = [k for k in list(data.keys()) - if k.endswith('_duration') or k in ('default_duration', 'transition_duration')] - if duration_fields: - if 'display' not in current_config: - current_config['display'] = {} - if 'display_durations' not in current_config['display']: - current_config['display']['display_durations'] = {} - - for field in duration_fields: - raw_value = data.pop(field) - try: - int_value = int(raw_value) - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', - 'message': f"Invalid duration for {field}: must be an integer"}), 400 - current_config['display']['display_durations'][field] = int_value - - # Per-mode durations from the Rotation & Durations page, posted as - # duration__ (mode keys are arbitrary plugin mode names, so - # they can't use the suffix convention above). Same pop-and-validate - # treatment, for the same reason. - mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')] - if mode_duration_fields: - if 'display' not in current_config: - current_config['display'] = {} - if 'display_durations' not in current_config['display']: - current_config['display']['display_durations'] = {} - - for field in mode_duration_fields: - raw_value = data.pop(field) - mode_key = field[len('duration__'):] - if not mode_key: - continue - try: - int_value = int(raw_value) - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', - 'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400 - current_config['display']['display_durations'][mode_key] = int_value - - # Handle plugin configurations dynamically - # Any key that matches a plugin ID should be saved as plugin config - # This includes proper secret field handling from schema - plugin_keys_to_remove = [] - for key in data: - # Check if this key is a plugin ID - if api_v3.plugin_manager and key in api_v3.plugin_manager.plugin_manifests: - plugin_id = key - plugin_config = data[key] - - # Load plugin schema to identify secret fields (same logic as save_plugin_config) - secret_fields = set() - if api_v3.plugin_manager: - plugins_dir = api_v3.plugin_manager.plugins_dir - else: - plugin_system_config = current_config.get('plugin_system', {}) - plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos') - if os.path.isabs(plugins_dir_name): - plugins_dir = Path(plugins_dir_name) - else: - plugins_dir = PROJECT_ROOT / plugins_dir_name - schema_path = plugins_dir / plugin_id / 'config_schema.json' - - if schema_path.exists(): - try: - with open(schema_path, 'r', encoding='utf-8') as f: - schema = json.load(f) - if 'properties' in schema: - secret_fields = find_secret_fields(schema['properties']) - except Exception as e: - logger.debug("Error reading schema for secret detection: %s", e) - - # Separate secrets from regular config (same logic as save_plugin_config) - regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) - # The config form renders secrets masked, so every save posts - # them back blank. Without this the blank is merged over the - # stored value and the credential is destroyed by the act of - # changing an unrelated setting. A blank means "unchanged". - secrets_config = remove_empty_secrets(secrets_config) - - # PRE-PROCESSING: Preserve 'enabled' state if not in regular_config - # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle - if 'enabled' not in regular_config: - try: - if plugin_id in current_config and 'enabled' in current_config[plugin_id]: - regular_config['enabled'] = current_config[plugin_id]['enabled'] - elif api_v3.plugin_manager: - # Fallback to plugin instance if config doesn't have it - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - regular_config['enabled'] = plugin_instance.enabled - # Final fallback: default to True if plugin is loaded (matches BasePlugin default) - if 'enabled' not in regular_config: - regular_config['enabled'] = True - except Exception as e: - logger.debug("Error preserving enabled state: %s", e) - # Default to True on error to avoid disabling plugins - regular_config['enabled'] = True - - # Get current secrets config - current_secrets = api_v3.config_manager.get_raw_file_content('secrets') - - # Deep merge regular config into main config - if plugin_id not in current_config: - current_config[plugin_id] = {} - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) - - # Deep merge secrets into secrets config - if secrets_config: - if plugin_id not in current_secrets: - current_secrets[plugin_id] = {} - # Lists merge by replacement, so deep_merge here wrote a - # blanked array straight over the stored credentials. - current_secrets[plugin_id] = merge_secrets( - current_secrets[plugin_id], secrets_config) - # Save secrets file - api_v3.config_manager.save_raw_file_content('secrets', current_secrets) - - # Mark for removal from data dict (already processed) - plugin_keys_to_remove.append(key) - - # Notify plugin of config change if loaded (with merged config including secrets) - try: - if api_v3.plugin_manager: - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - # Reload merged config (includes secrets) and pass the plugin-specific section - merged_config = api_v3.config_manager.load_config() - plugin_full_config = merged_config.get(plugin_id, {}) - if hasattr(plugin_instance, 'on_config_change'): - plugin_instance.on_config_change(plugin_full_config) - except Exception as hook_err: - # Don't fail the save if hook fails - logger.warning("on_config_change failed: %s", hook_err) - - # Remove processed plugin keys from data (they're already in current_config) - for key in plugin_keys_to_remove: - del data[key] - - # Handle any remaining config keys - # System settings (timezone, city, etc.) are already handled above - # Plugin configs should use /api/v3/plugins/config endpoint, but we'll handle them here too for flexibility - for key in data: - # Skip system settings that are already handled above - if key in ['timezone', 'city', 'state', 'country', - 'web_display_autostart', 'auto_discover', - 'auto_load_enabled', 'development_mode', - 'plugins_directory', 'target_fps']: - continue - # Skip fields that are already handled above in their own named sections. - # Without this, every form field name lands as a top-level config key too. - if key in display_fields: - continue - if key in sync_fields: - continue - if key in vegas_fields: - continue - if key in double_sided_fields: - continue - # For any remaining keys (including plugin keys), use deep merge to preserve existing settings - if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict): - # Deep merge to preserve existing settings - current_config[key] = deep_merge(current_config[key], data[key]) - else: - current_config[key] = data[key] - - # Save the merged config using atomic save - success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) - if not success: - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save configuration: {error_msg}", - status_code=500 - ) - - # Invalidate cache on config change - try: - from web_interface.cache import invalidate_cache - invalidate_cache() - except ImportError: - pass - - return success_response(message='Configuration saved successfully') - except Exception as e: - logger.error("Error saving config", exc_info=True) - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - "An error occurred; see logs for details", - status_code=500, details=describe_exception(e) - ) - -@api_v3.route('/config/secrets', methods=['GET']) -def get_secrets_config(): - """Get secrets configuration""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - config = api_v3.config_manager.get_raw_file_content('secrets') - # This interface has no authentication, and this file is nothing but - # credentials. It was handing all of them to anyone who could reach - # the port. Values are masked; empty and YOUR_* placeholders are left - # alone so a client can still tell "set" from "not set". - return jsonify({'status': 'success', - 'data': mask_all_secret_values(config)}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/config/raw/main', methods=['POST']) -def save_raw_main_config(): - """Save raw main configuration JSON""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - # silent=True so a malformed body returns None instead of raising - # Werkzeug's own BadRequest, which would answer in a different - # shape than this API's. Distinguish the two causes: a body that - # was sent but does not parse is a different mistake from no body. - data = request.get_json(silent=True) - if data is None and request.get_data(): - return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400 - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Save the raw config file - api_v3.config_manager.save_raw_file_content('main', data) - - return jsonify({'status': 'success', 'message': 'Main configuration saved successfully'}) - 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 - ) - -@api_v3.route('/config/raw/secrets', methods=['POST']) -def save_raw_secrets_config(): - """Save raw secrets configuration JSON""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - # See save_raw_main_config: silent parsing, with a sent-but-broken - # body reported separately from a missing one. - data = request.get_json(silent=True) - if data is None and request.get_data(): - return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400 - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # The GET above masks what it returns, and this endpoint's only client - # reads the whole file, edits one field and posts all of it back. So - # most of what arrives here is the mask, echoed rather than changed -- - # storing it verbatim would replace every untouched credential with - # eight bullets. Strip those, then merge onto what is already stored, - # which makes "unchanged" mean unchanged. - # - # The cost is that a secret can no longer be cleared by blanking it. - # That needs its own affordance; a control that erases credentials as - # a side effect of saving an unrelated one is not it. - current = api_v3.config_manager.get_raw_file_content('secrets') or {} - merged = deep_merge(current, strip_masked_values(data)) - api_v3.config_manager.save_raw_file_content('secrets', merged) - - # Reload GitHub token in plugin store manager if it exists - if api_v3.plugin_store_manager: - api_v3.plugin_store_manager.github_token = api_v3.plugin_store_manager._load_github_token() - - 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 - -@api_v3.route('/system/status', methods=['GET']) -def get_system_status(): - """Get system status""" - try: - # Check cache first (10 second TTL for system status) - try: - from web_interface.cache import get_cached, set_cached - cached_result = get_cached('system_status', ttl_seconds=10) - if cached_result is not None: - return jsonify({'status': 'success', 'data': cached_result}) - except ImportError: - # Cache not available, continue without caching - get_cached = None - set_cached = None - - # Import psutil for system monitoring - try: - import psutil - except ImportError: - # Fallback if psutil not available - return jsonify({ - 'status': 'error', - 'message': 'psutil not available for system monitoring' - }), 503 - - # Get system metrics using psutil - cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness - memory = psutil.virtual_memory() - memory_percent = memory.percent - disk = psutil.disk_usage('/') - disk_percent = disk.percent - - # Calculate uptime - boot_time = psutil.boot_time() - uptime_seconds = time.time() - boot_time - uptime_hours = uptime_seconds / 3600 - uptime_days = uptime_hours / 24 - - # Format uptime string - if uptime_days >= 1: - uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h" - elif uptime_hours >= 1: - uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m" - else: - uptime_str = f"{int(uptime_seconds / 60)}m" - - # Get CPU temperature (Raspberry Pi) - cpu_temp = None - try: - temp_file = '/sys/class/thermal/thermal_zone0/temp' - if os.path.exists(temp_file): - with open(temp_file, 'r') as f: - temp_millidegrees = int(f.read().strip()) - cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius - except (IOError, ValueError, OSError): - # Temperature sensor not available or error reading - cpu_temp = None - - # Get display service status - service_status = _get_display_service_status() - - status = { - 'timestamp': time.time(), - 'uptime': uptime_str, - 'uptime_seconds': int(uptime_seconds), - 'service_active': service_status.get('active', False), - 'cpu_percent': round(cpu_percent, 1), - 'memory_used_percent': round(memory_percent, 1), - 'memory_total_mb': round(memory.total / (1024 * 1024), 1), - 'memory_used_mb': round(memory.used / (1024 * 1024), 1), - # MemAvailable, not total-minus-used: it accounts for reclaimable - # page cache, so it is what actually predicts memory trouble. A - # board can read 70% "used" and be fine, or read the same and be - # about to fail fork(), and only this number tells them apart. - 'memory_available_mb': round(memory.available / (1024 * 1024), 1), - 'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None, - 'disk_used_percent': round(disk_percent, 1), - 'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1), - 'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1) - } - - # Cache the result if available - if set_cached: - try: - set_cached('system_status', status, ttl_seconds=10) - except Exception: - pass # Cache write failed, but continue - - return jsonify({'status': 'success', 'data': status}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/health', methods=['GET']) -def get_health(): - """Get system health status""" - try: - health_status = { - 'status': 'healthy', - 'timestamp': time.time(), - 'services': {}, - 'checks': {} - } - - # Check web interface service - # Stamp the start time before measuring against it -- reading it with a - # fallback of time.time() and only assigning afterwards made the very - # first call subtract two separate clock reads, reporting a small - # negative uptime. - if not hasattr(get_health, '_start_time'): - get_health._start_time = time.time() - health_status['services']['web_interface'] = { - 'status': 'running', - 'uptime_seconds': time.time() - get_health._start_time - } - - # Check display service - display_service_status = _get_display_service_status() - health_status['services']['display_service'] = { - 'status': 'active' if display_service_status.get('active') else 'inactive', - 'details': display_service_status - } - - # Check config file accessibility - try: - if api_v3.config_manager: - test_config = api_v3.config_manager.load_config() - health_status['checks']['config_file'] = { - 'status': 'accessible', - 'readable': True - } - else: - health_status['checks']['config_file'] = { - 'status': 'unknown', - 'readable': False - } - except Exception as e: - health_status['checks']['config_file'] = { - 'status': 'error', - 'readable': False, - 'error': 'see logs for details' - } - - # Check plugin system - try: - if api_v3.plugin_manager: - # Try to discover plugins (lightweight check) - plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0 - health_status['checks']['plugin_system'] = { - 'status': 'operational', - 'plugin_count': plugin_count - } - else: - health_status['checks']['plugin_system'] = { - 'status': 'not_initialized' - } - except Exception as e: - health_status['checks']['plugin_system'] = { - 'status': 'error', - 'error': 'see logs for details' - } - - # Check hardware connectivity (if display manager available) - try: - snapshot_path = "/tmp/led_matrix_preview.png" - if os.path.exists(snapshot_path): - # Check if snapshot is recent (updated in last 60 seconds) - mtime = os.path.getmtime(snapshot_path) - age_seconds = time.time() - mtime - health_status['checks']['hardware'] = { - 'status': 'connected' if age_seconds < 60 else 'stale', - 'snapshot_age_seconds': round(age_seconds, 1) - } - else: - health_status['checks']['hardware'] = { - 'status': 'no_snapshot', - 'note': 'Display service may not be running' - } - except Exception as e: - health_status['checks']['hardware'] = { - 'status': 'unknown', - 'error': 'see logs for details' - } - - # Determine overall health - all_healthy = all( - check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active'] - for check in health_status['checks'].values() - ) - - if not all_healthy: - health_status['status'] = 'degraded' - - return jsonify({'status': 'success', 'data': health_status}) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e), - 'data': {'status': 'unhealthy'} - }), 500 - -def _git_current_branch(project_dir): - """Current branch name, or '' when detached or git fails.""" - try: - r = subprocess.run(['git', 'branch', '--show-current'], - capture_output=True, text=True, timeout=10, cwd=str(project_dir)) - return r.stdout.strip() if r.returncode == 0 else '' - except (subprocess.TimeoutExpired, OSError): - return '' - - -def _git_upstream(project_dir): - """Configured upstream for the current branch (e.g. 'origin/main'), or ''.""" - try: - r = subprocess.run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], - capture_output=True, text=True, timeout=10, cwd=str(project_dir)) - return r.stdout.strip() if r.returncode == 0 else '' - except (subprocess.TimeoutExpired, OSError): - return '' - - -def _git_remote_branch_exists(project_dir, branch): - """True when origin/ exists locally as a remote-tracking ref.""" - if not branch: - return False - try: - r = subprocess.run( - ['git', 'show-ref', '--verify', '--quiet', f'refs/remotes/origin/{branch}'], - capture_output=True, text=True, timeout=10, cwd=str(project_dir)) - return r.returncode == 0 - except (subprocess.TimeoutExpired, OSError): - return False - - -def resolve_pull_command(project_dir): - """Work out how to pull, for branches with and without an upstream. - - A plain ``git pull --rebase`` fails outright on a branch that has no - upstream ("There is no tracking information for the current branch"), - which is easy to end up on: checking out a branch by name, restoring a - backup, or following an install guide that names one. The update button - then reports a failure the user cannot act on. - - ``--autostash`` is passed for the same reason. Rebase refuses to start - when any tracked file is modified, and on these installs something always - is: first_time_install.sh chmods five scripts that git tracked as 644, so - every machine that ran the installer carries five permanent mode changes - and the update button reports "cannot pull with rebase: You have unstaged - changes". Those modes are corrected in this commit, but a user cannot pull - the correction while the pull is what is blocked, and any other local edit - would reproduce it anyway. Autostash reapplies the changes afterwards. - - Returns ``(args, note, error)``. When ``origin/`` exists the pull - is made explicit against it, so the update proceeds and the branch is - given tracking information afterwards. - """ - upstream = _git_upstream(project_dir) - if upstream: - return ['git', 'pull', '--rebase', '--autostash'], '', None - - branch = _git_current_branch(project_dir) - if not branch: - return None, '', ( - "This checkout is in a detached HEAD state, so there is no branch " - "to update. Switch to a branch first (Tools -> Switch branch)." - ) - if _git_remote_branch_exists(project_dir, branch): - return ( - ['git', 'pull', '--rebase', '--autostash', 'origin', branch], - f"Branch '{branch}' had no upstream; pulled from origin/{branch} and set it as the upstream.", - None, - ) - return None, '', ( - f"Branch '{branch}' has no upstream and there is no origin/{branch} to " - f"pull from. Use Switch branch to move to a branch that exists on the " - f"remote, or push this one first." - ) - - -_BRANCH_NAME_RE = re.compile(r'[A-Za-z0-9._/-]{1,200}') - - -def is_valid_branch_name(name): - """Accept only plain branch names. - - This value becomes a subprocess argument, so anything exotic is refused - rather than escaped. '..' is excluded because it is range syntax to git. - """ - if not name or not _BRANCH_NAME_RE.fullmatch(name): - return False - return '..' not in name and not name.startswith('-') - - -def checkout_branch(project_dir, target, stash=False): - """Switch the checkout to `target`, returning (payload, http_status). - - Split out of the route so it can be tested against real repositories. - Attaches tracking when the branch exists on origin, so the next - Pull Latest is a plain `git pull` rather than the no-upstream fallback. - """ - target = (target or '').strip() - if not target: - return {'status': 'error', 'message': 'Branch name required'}, 400 - if not is_valid_branch_name(target): - return {'status': 'error', 'message': 'Invalid branch name'}, 400 - - try: - subprocess.run(['git', 'fetch', 'origin', '--prune'], - capture_output=True, text=True, timeout=60, cwd=project_dir) - - local_exists = subprocess.run( - ['git', 'show-ref', '--verify', '--quiet', f'refs/heads/{target}'], - capture_output=True, text=True, timeout=10, cwd=project_dir).returncode == 0 - remote_exists = _git_remote_branch_exists(project_dir, target) - if not local_exists and not remote_exists: - return {'status': 'error', - 'message': f"No branch '{target}' locally or on origin"}, 404 - - # Local edits block a checkout. Pull Latest already stashes for the - # same reason, so offer it here too -- but only when asked, never - # silently: putting someone's edits away unasked is worse than - # refusing the switch. - stash_note = '' - if stash: - stashed = subprocess.run(['git', 'stash', 'push', '-m', f'switch to {target}'], - capture_output=True, text=True, timeout=60, cwd=project_dir) - if stashed.returncode == 0 and 'No local changes' not in stashed.stdout: - stash_note = ' Local changes were stashed (recover them with git stash list).' - - if local_exists: - co = subprocess.run(['git', 'checkout', target], - capture_output=True, text=True, timeout=60, cwd=project_dir) - else: - # -B so a stale local ref does not block the checkout. - co = subprocess.run(['git', 'checkout', '-B', target, f'origin/{target}'], - capture_output=True, text=True, timeout=60, cwd=project_dir) - - if co.returncode != 0: - logger.warning("git checkout %s failed: %s", target, co.stderr) - return { - 'status': 'error', - 'message': f"Could not switch to '{target}'.", - # Keep git's full list of blocking files: naming them is the - # difference between an error the user can act on and one they - # cannot. - 'detail': (co.stderr or '').strip(), - 'can_retry_with_stash': 'would be overwritten by checkout' in (co.stderr or ''), - }, 200 - - if remote_exists: - subprocess.run(['git', 'branch', f'--set-upstream-to=origin/{target}', target], - capture_output=True, text=True, timeout=10, cwd=project_dir) - - logger.info("Switched checkout to branch %s", target) - return { - 'status': 'success', - 'message': f"Now on '{target}'.{stash_note} Use Pull Latest to fetch its newest code.", - }, 200 - except subprocess.TimeoutExpired: - return {'status': 'error', 'message': 'Timed out talking to git'}, 504 - except OSError as exc: - logger.error("checkout_branch failed: %s", exc, exc_info=True) - return {'status': 'error', 'message': 'Could not switch branch'}, 500 - - -def get_git_version(project_dir=None): - """Get git version information from the repository""" - if project_dir is None: - project_dir = PROJECT_ROOT - - try: - # Try to get tag description (e.g., v2.4-10-g123456) - result = subprocess.run( - ['git', 'describe', '--tags', '--dirty'], - capture_output=True, - text=True, - timeout=5, - cwd=str(project_dir) - ) - - if result.returncode == 0: - version_str = result.stdout.strip() - if re.match(r'^[a-zA-Z0-9._\-]+$', version_str): - return version_str - - # Fallback to short commit hash - result = subprocess.run( - ['git', 'rev-parse', '--short', 'HEAD'], - capture_output=True, - text=True, - timeout=5, - cwd=str(project_dir) - ) - - if result.returncode == 0: - version_str = result.stdout.strip() - if re.match(r'^[a-zA-Z0-9._\-]+$', version_str): - return version_str - - return 'Unknown' - except Exception: - return 'Unknown' - -@api_v3.route('/system/version', methods=['GET']) -def get_system_version(): - """Get LEDMatrix repository version""" - try: - version = get_git_version() - return jsonify({'status': 'success', 'data': {'version': version}}) - except Exception as e: - logger.error("get_system_version failed: %s", e, exc_info=True) - return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500 - -_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0} -_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load - -def _update_check_failed(detail: str) -> Dict[str, Any]: - """A check that could not run is not the same as being up to date. - - Reporting update_available=False on a git failure hides the banner, and - the banner is the only route to the update button -- so a checkout git - refuses to touch looks exactly like a current one, permanently. The most - common cause is an install performed as root: git then reports "dubious - ownership" and every command fails, including the fetch here. - """ - return {'update_available': False, 'remote_sha': 'unknown', - 'commits_behind': 0, 'check_failed': True, 'error': detail} - - -def _describe_git_failure(stderr: str) -> str: - """Turn git's stderr into something the user can act on.""" - text = (stderr or '').strip() - if 'dubious ownership' in text or 'detected dubious ownership' in text: - return ("This checkout is owned by a different user than the one " - "running the web interface, so git refuses to use it. It is " - "usually the result of installing as root. Fix the ownership " - "and the update will work: sudo chown -R $USER:$USER " - + str(PROJECT_ROOT)) - if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower(): - return "Could not reach GitHub to check for updates." - return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed") - - -@api_v3.route('/system/check-update', methods=['GET']) -def check_for_update(): - """Check whether a newer LEDMatrix commit is available on origin/main.""" - now = time.time() - if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL: - return jsonify(_update_check_cache['result']) - - _safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0} - try: - cwd = str(PROJECT_ROOT) - fetch_result = subprocess.run( - ['git', 'fetch', 'origin', 'main', '--quiet'], - capture_output=True, timeout=10, cwd=cwd, - ) - if fetch_result.returncode != 0: - stderr = fetch_result.stderr.decode(errors='replace').strip() - logger.warning("check-update: git fetch failed (rc=%d): %s", - fetch_result.returncode, stderr) - failed = _update_check_failed(_describe_git_failure(stderr)) - _update_check_cache['result'] = failed - _update_check_cache['ts'] = now - return jsonify(failed) - local = subprocess.run( - ['git', 'rev-parse', 'HEAD'], - capture_output=True, text=True, timeout=5, cwd=cwd, - ).stdout.strip() - remote = subprocess.run( - ['git', 'rev-parse', 'origin/main'], - capture_output=True, text=True, timeout=5, cwd=cwd, - ).stdout.strip() - - if not local or not remote: - return jsonify(_safe) - - if local == remote: - result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0} - else: - count_str = subprocess.run( - ['git', 'rev-list', 'HEAD..origin/main', '--count'], - capture_output=True, text=True, timeout=5, cwd=cwd, - ).stdout.strip() - count = int(count_str) if count_str.isdigit() else 0 - result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count} - - _update_check_cache['result'] = result - _update_check_cache['ts'] = now - return jsonify(result) - except Exception as e: - logger.warning("check-update failed: %s", e) - return jsonify(_update_check_failed( - "Could not check for updates; see logs for details.")) - -@api_v3.route('/system/action', methods=['POST']) -def execute_system_action(): - """Execute system actions (start/stop/reboot/etc)""" - try: - # HTMX sends data as form data, not JSON - data = request.get_json(silent=True) or {} - if not data: - # Try to get from form data if JSON fails - data = { - 'action': request.form.get('action'), - 'mode': request.form.get('mode') - } - - if not data or 'action' not in data: - return jsonify({'status': 'error', 'message': 'Action required'}), 400 - - action = data['action'] - mode = data.get('mode') # For on-demand modes - - # Map actions to subprocess calls (similar to original implementation) - if action == 'start_display': - if mode: - # For on-demand modes, we would need to integrate with the display controller - # For now, just start the display service - try: - result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - except subprocess.TimeoutExpired as e: - logger.error("start_display (%s) timed out: %s", mode, e) - return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'}) - logger.info("start_display (%s) returned code %d", mode, result.returncode) - if result.returncode != 0 and result.stderr: - logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip()) - resp = { - 'status': 'success' if result.returncode == 0 else 'error', - 'message': 'Display started' if result.returncode == 0 else 'Failed to start display', - } - if result.returncode != 0: - resp['returncode'] = result.returncode - resp['stderr'] = result.stderr.strip() - return jsonify(resp) - else: - result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - elif action == 'stop_display': - result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - elif action == 'enable_autostart': - result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - elif action == 'disable_autostart': - result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - elif action == 'reboot_system': - result = subprocess.run(['sudo', 'reboot'], - capture_output=True, text=True, timeout=10) - elif action == 'shutdown_system': - result = subprocess.run(['sudo', 'poweroff'], - capture_output=True, text=True, timeout=10) - elif action == 'git_pull': - # Use PROJECT_ROOT instead of hardcoded path - project_dir = str(PROJECT_ROOT) - - # Decide how to pull BEFORE stashing. If this checkout cannot be - # updated at all, stashing first would put the user's local changes - # away for an update that was never going to run. - pull_args, upstream_note, pull_error = resolve_pull_command(project_dir) - if pull_error: - logger.warning("git pull not attempted: %s", pull_error) - return jsonify({'status': 'error', 'message': pull_error}) - - # Check if there are local changes that need to be stashed - # Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project - # Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins) - try: - status_result = subprocess.run( - ['git', 'status', '--porcelain', '--untracked-files=no'], - capture_output=True, - text=True, - timeout=30, - cwd=project_dir - ) - # Filter out any changes in plugins directory - plugins are separate repositories - # Git status format: XY filename (where X is status of index, Y is status of work tree) - status_lines = [line for line in status_result.stdout.strip().split('\n') - if line.strip() and 'plugins/' not in line] - has_changes = bool('\n'.join(status_lines).strip()) - except subprocess.TimeoutExpired: - # If status check times out, assume there might be changes and proceed - # This is safer than failing the update - has_changes = True - status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})() - - stash_info = "" - - # Stash local changes if they exist (excluding plugins) - # Plugins are separate repositories and shouldn't be stashed with base project updates - if has_changes: - try: - # Use pathspec to exclude plugins directory from stash - stash_result = subprocess.run( - ['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'], - capture_output=True, - text=True, - timeout=30, - cwd=project_dir - ) - if stash_result.returncode == 0: - logger.debug("git stash: stashed local changes before pull") - stash_info = " Local changes were stashed." - else: - logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode) - except subprocess.TimeoutExpired: - logger.warning("git stash timed out, proceeding with pull") - - # Record HEAD before the pull so dependency changes can be detected - old_head = None - try: - _pre = subprocess.run(['git', 'rev-parse', 'HEAD'], - capture_output=True, text=True, timeout=10, cwd=project_dir) - if _pre.returncode == 0: - old_head = _pre.stdout.strip() - except subprocess.TimeoutExpired: - logger.warning("git rev-parse timed out before pull") - - # Whether the pull actually brought new code in. "Already up to - # date" is a success too, and prompting for a restart then would - # train users to ignore the prompt. - code_changed = False - - # Perform the git pull. Branches without an upstream were given - # an explicit "origin " above so the update still works. - result = subprocess.run( - pull_args, - capture_output=True, - text=True, - timeout=60, - cwd=project_dir - ) - - # Give the branch tracking information so the next pull is a plain - # `git pull` — otherwise every update repeats the fallback. - if result.returncode == 0 and upstream_note: - branch = _git_current_branch(project_dir) - if branch: - try: - subprocess.run( - ['git', 'branch', f'--set-upstream-to=origin/{branch}', branch], - capture_output=True, text=True, timeout=10, cwd=project_dir) - except (subprocess.TimeoutExpired, OSError) as exc: - logger.debug("could not set upstream for %s: %s", branch, exc) - - # Return custom response for git_pull - if result.returncode == 0: - pull_message = "Code updated successfully." - if has_changes: - pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" - if result.stdout and "Already up to date" not in result.stdout: - pull_message = f"Code updated successfully.{stash_info}" - if upstream_note: - pull_message = f"{pull_message} {upstream_note}" - - # Keep Python dependencies in sync automatically: if the pull - # changed a requirements file, install it now — users updating - # from the web UI (most of them) never SSH in to pip install. - # Installs go through the same root-visible path as the - # Tools-tab buttons (_pip_install_requirements). - dep_notes = [] - try: - _post = subprocess.run(['git', 'rev-parse', 'HEAD'], - capture_output=True, text=True, timeout=10, cwd=project_dir) - new_head = _post.stdout.strip() if _post.returncode == 0 else None - if old_head and new_head and old_head != new_head: - code_changed = True - diff = subprocess.run( - ['git', 'diff', '--name-only', f'{old_head}..{new_head}'], - capture_output=True, text=True, timeout=15, cwd=project_dir) - changed = set(diff.stdout.split()) if diff.returncode == 0 else set() - for rel in ('requirements.txt', 'web_interface/requirements.txt'): - req_path = PROJECT_ROOT / rel - if rel not in changed or not req_path.exists(): - continue - # Each file's install is isolated: a timeout or - # OSError (e.g. the sudo wrapper/interpreter - # missing) on one file must not abort the other. - try: - r = _pip_install_requirements(req_path, timeout=180) - if r.returncode == 0: - dep_notes.append(f"Dependencies from {rel} updated.") - else: - dep_notes.append( - f"Dependency install from {rel} failed — " - "run Install Base Requirements from the Tools tab.") - logger.warning("post-update pip install failed for %s: %s", - rel, _truncate_output(r.stdout, r.stderr)) - except subprocess.TimeoutExpired: - dep_notes.append( - f"Dependency install from {rel} timed out — " - "run Install Base Requirements from the Tools tab.") - logger.warning("post-update pip install timed out for %s", rel) - except OSError as install_err: - dep_notes.append( - f"Dependency install from {rel} failed — " - "run Install Base Requirements from the Tools tab.") - logger.warning("post-update pip install errored for %s: %s", - rel, install_err) - except subprocess.TimeoutExpired: - logger.warning("post-update dependency sync timed out") - if dep_notes: - pull_message += " " + " ".join(dep_notes) - # A `git pull` restores built-in plugins (committed under - # plugin-repos/) even if the user uninstalled them. Re-remove - # any the user previously uninstalled so the update doesn't - # resurrect them. - if api_v3.plugin_store_manager: - try: - purged = api_v3.plugin_store_manager.purge_uninstalled_plugins() - if purged: - logger.info( - "Re-removed %d uninstalled plugin(s) restored by update: %s", - len(purged), ", ".join(purged), - ) - except (OSError, RuntimeError) as purge_err: - logger.warning("Post-update plugin purge failed: %s", purge_err) - else: - logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr) - # Show git's own first line: "check logs" leaves the user with - # nothing to act on, and these failures are usually actionable - # (conflicting local commits, no upstream, network). - detail = next((ln.strip() for ln in (result.stderr or '').splitlines() - if ln.strip()), '') - pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details" - - # Nothing here restarts anything: the pull replaces files on - # disk while the display and web services keep running the code - # they loaded at boot. Without this the user is told the update - # succeeded and sees no change until they happen to reboot. - return jsonify({ - 'status': 'success' if result.returncode == 0 else 'error', - 'message': pull_message, - 'restart_required': bool(result.returncode == 0 and code_changed), - }) - elif action == 'checkout_branch': - # Switch branches from the Tools tab. Needed because a checkout - # that predates tracking (or a restored backup) can leave the pi - # on a branch the update button cannot pull. - result_payload, http_status = checkout_branch( - str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash'))) - return jsonify(result_payload), http_status - - elif action == 'restart_display_service': - result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'], - capture_output=True, text=True, timeout=10) - elif action == 'restart_web_service': - # Try to restart the web service (assuming it's ledmatrix-web.service) - result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'], - capture_output=True, text=True, timeout=10) - elif action == 'install_base_requirements': - # Base + web interface requirements: flask-compress and friends - # live in web_interface/requirements.txt, not the root file. - req_files = [f for f in (PROJECT_ROOT / 'requirements.txt', - PROJECT_ROOT / 'web_interface' / 'requirements.txt') - if f.exists()] - if not req_files: - return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'}) - outputs = [] - all_ok = True - for req_file in req_files: - label = req_file.relative_to(PROJECT_ROOT) - # Isolate each file's install: a timeout or OSError on one - # (e.g. requirements.txt) must not abort the rest of the - # loop (e.g. web_interface/requirements.txt never attempted). - try: - result = _pip_install_requirements(req_file, timeout=120) - all_ok = all_ok and result.returncode == 0 - outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr)) - except subprocess.TimeoutExpired: - all_ok = False - outputs.append(f"== {label} ==\nTimed out after 120s") - logger.warning("install_base_requirements timed out for %s", label) - except OSError as install_err: - all_ok = False - outputs.append(f"== {label} ==\nFailed: {install_err}") - logger.warning("install_base_requirements errored for %s: %s", label, install_err) - return jsonify({ - 'status': 'success' if all_ok else 'error', - 'message': 'Base requirements installed successfully' if all_ok else 'pip install failed', - 'output': "\n".join(outputs) - }) - elif action == 'install_plugin_requirements': - active_pm = getattr(api_v3, 'plugin_manager', None) - if active_pm: - plugins_dir = Path(active_pm.plugins_dir) - else: - _cm = getattr(api_v3, 'config_manager', None) - _cfg = _cm.load_config() if _cm else {} - _dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') - plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name - results = [] - if plugins_dir.exists(): - for p in sorted(plugins_dir.iterdir()): - req = p / 'requirements.txt' - if p.is_dir() and req.exists(): - try: - r = _pip_install_requirements(req, timeout=60) - results.append({ - 'plugin': p.name, - 'ok': r.returncode == 0, - 'output': _truncate_output(r.stdout, r.stderr) - }) - except subprocess.TimeoutExpired: - results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'}) - except OSError as exc: - results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'}) - ok_count = sum(1 for r in results if r['ok']) - all_ok = all(r['ok'] for r in results) if results else True - return jsonify({ - 'status': 'success' if all_ok else 'error', - 'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found', - 'details': results - }) - elif action == 'force_git_reset': - if not _GIT: - return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 - project_dir = str(PROJECT_ROOT) - fetch = subprocess.run( - [_GIT, 'fetch', 'origin'], - capture_output=True, text=True, timeout=30, cwd=project_dir - ) - if fetch.returncode != 0: - return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()}) - reset = subprocess.run( - [_GIT, 'reset', '--hard', 'origin/main'], - capture_output=True, text=True, timeout=30, cwd=project_dir - ) - return jsonify({ - 'status': 'success' if reset.returncode == 0 else 'error', - 'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed', - 'output': (reset.stdout + reset.stderr).strip() - }) - elif action == 'clear_pycache': - cleared = 0 - failed = 0 - for d in PROJECT_ROOT.rglob('__pycache__'): - if d.is_dir(): - try: - shutil.rmtree(d) - cleared += 1 - except OSError: - failed += 1 - msg = f'Cleared {cleared} __pycache__ directories' - if failed: - msg += f' ({failed} could not be removed)' - return jsonify({'status': 'success', 'message': msg}) - else: - return jsonify({'status': 'error', 'message': 'Unknown action'}), 400 - - logger.info("system action '%s' returncode=%d", action, result.returncode) - if result.returncode != 0 and result.stderr: - logger.error("system action '%s' stderr: %s", action, result.stderr.strip()) - resp = { - 'status': 'success' if result.returncode == 0 else 'error', - 'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details', - } - if result.returncode != 0: - resp['returncode'] = result.returncode - resp['stderr'] = result.stderr.strip() - return jsonify(resp) - - except subprocess.TimeoutExpired as e: - logger.error("system action '%s' timed out: %s", action, e) - return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'}) - except Exception as e: - logger.error("execute_system_action failed: %s", e, exc_info=True) - return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500 - -@api_v3.route('/system/git-info', methods=['GET']) -def get_git_info(): - """Return branch, dirty state, recent commits and remote URL for the Tools tab.""" - if not _GIT: - return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 - d = str(PROJECT_ROOT) - try: - branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d) - if branch.returncode != 0: - return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500 - - status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d) - if status.returncode != 0: - return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500 - - log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d) - remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d) - branch_name = branch.stdout.strip() - upstream = _git_upstream(d) - return jsonify({ - 'branch': branch_name, - 'dirty': bool(status.stdout.strip()), - 'status': status.stdout.strip(), - 'recent_commits': log.stdout.strip() if log.returncode == 0 else '', - 'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '', - # Surfaced so the Tools tab can warn before the user clicks Pull - # Latest, rather than after it fails. - 'upstream': upstream, - 'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name), - }) - except Exception as e: - logger.error("get_git_info failed: %s", e, exc_info=True) - return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500 - - -@api_v3.route('/system/git-branches', methods=['GET']) -def get_git_branches(): - """List branches available to switch to, for the Tools tab picker.""" - if not _GIT: - return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 - d = str(PROJECT_ROOT) - try: - # Refresh remote refs so a branch created since the last fetch shows up. - subprocess.run([_GIT, 'fetch', 'origin', '--prune'], - capture_output=True, text=True, timeout=60, cwd=d) - - local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'], - capture_output=True, text=True, timeout=15, cwd=d) - remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'], - capture_output=True, text=True, timeout=15, cwd=d) - if local.returncode != 0: - return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500 - - local_names = [b for b in local.stdout.split() if b] - remote_names = [] - for ref in remote.stdout.split() if remote.returncode == 0 else []: - name = ref.split('origin/', 1)[-1] - # origin/HEAD is a symbolic alias, not a branch a user can pick. - if name and name != 'HEAD' and name not in local_names: - remote_names.append(name) - - return jsonify({ - 'status': 'success', - 'current': _git_current_branch(d), - 'upstream': _git_upstream(d), - 'local': sorted(local_names), - 'remote_only': sorted(remote_names), - }) - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504 - except OSError as e: - logger.error("get_git_branches failed: %s", e, exc_info=True) - return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500 - - -@api_v3.route('/hardware/status', methods=['GET']) -def get_hardware_status(): - """Return LED matrix hardware initialization status written by display_manager at startup.""" - status_path = "/tmp/led_matrix_hw_status.json" # nosec B108 - try: - with open(status_path) as f: - hw_data = json.load(f) - return jsonify({"status": "success", "data": hw_data}) - except FileNotFoundError: - return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}}) - except PermissionError: - logger.warning("Permission denied reading hardware status file; display service may be running as a different user") - return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}}) - except json.JSONDecodeError: - logger.error("Failed to parse hardware status file", exc_info=True) - return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}}) - except Exception: - logger.error("Unexpected error reading hardware status", exc_info=True) - return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 - -@api_v3.route('/display/current', methods=['GET']) -def get_display_current(): - """Get current display state""" - try: - import base64 - from PIL import Image - import io - - snapshot_path = "/tmp/led_matrix_preview.png" - - # Get display dimensions from config - try: - if api_v3.config_manager: - main_config = api_v3.config_manager.load_config() - hardware_config = main_config.get('display', {}).get('hardware', {}) - cols = hardware_config.get('cols', 64) - chain_length = hardware_config.get('chain_length', 2) - rows = hardware_config.get('rows', 32) - parallel = hardware_config.get('parallel', 1) - width = cols * chain_length - height = rows * parallel - else: - width = 128 - height = 64 - except Exception: - width = 128 - height = 64 - - # Try to read snapshot file - image_data = None - if os.path.exists(snapshot_path): - try: - with Image.open(snapshot_path) as img: - # Convert to PNG and encode as base64 - buffer = io.BytesIO() - img.save(buffer, format='PNG') - image_data = base64.b64encode(buffer.getvalue()).decode('utf-8') - except Exception as img_err: - # File might be being written or corrupted, return None - pass - - display_data = { - 'timestamp': time.time(), - 'width': width, - 'height': height, - 'image': image_data # Base64 encoded image data or None if unavailable - } - return jsonify({'status': 'success', 'data': display_data}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/display/modes', methods=['GET']) -def get_display_modes(): - """Every display mode that can be requested on-demand, with its plugin. - - /plugins/installed carries no mode information, so anything driving the - display from outside the web UI (the Home Assistant MQTT bridge, a script) - had to read each plugin's manifest.json off disk and reimplement the - fallbacks in PluginManager.get_plugin_display_modes to do it. This is the - same list the force-display dialog offers, from the source that owns it. - - Knowing each mode's plugin_id also matters because /display/on-demand/start - falls back to find_plugin_for_mode when plugin_id is omitted, and that - lookup only sees modes declared in a static manifest -- a plugin whose - modes are generated (each installed Starlark app is one) 404s there. - Sending the plugin_id from this list skips the lookup entirely. - - Query params: - include_disabled: '1' to list modes of disabled plugins too. They can - still be requested on-demand -- the controller enables the plugin - for the duration -- so they are reported with enabled: false - rather than omitted. - """ - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Discovery is lazy and normally triggered by whichever endpoint runs - # first, which is a person opening the dashboard. A caller that never - # visits it would otherwise see an empty list. - api_v3.plugin_manager.discover_plugins() - - include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True') - full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - - modes = [] - for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()): - # A hand-edited or migrated config.json can hold a non-dict under a - # plugin id; DisplayController._reconcile guards the same shape, so - # it happens in practice. Without this, .get() raises AttributeError, - # the loop aborts and the endpoint answers 500 with no modes at all - # -- one bad section would blank every entity the MQTT bridge builds - # from this list. - plugin_config = full_config.get(plugin_id) - if not isinstance(plugin_config, dict): - if plugin_config is not None: - logger.warning( - "Config for plugin %r is %s, not an object; treating it as disabled", - plugin_id, type(plugin_config).__name__) - plugin_config = {} - enabled = bool(plugin_config.get('enabled', False)) - if not enabled and not include_disabled: - continue - plugin_name = (manifest or {}).get('name') or plugin_id - plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id] - for mode in plugin_modes: - # A single-mode plugin's mode is the plugin, so its own name is - # the readable label. Multi-mode plugins have no per-mode name - # anywhere, so the raw mode string is the only thing to show. - modes.append({ - 'mode': mode, - 'plugin_id': plugin_id, - 'plugin_name': plugin_name, - 'name': plugin_name if len(plugin_modes) == 1 else mode, - 'enabled': enabled, - }) - - return jsonify({'status': 'success', 'data': {'modes': modes}}) - except Exception as exc: - # describe_exception, not a bare message: test_web_error_detail.py - # enforces that every handler here returns it, because a device whose - # storage is failing otherwise answers "see logs for details" from the - # log viewer too. It redacts credentials out of the exception text. - # CodeQL flags this as stack-trace exposure across all ~75 handlers; - # it is the project's deliberate, reviewed trade-off. - logger.error('Error in get_display_modes', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 - -@api_v3.route('/display/on-demand/status', methods=['GET']) -def get_on_demand_status(): - """Return the current on-demand display state.""" - try: - cache = _ensure_cache_manager() - state = cache.get('display_on_demand_state', max_age=120) - if state is None: - state = { - 'active': False, - 'status': 'idle', - 'last_updated': None - } - service_status = _get_display_service_status() - return jsonify({ - 'status': 'success', - 'data': { - 'state': state, - 'service': service_status - } - }) - except Exception as exc: - logger.error('Error in get_on_demand_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 - -@api_v3.route('/display/on-demand/start', methods=['POST']) -def start_on_demand_display(): - """Request the display controller to run a specific plugin on-demand.""" - try: - data = request.get_json(silent=True) or {} - 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) - - if not plugin_id and not mode: - return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400 - - resolved_plugin = plugin_id - resolved_mode = mode - - if api_v3.plugin_manager: - if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests: - return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404 - - if resolved_plugin and not resolved_mode: - modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin) - resolved_mode = modes[0] if modes else resolved_plugin - elif resolved_mode and not resolved_plugin: - resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode) - if not resolved_plugin: - return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 - - # Note: On-demand can work with disabled plugins - the display controller - # will temporarily enable them during initialization if needed - # We don't block the request here, but log it for debugging - if api_v3.config_manager and resolved_plugin: - config = api_v3.config_manager.load_config() - plugin_config = config.get(resolved_plugin, {}) - if 'enabled' in plugin_config and not plugin_config.get('enabled', False): - logger.info( - "On-demand request for disabled plugin '%s' - will be temporarily enabled", - resolved_plugin, - ) - - # Set the on-demand request in cache FIRST (before starting service) - # This ensures the request is available when the service starts/restarts - cache = _ensure_cache_manager() - request_id = data.get('request_id') or str(uuid.uuid4()) - request_payload = { - 'request_id': request_id, - 'action': 'start', - 'plugin_id': resolved_plugin, - 'mode': resolved_mode, - 'duration': duration, - 'pinned': pinned, - 'timestamp': time.time() - } - cache.set('display_on_demand_request', request_payload) - - # Check if display service is running (or will be started) - service_status = _get_display_service_status() - service_was_running = service_status.get('active', False) - - # Stop the display service first to ensure clean state when we will restart it - if service_was_running and start_service: - import time as time_module - logger.debug("Stopping display service before starting on-demand mode") - _stop_display_service() - # Wait a brief moment for the service to fully stop - time_module.sleep(1.5) - logger.debug("Display service stopped, now starting with on-demand request") - - if not service_status.get('active') and not start_service: - return jsonify({ - 'status': 'error', - 'message': 'Display service is not running. Please start the display service or enable "Start Service" option.', - 'service_status': service_status - }), 400 - - service_result = None - if start_service: - service_result = _ensure_display_service_running() - # Check if service actually started - if service_result and not service_result.get('active'): - return jsonify({ - 'status': 'error', - 'message': 'Failed to start display service. Please check service logs or start it manually.', - 'service_result': service_result - }), 500 - - # Service was restarted (or started fresh) with on-demand request in cache - # The display controller will read the request during initialization or when it polls - - response_data = { - 'request_id': request_id, - 'plugin_id': resolved_plugin, - 'mode': resolved_mode, - 'duration': duration, - 'pinned': pinned, - 'service': service_result - } - return jsonify({'status': 'success', 'data': response_data}) - except Exception as exc: - logger.error('Error in start_on_demand_display', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 - -@api_v3.route('/display/on-demand/stop', methods=['POST']) -def stop_on_demand_display(): - """Request the display controller to stop on-demand mode.""" - try: - data = request.get_json(silent=True) or {} - stop_service = data.get('stop_service', False) - - # Set the stop request in cache FIRST - # The display controller will poll this and restart without the on-demand filter - cache = _ensure_cache_manager() - request_id = data.get('request_id') or str(uuid.uuid4()) - request_payload = { - 'request_id': request_id, - 'action': 'stop', - 'timestamp': time.time() - } - cache.set('display_on_demand_request', request_payload) - - # Note: The display controller's _clear_on_demand() will handle the restart - # to restore normal operation with all plugins - - service_result = None - if stop_service: - service_result = _stop_display_service() - - return jsonify({ - 'status': 'success', - 'data': { - 'request_id': request_id, - 'service': service_result - } - }) - except Exception as exc: - logger.error('Error in stop_on_demand_display', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 - -@api_v3.route('/plugins/installed', methods=['GET']) -def get_installed_plugins(): - """Get installed plugins""" - try: - if not api_v3.plugin_manager or not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500 - - import json - from pathlib import Path - - # Re-discover plugins to ensure we have the latest list - # This handles cases where plugins are added/removed after app startup - api_v3.plugin_manager.discover_plugins() - - # Get all installed plugin info from the plugin manager - all_plugin_info = api_v3.plugin_manager.get_all_plugin_info() - - # Load config once before the loop (not per-plugin) - full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - - def _build_plugin_entry(plugin_info): - plugin_id = plugin_info.get('id') - try: - return _build_plugin_entry_inner(plugin_info, plugin_id) - except Exception: - logger.exception("Error building plugin entry for %s — skipping", plugin_id) - return None - - def _build_plugin_entry_inner(plugin_info, plugin_id): - # Capture runtime state (state machine + error context) before the - # manifest merge below can shadow the 'state' key. get_all_plugin_info - # attaches this via PluginStateManager.get_state_info(); surfacing it - # lets the UI show *why* a plugin isn't running instead of just - # 'loaded: false'. - state_info = plugin_info.get('state') - plugin_state = None - plugin_error_info = None - if isinstance(state_info, dict): - 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(): - try: - with open(manifest_path, 'r', encoding='utf-8') as f: - fresh_manifest = json.load(f) - if isinstance(fresh_manifest, dict): - plugin_info.update(fresh_manifest) - else: - logger.debug("Manifest for %s is not a dict (%s) — skipping merge", - plugin_id, type(fresh_manifest).__name__) - except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e: - logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e) - - # Enabled status: config is source of truth, fall back to instance - enabled = None - plugin_config = full_config.get(plugin_id, {}) - if 'enabled' in plugin_config: - enabled = bool(plugin_config['enabled']) - - # Single get_plugin() call shared for both enabled fallback and Vegas mode - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if enabled is None: - enabled = plugin_instance.enabled if plugin_instance else True - - # Verified + latest published version from registry (no network call) - store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id) - verified = store_info.get('verified', False) if store_info else False - latest_version = store_info.get('latest_version', '') if store_info else '' - installed_version = plugin_info.get('version', '') - 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 - - if local_git_info: - sha = local_git_info.get('sha', '') - last_commit = local_git_info.get('short_sha') or (sha[:7] if sha else None) - branch = local_git_info.get('branch') - last_updated = local_git_info.get('date_iso') or local_git_info.get('date') - else: - 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') - - last_commit_message = plugin_info.get('last_commit_message') - if store_info and not last_commit_message: - last_commit_message = store_info.get('last_commit_message') - - # Vegas mode from instance, overridden by explicit config value - vegas_mode = None - vegas_content_type = None - if plugin_instance: - try: - if hasattr(plugin_instance, 'get_vegas_display_mode'): - mode = plugin_instance.get_vegas_display_mode() - vegas_mode = mode.value if hasattr(mode, 'value') else str(mode) - except (AttributeError, TypeError, ValueError) as e: - logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e) - try: - if hasattr(plugin_instance, 'get_vegas_content_type'): - vegas_content_type = plugin_instance.get_vegas_content_type() - except (AttributeError, TypeError, ValueError) as e: - logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e) - - if 'vegas_mode' in plugin_config: - vegas_mode = plugin_config['vegas_mode'] - - return { - 'id': plugin_id, - 'name': plugin_info.get('name', plugin_id), - 'version': plugin_info.get('version', ''), - 'latest_version': latest_version, - 'update_available': update_available, - '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), - 'state': plugin_state, - 'error_info': plugin_error_info, - 'last_updated': last_updated, - 'last_commit': last_commit, - 'last_commit_message': last_commit_message, - 'branch': branch, - 'web_ui_actions': plugin_info.get('web_ui_actions', []), - 'vegas_mode': vegas_mode, - 'vegas_content_type': vegas_content_type, - } - - from concurrent.futures import ThreadPoolExecutor - with ThreadPoolExecutor(max_workers=8) as executor: - results = list(executor.map(_build_plugin_entry, all_plugin_info)) - plugins = [r for r in results if r is not None] - plugins.extend(_starlark_virtual_plugins()) - - return jsonify({'status': 'success', 'data': {'plugins': plugins}}) - except Exception as e: - logger.error('Error in get_installed_plugins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -def _installed_plugin_ids(): - """Best-effort list of installed plugin IDs for the web process. - - Health/metrics state is written by the separate display service to the - shared on-disk cache, so the tracker's in-memory set is empty here. We - enumerate the installed plugins and read each one's persisted summary by ID - instead of relying on the tracker's in-memory `get_all_*` view. - """ - pm = api_v3.plugin_manager - manifests = getattr(pm, 'plugin_manifests', None) - if not manifests: - # Only pay for a discovery scan when we haven't discovered anything yet; - # subsequent polls reuse the already-populated manifest map. - try: - pm.discover_plugins() - except Exception: - logger.debug('discover_plugins failed while listing plugin ids', exc_info=True) - manifests = getattr(pm, 'plugin_manifests', None) - try: - return list(manifests.keys()) if manifests else [] - except Exception: - logger.debug('listing plugin_manifests failed while building plugin ids', exc_info=True) - return [] - - -@api_v3.route('/plugins/health', methods=['GET']) -def get_plugin_health(): - """Get health metrics for all plugins""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if health tracker is available - if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: - return jsonify({ - 'status': 'success', - 'data': {}, - 'message': 'Health tracking not available' - }) - - tracker = api_v3.plugin_manager.health_tracker - # Build per-plugin summaries by ID so persisted (cross-process) health - # is included, then fold in any in-memory-only entries. - health_summaries = {} - for pid in _installed_plugin_ids(): - try: - # force_reload: this process only reads; bypass the in-memory - # snapshot so each poll reflects the display service's latest - # persisted state. - health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True) - except Exception: - logger.debug('Could not read health summary for %s', pid, exc_info=True) - try: - for pid, summary in tracker.get_all_health_summaries().items(): - health_summaries.setdefault(pid, summary) - except Exception: - logger.debug('get_all_health_summaries failed', exc_info=True) - - return jsonify({ - 'status': 'success', - 'data': health_summaries - }) - except Exception as e: - logger.error('Error in get_plugin_health', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/health/', methods=['GET']) -def get_plugin_health_single(plugin_id): - """Get health metrics for a specific plugin""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if health tracker is available - if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: - return jsonify({ - 'status': 'error', - 'message': 'Health tracking not available' - }), 503 - - # Get health summary for specific plugin - health_summary = api_v3.plugin_manager.health_tracker.get_health_summary(plugin_id) - - return jsonify({ - 'status': 'success', - 'data': health_summary - }) - except Exception as e: - logger.error('Error in get_plugin_health_single', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/health//reset', methods=['POST']) -def reset_plugin_health(plugin_id): - """Reset health state for a plugin (manual recovery)""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if health tracker is available - if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: - return jsonify({ - 'status': 'error', - 'message': 'Health tracking not available' - }), 503 - - # Reset health state - api_v3.plugin_manager.health_tracker.reset_health(plugin_id) - - return jsonify({ - 'status': 'success', - 'message': f'Health state reset for plugin {plugin_id}' - }) - except Exception as e: - logger.error('Error in reset_plugin_health', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/metrics', methods=['GET']) -def get_plugin_metrics(): - """Get resource metrics for all plugins""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if resource monitor is available - if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: - return jsonify({ - 'status': 'success', - 'data': {}, - 'message': 'Resource monitoring not available' - }) - - monitor = api_v3.plugin_manager.resource_monitor - # Build per-plugin summaries by ID so persisted (cross-process) metrics - # are included, then fold in any in-memory-only entries. - metrics_summaries = {} - for pid in _installed_plugin_ids(): - try: - # force_reload: read-only path — bypass the in-memory snapshot so - # each poll reflects the display service's latest persisted metrics. - metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True) - except Exception: - logger.debug('Could not read metrics summary for %s', pid, exc_info=True) - try: - for pid, summary in monitor.get_all_metrics_summaries().items(): - metrics_summaries.setdefault(pid, summary) - except Exception: - logger.debug('get_all_metrics_summaries failed', exc_info=True) - - return jsonify({ - 'status': 'success', - 'data': metrics_summaries - }) - except Exception as e: - logger.error('Error in get_plugin_metrics', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/metrics/', methods=['GET']) -def get_plugin_metrics_single(plugin_id): - """Get resource metrics for a specific plugin""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if resource monitor is available - if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: - return jsonify({ - 'status': 'error', - 'message': 'Resource monitoring not available' - }), 503 - - # Get metrics summary for specific plugin - metrics_summary = api_v3.plugin_manager.resource_monitor.get_metrics_summary(plugin_id) - - return jsonify({ - 'status': 'success', - 'data': metrics_summary - }) - except Exception as e: - logger.error('Error in get_plugin_metrics_single', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/metrics//reset', methods=['POST']) -def reset_plugin_metrics(plugin_id): - """Reset metrics for a plugin""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if resource monitor is available - if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: - return jsonify({ - 'status': 'error', - 'message': 'Resource monitoring not available' - }), 503 - - # Reset metrics - api_v3.plugin_manager.resource_monitor.reset_metrics(plugin_id) - - return jsonify({ - 'status': 'success', - 'message': f'Metrics reset for plugin {plugin_id}' - }) - except Exception as e: - logger.error('Error in reset_plugin_metrics', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/limits/', methods=['GET', 'POST']) -def manage_plugin_limits(plugin_id): - """Get or set resource limits for a plugin""" - try: - if not api_v3.plugin_manager: - return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 - - # Check if resource monitor is available - if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: - return jsonify({ - 'status': 'error', - 'message': 'Resource monitoring not available' - }), 503 - - if request.method == 'GET': - # Get limits - limits = api_v3.plugin_manager.resource_monitor.get_limits(plugin_id) - if limits: - return jsonify({ - 'status': 'success', - 'data': { - 'max_memory_mb': limits.max_memory_mb, - 'max_cpu_percent': limits.max_cpu_percent, - 'max_execution_time': limits.max_execution_time, - 'warning_threshold': limits.warning_threshold - } - }) - else: - return jsonify({ - 'status': 'success', - 'data': None, - 'message': 'No limits configured for this plugin' - }) - else: - # POST - Set limits - data = request.get_json(silent=True) or {} - from src.plugin_system.resource_monitor import ResourceLimits - - limits = ResourceLimits( - max_memory_mb=data.get('max_memory_mb'), - max_cpu_percent=data.get('max_cpu_percent'), - max_execution_time=data.get('max_execution_time'), - warning_threshold=data.get('warning_threshold', 0.8) - ) - - api_v3.plugin_manager.resource_monitor.set_limits(plugin_id, limits) - - return jsonify({ - 'status': 'success', - 'message': f'Resource limits updated for plugin {plugin_id}' - }) - except Exception as e: - logger.error('Error in manage_plugin_limits', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/toggle', methods=['POST']) -def toggle_plugin(): - """Toggle plugin enabled/disabled""" - try: - if not api_v3.plugin_manager or not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500 - - # Support both JSON and form data (for HTMX submissions) - content_type = request.content_type or '' - - if 'application/json' in content_type: - data = request.get_json(silent=True) - 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'] - else: - # Form data or query string (HTMX submission) - plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') - if not plugin_id: - return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 - - # For checkbox toggle, if form was submitted, the checkbox was checked (enabled) - # If using HTMX with hx-trigger="change", we need to check if checkbox is checked - # The checkbox value or 'enabled' form field indicates the state - enabled_str = request.form.get('enabled', request.args.get('enabled', '')) - - # Handle various truthy/falsy values - if enabled_str.lower() in ('true', '1', 'on', 'yes'): - enabled = True - elif enabled_str.lower() in ('false', '0', 'off', 'no', ''): - # Empty string means checkbox was unchecked (toggle off) - enabled = False - else: - # Default: toggle based on current state - config = api_v3.config_manager.load_config() - current_enabled = config.get(plugin_id, {}).get('enabled', False) - enabled = not current_enabled - - # A Starlark app is not a plugin in plugin_manager's sense -- it is an - # entry in starlark-apps' own manifest -- so its enable/disable is - # handled here rather than falling through to the check below, which - # would answer "Plugin not found". - if plugin_id.startswith('starlark:'): - return _toggle_starlark_app(plugin_id[len('starlark:'):], enabled) - - # Check if plugin exists in manifests (discovered but may not be loaded) - if plugin_id not in api_v3.plugin_manager.plugin_manifests: - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Update config (this is what the display controller reads) - config = api_v3.config_manager.load_config() - if plugin_id not in config: - config[plugin_id] = {} - config[plugin_id]['enabled'] = enabled - - # Use atomic save if available - if hasattr(api_v3.config_manager, 'save_config_atomic'): - result = api_v3.config_manager.save_config_atomic(config, create_backup=True) - if result.status.value != 'success': - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save configuration: {result.message}", - status_code=500 - ) - else: - api_v3.config_manager.save_config(config) - - # Update state manager if available - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled) - - # Log operation - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "enable" if enabled else "disable", - plugin_id=plugin_id, - status="success" - ) - - # If plugin is loaded, also call its lifecycle methods - # Wrap in try/except to prevent lifecycle errors from failing the toggle - plugin = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin: - try: - if enabled: - if hasattr(plugin, 'on_enable'): - plugin.on_enable() - else: - if hasattr(plugin, 'on_disable'): - plugin.on_disable() - except Exception as lifecycle_error: - # Log the error but don't fail the toggle - config is already saved - import logging - logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True) - - return success_response( - message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully" - ) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT) - if api_v3.operation_history: - toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable" - api_v3.operation_history.record_operation( - toggle_type, - plugin_id=data.get('plugin_id') if 'data' in locals() else None, - status="failed", - error=str(e) - ) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/operation/', methods=['GET']) -def get_operation_status(operation_id): - """Get status of a plugin operation""" - try: - if not api_v3.operation_queue: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Operation queue not initialized', - status_code=500 - ) - - operation = api_v3.operation_queue.get_operation_status(operation_id) - if not operation: - return error_response( - ErrorCode.PLUGIN_NOT_FOUND, - f'Operation {operation_id} not found', - status_code=404 - ) - - return success_response(data=operation.to_dict()) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) - return error_response( - error.error_code, - error.message, - details=error.details, - status_code=500 - ) - -@api_v3.route('/plugins/operation/history', methods=['GET']) -def get_operation_history() -> Response: - """Get operation history from the audit log.""" - if not api_v3.operation_history: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Operation history not initialized', - status_code=500 - ) - - try: - limit = request.args.get('limit', 50, type=int) - plugin_id = request.args.get('plugin_id') - operation_type = request.args.get('operation_type') - except (ValueError, TypeError) as e: - return error_response(ErrorCode.INVALID_INPUT, f'Invalid query parameter: {e}', status_code=400) - - try: - history = api_v3.operation_history.get_history( - limit=limit, - plugin_id=plugin_id, - operation_type=operation_type - ) - except (AttributeError, RuntimeError) as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) - return error_response(error.error_code, error.message, details=error.details, status_code=500) - - return success_response(data=[record.to_dict() for record in history]) - -@api_v3.route('/plugins/operation/history', methods=['DELETE']) -def clear_operation_history() -> Response: - """Clear operation history.""" - if not api_v3.operation_history: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Operation history not initialized', - status_code=500 - ) - - try: - api_v3.operation_history.clear_history() - except (OSError, RuntimeError) as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) - return error_response(error.error_code, error.message, details=error.details, status_code=500) - - return success_response(message='Operation history cleared') - -@api_v3.route('/plugins/state', methods=['GET']) -def get_plugin_state(): - """Get plugin state from state manager""" - try: - if not api_v3.plugin_state_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'State manager not initialized', - status_code=500 - ) - - plugin_id = request.args.get('plugin_id') - - if plugin_id: - # Get state for specific plugin - state = api_v3.plugin_state_manager.get_plugin_state(plugin_id) - if not state: - return error_response( - ErrorCode.PLUGIN_NOT_FOUND, - f'Plugin {plugin_id} not found in state manager', - context={'plugin_id': plugin_id}, - status_code=404 - ) - return success_response(data=state.to_dict()) - else: - # Get all plugin states - all_states = api_v3.plugin_state_manager.get_all_states() - return success_response(data={ - plugin_id: state.to_dict() - for plugin_id, state in all_states.items() - }) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/state/reconcile', methods=['POST']) -def reconcile_plugin_state(): - """Reconcile plugin state across all sources""" - try: - if not api_v3.plugin_state_manager or not api_v3.plugin_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'State manager or plugin manager not initialized', - status_code=500 - ) - - from src.plugin_system.state_reconciliation import StateReconciliation - - # Parse optional `force` flag from request body, guarding against - # non-dict bodies (bare string, array, null) that would raise AttributeError. - payload = request.get_json(silent=True) - if not isinstance(payload, dict): - payload = {} - force = _coerce_to_bool(payload.get('force', False)) - - reconciler = StateReconciliation( - state_manager=api_v3.plugin_state_manager, - config_manager=api_v3.config_manager, - plugin_manager=api_v3.plugin_manager, - plugins_dir=Path(api_v3.plugin_manager.plugins_dir) - ) - - result = reconciler.reconcile_state(force=force) - - return success_response( - data={ - 'inconsistencies_found': len(result.inconsistencies_found), - 'inconsistencies_fixed': len(result.inconsistencies_fixed), - 'inconsistencies_manual': len(result.inconsistencies_manual), - 'inconsistencies': [ - { - 'plugin_id': inc.plugin_id, - 'type': inc.inconsistency_type.value, - 'description': inc.description, - 'fix_action': inc.fix_action.value - } - for inc in result.inconsistencies_found - ], - 'fixed': [ - { - 'plugin_id': inc.plugin_id, - 'type': inc.inconsistency_type.value, - 'description': inc.description - } - for inc in result.inconsistencies_fixed - ], - 'manual_fix_required': [ - { - 'plugin_id': inc.plugin_id, - 'type': inc.inconsistency_type.value, - 'description': inc.description - } - for inc in result.inconsistencies_manual - ] - }, - message=result.message - ) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/reconciliation-status', methods=['GET']) -def get_reconciliation_status(): - """Return the result of the last startup reconciliation from /tmp status file.""" - _recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json") - try: - st = os.lstat(_recon_path) - except FileNotFoundError: - return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) - if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode): - logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path) - return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) - try: - with open(_recon_path) as _f: - data = json.load(_f) - return jsonify({'status': 'success', 'data': data}) - except json.JSONDecodeError: - logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path) - return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) - except PermissionError: - logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path) - return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) - -@api_v3.route('/plugins/config', methods=['GET']) -def get_plugin_config(): - """Get plugin configuration""" - try: - if not api_v3.config_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Config manager not initialized', - status_code=500 - ) - - plugin_id = request.args.get('plugin_id') - if not plugin_id: - return error_response( - ErrorCode.INVALID_INPUT, - 'plugin_id required', - context={'missing_params': ['plugin_id']}, - status_code=400 - ) - - # Get plugin configuration from config manager - main_config = api_v3.config_manager.load_config() - plugin_config = main_config.get(plugin_id, {}) - - # Merge with defaults from schema so form shows default values for missing fields - schema_mgr = api_v3.schema_manager - if schema_mgr: - try: - defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) - plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) - except Exception as e: - # Log but don't fail - defaults merge is best effort - import logging - logging.warning(f"Could not merge defaults for {plugin_id}: {e}") - - # Special handling for of-the-day plugin: populate uploaded_files and categories from disk - if plugin_id == 'of-the-day' or plugin_id == 'ledmatrix-of-the-day': - # Get plugin directory - plugin_id in manifest is 'of-the-day', but directory is 'ledmatrix-of-the-day' - plugin_dir_name = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_dir_name) - # If not found, try with the plugin_id - if not plugin_dir or not Path(plugin_dir).exists(): - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_dir_name - if not plugin_dir.exists(): - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if plugin_dir and Path(plugin_dir).exists(): - data_dir = Path(plugin_dir) / 'of_the_day' - if data_dir.exists(): - # Scan for JSON files - uploaded_files = [] - categories_from_files = {} - - for json_file in data_dir.glob('*.json'): - try: - # Get file stats - stat = json_file.stat() - - # Read JSON to count entries - with open(json_file, 'r', encoding='utf-8') as f: - json_data = json.load(f) - entry_count = len(json_data) if isinstance(json_data, dict) else 0 - - # Extract category name from filename - category_name = json_file.stem - filename = json_file.name - - # Create file entry - file_entry = { - 'id': category_name, - 'category_name': category_name, - 'filename': filename, - 'original_filename': filename, - 'path': f'of_the_day/{filename}', - 'size': stat.st_size, - 'uploaded_at': datetime.fromtimestamp(stat.st_mtime).isoformat() + 'Z', - 'entry_count': entry_count - } - uploaded_files.append(file_entry) - - # Create/update category entry if not in config - if category_name not in plugin_config.get('categories', {}): - display_name = category_name.replace('_', ' ').title() - categories_from_files[category_name] = { - 'enabled': False, # Default to disabled, user can enable - 'data_file': f'of_the_day/{filename}', - 'display_name': display_name - } - else: - # Update with file info if needed - categories_from_files[category_name] = plugin_config['categories'][category_name] - # Ensure data_file is correct - categories_from_files[category_name]['data_file'] = f'of_the_day/{filename}' - - except Exception as e: - logger.debug("Could not read json file: %s", e) - continue - - # Update plugin_config with scanned files - if uploaded_files: - plugin_config['uploaded_files'] = uploaded_files - - # Merge categories from files with existing config - # Start with existing categories (preserve user settings like enabled/disabled) - existing_categories = plugin_config.get('categories', {}).copy() - - # Update existing categories with file info, add new ones from files - for cat_name, cat_data in categories_from_files.items(): - if cat_name in existing_categories: - # Preserve existing enabled state and display_name, but update data_file path - existing_categories[cat_name]['data_file'] = cat_data['data_file'] - if 'display_name' not in existing_categories[cat_name] or not existing_categories[cat_name]['display_name']: - existing_categories[cat_name]['display_name'] = cat_data['display_name'] - else: - # Add new category from file (default to disabled) - existing_categories[cat_name] = cat_data - - if existing_categories: - plugin_config['categories'] = existing_categories - - # Update category_order to include all categories - category_order = plugin_config.get('category_order', []).copy() - all_category_names = set(existing_categories.keys()) - for cat_name in all_category_names: - if cat_name not in category_order: - category_order.append(cat_name) - if category_order: - plugin_config['category_order'] = category_order - - # If no config exists, return defaults - if not plugin_config: - plugin_config = { - 'enabled': True, - 'display_duration': 30 - } - - return success_response(data=plugin_config) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_LOAD_FAILED) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/update', methods=['POST']) -def update_plugin(): - """Update plugin""" - try: - # Support both JSON and form data - content_type = request.content_type or '' - - if 'application/json' in content_type: - # JSON request - data, error = validate_request_json(['plugin_id']) - if error: - logger.debug("[UPDATE] JSON validation failed. Content-Type: %s", content_type) - return error - else: - # Form data or query string - plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') - if not plugin_id: - logger.debug("[UPDATE] Missing plugin_id. Content-Type: %s", content_type) - return error_response( - ErrorCode.INVALID_INPUT, - 'plugin_id required', - status_code=400 - ) - data = {'plugin_id': plugin_id} - - if not api_v3.plugin_store_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Plugin store manager not initialized', - status_code=500 - ) - - plugin_id = data['plugin_id'] - - # Always do direct updates (they're fast git pull operations) - # Operation queue is reserved for longer operations like install/uninstall - plugin_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id - manifest_path = plugin_dir / "manifest.json" - - current_last_updated = None - current_commit = None - current_branch = None - - if manifest_path.exists(): - try: - import json - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - current_last_updated = manifest.get('last_updated') - if manifest.get('local_only'): - logger.debug("Skipping update for local-only plugin: %s", plugin_id) - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "update", - plugin_id=plugin_id, - status="skipped", - details={"reason": "local_only"} - ) - return success_response(message=f'Plugin {plugin_id} is managed locally and does not receive registry updates') - 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_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id - 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") - - 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 - remote_branch = remote_info.get('branch') if remote_info else None - - # Update the plugin - success = api_v3.plugin_store_manager.update_plugin(plugin_id) - - if success: - updated_last_updated = current_last_updated - try: - if manifest_path.exists(): - import json - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - updated_last_updated = manifest.get('last_updated', current_last_updated) - except Exception as e: - logger.debug("Could not read updated manifest after update: %s", e) - - 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 - - message = f'Plugin {plugin_id} updated successfully' - if current_commit and updated_commit and current_commit == updated_commit: - message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})' - elif updated_commit: - message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}' - if updated_branch: - message += f' on branch {updated_branch}' - elif updated_last_updated and updated_last_updated != current_last_updated: - message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})' - - remote_commit_short = remote_commit[:7] if remote_commit else None - if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]: - message += f' (remote latest {remote_commit_short})' - - # Invalidate schema cache - if api_v3.schema_manager: - api_v3.schema_manager.invalidate_cache(plugin_id) - - # Rediscover plugins - if api_v3.plugin_manager: - api_v3.plugin_manager.discover_plugins() - if plugin_id in api_v3.plugin_manager.plugins: - api_v3.plugin_manager.reload_plugin(plugin_id) - - # Update state and history - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.update_plugin_state( - plugin_id, - {'last_updated': datetime.now()} - ) - if api_v3.operation_history: - version = _get_plugin_version(plugin_id) - api_v3.operation_history.record_operation( - "update", - plugin_id=plugin_id, - status="success", - details={ - "version": version, - "previous_commit": current_commit[:7] if current_commit else None, - "commit": updated_commit[:7] if updated_commit else None, - "branch": updated_branch - } - ) - - return success_response( - data={ - 'last_updated': updated_last_updated, - 'commit': updated_commit - }, - message=message - ) - else: - plugin_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id - if not plugin_path_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) - if not git_info: - plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) - if not plugin_info: - client_msg = 'Plugin update failed: not found in registry' - else: - client_msg = 'Plugin update failed; check logs for details' - else: - client_msg = 'Plugin update failed; check logs for details' - logger.error("update_plugin failed for plugin_id=%s: %s", plugin_id, client_msg) - - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "update", - plugin_id=plugin_id, - status="failed", - error=client_msg, - details={ - "previous_commit": current_commit[:7] if current_commit else None, - "branch": current_branch - } - ) - - return error_response( - ErrorCode.PLUGIN_UPDATE_FAILED, - client_msg, - status_code=500 - ) - - except Exception as e: - logger.error("Unhandled exception in update endpoint", exc_info=True) - - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UPDATE_FAILED) - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "update", - plugin_id=data.get('plugin_id') if 'data' in locals() else None, - status="failed", - error=str(e) - ) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -def _do_transactional_uninstall(plugin_id, preserve_config): - """Execute an uninstall with snapshot-based rollback. - - Order of operations: - 1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors). - 2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files). - 3. Unload plugin from runtime if loaded (rollback + 500 if this raises). - 4. Remove plugin files (rollback + 500 if this returns False or raises). - 5. Finish (remove state, invalidate caches). - - Rollback restores the config snapshot and, if the plugin had been - loaded before unload, calls load_plugin to restore runtime state. - - Returns (True, None) on success or (False, error_message) on failure. - """ - from src.exceptions import ConfigError - - # --- Step 1: snapshot main + secrets --- - main_snapshot = None - secrets_snapshot = None - try: - main_snapshot = api_v3.config_manager.get_raw_file_content('main') - except (OSError, ConfigError): - pass # Proceed without snapshot; narrow catch preserves TypeError/AttributeError - try: - secrets_snapshot = api_v3.config_manager.get_raw_file_content('secrets') - except (OSError, ConfigError): - pass - - # --- Step 2: cleanup config first (abort before touching filesystem) --- - if not preserve_config: - api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True) - - # Record whether the plugin was running before we touch anything. - was_loaded = ( - api_v3.plugin_manager is not None - and plugin_id in api_v3.plugin_manager.plugins - ) - - def _rollback(reload_plugin): - if main_snapshot is not None: - try: - api_v3.config_manager.save_raw_file_content('main', main_snapshot) - except Exception as restore_err: - logger.error("Failed to restore main config snapshot for %s: %s", plugin_id, restore_err) - if secrets_snapshot is not None: - try: - api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot) - except Exception as restore_err: - logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err) - if reload_plugin and api_v3.plugin_manager is not None: - try: - api_v3.plugin_manager.load_plugin(plugin_id) - except Exception as reload_err: - logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err) - - # --- Step 3: unload --- - if was_loaded: - try: - api_v3.plugin_manager.unload_plugin(plugin_id) - except Exception as unload_err: - _rollback(reload_plugin=False) # unload failed — runtime state unchanged - return False, f"Failed to unload plugin {plugin_id}: {unload_err}" - - # --- Step 4: remove files --- - try: - success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id) - except Exception as remove_err: - _rollback(reload_plugin=was_loaded) - return False, f"Failed to remove plugin {plugin_id}: {remove_err}" - - if not success: - _rollback(reload_plugin=was_loaded) - return False, f"Failed to uninstall plugin {plugin_id}" - - # --- Step 5: finish --- - if api_v3.schema_manager: - api_v3.schema_manager.invalidate_cache(plugin_id) - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.remove_plugin_state(plugin_id) - # Persistently record the uninstall so a later core `git pull` update - # cannot resurrect a built-in plugin (committed under plugin-repos/) that - # the user removed. Best-effort: never fail the uninstall over this. - try: - api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id) - except Exception as record_err: - logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err) - return True, None - - -@api_v3.route('/plugins/uninstall', methods=['POST']) -def uninstall_plugin(): - """Uninstall plugin""" - try: - # Validate request - data, error = validate_request_json(['plugin_id']) - if error: - return error - - if not api_v3.plugin_store_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Plugin store manager not initialized', - status_code=500 - ) - - plugin_id = data['plugin_id'] - preserve_config = data.get('preserve_config', False) - - # Both queued and direct paths use the same transactional helper so - # snapshot/rollback behaviour is consistent regardless of deployment. - if api_v3.operation_queue: - def uninstall_callback(operation): - """Callback to execute plugin uninstallation via transactional helper.""" - success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) - if not success: - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "uninstall", - plugin_id=plugin_id, - status="failed", - error=error_msg - ) - raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}') - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "uninstall", - plugin_id=plugin_id, - status="success", - details={"preserve_config": preserve_config} - ) - return {'success': True, 'message': 'Plugin uninstalled successfully'} - - # Enqueue operation - operation_id = api_v3.operation_queue.enqueue_operation( - OperationType.UNINSTALL, - plugin_id, - operation_callback=uninstall_callback - ) - - return success_response( - data={'operation_id': operation_id}, - message='Plugin uninstallation queued' - ) - else: - # Direct (non-queued) transactional uninstall - success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) - - if success: - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "uninstall", - plugin_id=plugin_id, - status="success", - details={"preserve_config": preserve_config} - ) - return success_response(message='Plugin uninstalled successfully') - else: - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "uninstall", - plugin_id=plugin_id, - status="failed", - error=error_msg - ) - return error_response( - ErrorCode.PLUGIN_UNINSTALL_FAILED, - error_msg or 'Plugin uninstall failed', - status_code=500 - ) - - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UNINSTALL_FAILED) - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "uninstall", - plugin_id=data.get('plugin_id') if 'data' in locals() else None, - status="failed", - error=str(e) - ) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/install', methods=['POST']) -def install_plugin(): - """Install plugin from store""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data or 'plugin_id' not in data: - return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 - - plugin_id = data['plugin_id'] - branch = data.get('branch') # Optional branch parameter - - # Install the plugin - # Log the plugins directory being used for debugging - plugins_dir = api_v3.plugin_store_manager.plugins_dir - branch_info = f" (branch: {branch})" if branch else "" - logger.info("Installing plugin to directory: %s", plugins_dir) - - # Use operation queue if available - if api_v3.operation_queue: - def install_callback(operation): - """Callback to execute plugin installation.""" - success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) - - if success: - # Invalidate schema cache - if api_v3.schema_manager: - api_v3.schema_manager.invalidate_cache(plugin_id) - - # Discover and load the new plugin - if api_v3.plugin_manager: - api_v3.plugin_manager.discover_plugins() - api_v3.plugin_manager.load_plugin(plugin_id) - - # Update state manager - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.set_plugin_installed(plugin_id) - - # Record in history - if api_v3.operation_history: - version = _get_plugin_version(plugin_id) - api_v3.operation_history.record_operation( - "install", - plugin_id=plugin_id, - status="success", - details={"version": version, "branch": branch} - ) - - branch_msg = f" (branch: {branch})" if branch else "" - return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'} - else: - error_msg = f'Failed to install plugin {plugin_id}' - if branch: - error_msg += f' (branch: {branch})' - plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) - if not plugin_info: - error_msg += ' (plugin not found in registry)' - - # Record failure in history - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "install", - plugin_id=plugin_id, - status="failed", - error=error_msg, - details={"branch": branch} - ) - - raise Exception(error_msg) - - # Enqueue operation - operation_id = api_v3.operation_queue.enqueue_operation( - OperationType.INSTALL, - plugin_id, - operation_callback=install_callback - ) - - branch_msg = f" (branch: {branch})" if branch else "" - return success_response( - data={'operation_id': operation_id}, - message=f'Plugin {plugin_id} installation queued{branch_msg}' - ) - else: - # Fallback to direct installation - success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) - - if success: - if api_v3.schema_manager: - api_v3.schema_manager.invalidate_cache(plugin_id) - if api_v3.plugin_manager: - api_v3.plugin_manager.discover_plugins() - api_v3.plugin_manager.load_plugin(plugin_id) - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.set_plugin_installed(plugin_id) - if api_v3.operation_history: - version = _get_plugin_version(plugin_id) - api_v3.operation_history.record_operation( - "install", - plugin_id=plugin_id, - status="success", - details={"version": version, "branch": branch} - ) - - branch_msg = f" (branch: {branch})" if branch else "" - return success_response(message=f'Plugin installed successfully{branch_msg}') - else: - error_msg = f'Failed to install plugin {plugin_id}' - if branch: - error_msg += f' (branch: {branch})' - plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) - if not plugin_info: - error_msg += ' (plugin not found in registry)' - - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "install", - plugin_id=plugin_id, - status="failed", - error=error_msg, - details={"branch": branch} - ) - - return error_response( - ErrorCode.PLUGIN_INSTALL_FAILED, - error_msg, - status_code=500 - ) - - except Exception as e: - logger.error('Error in install_plugin', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/install-from-url', methods=['POST']) -def install_plugin_from_url(): - """Install plugin from custom GitHub URL""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data or 'repo_url' not in data: - return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 - - # A non-string repo_url is a client mistake, not a server fault: - # .strip() would raise and the catch-all would report it as a 500. - if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): - return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 - - repo_url = data['repo_url'].strip() - plugin_id = data.get('plugin_id') # Optional, for monorepo installations - plugin_path = data.get('plugin_path') # Optional, for monorepo subdirectory - branch = data.get('branch') # Optional branch parameter - - # Install the plugin - result = api_v3.plugin_store_manager.install_from_url( - repo_url=repo_url, - plugin_id=plugin_id, - plugin_path=plugin_path, - branch=branch - ) - - if result.get('success'): - # Invalidate schema cache for the installed plugin - installed_plugin_id = result.get('plugin_id') - if api_v3.schema_manager and installed_plugin_id: - api_v3.schema_manager.invalidate_cache(installed_plugin_id) - - # Discover and load the new plugin - if api_v3.plugin_manager and installed_plugin_id: - api_v3.plugin_manager.discover_plugins() - api_v3.plugin_manager.load_plugin(installed_plugin_id) - - branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else "" - response_data = { - 'status': 'success', - 'message': f"Plugin {installed_plugin_id} installed successfully{branch_msg}", - 'plugin_id': installed_plugin_id, - 'name': result.get('name') - } - if result.get('branch'): - response_data['branch'] = result.get('branch') - return jsonify(response_data) - else: - return jsonify({ - 'status': 'error', - 'message': result.get('error', 'Failed to install plugin from URL') - }), 500 - - except Exception as e: - logger.error('Error in install_plugin_from_url', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/registry-from-url', methods=['POST']) -def get_registry_from_url(): - """Get plugin list from a registry-style monorepo URL""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data or 'repo_url' not in data: - return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 - - # A non-string repo_url is a client mistake, not a server fault: - # .strip() would raise and the catch-all would report it as a 500. - if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): - return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 - - repo_url = data['repo_url'].strip() - - # Get registry from the URL - registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) - - if registry: - return jsonify({ - 'status': 'success', - 'plugins': registry.get('plugins', []), - 'registry_url': repo_url - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Failed to fetch registry from URL or URL does not contain a valid registry' - }), 400 - - except Exception as e: - logger.error('Error in get_registry_from_url', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/saved-repositories', methods=['GET']) -def get_saved_repositories(): - """Get all saved repositories""" - try: - if not api_v3.saved_repositories_manager: - return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 - - repositories = api_v3.saved_repositories_manager.get_all() - return jsonify({'status': 'success', 'data': {'repositories': repositories}}) - except Exception as e: - logger.error('Error in get_saved_repositories', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/saved-repositories', methods=['POST']) -def add_saved_repository(): - """Add a repository to saved list""" - try: - if not api_v3.saved_repositories_manager: - return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data or 'repo_url' not in data: - return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 - - # A non-string repo_url is a client mistake, not a server fault: - # .strip() would raise and the catch-all would report it as a 500. - if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): - return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 - - repo_url = data['repo_url'].strip() - name = data.get('name') - - success = api_v3.saved_repositories_manager.add(repo_url, name) - - if success: - return jsonify({ - 'status': 'success', - 'message': 'Repository saved successfully', - 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Repository already exists or failed to save' - }), 400 - except Exception as e: - logger.error('Error in add_saved_repository', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/saved-repositories', methods=['DELETE']) -def remove_saved_repository(): - """Remove a repository from saved list""" - try: - if not api_v3.saved_repositories_manager: - return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 - - data = request.get_json(silent=True) - if not data or 'repo_url' not in data: - return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 - - repo_url = data['repo_url'] - - success = api_v3.saved_repositories_manager.remove(repo_url) - - if success: - return jsonify({ - 'status': 'success', - 'message': 'Repository removed successfully', - 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Repository not found' - }), 404 - except Exception as e: - logger.error('Error in remove_saved_repository', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/store/list', methods=['GET']) -def list_plugin_store(): - """Search plugin store""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - query = request.args.get('query', '') - category = request.args.get('category', '') - tags = request.args.getlist('tags') - # Default to fetching commit metadata to ensure accurate commit timestamps - fetch_commit_param = request.args.get('fetch_commit_info', request.args.get('fetch_latest_versions', '')).lower() - fetch_commit = fetch_commit_param != 'false' - - # Search plugins from the registry (including saved repositories) - plugins = api_v3.plugin_store_manager.search_plugins( - query=query, - category=category, - tags=tags, - fetch_commit_info=fetch_commit, - include_saved_repos=True, - saved_repositories_manager=api_v3.saved_repositories_manager - ) - - # Format plugins for the web interface - formatted_plugins = [] - for plugin in plugins: - formatted_plugins.append({ - 'id': plugin.get('id'), - 'name': plugin.get('name'), - 'author': plugin.get('author'), - 'category': plugin.get('category'), - 'description': plugin.get('description'), - 'tags': plugin.get('tags', []), - 'stars': plugin.get('stars', 0), - 'verified': plugin.get('verified', False), - 'repo': plugin.get('repo', ''), - 'last_updated': plugin.get('last_updated') or plugin.get('last_updated_iso', ''), - 'last_updated_iso': plugin.get('last_updated_iso', ''), - 'last_commit': plugin.get('last_commit') or plugin.get('last_commit_sha'), - 'last_commit_message': plugin.get('last_commit_message'), - 'last_commit_author': plugin.get('last_commit_author'), - 'version': plugin.get('latest_version') or plugin.get('version', ''), - 'branch': plugin.get('branch') or plugin.get('default_branch'), - 'default_branch': plugin.get('default_branch'), - 'plugin_path': plugin.get('plugin_path', '') - }) - - return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) - except Exception as e: - logger.error('Error in list_plugin_store', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/store/github-status', methods=['GET']) -def get_github_auth_status(): - """Check if GitHub authentication is configured and validate token""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - token = api_v3.plugin_store_manager.github_token - - # Check if GitHub token is configured - if not token or len(token) == 0: - return jsonify({ - 'status': 'success', - 'data': { - 'token_status': 'none', - 'authenticated': False, - 'rate_limit': 60, - 'message': 'No GitHub token configured', - 'error': None - } - }) - - # Validate the token - is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token) - - if is_valid: - return jsonify({ - 'status': 'success', - 'data': { - 'token_status': 'valid', - 'authenticated': True, - 'rate_limit': 5000, - 'message': 'GitHub API authenticated', - 'error': None - } - }) - else: - return jsonify({ - 'status': 'success', - 'data': { - 'token_status': 'invalid', - 'authenticated': False, - 'rate_limit': 60, - 'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid', - 'error': error_message - } - }) - except Exception as e: - logger.error('Error in get_github_auth_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/store/refresh', methods=['POST']) -def refresh_plugin_store(): - """Refresh plugin store repository""" - try: - if not api_v3.plugin_store_manager: - return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 - - data = request.get_json(silent=True) or {} - fetch_commit_info = data.get('fetch_commit_info', data.get('fetch_latest_versions', False)) - - # Force refresh the registry - registry = api_v3.plugin_store_manager.fetch_registry(force_refresh=True) - plugin_count = len(registry.get('plugins', [])) - - message = 'Plugin store refreshed' - if fetch_commit_info: - message += ' (with refreshed commit metadata from GitHub)' - - return jsonify({ - 'status': 'success', - 'message': message, - 'plugin_count': plugin_count - }) - except Exception as e: - logger.error('Error in refresh_plugin_store', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -def deep_merge(base_dict, update_dict): - """ - Deep merge update_dict into base_dict. - For nested dicts, recursively merge. For other types, update_dict takes precedence. - - Lists are intentionally REPLACED wholesale, never index-merged: form posts - carry complete arrays, and index-merging would resurrect items the user - deleted. This also applies to the parallel secrets lists produced by - separate_secrets — a newly saved secrets list is authoritative. - """ - result = base_dict.copy() - for key, value in update_dict.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - # Recursively merge nested dicts - result[key] = deep_merge(result[key], value) - else: - # For non-dict values or new keys, use the update value - result[key] = value - return result - - -def _parse_form_value(value): - """ - Parse a form value into the appropriate Python type. - Handles booleans, numbers, JSON arrays/objects, and strings. - """ - import json - - if value is None: - return None - - # Handle string values - if isinstance(value, str): - stripped = value.strip() - - # Check for boolean strings - if stripped.lower() == 'true': - return True - if stripped.lower() == 'false': - return False - if stripped.lower() in ('null', 'none') or stripped == '': - return None - - # Try parsing as JSON (for arrays and objects) - do this BEFORE number parsing - # This handles RGB arrays like "[255, 0, 0]" correctly - if stripped.startswith('[') or stripped.startswith('{'): - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - - # Try parsing as number - try: - if '.' in stripped: - return float(stripped) - return int(stripped) - except ValueError: - pass - - # Return as string (original value, not stripped) - return value - - return value - - -def _get_schema_property(schema, key_path): - """ - Get the schema property for a given key path (supports dot notation). - - Args: - schema: The JSON schema dict - key_path: Dot-separated path like "customization.time_text.font" - - Returns: - The property schema dict or None if not found - """ - if not schema or 'properties' not in schema: - return None - - parts = key_path.split('.') - current = schema['properties'] - i = 0 - - while i < len(parts): - # Try progressively longer candidates, longest first, so schema keys that - # themselves contain dots (e.g. league keys like "fifa.world") are matched - # instead of being mistaken for nested "fifa" -> "world" objects. - matched = False - for j in range(len(parts), i, -1): - candidate = '.'.join(parts[i:j]) - if isinstance(current, dict) and candidate in current: - prop = current[candidate] - # Consumed all remaining parts — this is the target property. - if j == len(parts): - return prop - # Navigate deeper through an object with properties. - if isinstance(prop, dict) and 'properties' in prop: - current = prop['properties'] - i = j - matched = True - break - # Matched a non-object before consuming the path — can't go deeper. - return None - if not matched: - return None - - return None - - -def _is_field_required(key_path, schema): - """ - Check if a field is required according to the schema. - - Args: - key_path: Dot-separated path like "mqtt.username" - schema: The JSON schema dict - - Returns: - True if field is required, False otherwise - """ - if not schema or 'properties' not in schema: - return False - - parts = key_path.split('.') - if len(parts) == 1: - # Top-level field - required = schema.get('required', []) - return parts[0] in required - else: - # Nested field - navigate to parent object - parent_path = '.'.join(parts[:-1]) - field_name = parts[-1] - - # Get parent property - parent_prop = _get_schema_property(schema, parent_path) - if not parent_prop or 'properties' not in parent_prop: - return False - - # Check if field is required in parent - required = parent_prop.get('required', []) - return field_name in required - - -# Sentinel object to indicate a field should be skipped (not set in config) -_SKIP_FIELD = object() - -def _parse_form_value_with_schema(value, key_path, schema): - """ - Parse a form value using schema information to determine correct type. - Handles arrays (comma-separated strings), objects, and other types. - - Args: - value: The form value (usually a string) - key_path: Dot-separated path like "category_order" or "customization.time_text.font" - schema: The plugin's JSON schema - - Returns: - Parsed value with correct type, or _SKIP_FIELD to indicate the field should not be set - """ - import json - - # Get the schema property for this field - prop = _get_schema_property(schema, key_path) - - # Handle None/empty values - if value is None or (isinstance(value, str) and value.strip() == ''): - # If schema says it's an array, return empty array instead of None - if prop and prop.get('type') == 'array': - return [] - # If schema says it's an object, return empty dict instead of None - if prop and prop.get('type') == 'object': - return {} - # If it's an optional string field, preserve empty string instead of None - if prop and prop.get('type') == 'string': - if not _is_field_required(key_path, schema): - return "" # Return empty string for optional string fields - # For number/integer fields, check if they have defaults or are required - if prop: - prop_type = prop.get('type') - if prop_type in ('number', 'integer'): - # If field has a default, use it - if 'default' in prop: - return prop['default'] - # If field is not required and has no default, skip setting it - if not _is_field_required(key_path, schema): - return _SKIP_FIELD - # If field is required but empty, return None (validation will fail, which is correct) - return None - return None - - # Handle string values - if isinstance(value, str): - stripped = value.strip() - - # Check for boolean strings - if stripped.lower() == 'true': - return True - if stripped.lower() == 'false': - return False - # "on"/"off" come from HTML checkboxes — only coerce when schema says boolean - if prop and prop.get('type') == 'boolean': - if stripped.lower() == 'on': - return True - if stripped.lower() == 'off': - return False - - # Handle arrays based on schema - if prop and prop.get('type') == 'array': - # Try parsing as JSON first (handles "[1,2,3]" format) - if stripped.startswith('['): - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - - # Otherwise, treat as comma-separated string - if stripped: - # Split by comma and strip each item - items = [item.strip() for item in stripped.split(',') if item.strip()] - # Try to convert items to numbers if schema items are numbers - items_schema = prop.get('items', {}) - if items_schema.get('type') in ('number', 'integer'): - try: - return [int(item) if '.' not in item else float(item) for item in items] - except ValueError: - pass - return items - return [] - - # Handle objects based on schema - if prop and prop.get('type') == 'object': - # Try parsing as JSON - if stripped.startswith('{'): - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - # If it's not JSON, return empty dict (form shouldn't send objects as strings) - return {} - - # Try parsing as JSON (for arrays and objects) - do this BEFORE number parsing - if stripped.startswith('[') or stripped.startswith('{'): - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - - # Handle numbers based on schema - if prop: - prop_type = prop.get('type') - if prop_type == 'integer': - try: - return int(stripped) - except ValueError: - return prop.get('default', 0) - elif prop_type == 'number': - try: - return float(stripped) - except ValueError: - return prop.get('default', 0.0) - - # Try parsing as number (fallback) — skip when schema explicitly says string - if not (prop and prop.get('type') == 'string'): - try: - if '.' in stripped: - return float(stripped) - return int(stripped) - except ValueError: - pass - - # Return as string - return value - - return value - - -def _resolve_key_segments(key_path, config): - """Split a dot-notation path into segments, greedily preserving keys that - themselves contain dots (e.g. league keys like "fifa.world"). - - At each level the longest candidate that matches a key already present in the - config wins; otherwise the path splits on the next dot (the normal - nested-create case). Because dotted keys such as ``leagues."fifa.world"`` - always exist in the saved config being updated, this routes the value to the - real league object instead of fabricating a ``leagues.fifa.world`` tree. - """ - parts = key_path.split('.') - segments = [] - node = config - i = 0 - while i < len(parts): - matched = False - if isinstance(node, dict): - for j in range(len(parts), i, -1): - candidate = '.'.join(parts[i:j]) - if candidate in node: - segments.append(candidate) - node = node[candidate] - i = j - matched = True - break - if not matched: - part = parts[i] - segments.append(part) - node = node.get(part) if isinstance(node, dict) else None - i += 1 - return segments - - -def _set_nested_value(config, key_path, value): - """ - Set a value in a nested dict using dot notation path. - Handles existing nested dicts correctly by merging instead of replacing. - Keys containing dots (e.g. league keys like "fifa.world") are preserved when - they already exist in the config rather than being split into nested objects. - - Args: - config: The config dict to modify - key_path: Dot-separated path (e.g., "customization.period_text.font") - value: The value to set (or _SKIP_FIELD to skip setting) - """ - # Skip setting if value is the sentinel - if value is _SKIP_FIELD: - return - - segments = _resolve_key_segments(key_path, config) - current = config - - # Navigate/create intermediate dicts - for seg in segments[:-1]: - if seg not in current: - current[seg] = {} - elif not isinstance(current[seg], dict): - # If the existing value is not a dict, replace it with a dict - current[seg] = {} - current = current[seg] - - # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) - if value is not None or segments[-1] not in current: - current[segments[-1]] = value - - -def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): - """Walk schema and set missing boolean form fields to False. - - HTML checkboxes don't submit values when unchecked. When saving plugin config, - the backend starts from existing config (to support partial form updates), which - means an unchecked checkbox's old ``True`` value persists. This function detects - boolean schema properties not present in the form submission and explicitly sets - them to ``False``. - - The top-level ``enabled`` field is excluded because it has its own preservation - logic in the save endpoint. - - Handles boolean fields inside nested objects and inside arrays of objects - (e.g. ``feeds.custom_feeds.0.enabled``). - - Args: - config: The root plugin config dict (used for pure-dict paths) - schema_props: Schema ``properties`` dict at the current nesting level - form_keys: Set of form field names that were submitted - prefix: Dot-notation prefix for the current nesting level - config_node: The current config subtree when inside an array item (avoids - using _set_nested_value which corrupts lists) - """ - # Determine which config node to operate on - node = config_node if config_node is not None else config - - for prop_name, prop_schema in schema_props.items(): - if not isinstance(prop_schema, dict): - continue - - full_path = f"{prefix}.{prop_name}" if prefix else prop_name - prop_type = prop_schema.get('type') - - if prop_type == 'boolean' and full_path != 'enabled': - # If this boolean wasn't submitted in the form, it's an unchecked checkbox - if full_path not in form_keys: - if config_node is not None: - # Inside an array item — set directly on the item dict - node[prop_name] = False - else: - # Pure dict path — use helper - _set_nested_value(config, full_path, False) - - elif prop_type == 'object' and 'properties' in prop_schema: - # Recurse into nested objects - if config_node is not None: - # Inside an array item — ensure nested dict exists in item - if prop_name not in node or not isinstance(node[prop_name], dict): - node[prop_name] = {} - _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path, - config_node=node[prop_name] - ) - else: - _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path - ) - - elif prop_type == 'array': - # Handle arrays of objects that may contain boolean fields - # Form keys use indexed notation: "path.0.field", "path.1.field" - items_schema = prop_schema.get('items', {}) - if isinstance(items_schema, dict) and items_schema.get('type') == 'object' and 'properties' in items_schema: - array_prefix = f"{full_path}." - # Collect unique item indices from submitted form keys - indices = set() - for k in form_keys: - if k.startswith(array_prefix): - # Extract index: "path.0.field" -> "0" - rest = k[len(array_prefix):] - idx = rest.split('.', 1)[0] - if idx.isdigit(): - indices.add(int(idx)) - - if not indices: - continue - - # Navigate to the array in the config (create if missing) - if config_node is not None: - if prop_name not in node or not isinstance(node[prop_name], list): - node[prop_name] = [] - array_list = node[prop_name] - else: - # Navigate from root config through dict keys to get the list - parts = full_path.split('.') - current = config - for part in parts[:-1]: - if part not in current or not isinstance(current[part], dict): - current[part] = {} - current = current[part] - arr_key = parts[-1] - if arr_key not in current or not isinstance(current[arr_key], list): - current[arr_key] = [] - array_list = current[arr_key] - - # Recurse into each array item so its missing booleans get set to False - for idx in indices: - # Ensure list is long enough and item is a dict - while len(array_list) <= idx: - array_list.append({}) - if not isinstance(array_list[idx], dict): - array_list[idx] = {} - item_prefix = f"{full_path}.{idx}" - _set_missing_booleans_to_false( - config, items_schema['properties'], form_keys, item_prefix, - config_node=array_list[idx] - ) - - -def _enhance_schema_with_core_properties(schema): - """ - Enhance schema with core plugin properties (enabled, display_duration, live_priority). - These properties are system-managed and should always be allowed even if not in the plugin's schema. - - Args: - schema: The original JSON schema dict - - Returns: - Enhanced schema dict with core properties injected - """ - import copy - - if not schema: - return schema - - # Core plugin properties that should always be allowed - # These match the definitions in SchemaManager.validate_config_against_schema() - core_properties = { - "enabled": { - "type": "boolean", - "default": True, - "description": "Enable or disable this plugin" - }, - "display_duration": { - "type": "number", - "default": 15, - "minimum": 1, - "maximum": 300, - "description": "How long to display this plugin in seconds" - }, - "live_priority": { - "type": "boolean", - "default": False, - "description": "Enable live priority takeover when plugin has live content" - } - } - - # Create a deep copy of the schema to modify (to avoid mutating the original) - enhanced_schema = copy.deepcopy(schema) - if "properties" not in enhanced_schema: - enhanced_schema["properties"] = {} - - # Inject core properties if they're not already defined in the schema - for prop_name, prop_def in core_properties.items(): - if prop_name not in enhanced_schema["properties"]: - enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def) - - return enhanced_schema - - -def _filter_config_by_schema(config, schema, prefix=''): - """ - Filter config to only include fields defined in the schema. - Removes fields not in schema, especially important when additionalProperties is false. - - Args: - config: The config dict to filter - schema: The JSON schema dict - prefix: Prefix for nested paths (used recursively) - - Returns: - Filtered config dict containing only schema-defined fields - """ - if not schema or 'properties' not in schema: - return config - - filtered = {} - schema_props = schema.get('properties', {}) - - for key, value in config.items(): - if key not in schema_props: - # Field not in schema, skip it - continue - - prop_schema = schema_props[key] - - # Handle nested objects recursively - if isinstance(value, dict) and prop_schema.get('type') == 'object' and 'properties' in prop_schema: - filtered[key] = _filter_config_by_schema(value, prop_schema, f"{prefix}.{key}" if prefix else key) - else: - # Keep the value as-is for non-object types - filtered[key] = value - - return filtered - - -@api_v3.route('/plugins/config', methods=['POST']) -def save_plugin_config(): - """Save plugin configuration, separating secrets from regular config""" - try: - if not api_v3.config_manager: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Config manager not initialized', - status_code=500 - ) - - # Support both JSON and form data (for HTMX submissions) - content_type = request.content_type or '' - - if 'application/json' in content_type: - # JSON request - data, error = validate_request_json(['plugin_id']) - if error: - return error - plugin_id = data['plugin_id'] - plugin_config = data.get('config', {}) - else: - # Form data (HTMX submission) - # plugin_id comes from query string, config from form fields - plugin_id = request.args.get('plugin_id') - if not plugin_id: - return error_response( - ErrorCode.INVALID_INPUT, - 'plugin_id required in query string', - status_code=400 - ) - - # Load existing config as base (partial form updates should merge, not replace) - existing_config = {} - if api_v3.config_manager: - full_config = api_v3.config_manager.load_config() - existing_config = full_config.get(plugin_id, {}).copy() - - # Get schema manager instance (needed for type conversion) - schema_mgr = api_v3.schema_manager - if not schema_mgr: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Schema manager not initialized', - status_code=500 - ) - - # Load plugin schema BEFORE processing form data (needed for type conversion) - schema = schema_mgr.load_schema(plugin_id, use_cache=False) - - # Start with existing config and apply form updates - plugin_config = existing_config - - # Convert form data to config dict - # Form fields can use dot notation for nested values (e.g., "transition.type") - form_data = request.form.to_dict() - - # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) - # These fields use getlist() to preserve all values, then replace in form_data - # Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked - bracket_array_fields = {} # Maps base field path to list of values - for key in request.form.keys(): - # Check if key ends with "[]" (bracket notation for array fields) - if key.endswith('[]'): - base_path = key[:-2] # Remove "[]" suffix - values = request.form.getlist(key) - # Filter out sentinel empty string - if only sentinel present, array should be [] - # If sentinel + values present, use the actual values - filtered_values = [v for v in values if v and v.strip()] - # If no non-empty values but key exists, it means all checkboxes unchecked (empty array) - bracket_array_fields[base_path] = filtered_values - # Remove the bracket notation key from form_data if present - if key in form_data: - del form_data[key] - - # Process bracket notation fields and set directly in plugin_config - # Use JSON encoding instead of comma-join to handle values containing commas - import json - for base_path, values in bracket_array_fields.items(): - # Get schema property to verify it's an array - base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': - # Filter out empty values and sentinel empty strings - filtered_values = [v for v in values if v and v.strip()] - # Set directly in plugin_config (values are already strings, no need to parse) - # Empty array (all unchecked) is represented as [] - _set_nested_value(plugin_config, base_path, filtered_values) - logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}") - # Remove from form_data to avoid double processing - if base_path in form_data: - del form_data[base_path] - - # Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) - # This handles cases where forms send array fields as indexed inputs - array_fields = {} # Maps base field path to list of (index, value) tuples - processed_keys = set() - indexed_base_paths = set() # Track which base paths have indexed fields - - for key, value in form_data.items(): - # Check if this looks like an array index field (ends with .0, .1, .2, etc.) - if '.' in key: - parts = key.rsplit('.', 1) # Split on last dot - if len(parts) == 2: - base_path, last_part = parts - # Check if last part is a numeric string (array index) - if last_part.isdigit(): - # Get schema property for the base path to verify it's an array - base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': - # This is an array index field - index = int(last_part) - if base_path not in array_fields: - array_fields[base_path] = [] - array_fields[base_path].append((index, value)) - processed_keys.add(key) - indexed_base_paths.add(base_path) - continue - - # Process combined array fields - for base_path, index_values in array_fields.items(): - # Sort by index and extract values - index_values.sort(key=lambda x: x[0]) - values = [v for _, v in index_values] - # Combine values into comma-separated string for parsing - combined_value = ', '.join(str(v) for v in values) - # Parse as array using schema - parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema) - # Debug logging - logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}") - # Only set if not skipped - if parsed_value is not _SKIP_FIELD: - _set_nested_value(plugin_config, base_path, parsed_value) - - # Process remaining (non-indexed) fields - # Skip any base paths that were processed as indexed arrays - for key, value in form_data.items(): - if key not in processed_keys: - # Skip if this key is a base path that was processed as indexed array - # (to avoid overwriting the combined array with a single value) - if key not in indexed_base_paths: - # Parse value using schema to determine correct type - parsed_value = _parse_form_value_with_schema(value, key, schema) - # Debug logging for array fields - if schema: - prop = _get_schema_property(schema, key) - if prop and prop.get('type') == 'array': - logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}") - # Use helper to set nested values correctly (skips if _SKIP_FIELD) - if parsed_value is not _SKIP_FIELD: - _set_nested_value(plugin_config, key, parsed_value) - - # Post-process: Fix array fields that might have been incorrectly structured - # This handles cases where array fields are stored as dicts (e.g., from indexed form fields) - def fix_array_structures(config_dict, schema_props, prefix=''): - """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" - for prop_key, prop_schema in schema_props.items(): - prop_type = prop_schema.get('type') - - if prop_type == 'array': - # Navigate to the field location - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - - if parent is not None and isinstance(parent, dict) and prop_key in parent: - current_value = parent[prop_key] - # If it's a dict with numeric string keys, convert to array - if isinstance(current_value, dict) and not isinstance(current_value, list): - try: - # Check if all keys are numeric strings (array indices) - keys = [k for k in current_value.keys()] - if all(k.isdigit() for k in keys): - # Convert to sorted array by index - sorted_keys = sorted(keys, key=int) - array_value = [current_value[k] for k in sorted_keys] - # Convert array elements to correct types based on schema - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in array_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError, OverflowError): - converted_array.append(v) - else: - converted_array.append(v) - array_value = converted_array - parent[prop_key] = array_value - current_value = array_value # Update for length check below - except (ValueError, KeyError, TypeError): - # Conversion failed, check if we should use default - pass - - # If it's an array, ensure correct types and check minItems - if isinstance(current_value, list): - # First, ensure array elements are correct types - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in current_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError, OverflowError): - converted_array.append(v) - else: - converted_array.append(v) - parent[prop_key] = converted_array - current_value = converted_array - - # Then check minItems - min_items = prop_schema.get('minItems') - if min_items is not None and len(current_value) < min_items: - # Use default if available, otherwise keep as-is (validation will catch it) - default = prop_schema.get('default') - if default and isinstance(default, list) and len(default) >= min_items: - parent[prop_key] = default - else: - # Top-level field - if prop_key in config_dict: - current_value = config_dict[prop_key] - # If it's a dict with numeric string keys, convert to array - if isinstance(current_value, dict) and not isinstance(current_value, list): - try: - keys = list(current_value.keys()) - if keys and all(str(k).isdigit() for k in keys): - sorted_keys = sorted(keys, key=lambda x: int(str(x))) - array_value = [current_value[k] for k in sorted_keys] - # Convert array elements to correct types based on schema - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in array_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError, OverflowError): - converted_array.append(v) - else: - converted_array.append(v) - array_value = converted_array - config_dict[prop_key] = array_value - current_value = array_value # Update for length check below - except (ValueError, KeyError, TypeError) as e: - logger.debug(f"Failed to convert {prop_key} to array: {e}") - - # If it's an array, ensure correct types and check minItems - if isinstance(current_value, list): - # First, ensure array elements are correct types - items_schema = prop_schema.get('items', {}) - item_type = items_schema.get('type') - if item_type in ('number', 'integer'): - converted_array = [] - for v in current_value: - if isinstance(v, str): - try: - if item_type == 'integer': - converted_array.append(int(v)) - else: - converted_array.append(float(v)) - except (ValueError, TypeError, OverflowError): - converted_array.append(v) - else: - converted_array.append(v) - config_dict[prop_key] = converted_array - current_value = converted_array - - # Then check minItems - min_items = prop_schema.get('minItems') - if min_items is not None and len(current_value) < min_items: - default = prop_schema.get('default') - if default and isinstance(default, list) and len(default) >= min_items: - config_dict[prop_key] = default - - # Recurse into nested objects - elif prop_type == 'object' and 'properties' in prop_schema: - nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None - else: - nested_dict = config_dict.get(prop_key) - - if isinstance(nested_dict, dict): - # Pass no prefix: config_dict is already the navigated sub-dict, - # so path segments from the parent would mis-navigate it. - fix_array_structures(nested_dict, prop_schema['properties']) - - # Also ensure array fields that are None get converted to empty arrays - def ensure_array_defaults(config_dict, schema_props, prefix=''): - """Recursively ensure array fields have defaults if None""" - for prop_key, prop_schema in schema_props.items(): - prop_type = prop_schema.get('type') - - if prop_type == 'array': - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - - if parent is not None and isinstance(parent, dict): - if prop_key not in parent or parent[prop_key] is None: - default = prop_schema.get('default', []) - parent[prop_key] = default if default else [] - else: - if prop_key not in config_dict or config_dict[prop_key] is None: - default = prop_schema.get('default', []) - config_dict[prop_key] = default if default else [] - - elif prop_type == 'object' and 'properties' in prop_schema: - nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if isinstance(parent, dict) and part in parent: - parent = parent[part] - else: - parent = None - break - nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None - else: - nested_dict = config_dict.get(prop_key) - - if nested_dict is None: - if prefix: - parent_parts = prefix.split('.') - parent = config_dict - for part in parent_parts: - if part not in parent: - parent[part] = {} - parent = parent[part] - if prop_key not in parent: - parent[prop_key] = {} - nested_dict = parent[prop_key] - else: - if prop_key not in config_dict: - config_dict[prop_key] = {} - nested_dict = config_dict[prop_key] - - if isinstance(nested_dict, dict): - # Pass no prefix: config_dict is already navigated. - ensure_array_defaults(nested_dict, prop_schema['properties']) - - if schema and 'properties' in schema: - # First, fix any dict structures that should be arrays - # This must be called BEFORE validation to convert dicts with numeric keys to arrays - fix_array_structures(plugin_config, schema['properties']) - # Then, ensure None arrays get defaults - ensure_array_defaults(plugin_config, schema['properties']) - - # Debug: Log the structure after fixing - if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): - custom_feeds = plugin_config['feeds']['custom_feeds'] - logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") - - # Force fix for feeds.custom_feeds if it's still a dict (fallback) - if 'feeds' in plugin_config: - feeds_config = plugin_config.get('feeds') or {} - if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): - custom_feeds_dict = feeds_config['custom_feeds'] - # Check if all keys are numeric - keys = list(custom_feeds_dict.keys()) - if keys and all(str(k).isdigit() for k in keys): - # Convert to array - sorted_keys = sorted(keys, key=lambda x: int(str(x))) - feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys] - logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items") - - # Fix unchecked boolean checkboxes: HTML checkboxes don't submit values - # when unchecked, so the existing config value (potentially True) persists. - # Walk the schema and set any boolean fields missing from form data to False. - if schema and 'properties' in schema: - form_keys = set(request.form.keys()) - _set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) - - # Get schema manager instance (for JSON requests) - schema_mgr = api_v3.schema_manager - if not schema_mgr: - return error_response( - ErrorCode.SYSTEM_ERROR, - 'Schema manager not initialized', - status_code=500 - ) - - # Load plugin schema using SchemaManager (force refresh to get latest schema) - # For JSON requests, schema wasn't loaded yet - if 'application/json' in content_type: - schema = schema_mgr.load_schema(plugin_id, use_cache=False) - - # JSON path: fix numeric-keyed dicts that should be arrays. - # JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}} - # instead of [{name:...}]. The form-data path has fix_array_structures for this; - # mirror that logic here for JSON submissions. - if 'application/json' in content_type and schema and 'properties' in schema: - def _fix_json_arrays(cfg, props): - for k, ps in props.items(): - if not isinstance(cfg, dict) or k not in cfg: - continue - pt = ps.get('type') - val = cfg[k] - if pt == 'array': - items_schema = ps.get('items', {}) - item_type = items_schema.get('type') - if isinstance(val, dict): - keys = list(val.keys()) - if keys and all(str(x).isdigit() for x in keys): - sorted_keys = sorted(keys, key=lambda x: int(str(x))) - arr = [val[sk] for sk in sorted_keys] - if item_type in ('integer', 'number'): - converted = [] - for v in arr: - if isinstance(v, str): - try: - converted.append(int(v) if item_type == 'integer' else float(v)) - except (ValueError, TypeError, OverflowError): - converted.append(v) - else: - converted.append(v) - arr = converted - cfg[k] = arr - elif not keys: - cfg[k] = [] - # Recurse into each element when items are objects with properties, - # covering both freshly-converted and already-list values. - if item_type == 'object' and 'properties' in items_schema: - for elem in (cfg[k] if isinstance(cfg[k], list) else []): - if isinstance(elem, dict): - _fix_json_arrays(elem, items_schema['properties']) - elif pt == 'object' and 'properties' in ps and isinstance(val, dict): - _fix_json_arrays(val, ps['properties']) - _fix_json_arrays(plugin_config, schema['properties']) - - # PRE-PROCESSING: Preserve 'enabled' state if not in request - # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle - if 'enabled' not in plugin_config: - try: - current_config = api_v3.config_manager.load_config() - if plugin_id in current_config and 'enabled' in current_config[plugin_id]: - plugin_config['enabled'] = current_config[plugin_id]['enabled'] - # logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}") - elif api_v3.plugin_manager: - # Fallback to plugin instance if config doesn't have it - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - plugin_config['enabled'] = plugin_instance.enabled - # Final fallback: default to True if plugin is loaded (matches BasePlugin default) - if 'enabled' not in plugin_config: - plugin_config['enabled'] = True - except Exception as e: - logger.debug("Error preserving enabled state: %s", e) - # Default to True on error to avoid disabling plugins - plugin_config['enabled'] = True - - # Find secret fields (supports nested schemas and array-item secrets) - secret_fields = set() - - if schema and 'properties' in schema: - secret_fields = find_secret_fields(schema['properties']) - - # Apply defaults from schema to config BEFORE validation - # This ensures required fields with defaults are present before validation - # Store preserved enabled value before merge to protect it from defaults - preserved_enabled = None - if 'enabled' in plugin_config: - preserved_enabled = plugin_config['enabled'] - - if schema: - defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) - plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) - - # After merging defaults, replace any None array values with their schema defaults. - # merge_with_defaults gives user config higher priority, so a None submitted by - # the client can survive the merge — this pass cleans those up. - def _fix_none_arrays(cfg, props): - for k, pschema in props.items(): - if pschema.get('type') == 'array': - if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None): - cfg[k] = pschema.get('default', []) - elif pschema.get('type') == 'object' and 'properties' in pschema: - if isinstance(cfg, dict) and isinstance(cfg.get(k), dict): - _fix_none_arrays(cfg[k], pschema['properties']) - - if schema and 'properties' in schema and isinstance(plugin_config, dict): - _fix_none_arrays(plugin_config, schema['properties']) - - # Ensure enabled state is preserved after defaults merge - # Defaults should not overwrite an explicitly preserved enabled value - if preserved_enabled is not None: - # Restore preserved value if it was changed by defaults merge - if plugin_config.get('enabled') != preserved_enabled: - plugin_config['enabled'] = preserved_enabled - - # Normalize config data: convert string numbers to integers/floats where schema expects numbers - # This handles form data which sends everything as strings - def normalize_config_values(config, schema_props, prefix=''): - """Recursively normalize config values based on schema types""" - if not isinstance(config, dict) or not isinstance(schema_props, dict): - return config - - normalized = {} - for key, value in config.items(): - field_path = f"{prefix}.{key}" if prefix else key - - if key not in schema_props: - # Field not in schema, keep as-is (will be caught by additionalProperties check if needed) - normalized[key] = value - continue - - prop_schema = schema_props[key] - prop_type = prop_schema.get('type') - - # Handle union types (e.g., ["integer", "null"]) - if isinstance(prop_type, list): - # Check if null is allowed and value is empty/null - if 'null' in prop_type: - # Handle various representations of null/empty - if value is None: - normalized[key] = None - continue - elif isinstance(value, str): - # Strip whitespace and check for null representations - value_stripped = value.strip() - if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'): - normalized[key] = None - continue - - # Try to normalize based on non-null types in the union - # Check integer first (more specific than number) - if 'integer' in prop_type: - if isinstance(value, str): - value_stripped = value.strip() - if value_stripped == '': - # Empty string with null allowed - already handled above, but double-check - if 'null' in prop_type: - normalized[key] = None - continue - try: - normalized[key] = int(value_stripped) - continue - except (ValueError, TypeError, OverflowError): - pass - elif isinstance(value, (int, float)): - normalized[key] = int(value) - continue - - # Check number (less specific, but handles floats) - if 'number' in prop_type: - if isinstance(value, str): - value_stripped = value.strip() - if value_stripped == '': - # Empty string with null allowed - already handled above, but double-check - if 'null' in prop_type: - normalized[key] = None - continue - try: - normalized[key] = float(value_stripped) - continue - except (ValueError, TypeError, OverflowError): - pass - elif isinstance(value, (int, float)): - normalized[key] = float(value) - continue - - # Check boolean - if 'boolean' in prop_type: - if isinstance(value, str): - normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes') - continue - - # If no conversion worked and null is allowed, try to set to None - # This handles cases where the value is an empty string or can't be converted - if 'null' in prop_type: - if isinstance(value, str): - value_stripped = value.strip() - if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'): - normalized[key] = None - continue - # If it's already None, keep it - if value is None: - normalized[key] = None - continue - - # If no conversion worked, keep original value (will fail validation, but that's expected) - # Log a warning for debugging - logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}") - normalized[key] = value - continue - - if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema: - # Recursively normalize nested objects - normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path) - elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema: - # Normalize array items - items_schema = prop_schema['items'] - item_type = items_schema.get('type') - - # Handle union types in array items - if isinstance(item_type, list): - normalized_array = [] - for v in value: - # Check if null is allowed - if 'null' in item_type: - if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')): - normalized_array.append(None) - continue - - # Try to normalize based on non-null types - if 'integer' in item_type: - if isinstance(v, str): - try: - normalized_array.append(int(v)) - continue - except (ValueError, TypeError, OverflowError): - pass - elif isinstance(v, (int, float)): - normalized_array.append(int(v)) - continue - elif 'number' in item_type: - if isinstance(v, str): - try: - normalized_array.append(float(v)) - continue - except (ValueError, TypeError, OverflowError): - pass - elif isinstance(v, (int, float)): - normalized_array.append(float(v)) - continue - - # If no conversion worked, keep original value - normalized_array.append(v) - normalized[key] = normalized_array - elif item_type == 'integer': - # Convert string numbers to integers - normalized_array = [] - for v in value: - if isinstance(v, str): - try: - normalized_array.append(int(v)) - except (ValueError, TypeError, OverflowError): - normalized_array.append(v) - elif isinstance(v, (int, float)): - normalized_array.append(int(v)) - else: - normalized_array.append(v) - normalized[key] = normalized_array - elif item_type == 'number': - # Convert string numbers to floats - normalized_array = [] - for v in value: - if isinstance(v, str): - try: - normalized_array.append(float(v)) - except (ValueError, TypeError, OverflowError): - normalized_array.append(v) - else: - normalized_array.append(v) - normalized[key] = normalized_array - elif item_type == 'object' and 'properties' in items_schema: - # Recursively normalize array of objects - normalized_array = [] - for v in value: - if isinstance(v, dict): - normalized_array.append( - normalize_config_values(v, items_schema['properties'], f"{field_path}[]") - ) - else: - normalized_array.append(v) - normalized[key] = normalized_array - else: - normalized[key] = value - elif prop_type == 'integer': - # Convert string to integer - if isinstance(value, str): - try: - normalized[key] = int(value) - except (ValueError, TypeError, OverflowError): - normalized[key] = value - else: - normalized[key] = value - elif prop_type == 'number': - # Convert string to float - if isinstance(value, str): - try: - normalized[key] = float(value) - except (ValueError, TypeError, OverflowError): - normalized[key] = value - else: - normalized[key] = value - elif prop_type == 'boolean': - # Convert string booleans - if isinstance(value, str): - normalized[key] = value.lower() in ('true', '1', 'on', 'yes') - else: - normalized[key] = value - else: - normalized[key] = value - - return normalized - - # Normalize config before validation - if schema and 'properties' in schema: - plugin_config = normalize_config_values(plugin_config, schema['properties']) - - # Filter config to only include schema-defined fields (important when additionalProperties is false) - # Use enhanced schema with core properties to ensure core properties are preserved during filtering - if schema and 'properties' in schema: - enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema) - plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering) - - # Debug logging for union type fields (temporary) - if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}): - seed_value = plugin_config['rotation_settings']['random_seed'] - logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}") - - # Validate configuration against schema before saving - if schema: - # Log what we're validating for debugging - logger.info(f"Validating config for {plugin_id}") - # Only the shape. plugin_config still holds the submitted secret - # values at this point -- separate_secrets does not run until - # below -- so logging it wrote live credentials to the journal. - logger.info(f"Config keys being validated: {list(plugin_config.keys())}") - - # Get enhanced schema keys (including injected core properties) - # We need to create an enhanced schema to get the actual allowed keys - import copy - enhanced_schema = copy.deepcopy(schema) - if "properties" not in enhanced_schema: - enhanced_schema["properties"] = {} - - # Core properties that are always injected during validation - core_properties = ["enabled", "display_duration", "live_priority"] - for prop_name in core_properties: - if prop_name not in enhanced_schema["properties"]: - # Add placeholder to get the full list of allowed keys - enhanced_schema["properties"][prop_name] = {"type": "any"} - - is_valid, validation_errors = schema_mgr.validate_config_against_schema( - plugin_config, schema, plugin_id - ) - if not is_valid: - # Log validation errors for debugging - logger.error(f"Config validation failed for {plugin_id}") - logger.error(f"Validation errors: {validation_errors}") - # Keys only, for the same reason as above. - logger.error(f"Config keys that failed: {list(plugin_config.keys())}") - logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}") - - # Also print to console for immediate visibility - import json - logger.warning("Config validation failed for plugin (see debug logs)") - - # Log raw form data if this was a form submission - if 'application/json' not in (request.content_type or ''): - form_data = request.form.to_dict() - return error_response( - ErrorCode.CONFIG_VALIDATION_FAILED, - 'Configuration validation failed', - details='; '.join(validation_errors) if validation_errors else 'Unknown validation error', - context={ - 'plugin_id': plugin_id, - 'validation_errors': validation_errors, - 'config_keys': list(plugin_config.keys()), - 'schema_keys': list(enhanced_schema.get('properties', {}).keys()) - }, - suggested_fixes=[ - 'Review validation errors above', - 'Check config against schema', - 'Verify all required fields are present' - ], - status_code=400 - ) - - # Separate secrets from regular config (handles nested configs and - # array-item secrets — see src/web_interface/secret_helpers.py) - regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) - # The config form renders secrets masked, so every save posts - # them back blank. Without this the blank is merged over the - # stored value and the credential is destroyed by the act of - # changing an unrelated setting. A blank means "unchanged". - secrets_config = remove_empty_secrets(secrets_config) - - # Get current configs - current_config = api_v3.config_manager.load_config() - current_secrets = api_v3.config_manager.get_raw_file_content('secrets') - - # Deep merge plugin configuration in main config (preserves nested structures) - if plugin_id not in current_config: - current_config[plugin_id] = {} - - current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) - - # Deep merge plugin secrets in secrets config - if secrets_config: - if plugin_id not in current_secrets: - current_secrets[plugin_id] = {} - # See above -- secrets lists must merge element-wise. - current_secrets[plugin_id] = merge_secrets( - current_secrets[plugin_id], secrets_config) - # Save secrets file - try: - api_v3.config_manager.save_raw_file_content('secrets', current_secrets) - except PermissionError as e: - # Log the error with more details - import os - secrets_path = api_v3.config_manager.secrets_path - secrets_dir = os.path.dirname(secrets_path) if secrets_path else None - - # Check permissions - dir_readable = os.access(secrets_dir, os.R_OK) if secrets_dir and os.path.exists(secrets_dir) else False - dir_writable = os.access(secrets_dir, os.W_OK) if secrets_dir and os.path.exists(secrets_dir) else False - file_writable = os.access(secrets_path, os.W_OK) if secrets_path and os.path.exists(secrets_path) else False - - logger.error( - f"Permission error saving secrets config for {plugin_id}: {e}\n" - f"Secrets path: {secrets_path}\n" - f"Directory readable: {dir_readable}, writable: {dir_writable}\n" - f"File writable: {file_writable}", - exc_info=True - ) - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save secrets configuration: Permission denied. Check file permissions on {secrets_path}", - status_code=500 - ) - except Exception as e: - # Log the error but don't fail the entire config save - import os - secrets_path = api_v3.config_manager.secrets_path - logger.error("Error saving secrets config for %s (path=%s)", plugin_id, secrets_path, exc_info=True) - # Return error response with more context - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - "Failed to save secrets configuration; see logs for details", - status_code=500 - ) - - # Save the updated main config using atomic save - success, error_msg = _save_config_atomic(api_v3.config_manager, current_config, create_backup=True) - if not success: - return error_response( - ErrorCode.CONFIG_SAVE_FAILED, - f"Failed to save configuration: {error_msg}", - status_code=500 - ) - - # If the plugin is loaded, notify it of the config change with merged config - try: - if api_v3.plugin_manager: - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - # Reload merged config (includes secrets) and pass the plugin-specific section - merged_config = api_v3.config_manager.load_config() - plugin_full_config = merged_config.get(plugin_id, {}) - if hasattr(plugin_instance, 'on_config_change'): - plugin_instance.on_config_change(plugin_full_config) - - # Update plugin state manager and call lifecycle methods based on enabled state - # This ensures the plugin state is synchronized with the config - enabled = plugin_full_config.get('enabled', plugin_instance.enabled) - - # Update state manager if available - if api_v3.plugin_state_manager: - api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled) - - # Call lifecycle methods to ensure plugin state matches config - try: - if enabled: - if hasattr(plugin_instance, 'on_enable'): - plugin_instance.on_enable() - else: - if hasattr(plugin_instance, 'on_disable'): - plugin_instance.on_disable() - except Exception as lifecycle_error: - # Log the error but don't fail the save - config is already saved - import logging - logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True) - except Exception as hook_err: - # Do not fail the save if hook fails; just log - logger.warning("on_config_change failed: %s", hook_err) - - secret_count = len(secrets_config) - message = f'Plugin {plugin_id} configuration saved successfully' - if secret_count > 0: - message += f' ({secret_count} secret field(s) saved to config_secrets.json)' - - return success_response(message=message) - except Exception as e: - from src.web_interface.errors import WebInterfaceError - error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_SAVE_FAILED) - if api_v3.operation_history: - api_v3.operation_history.record_operation( - "configure", - plugin_id=data.get('plugin_id') if 'data' in locals() else None, - status="failed", - error=str(e) - ) - return error_response( - error.error_code, - error.message, - details=error.details, - context=error.context, - status_code=500 - ) - -@api_v3.route('/plugins/schema', methods=['GET']) -def get_plugin_schema(): - """Get plugin configuration schema""" - try: - plugin_id = request.args.get('plugin_id') - if not plugin_id: - return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 - - # Get schema manager instance - schema_mgr = api_v3.schema_manager - if not schema_mgr: - return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500 - - # Load schema using SchemaManager (uses caching) - schema = schema_mgr.load_schema(plugin_id, use_cache=True) - - if schema: - # Offer installed visual skins as a dropdown (returns a copy; - # the cached schema and validation are never enum-restricted) - try: - current_skin = None - if api_v3.config_manager: - config = api_v3.config_manager.load_config() - current_skin = config.get(plugin_id, {}).get('skin') - injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin) - if isinstance(injected, dict): - schema = injected - except Exception: - logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True) - return jsonify({'status': 'success', 'data': {'schema': schema}}) - - # Return a simple default schema if file not found - default_schema = { - 'type': 'object', - 'properties': { - 'enabled': { - 'type': 'boolean', - 'title': 'Enable Plugin', - 'description': 'Enable or disable this plugin', - 'default': True - }, - 'display_duration': { - 'type': 'integer', - 'title': 'Display Duration', - 'description': 'How long to show content (seconds)', - 'minimum': 5, - 'maximum': 300, - 'default': 30 - } - } - } - - return jsonify({'status': 'success', 'data': {'schema': default_schema}}) - except Exception as e: - logger.error('Error in get_plugin_schema', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/skins', methods=['GET']) -def list_skins(): - """List installed visual skins (docs/SKIN_SYSTEM.md). - - Optional ?plugin_id=... filters to skins matching that plugin. - """ - try: - from src.skin_system import skin_runtime - - plugin_id = request.args.get('plugin_id') - if plugin_id: - skins = skin_runtime.skins_for_plugin(plugin_id) - else: - # The discovery cache self-invalidates on directory/manifest - # mtime changes, so no force_refresh — keeps Pi disk I/O down. - skins = skin_runtime.discover_skins() - - payload = [] - for skin_id, manifest in sorted(skins.items()): - skin_dir = Path(manifest['_skin_dir']) - preview = manifest.get('preview') - payload.append({ - 'id': skin_id, - 'name': manifest.get('name', skin_id), - 'version': manifest.get('version'), - 'author': manifest.get('author'), - 'description': manifest.get('description', ''), - 'skin_api_version': manifest.get('skin_api_version'), - 'targets': manifest.get('targets', {}), - 'modes': manifest.get('modes', []), - 'has_preview': bool(preview and (skin_dir / preview).is_file()), - }) - return jsonify({'status': 'success', 'data': {'skins': payload}}) - except Exception as e: - logger.error('Error in list_skins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/config/reset', methods=['POST']) -def reset_plugin_config(): - """Reset plugin configuration to schema defaults""" - try: - if not api_v3.config_manager: - return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 - - data = request.get_json(silent=True) or {} - plugin_id = data.get('plugin_id') - preserve_secrets = data.get('preserve_secrets', True) - - if not plugin_id: - return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 - - # Get schema manager instance - schema_mgr = api_v3.schema_manager - if not schema_mgr: - return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500 - - # Generate defaults from schema - defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) - - # Get current configs - current_config = api_v3.config_manager.load_config() - current_secrets = api_v3.config_manager.get_raw_file_content('secrets') - - # Load schema to identify secret fields - schema = schema_mgr.load_schema(plugin_id, use_cache=True) - secret_fields = set() - - if schema and 'properties' in schema: - secret_fields = find_secret_fields(schema['properties']) - - # Separate defaults into regular and secret configs - default_regular, default_secrets = separate_secrets(defaults, secret_fields) - - # Update main config with defaults - current_config[plugin_id] = default_regular - - # Update secrets config (preserve existing secrets if preserve_secrets=True) - if preserve_secrets: - # Keep existing secrets for this plugin - if plugin_id in current_secrets: - # Merge defaults with existing secrets - existing_secrets = current_secrets[plugin_id] - for key, value in default_secrets.items(): - if key not in existing_secrets or not existing_secrets[key]: - existing_secrets[key] = value - else: - current_secrets[plugin_id] = default_secrets - else: - # Replace all secrets with defaults - current_secrets[plugin_id] = default_secrets - - # Save updated configs - api_v3.config_manager.save_config(current_config) - if default_secrets or not preserve_secrets: - api_v3.config_manager.save_raw_file_content('secrets', current_secrets) - - # Notify plugin of config change if loaded - try: - if api_v3.plugin_manager: - plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) - if plugin_instance: - merged_config = api_v3.config_manager.load_config() - plugin_full_config = merged_config.get(plugin_id, {}) - if hasattr(plugin_instance, 'on_config_change'): - plugin_instance.on_config_change(plugin_full_config) - except Exception as hook_err: - logger.warning("on_config_change failed: %s", hook_err) - - return jsonify({ - 'status': 'success', - 'message': f'Plugin {plugin_id} configuration reset to defaults', - 'data': {'config': defaults} - }) - except Exception as e: - logger.error('Error in reset_plugin_config', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/action', methods=['POST']) -def execute_plugin_action(): - """Execute a plugin-defined action (e.g., authentication)""" - try: - # Try to get JSON data, with better error handling - try: - data = request.get_json(force=True) or {} - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error parsing JSON in execute_plugin_action: {e}") - return jsonify({ - 'status': 'error', - 'message': 'Invalid JSON in request body', - 'content_type': request.content_type }), 400 - - plugin_id = data.get('plugin_id') - action_id = data.get('action_id') - action_params = data.get('params', {}) - - if not plugin_id or not action_id: - return jsonify({ - 'status': 'error', - 'message': 'plugin_id and action_id required', - 'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)} - }), 400 - - # Get plugin directory - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Load manifest to get action definition - manifest_path = Path(plugin_dir) / 'manifest.json' - if not manifest_path.exists(): - return jsonify({'status': 'error', 'message': 'Plugin manifest not found'}), 404 - - with open(manifest_path, 'r', encoding='utf-8') as f: - manifest = json.load(f) - - web_ui_actions = manifest.get('web_ui_actions', []) - action_def = None - for action in web_ui_actions: - if action.get('id') == action_id: - action_def = action - break - - if not action_def: - return jsonify({'status': 'error', 'message': f'Action {action_id} not found in plugin manifest'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - # Execute action based on type - action_type = action_def.get('type', 'script') - - if action_type == 'script': - # Execute a Python script - script_path = action_def.get('script') - if not script_path: - return jsonify({'status': 'error', 'message': 'Script path not defined for action'}), 400 - - script_file = Path(plugin_dir) / script_path - if not script_file.exists(): - return jsonify({'status': 'error', 'message': f'Script not found: {script_path}'}), 404 - - # Handle multi-step actions (like Spotify OAuth) - step = action_params.get('step') - - if step == '2' and action_params.get('redirect_url'): - # Step 2: Complete authentication with redirect URL - redirect_url = action_params.get('redirect_url') - import tempfile - import json as json_lib - - redirect_url_escaped = json_lib.dumps(redirect_url) - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the script and provide redirect URL -proc = subprocess.Popen( - [sys.executable, r"{script_file}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send redirect URL to stdin -redirect_url = {redirect_url_escaped} -stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name - - try: - result = subprocess.run( - ['python3', wrapper_path], - capture_output=True, - text=True, - timeout=120, - env=env - ) - os.unlink(wrapper_path) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': action_def.get('success_message', 'Action completed successfully'), - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': action_def.get('error_message', 'Action failed'), - 'output': result.stdout + result.stderr - }), 400 - except subprocess.TimeoutExpired: - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) - return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 - else: - # Regular script execution - pass params via stdin if provided - if action_params: - # Pass params as JSON via stdin - import tempfile - import json as json_lib - - params_json = json_lib.dumps(action_params) - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os -import json - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the script and provide params as JSON via stdin -proc = subprocess.Popen( - [sys.executable, r"{script_file}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send params as JSON to stdin -params = {params_json} -stdout, _ = proc.communicate(input=json.dumps(params), timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name - - try: - result = subprocess.run( - ['python3', wrapper_path], - capture_output=True, - text=True, - timeout=120, - env=env - ) - os.unlink(wrapper_path) - - # Try to parse output as JSON - try: - output_data = json.loads(result.stdout) - if result.returncode == 0: - return jsonify(output_data) - else: - return jsonify({ - 'status': 'error', - 'message': output_data.get('message', action_def.get('error_message', 'Action failed')), - 'output': result.stdout + result.stderr - }), 400 - except json.JSONDecodeError: - # Output is not JSON, return as text - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': action_def.get('success_message', 'Action completed successfully'), - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': action_def.get('error_message', 'Action failed'), - 'output': result.stdout + result.stderr - }), 400 - except subprocess.TimeoutExpired: - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) - return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 - else: - # No params - check for OAuth flow first, then run script normally - # Step 1: Get initial data (like auth URL) - # For OAuth flows, we might need to import the script as a module - if action_def.get('oauth_flow'): - # Import script as module to get auth URL - import sys - import importlib.util - - spec = importlib.util.spec_from_file_location("plugin_action", script_file) - action_module = importlib.util.module_from_spec(spec) - sys.modules["plugin_action"] = action_module - - try: - spec.loader.exec_module(action_module) - - # Try to get auth URL using common patterns - auth_url = None - if hasattr(action_module, 'get_auth_url'): - auth_url = action_module.get_auth_url() - elif hasattr(action_module, 'load_spotify_credentials'): - # Spotify-specific pattern - client_id, client_secret, redirect_uri = action_module.load_spotify_credentials() - if all([client_id, client_secret, redirect_uri]): - from spotipy.oauth2 import SpotifyOAuth - sp_oauth = SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope=getattr(action_module, 'SCOPE', ''), - cache_path=getattr(action_module, 'SPOTIFY_AUTH_CACHE_PATH', None), - open_browser=False - ) - auth_url = sp_oauth.get_authorize_url() - - if auth_url: - return jsonify({ - 'status': 'success', - 'message': action_def.get('step1_message', 'Authorization URL generated'), - 'auth_url': auth_url, - 'requires_step2': True - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Could not generate authorization URL' - }), 400 - except Exception as e: - logger.error("Error executing action step 1", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - else: - # Simple script execution - result = subprocess.run( - ['python3', str(script_file)], - capture_output=True, - text=True, - timeout=60, - env=env - ) - - # Try to parse output as JSON - try: - import json as json_module - output_data = json_module.loads(result.stdout) - if result.returncode == 0: - return jsonify(output_data) - else: - return jsonify({ - 'status': 'error', - 'message': output_data.get('message', action_def.get('error_message', 'Action failed')), - 'output': result.stdout + result.stderr - }), 400 - except json.JSONDecodeError: - # Output is not JSON, return as text - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': action_def.get('success_message', 'Action completed successfully'), - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': action_def.get('error_message', 'Action failed'), - 'output': result.stdout + result.stderr - }), 400 - - elif action_type == 'endpoint': - # Call a plugin-defined HTTP endpoint (future feature) - return jsonify({'status': 'error', 'message': 'Endpoint actions not yet implemented'}), 501 - - else: - return jsonify({'status': 'error', 'message': f'Unknown action type: {action_type}'}), 400 - - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 - except Exception as e: - logger.error('Error in execute_plugin_action', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/authenticate/spotify', methods=['POST']) -def authenticate_spotify(): - """Run Spotify authentication script""" - try: - data = request.get_json(silent=True) or {} - redirect_url = data.get('redirect_url', '').strip() - - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_spotify.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - if redirect_url: - # Step 2: Complete authentication with redirect URL - # Create a wrapper script that provides the redirect URL as input - import tempfile - - # Create a wrapper script that provides the redirect URL - import json - redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the auth script and provide redirect URL -proc = subprocess.Popen( - [sys.executable, r"{auth_script}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send redirect URL to stdin -redirect_url = {redirect_url_escaped} -stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name - - try: - result = subprocess.run( - ['python3', wrapper_path], - capture_output=True, - text=True, - timeout=120, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'Spotify authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Spotify authentication failed', - 'output': result.stdout + result.stderr - }), 400 - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - finally: - # The wrapper carries the user's redirect URL, so it must not - # survive the request on any path — including a failure to - # launch, which the previous per-branch unlinks missed. - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) - else: - # Step 1: Get authorization URL - # Import the script's functions directly to get the auth URL - import sys - import importlib.util - - # Load the authentication script as a module - spec = importlib.util.spec_from_file_location("auth_spotify", auth_script) - auth_module = importlib.util.module_from_spec(spec) - sys.modules["auth_spotify"] = auth_module - - # Set LEDMATRIX_ROOT before loading - os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - try: - spec.loader.exec_module(auth_module) - - # Get credentials and create OAuth object - client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials() - if not all([client_id, client_secret, redirect_uri]): - return jsonify({ - 'status': 'error', - 'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.' - }), 400 - - from spotipy.oauth2 import SpotifyOAuth - sp_oauth = SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope=auth_module.SCOPE, - cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH, - open_browser=False - ) - - auth_url = sp_oauth.get_authorize_url() - - return jsonify({ - 'status': 'success', - 'message': 'Authorization URL generated', - 'auth_url': auth_url - }) - except Exception as e: - logger.error("Error getting Spotify auth URL", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - - except Exception as e: - logger.error('Error in authenticate_spotify', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/authenticate/ytm', methods=['POST']) -def authenticate_ytm(): - """Run YouTube Music authentication script""" - try: - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_ytm.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - # Run the authentication script - result = subprocess.run( - ['python3', str(auth_script)], - capture_output=True, - text=True, - timeout=60, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'YouTube Music authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'YouTube Music authentication failed', - 'output': result.stdout + result.stderr - }), 400 - - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - except Exception as e: - logger.error('Error in authenticate_ytm', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/catalog', methods=['GET']) -def get_fonts_catalog(): - """Get fonts catalog""" - try: - # Check cache first (5 minute TTL) - try: - from web_interface.cache import get_cached, set_cached - cached_result = get_cached('fonts_catalog', ttl_seconds=300) - if cached_result is not None: - return jsonify({'status': 'success', 'data': {'catalog': cached_result}}) - except ImportError: - # Cache not available, continue without caching - get_cached = None - set_cached = None - - # Try to import freetype, but continue without it if unavailable - try: - import freetype - freetype_available = True - except ImportError: - freetype_available = False - - # Scan assets/fonts directory for actual font files - fonts_dir = PROJECT_ROOT / "assets" / "fonts" - catalog = {} - - if fonts_dir.exists() and fonts_dir.is_dir(): - for filename in os.listdir(fonts_dir): - if filename.endswith(('.ttf', '.otf', '.bdf')): - filepath = fonts_dir / filename - # Generate family name from filename (without extension) - family_name = os.path.splitext(filename)[0] - - # Try to get font metadata using freetype (for TTF/OTF) - metadata = {} - if filename.endswith(('.ttf', '.otf')) and freetype_available: - try: - face = freetype.Face(str(filepath)) - if face.valid: - # Get font family name from font file - family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name - metadata = { - 'family': family_name_from_font, - 'style': face.style_name.decode('utf-8') if face.style_name else 'Regular', - 'num_glyphs': face.num_glyphs, - 'units_per_em': face.units_per_EM - } - # Use font's family name if available - if family_name_from_font: - family_name = family_name_from_font - except Exception: - # If freetype fails, use filename-based name - pass - - # Store relative path from project root - relative_path = str(filepath.relative_to(PROJECT_ROOT)) - font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf' - - # Generate human-readable display name from family_name - display_name = family_name.replace('-', ' ').replace('_', ' ') - # Add space before capital letters for camelCase names - display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name) - # Add space before numbers that follow letters - display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name) - # Clean up multiple spaces - display_name = ' '.join(display_name.split()) - - # Use filename (without extension) as unique key to avoid collisions - # when multiple files share the same family_name from font metadata - catalog_key = os.path.splitext(filename)[0] - - # Check if this is a system font (cannot be deleted) - is_system = catalog_key.lower() in SYSTEM_FONTS - - catalog[catalog_key] = { - 'filename': filename, - 'family_name': family_name, - 'display_name': display_name, - 'path': relative_path, - 'type': font_type, - 'is_system': is_system, - 'metadata': metadata if metadata else None - } - - # Cache the result (5 minute TTL) if available - if set_cached: - try: - set_cached('fonts_catalog', catalog, ttl_seconds=300) - except Exception: - logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True) - - return jsonify({'status': 'success', 'data': {'catalog': catalog}}) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/tokens', methods=['GET']) -def get_font_tokens(): - """Get font size tokens""" - try: - # This would integrate with the actual font system - # For now, return sample tokens - tokens = { - 'xs': 6, - 'sm': 8, - 'md': 10, - 'lg': 12, - 'xl': 14, - 'xxl': 16 - } - return jsonify({'status': 'success', 'data': {'tokens': tokens}}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/overrides', methods=['GET']) -def get_fonts_overrides(): - """Get font overrides""" - try: - # This would integrate with the actual font system - # For now, return empty overrides - overrides = {} - return jsonify({'status': 'success', 'data': {'overrides': overrides}}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/overrides', methods=['POST']) -def save_fonts_overrides(): - """Save font overrides""" - try: - data = request.get_json(silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # This would integrate with the actual font system - return jsonify({'status': 'success', 'message': 'Font overrides saved'}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/overrides/', methods=['DELETE']) -def delete_font_override(element_key): - """Delete font override""" - try: - # This would integrate with the actual font system - return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/fonts/upload', methods=['POST']) -def upload_font(): - """Upload font file""" - try: - if 'font_file' not in request.files: - return jsonify({'status': 'error', 'message': 'No font file provided'}), 400 - - font_file = request.files['font_file'] - if font_file.filename == '': - return jsonify({'status': 'error', 'message': 'No file selected'}), 400 - - # Validate filename - is_valid, error_msg = validate_file_upload( - font_file.filename, - max_size_mb=10, - allowed_extensions=['.ttf', '.otf', '.bdf'] - ) - if not is_valid: - return jsonify({'status': 'error', 'message': error_msg}), 400 - - font_family = request.form.get('font_family', '') - - if not font_family: - return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400 - - # Validate font family name - if not font_family.replace('_', '').replace('-', '').isalnum(): - return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400 - - # Save the font file to assets/fonts directory - fonts_dir = PROJECT_ROOT / "assets" / "fonts" - fonts_dir.mkdir(parents=True, exist_ok=True) - - # Create filename from family name - original_ext = os.path.splitext(font_file.filename)[1].lower() - safe_filename = f"{font_family}{original_ext}" - filepath = fonts_dir / safe_filename - - # Check if file already exists - if filepath.exists(): - return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400 - - # Save the file - font_file.save(str(filepath)) - - # Clear font catalog cache - try: - from web_interface.cache import delete_cached - delete_cached('fonts_catalog') - except ImportError as e: - logger.warning("[FontUpload] Cache module not available: %s", e) - except Exception: - logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True) - - return jsonify({ - 'status': 'success', - 'message': f'Font {font_family} uploaded successfully', - 'font_family': font_family, - 'filename': safe_filename, - 'path': f'assets/fonts/{safe_filename}' - }) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - - -@api_v3.route('/fonts/preview', methods=['GET']) -def get_font_preview() -> tuple[Response, int] | Response: - """Generate a preview image of text rendered with a specific font""" - try: - from PIL import Image, ImageDraw, ImageFont - import io - import base64 - - # Limits to prevent DoS via large image generation on constrained devices - MAX_TEXT_CHARS = 100 - MAX_TEXT_LINES = 3 - MAX_DIM = 1024 # Max width or height in pixels - MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700) - - font_filename = request.args.get('font', '') - text = request.args.get('text', 'Sample Text 123') - bg_color = request.args.get('bg', '000000') - fg_color = request.args.get('fg', 'ffffff') - - # Validate text length and line count early - if len(text) > MAX_TEXT_CHARS: - return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400 - if text.count('\n') >= MAX_TEXT_LINES: - return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400 - - # Safe integer parsing for size - try: - size = int(request.args.get('size', 12)) - except (ValueError, TypeError, OverflowError): - return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400 - - if not font_filename: - return jsonify({'status': 'error', 'message': 'Font filename required'}), 400 - - # Validate size - if size < 4 or size > 72: - return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400 - - # Security: Validate font_filename to prevent path traversal - # Only allow alphanumeric, hyphen, underscore, and dot (for extension) - safe_name = Path(font_filename).name # Strip any directory components - if safe_name != font_filename or '..' in font_filename: - return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400 - - # Validate extension - allowed_extensions = ['.ttf', '.otf', '.bdf'] - has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions) - name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name - - # Find the font file - fonts_dir = PROJECT_ROOT / "assets" / "fonts" - if not fonts_dir.exists(): - return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404 - - font_path = fonts_dir / safe_name - - if not font_path.exists() and not has_valid_ext: - # Try finding by family name (without extension) - for ext in allowed_extensions: - potential_path = fonts_dir / f"{name_without_ext}{ext}" - if potential_path.exists(): - font_path = potential_path - break - - # Final security check: ensure path is within fonts_dir - try: - font_path.resolve().relative_to(fonts_dir.resolve()) - except ValueError: - return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400 - - if not font_path.exists(): - return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404 - - # Parse colors - try: - bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4)) - fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4)) - except (ValueError, IndexError): - bg_rgb = (0, 0, 0) - fg_rgb = (255, 255, 255) - - # 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 - else: - # TTF/OTF fonts - try: - font = ImageFont.truetype(str(font_path), size) - except (IOError, OSError) as e: - # IOError/OSError raised for invalid/corrupt font files - 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] - - # Create image with padding - padding = 10 - img_width = max(text_width + padding * 2, 100) - img_height = max(text_height + padding * 2, 30) - - # Validate resulting image size to prevent memory/CPU spikes - if img_width > MAX_DIM or img_height > MAX_DIM: - return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400 - if img_width * img_height > MAX_PIXELS: - return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400 - - img = Image.new('RGB', (img_width, img_height), bg_rgb) - draw = ImageDraw.Draw(img) - - # Center text - x = (img_width - text_width) // 2 - y = (img_height - text_height) // 2 - - draw.text((x, y), text, font=font, fill=fg_rgb) - - # Convert to base64 - buffer = io.BytesIO() - img.save(buffer, format='PNG') - buffer.seek(0) - img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') - - return jsonify({ - 'status': 'success', - 'data': { - 'image': f'data:image/png;base64,{img_base64}', - 'width': img_width, - 'height': img_height - } - }) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - - -@api_v3.route('/fonts/', methods=['DELETE']) -def delete_font(font_family: str) -> tuple[Response, int] | Response: - """Delete a user-uploaded font file""" - try: - # Security: Validate font_family to prevent path traversal - # Reject if it contains path separators or .. - if '..' in font_family or '/' in font_family or '\\' in font_family: - return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400 - - # Only allow safe characters: alphanumeric, hyphen, underscore, dot - if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family): - return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400 - - # Check if this is a system font (uses module-level SYSTEM_FONTS frozenset) - if font_family.lower() in SYSTEM_FONTS: - return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403 - - # Find and delete the font file - fonts_dir = PROJECT_ROOT / "assets" / "fonts" - - # Ensure fonts directory exists - if not fonts_dir.exists() or not fonts_dir.is_dir(): - return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404 - - deleted = False - deleted_filename = None - - # Only try valid font extensions (no empty string to avoid matching directories) - for ext in ['.ttf', '.otf', '.bdf']: - potential_path = fonts_dir / f"{font_family}{ext}" - - # Security: Verify path is within fonts_dir - try: - potential_path.resolve().relative_to(fonts_dir.resolve()) - except ValueError: - continue # Path escapes fonts_dir, skip - - if potential_path.exists() and potential_path.is_file(): - potential_path.unlink() - deleted = True - deleted_filename = f"{font_family}{ext}" - break - - if not deleted: - # Try case-insensitive match within fonts directory - font_family_lower = font_family.lower() - for filename in os.listdir(fonts_dir): - # Only consider files with valid font extensions - if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']): - continue - - name_without_ext = os.path.splitext(filename)[0] - if name_without_ext.lower() == font_family_lower: - filepath = fonts_dir / filename - - # Security: Verify path is within fonts_dir - try: - filepath.resolve().relative_to(fonts_dir.resolve()) - except ValueError: - continue # Path escapes fonts_dir, skip - - if filepath.is_file(): - filepath.unlink() - deleted = True - deleted_filename = filename - break - - if not deleted: - return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404 - - # Clear font catalog cache - try: - from web_interface.cache import delete_cached - delete_cached('fonts_catalog') - except ImportError as e: - logger.warning("[FontDelete] Cache module not available: %s", e) - except Exception: - logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True) - - return jsonify({ - 'status': 'success', - 'message': f'Font {deleted_filename} deleted successfully' - }) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - - -@api_v3.route('/plugins/assets/upload', methods=['POST']) -def upload_plugin_asset(): - """Upload asset files for a plugin""" - try: - plugin_id = request.form.get('plugin_id') - if not plugin_id: - return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 - - if 'files' not in request.files: - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - files = request.files.getlist('files') - if not files or all(not f.filename for f in files): - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - # Validate file count - if len(files) > 10: - return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400 - - # Setup plugin assets directory - assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' - assets_dir.mkdir(parents=True, exist_ok=True) - - # Load metadata file - metadata_file = assets_dir / '.metadata.json' - if metadata_file.exists(): - with open(metadata_file, 'r') as f: - metadata = json.load(f) - else: - metadata = {} - - uploaded_files = [] - total_size = 0 - max_size_per_file = 5 * 1024 * 1024 # 5MB - max_total_size = 50 * 1024 * 1024 # 50MB - - # Calculate current total size - for entry in metadata.values(): - if 'size' in entry: - total_size += entry.get('size', 0) - - for file in files: - if not file.filename: - continue - - # Validate file type - allowed_extensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif'] - file_ext = '.' + file.filename.lower().split('.')[-1] - if file_ext not in allowed_extensions: - return jsonify({ - 'status': 'error', - 'message': f'Invalid file type: {file_ext}. Allowed: {allowed_extensions}' - }), 400 - - # Read file to check size and validate - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > max_size_per_file: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} exceeds 5MB limit' - }), 400 - - if total_size + file_size > max_total_size: - return jsonify({ - 'status': 'error', - 'message': f'Upload would exceed 50MB total storage limit' - }), 400 - - # Validate file is actually an image (check magic bytes) - file_content = file.read(8) - file.seek(0) - is_valid_image = False - if file_content.startswith(b'\x89PNG\r\n\x1a\n'): # PNG - is_valid_image = True - elif file_content[:2] == b'\xff\xd8': # JPEG - is_valid_image = True - elif file_content[:2] == b'BM': # BMP - is_valid_image = True - elif file_content[:6] in [b'GIF87a', b'GIF89a']: # GIF - is_valid_image = True - - if not is_valid_image: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} is not a valid image file' - }), 400 - - # Generate unique filename - timestamp = int(time.time()) - file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8] - safe_filename = f"image_{timestamp}_{file_hash}{file_ext}" - file_path = assets_dir / safe_filename - - # Ensure filename is unique - counter = 1 - while file_path.exists(): - safe_filename = f"image_{timestamp}_{file_hash}_{counter}{file_ext}" - file_path = assets_dir / safe_filename - counter += 1 - - # Save file - file.save(str(file_path)) - - # Make file readable - os.chmod(file_path, 0o644) - - # Generate unique ID - image_id = str(uuid.uuid4()) - - # Store metadata - relative_path = f"assets/plugins/{plugin_id}/uploads/{safe_filename}" - metadata[image_id] = { - 'id': image_id, - 'filename': safe_filename, - 'path': relative_path, - 'size': file_size, - 'uploaded_at': datetime.utcnow().isoformat() + 'Z', - 'original_filename': file.filename - } - - uploaded_files.append({ - 'id': image_id, - 'filename': safe_filename, - 'path': relative_path, - 'size': file_size, - '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) - - return jsonify({ - 'status': 'success', - 'uploaded_files': uploaded_files, - 'total_files': len(metadata) - }) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) -def upload_of_the_day_json(): - """Upload JSON files for of-the-day plugin""" - try: - if 'files' not in request.files: - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - files = request.files.getlist('files') - if not files or all(not f.filename for f in files): - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Setup of_the_day directory - data_dir = Path(plugin_dir) / 'of_the_day' - data_dir.mkdir(parents=True, exist_ok=True) - - uploaded_files = [] - max_size_per_file = 5 * 1024 * 1024 # 5MB - - for file in files: - if not file.filename: - continue - - # Validate file extension - if not file.filename.lower().endswith('.json'): - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} must be a JSON file (.json)' - }), 400 - - # Read and validate file size - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > max_size_per_file: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} exceeds 5MB limit' - }), 400 - - # Read and validate JSON content - try: - file_content = file.read().decode('utf-8') - json_data = json.loads(file_content) - except json.JSONDecodeError as e: - return jsonify({ - 'status': 'error', - 'message': 'Invalid JSON in request body' - }), 400 - except UnicodeDecodeError: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} is not valid UTF-8 text' - }), 400 - - # Validate JSON structure (must be object with day number keys) - if not isinstance(json_data, dict): - return jsonify({ - 'status': 'error', - 'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys' - }), 400 - - # Check if keys are valid day numbers - for key in json_data.keys(): - try: - day_num = int(key) - if day_num < 1 or day_num > 365: - return jsonify({ - 'status': 'error', - 'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)' - }), 400 - except ValueError: - return jsonify({ - 'status': 'error', - 'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)' - }), 400 - - # Generate safe filename from original (preserve user's filename) - original_filename = file.filename - safe_filename = original_filename.lower().replace(' ', '_') - # Ensure it's a valid filename - safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-') - if not safe_filename.endswith('.json'): - safe_filename += '.json' - - file_path = data_dir / safe_filename - - # If file exists, add counter - counter = 1 - base_name = safe_filename.replace('.json', '') - while file_path.exists(): - safe_filename = f"{base_name}_{counter}.json" - file_path = data_dir / safe_filename - counter += 1 - - # Save file - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(json_data, f, indent=2, ensure_ascii=False) - - # Make file readable - os.chmod(file_path, 0o644) - - # Extract category name from filename (remove .json extension) - category_name = safe_filename.replace('.json', '') - display_name = category_name.replace('_', ' ').title() - - # Update plugin config to add category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import add_category_to_config - add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name) - except Exception as e: - logger.warning("Could not update config: %s", e) - # Continue anyway - file is uploaded - - # Generate file ID (use category name as ID for simplicity) - file_id = category_name - - uploaded_files.append({ - 'id': file_id, - 'filename': safe_filename, - 'original_filename': original_filename, - 'path': f'of_the_day/{safe_filename}', - 'size': file_size, - 'uploaded_at': datetime.utcnow().isoformat() + 'Z', - 'category_name': category_name, - 'display_name': display_name, - 'entry_count': len(json_data) - }) - - return jsonify({ - 'status': 'success', - 'uploaded_files': uploaded_files, - 'total_files': len(uploaded_files) - }) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) -def delete_of_the_day_json(): - """Delete a JSON file from of-the-day plugin""" - try: - data = request.get_json(silent=True) or {} - file_id = data.get('file_id') # This is the category_name - - if not file_id: - return jsonify({'status': 'error', 'message': 'file_id is required'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - data_dir = Path(plugin_dir) / 'of_the_day' - filename = f"{file_id}.json" - file_path = data_dir / filename - - if not file_path.exists(): - return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404 - - # Delete file - file_path.unlink() - - # Update config to remove category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import remove_category_from_config - remove_category_from_config(file_id) - except Exception as e: - logger.warning("Could not update config: %s", e) - - return jsonify({ - 'status': 'success', - 'message': f'File {filename} deleted successfully' - }) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins//static/', methods=['GET']) -def serve_plugin_static(plugin_id, file_path): - """Serve static files from plugin directory""" - try: - # Get plugin directory - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Resolve file path (prevent directory traversal) - plugin_dir = Path(plugin_dir).resolve() - requested_file = (plugin_dir / file_path).resolve() - - # Security check: ensure file is within plugin directory - if not str(requested_file).startswith(str(plugin_dir)): - return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403 - - # Check if file exists - if not requested_file.exists() or not requested_file.is_file(): - return jsonify({'status': 'error', 'message': 'File not found'}), 404 - - # Determine content type - content_type = 'text/plain' - if file_path.endswith('.html'): - content_type = 'text/html' - elif file_path.endswith('.js'): - content_type = 'application/javascript' - elif file_path.endswith('.css'): - content_type = 'text/css' - elif file_path.endswith('.json'): - content_type = 'application/json' - - # Read and return file - with open(requested_file, 'r', encoding='utf-8') as f: - content = f.read() - - return Response(content, mimetype=content_type) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - - -_MAX_CREDENTIAL_BACKUPS = 5 - - -def _prune_credential_backups(plugin_dir: Path) -> None: - """Keep only the newest _MAX_CREDENTIAL_BACKUPS credential backups. - - Every re-upload copies the previous credentials.json aside. Without - pruning those accumulate for the life of the install — each one a - complete set of OAuth client credentials sitting in the plugin - directory. - """ - backups = sorted( - plugin_dir.glob('credentials.json.backup.*'), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - for stale in backups[_MAX_CREDENTIAL_BACKUPS:]: - try: - stale.unlink() - except OSError: - logger.warning("Could not remove old credential backup %s", stale.name) - - -@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) -def upload_calendar_credentials(): - """Upload credentials.json file for calendar plugin""" - try: - if 'file' not in request.files: - return jsonify({'status': 'error', 'message': 'No file provided'}), 400 - - file = request.files['file'] - if not file or not file.filename: - return jsonify({'status': 'error', 'message': 'No file provided'}), 400 - - # Validate file extension - if not file.filename.lower().endswith('.json'): - return jsonify({'status': 'error', 'message': 'File must be a JSON file (.json)'}), 400 - - # Validate file size (max 1MB for credentials) - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > 1024 * 1024: # 1MB - return jsonify({'status': 'error', 'message': 'File exceeds 1MB limit'}), 400 - - # Validate it's valid JSON - try: - file_content = file.read() - file.seek(0) - creds_data = json.loads(file_content) - except json.JSONDecodeError: - return jsonify({'status': 'error', 'message': 'File is not valid JSON'}), 400 - - # Validate it looks like Google OAuth credentials. A bare scalar, a - # list, true/null — all valid JSON, none of them credentials. Reject - # rather than save: a file written as credentials.json but unusable - # as credentials only fails later, somewhere less obvious. - if not isinstance(creds_data, dict) or not ( - 'installed' in creds_data or 'web' in creds_data): - return jsonify({ - 'status': 'error', - 'message': 'File does not appear to be a valid Google OAuth credentials file' - }), 400 - - # Get plugin directory - plugin_id = 'calendar' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Save file to plugin directory - credentials_path = Path(plugin_dir) / 'credentials.json' - - # Backup existing file if it exists - if credentials_path.exists(): - backup_path = Path(plugin_dir) / f'credentials.json.backup.{int(time.time())}' - import shutil - 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 - - return jsonify({ - 'status': 'success', - 'message': 'Credentials file uploaded successfully', - 'path': str(credentials_path) - }) - - except Exception as e: - logger.error('Error in upload_calendar_credentials', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -# calendarList.list pages at 250 entries maximum. Ten pages is far past any -# real account and exists only so a malformed nextPageToken cannot spin here. -_CALENDAR_LIST_MAX_PAGES = 10 - - -def _calendar_plugin_dir() -> Optional[Path]: - """Where the calendar plugin is installed, or None if it is not.""" - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar') - else: - plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar' - if not plugin_dir: - return None - plugin_dir = Path(plugin_dir) - return plugin_dir if plugin_dir.exists() else None - - -def _run_calendar_registration(plugin_dir: Path, stdin_payload: str): - """Run the plugin's OAuth script and return the JSON object it prints. - - The script decides between web and terminal mode by whether stdin is a - tty, so it must be given a pipe. It emits one JSON object on stdout; the - last parsable line is taken, because an import warning or a library's - stderr redirection can land in front of it. - - Returns (payload, error_message). Exactly one is None. - """ - script = plugin_dir / 'calendar_registration.py' - if not script.exists(): - return None, 'Authentication script not found in the calendar plugin' - - try: - result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir - [sys.executable, str(script)], - input=stdin_payload, - capture_output=True, - text=True, - timeout=120, - cwd=str(plugin_dir), - ) - except subprocess.TimeoutExpired: - return None, 'Authentication timed out after 120s' - except OSError as e: - logger.error('Could not run calendar_registration.py', exc_info=True) - return None, 'Could not run the authentication script: %s' % describe_exception(e) - - for line in reversed((result.stdout or '').splitlines()): - line = line.strip() - if not line: - continue - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(payload, dict): - return payload, None - - raw = (result.stderr or result.stdout or '').strip() - # The unredacted text goes to the log, where it is worth having in full. - # What comes back over HTTP is redacted: this is a script that handles - # OAuth client secrets, and its stderr can quote them. - if raw: - logger.error('calendar_registration.py failed (exit %s): %s', - result.returncode, raw) - return None, 'Authentication script produced no result%s' % ( - ': %s' % redact_text(raw) if raw else '') - - -@api_v3.route('/plugins/calendar/authenticate', methods=['POST']) -def authenticate_calendar(): - """Google OAuth for the calendar plugin, in the two steps it requires. - - Step 1 (no body) returns the consent URL to open. Step 2 posts back the - URL Google redirected to -- it fails to load, because the redirect points - at a loopback address nothing is listening on, but the address bar carries - the authorization code -- and the script exchanges it for a token. - - Two calls rather than one because the user has to visit Google in between. - The script persists the PKCE verifier from step 1 for step 2 to reuse; the - exchange fails with "Missing code verifier" otherwise. - """ - try: - plugin_dir = _calendar_plugin_dir() - if plugin_dir is None: - return jsonify({ - 'status': 'error', - 'message': 'The calendar plugin is not installed' - }), 404 - - if not (plugin_dir / 'credentials.json').exists(): - return jsonify({ - 'status': 'error', - 'message': ('No credentials.json yet. Upload your Google OAuth ' - 'client file first (Step 1).') - }), 400 - - data = request.get_json(silent=True) or {} - redirect_url = (data.get('redirect_url') or data.get('code') or '').strip() - - payload, error = _run_calendar_registration(plugin_dir, redirect_url) - if error: - return jsonify({'status': 'error', 'message': error}), 500 - if payload.get('status') != 'success': - # The script's own diagnosis is more useful than anything that - # could be reconstructed here -- but it interpolates exceptions - # into its messages, so it reaches the client redacted and the - # original goes to the log. - logger.error('calendar authentication failed: %s', payload) - safe = dict(payload) - safe['message'] = redact_text(str(payload.get('message', '') - or 'Authentication failed')) - return jsonify(safe), 400 - return jsonify(payload) - - except Exception as e: - logger.error('Error in authenticate_calendar', exc_info=True) - return jsonify({'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e)}), 500 - - -@api_v3.route('/plugins/calendar/list-calendars', methods=['GET']) -def list_calendar_calendars(): - """The calendars this account can see, for the config picker. - - Reads the token the OAuth flow wrote rather than shelling out again: the - picker is used interactively and a subprocess per click is slower than the - API call it would be wrapping. - """ - try: - plugin_dir = _calendar_plugin_dir() - if plugin_dir is None: - return jsonify({ - 'status': 'error', - 'message': 'The calendar plugin is not installed' - }), 404 - - token_file = plugin_dir / 'token.pickle' - if not token_file.exists(): - return jsonify({ - 'status': 'error', - 'message': ('Not authenticated with Google yet. Complete Step 2 ' - 'first, then load your calendars.') - }), 400 - - try: - import pickle - from google.auth.transport.requests import Request as GoogleRequest - from googleapiclient.discovery import build as build_google_service - except ImportError as e: - return jsonify({ - 'status': 'error', - # The name of the missing module is the whole diagnosis, but it - # arrives as an exception, so it goes through the redactor like - # any other -- an ImportError can quote a path. - 'message': ('The Google API libraries are not installed. Install ' - "the calendar plugin's requirements.txt. (%s)" - % describe_exception(e)) - }), 500 - - with open(token_file, 'rb') as handle: - # Written only by this plugin's own OAuth flow, into its own - # directory, and read here exactly as the plugin itself reads it. - creds = pickle.load(handle) # nosec B301 - locally generated token - - if creds and creds.expired and creds.refresh_token: - creds.refresh(GoogleRequest()) - with open(token_file, 'wb') as handle: - pickle.dump(creds, handle) - os.chmod(token_file, 0o600) - - if not creds or not creds.valid: - return jsonify({ - 'status': 'error', - 'message': ('Stored Google credentials are no longer valid. ' - 'Run Step 2 again to re-authenticate.') - }), 400 - - service = build_google_service('calendar', 'v3', credentials=creds) - - # calendarList.list returns 100 entries per page by default and caps at - # 250, handing back a nextPageToken when there are more. Taking only - # the first page would silently hide calendars from the picker, and the - # user would have no way to tell the list was truncated. - entries = [] - page_token = None - for _ in range(_CALENDAR_LIST_MAX_PAGES): - response = service.calendarList().list( - maxResults=250, pageToken=page_token).execute() - entries.extend(response.get('items', [])) - page_token = response.get('nextPageToken') - if not page_token: - break - else: - # 2500 calendars in, something is wrong with the account or the - # token is looping; show what was collected rather than spin. - logger.warning( - 'calendarList paging stopped at %d pages with more remaining', - _CALENDAR_LIST_MAX_PAGES) - - calendars = [{ - 'id': entry.get('id'), - # The picker labels each row with summary and falls back to the id - # only in its own display, so send something either way. - 'summary': entry.get('summary') or entry.get('id'), - 'primary': bool(entry.get('primary', False)), - } for entry in entries if entry.get('id')] - - # Primary first, then alphabetically: the list is usually short but the - # one the user wants is almost always their own calendar. - calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower())) - - return jsonify({'status': 'success', 'calendars': calendars}) - - except Exception as e: - logger.error('Error in list_calendar_calendars', exc_info=True) - return jsonify({'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e)}), 500 - - -@api_v3.route('/plugins/assets/delete', methods=['POST']) -def delete_plugin_asset(): - """Delete an asset file for a plugin""" - try: - data = request.get_json() - plugin_id = data.get('plugin_id') - image_id = data.get('image_id') - - if not plugin_id or not image_id: - return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400 - - # Get asset directory - assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' - metadata_file = assets_dir / '.metadata.json' - - if not metadata_file.exists(): - return jsonify({'status': 'error', 'message': 'Metadata file not found'}), 404 - - # Load metadata - with open(metadata_file, 'r') as f: - metadata = json.load(f) - - if image_id not in metadata: - return jsonify({'status': 'error', 'message': 'Image not found'}), 404 - - # Delete file - file_path = PROJECT_ROOT / metadata[image_id]['path'] - if file_path.exists(): - file_path.unlink() - - # Remove from metadata - del metadata[image_id] - - # Save metadata - with open(metadata_file, 'w') as f: - json.dump(metadata, f, indent=2) - - return jsonify({'status': 'success', 'message': 'Image deleted successfully'}) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/plugins/assets/list', methods=['GET']) -def list_plugin_assets(): - """List asset files for a plugin""" - try: - plugin_id = request.args.get('plugin_id') - if not plugin_id: - return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 - - # Get asset directory - assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' - metadata_file = assets_dir / '.metadata.json' - - if not metadata_file.exists(): - return jsonify({'status': 'success', 'data': {'assets': []}}) - - # Load metadata - with open(metadata_file, 'r') as f: - metadata = json.load(f) - - # Convert to list - assets = list(metadata.values()) - - return jsonify({'status': 'success', 'data': {'assets': assets}}) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/display/current-status', methods=['GET']) -def get_current_display_status(): - """Return the display mode/plugin currently intended to be shown. - - Published by the display process (display_controller._publish_current_mode_state) - to the shared cache whenever the active mode changes, so the web UI (e.g. the - System Logs page) can show what's on screen without querying the display - process directly. - """ - try: - cache = _ensure_cache_manager() - state = cache.get('display_current_state', max_age=120) - if state is None: - state = { - 'mode': None, - 'plugin_id': None, - 'last_updated': None, - } - return jsonify({'status': 'success', 'data': state}) - except Exception as e: - logger.error('Error in get_current_display_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/logs', methods=['GET']) -def get_logs(): - """Get system logs from journalctl""" - try: - if not _JOURNALCTL: - return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503 - # Get recent logs from journalctl - _cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [ - '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service', - '-n', '100', '--no-pager', '--output=short-iso'] - result = subprocess.run( - _cmd, - capture_output=True, - text=True, - timeout=5 - ) - - if result.returncode == 0: - logs_text = result.stdout.strip() - return jsonify({ - 'status': 'success', - 'data': { - 'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service' - } - }) - else: - return jsonify({ - 'status': 'error', - 'message': f'Failed to get logs: {result.stderr}' - }), 500 - - except subprocess.TimeoutExpired: - return jsonify({ - 'status': 'error', - 'message': 'Timeout while fetching logs' - }), 500 - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -# Multi-Display Sync Endpoints -@api_v3.route('/sync/status', methods=['GET']) -def get_sync_status(): - """Return live multi-display sync status written by the display process.""" - import os as _os - status_file = "/tmp/led_matrix_sync_status.json" - # Also surface config so the UI can show the configured role even before - # the display process has written a status file. - cfg_role = "standalone" - cfg_port = 5765 - if api_v3.config_manager: - try: - cfg = api_v3.config_manager.load_config().get("sync", {}) - cfg_role = cfg.get("role", "standalone") - cfg_port = int(cfg.get("port", 5765)) - except Exception: - pass - - if _os.path.exists(status_file): - try: - with open(status_file) as f: - live = json.load(f) - return jsonify({"status": "success", "data": live}) - except Exception: - pass - - # Status file not yet written — return config-only placeholder - return jsonify({ - "status": "success", - "data": { - "role": cfg_role, - "port": cfg_port, - "state": "starting", - } - }) - - -# WiFi Management Endpoints -@api_v3.route('/wifi/status', methods=['GET']) -def get_wifi_status(): - """Get current WiFi connection status""" - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - status = wifi_manager.get_wifi_status() - - # Get auto-enable setting from config - auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period) - - return jsonify({ - 'status': 'success', - 'data': { - 'connected': status.connected, - 'ssid': status.ssid, - 'ip_address': status.ip_address, - 'signal': status.signal, - 'ap_mode_active': status.ap_mode_active, - 'auto_enable_ap_mode': auto_enable_ap - } - }) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/scan', methods=['GET']) -def scan_wifi_networks(): - """Scan for available WiFi networks - - If AP mode is active, it will be temporarily disabled during scanning - and automatically re-enabled afterward. Users connected to the AP will - be briefly disconnected during this process. - """ - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - - # Check if AP mode is active before scanning (for user notification) - ap_was_active = wifi_manager._is_ap_mode_active() - - # Perform the scan (this will handle AP mode disabling/enabling internally) - networks, _was_cached = wifi_manager.scan_networks() - - # Convert to dict format - networks_data = [ - { - 'ssid': net.ssid, - 'signal': net.signal, - 'security': net.security, - 'frequency': net.frequency - } - for net in networks - ] - - response_data = { - 'status': 'success', - 'data': networks_data - } - - # Inform user if AP mode was temporarily disabled - if ap_was_active: - response_data['message'] = ( - f'Found {len(networks_data)} networks. ' - 'Note: AP mode was temporarily disabled during scanning and has been re-enabled. ' - 'If you were connected to the setup network, you may need to reconnect.' - ) - - return jsonify(response_data) - except Exception as e: - logger.error("Error scanning WiFi networks", exc_info=True) - error_message = 'An error occurred while scanning WiFi networks; see logs for details' - - # Provide more specific error messages for common issues - error_str = str(e).lower() - if 'permission' in error_str or 'sudo' in error_str: - error_message = ( - 'Permission error while scanning. ' - 'The WiFi scan requires appropriate permissions. ' - 'Please ensure the application has necessary privileges.' - ) - elif 'timeout' in error_str: - error_message = ( - 'WiFi scan timed out. ' - 'The scan took too long to complete. ' - 'This may happen if the WiFi interface is busy or in use.' - ) - elif 'no wifi' in error_str or 'not available' in error_str: - error_message = ( - 'WiFi scanning tools are not available. ' - 'Please ensure NetworkManager (nmcli) or iwlist is installed.' - ) - - return jsonify({ - 'status': 'error', - 'message': error_message - }), 500 - -@api_v3.route('/wifi/connect', methods=['POST']) -def connect_wifi(): - """Connect to a WiFi network""" - try: - from src.wifi_manager import WiFiManager - - data = request.get_json(silent=True) - if not data: - return jsonify({ - 'status': 'error', - 'message': 'Request body is required' - }), 400 - - if 'ssid' not in data: - return jsonify({ - 'status': 'error', - 'message': 'SSID is required' - }), 400 - - ssid = data['ssid'] - if not ssid or not ssid.strip(): - return jsonify({ - 'status': 'error', - 'message': 'SSID cannot be empty' - }), 400 - - ssid = ssid.strip() - password = data.get('password', '') or '' - - wifi_manager = WiFiManager() - success, message = wifi_manager.connect_to_network(ssid, password) - - if success: - return jsonify({ - 'status': 'success', - 'message': message - }) - else: - return jsonify({ - 'status': 'error', - 'message': message or 'Failed to connect to network' - }), 400 - except Exception as e: - logger.error("Error connecting to WiFi", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/disconnect', methods=['POST']) -def disconnect_wifi(): - """Disconnect from the current WiFi network""" - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - success, message = wifi_manager.disconnect_from_network() - - if success: - return jsonify({ - 'status': 'success', - 'message': message - }) - else: - return jsonify({ - 'status': 'error', - 'message': message or 'Failed to disconnect from network' - }), 400 - except Exception as e: - logger.error("Error disconnecting from WiFi", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/ap/enable', methods=['POST']) -def enable_ap_mode(): - """Enable access point mode""" - try: - 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')) - success, message = wifi_manager.enable_ap_mode(force=force) - - if success: - return jsonify({ - 'status': 'success', - 'message': message - }) - else: - return jsonify({ - 'status': 'error', - 'message': message - }), 400 - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/ap/disable', methods=['POST']) -def disable_ap_mode(): - """Disable access point mode""" - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - success, message = wifi_manager.disable_ap_mode() - - if success: - return jsonify({ - 'status': 'success', - 'message': message - }) - else: - return jsonify({ - 'status': 'error', - 'message': message - }), 400 - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/ap/auto-enable', methods=['GET']) -def get_auto_enable_ap_mode(): - """Get auto-enable AP mode setting""" - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period) - - return jsonify({ - 'status': 'success', - 'data': { - 'auto_enable_ap_mode': auto_enable - } - }) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/ap/auto-enable', methods=['POST']) -def set_auto_enable_ap_mode(): - """Set auto-enable AP mode setting""" - try: - from src.wifi_manager import WiFiManager - - data = request.get_json(silent=True) - if data is None or 'auto_enable_ap_mode' not in data: - return jsonify({ - 'status': 'error', - 'message': 'auto_enable_ap_mode is required' - }), 400 - - auto_enable = bool(data['auto_enable_ap_mode']) - - wifi_manager = WiFiManager() - wifi_manager.config["auto_enable_ap_mode"] = auto_enable - wifi_manager._save_config() - - return jsonify({ - 'status': 'success', - 'message': f'Auto-enable AP mode set to {auto_enable}', - 'data': { - 'auto_enable_ap_mode': auto_enable - } - }) - except Exception as e: - logger.error("%s failed", request.path, exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', - 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/radio', methods=['GET']) -def get_wifi_radio(): - """Get current WiFi radio state (enabled/disabled) and wired-fallback status.""" - try: - from src.wifi_manager import WiFiManager - - wifi_manager = WiFiManager() - state = wifi_manager.get_wifi_radio_state() - - return jsonify({ - 'status': 'success', - 'data': state - }) - except Exception as e: - logger.error("Error getting WiFi radio state", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - -@api_v3.route('/wifi/radio', methods=['POST']) -def set_wifi_radio(): - """Turn the WiFi radio on or off. - - Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused - unless Ethernet is connected or force=True, to avoid locking the user out - of this web interface. - """ - try: - from src.wifi_manager import WiFiManager - - data = request.get_json(silent=True) or {} - if 'enabled' not in data: - return jsonify({ - 'status': 'error', - 'message': 'enabled is required' - }), 400 - - # Parse defensively: bool("false") is True, so mirror the string-aware - # coercion used for `force` — the endpoint is a public contract, not just - # the shipped UI (which always sends real JSON booleans). - _enabled_raw = data['enabled'] - enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes')) - _force_raw = data.get('force', False) - force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes')) - - wifi_manager = WiFiManager() - success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force) - - if success: - return jsonify({ - 'status': 'success', - 'message': message, - 'data': wifi_manager.get_wifi_radio_state() - }) - else: - return jsonify({ - 'status': 'error', - 'message': message, - 'reason': reason - }), 400 - except Exception as e: - logger.error("Error setting WiFi radio state", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - -@api_v3.route('/cache/list', methods=['GET']) -def list_cache_files(): - """List all cache files with metadata""" - try: - if not api_v3.cache_manager: - # Initialize cache manager if not already initialized - from src.cache_manager import CacheManager - api_v3.cache_manager = CacheManager() - - cache_files = api_v3.cache_manager.list_cache_files() - cache_dir = api_v3.cache_manager.get_cache_dir() - - return jsonify({ - 'status': 'success', - 'data': { - 'cache_files': cache_files, - 'cache_dir': cache_dir, - 'total_files': len(cache_files) - } - }) - except Exception as e: - logger.error('Error in list_cache_files', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - -@api_v3.route('/cache/delete', methods=['POST']) -def delete_cache_file(): - """Delete a specific cache file by key""" - try: - if not api_v3.cache_manager: - # Initialize cache manager if not already initialized - from src.cache_manager import CacheManager - api_v3.cache_manager = CacheManager() - - data = request.get_json(silent=True) - if not data or 'key' not in data: - return jsonify({'status': 'error', 'message': 'cache key is required'}), 400 - - cache_key = data['key'] - - # Delete the cache file - api_v3.cache_manager.clear_cache(cache_key) - - return jsonify({ - 'status': 'success', - 'message': f'Cache file for key "{cache_key}" deleted successfully' - }) - except Exception as e: - logger.error('Error in delete_cache_file', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 - - -# ============================================================================= -# Error Aggregation Endpoints -# ============================================================================= - -@api_v3.route('/errors/summary', methods=['GET']) -def get_error_summary(): - """ - Get summary of all errors for monitoring and debugging. - - Returns error counts, detected patterns, and recent errors. - """ - try: - aggregator = get_error_aggregator() - summary = aggregator.get_error_summary() - return success_response(data=summary, message="Error summary retrieved") - except Exception as e: - logger.error(f"Error getting error summary: {e}", exc_info=True) - return error_response( - error_code=ErrorCode.SYSTEM_ERROR, - message="Failed to retrieve error summary", - status_code=500 - ) - - -@api_v3.route('/errors/plugin/', methods=['GET']) -def get_plugin_errors(plugin_id): - """ - Get error health status for a specific plugin. - - Args: - plugin_id: Plugin identifier - - Returns health status and error statistics for the plugin. - """ - try: - aggregator = get_error_aggregator() - health = aggregator.get_plugin_health(plugin_id) - return success_response(data=health, message="Plugin health retrieved") - except Exception as e: - logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True) - return error_response( - error_code=ErrorCode.SYSTEM_ERROR, - message=f"Failed to retrieve health for plugin {plugin_id}", - status_code=500 - ) - - -@api_v3.route('/errors/clear', methods=['POST']) -def clear_old_errors(): - """ - Clear error records older than specified age. - - Request body (optional): - max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year) - """ - try: - data = request.get_json(silent=True) or {} - raw_max_age = data.get('max_age_hours', 24) - - # Validate and coerce max_age_hours - try: - max_age_hours = int(raw_max_age) - if max_age_hours < 1: - return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours must be at least 1", - context={'provided_value': raw_max_age}, - status_code=400 - ) - if max_age_hours > 8760: # 1 year max - return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours cannot exceed 8760 (1 year)", - context={'provided_value': raw_max_age}, - status_code=400 - ) - except (ValueError, TypeError, OverflowError): - return error_response( - error_code=ErrorCode.INVALID_INPUT, - message="max_age_hours must be a valid integer", - context={'provided_value': str(raw_max_age)}, - status_code=400 - ) - - aggregator = get_error_aggregator() - cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours) - - return success_response( - data={'cleared_count': cleared_count}, - message=f"Cleared {cleared_count} error records older than {max_age_hours} hours" - ) - except Exception as e: - logger.error(f"Error clearing old errors: {e}", exc_info=True) - return error_response( - error_code=ErrorCode.SYSTEM_ERROR, - message="Failed to clear old errors", - status_code=500 - ) - - -# --------------------------------------------------------------------------- -# Backup / Restore -# --------------------------------------------------------------------------- - -def _resolve_backup_export_dir() -> Path: - """Where exported backups live: beside the install, not inside it. - - They used to be written to ``/config/backups/exports``. That is - inside the directory a reinstall deletes, so the documented recovery path - -- export a backup, then reinstall -- destroyed the backup it had just - told the user to make. Anyone who downloaded the ZIP was fine; anyone - relying on the on-device copy was not. - - Falls back to the old location when the parent directory is not writable, - so an unusual layout degrades to previous behaviour instead of failing to - export at all. - """ - preferred = PROJECT_ROOT.parent / "ledmatrix-backups" - fallback = PROJECT_ROOT / "config" / "backups" / "exports" - try: - preferred.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=preferred, prefix=".writetest-"): - pass - return preferred - except OSError as e: - logger.warning( - f"[Backup] Export dir {preferred} is not writable ({e}); " - f"falling back to {fallback}, which a reinstall will delete" - ) - return fallback - - -_BACKUP_EXPORT_DIR = _resolve_backup_export_dir() - - -def _safe_backup_path(filename: str) -> Path: - """Resolve a filename to an absolute path inside the export dir, - rejecting any traversal attempts. Returns None if unsafe.""" - # Use basename first (CodeQL-recognized sanitizer) then validate format - filename = os.path.basename(filename or '') - if not filename or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$', filename): - return None - path = (_BACKUP_EXPORT_DIR / filename).resolve() - try: - path.relative_to(_BACKUP_EXPORT_DIR.resolve()) - except ValueError: - return None - return path - - -@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 - - -@api_v3.route('/backup/list', methods=['GET']) -def backup_list(): - """List backup ZIPs stored in the export directory.""" - try: - _BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True) - entries = [] - for p in sorted(_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 - - -@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=_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 - - -@api_v3.route('/backup/validate', methods=['POST']) -def backup_validate(): - """Validate an uploaded backup ZIP and return its manifest.""" - 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) - 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 - - -@api_v3.route('/backup/restore', methods=['POST']) -def backup_restore(): - """Restore a backup ZIP with optional RestoreOptions.""" - 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', '{}') - 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 - options = RestoreOptions( - restore_config=bool(opts_dict.get('restore_config', True)), - restore_secrets=bool(opts_dict.get('restore_secrets', True)), - restore_wifi=bool(opts_dict.get('restore_wifi', True)), - restore_fonts=bool(opts_dict.get('restore_fonts', True)), - restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)), - reinstall_plugins=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 - - # 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) or plugin_store_manager - 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'}) - 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}) - 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 - - -@api_v3.route('/backup/download/', 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(_BACKUP_EXPORT_DIR, filename, as_attachment=True) - except FileNotFoundError: - return jsonify({'status': 'error', 'message': 'Backup not found'}), 404 - - -@api_v3.route('/backup/', 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 _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 - - -# ── Starlark / Pixlet ──────────────────────────────────────────────────────── -# These routes were added by #253 and removed by #330, which rewrote this file -# and dropped all thirteen of them. Nothing else changed: the frontend still -# calls them and plugin-repos/starlark-apps still implements the work behind -# them, so every call has been landing on Flask's 404 handler and coming back -# as the generic "Resource not found" -- which is what the Pixlet install -# button reports. -# -# Restored verbatim from 1c4d5c52^ (the commit before the removal). The other -# eleven are still missing; see the PR description. - -_STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps' -_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json' - -def _get_starlark_plugin() -> Optional[Any]: - """Get the starlark-apps plugin instance, or None.""" - if not api_v3.plugin_manager: - return None - return api_v3.plugin_manager.get_plugin('starlark-apps') - -def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: - """Find pixlet binary: explicit path → bundled binary → system PATH.""" - import platform - if explicit_path and os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK): - return explicit_path - bin_dir = PROJECT_ROOT / "bin" / "pixlet" - system = platform.system().lower() - machine = platform.machine().lower() - if system == "linux": - if "aarch64" in machine or "arm64" in machine: - name = "pixlet-linux-arm64" - elif "x86_64" in machine or "amd64" in machine: - name = "pixlet-linux-amd64" - else: - name = None - elif system == "darwin": - name = "pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64" - else: - name = None - if name: - bundled = bin_dir / name - if bundled.is_file(): - if os.access(str(bundled), os.X_OK): - return str(bundled) - try: - bundled.chmod(0o755) - except OSError: - logger.warning("Could not make pixlet bundled binary executable (%s); falling back to PATH", bundled) - else: - if os.access(str(bundled), os.X_OK): - return str(bundled) - logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) - return shutil.which("pixlet") - -def _read_starlark_manifest() -> Dict[str, Any]: - """Read the starlark-apps manifest.json directly from disk.""" - try: - if _STARLARK_MANIFEST_FILE.exists(): - with open(_STARLARK_MANIFEST_FILE, 'r') as f: - return json.load(f) - except (json.JSONDecodeError, OSError) as e: - logger.error(f"Error reading starlark manifest: {e}") - return {'apps': {}} - -@api_v3.route('/starlark/status', methods=['GET']) -def get_starlark_status(): - """Get Starlark plugin status and Pixlet availability.""" - try: - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - info = starlark_plugin.get_info() - magnify_info = starlark_plugin.get_magnify_recommendation() - return jsonify({ - 'status': 'success', - 'pixlet_available': info.get('pixlet_available', False), - 'pixlet_version': info.get('pixlet_version'), - 'installed_apps': info.get('installed_apps', 0), - 'enabled_apps': info.get('enabled_apps', 0), - 'current_app': info.get('current_app'), - 'plugin_enabled': starlark_plugin.enabled, - 'display_info': magnify_info - }) - - # Plugin not loaded - check Pixlet availability via shared resolver - # (respects user-configured pixlet_path, bundled binary, and system PATH) - full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path')) - pixlet_available = pixlet_path is not None - - # Read app counts from manifest - manifest = _read_starlark_manifest() - apps = manifest.get('apps', {}) - installed_count = len(apps) - enabled_count = sum(1 for a in apps.values() if a.get('enabled', True)) - - return jsonify({ - 'status': 'success', - 'pixlet_available': pixlet_available, - 'pixlet_version': None, - 'installed_apps': installed_count, - 'enabled_apps': enabled_count, - 'plugin_enabled': True, - 'plugin_loaded': False, - 'display_info': {} - }) - - except Exception as e: - logger.exception("[Starlark] get_starlark_status failed") - return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/install-pixlet', methods=['POST']) -def install_pixlet(): - """Download and install Pixlet binary.""" - try: - script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh' - if not script_path.exists(): - return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404 - - os.chmod(script_path, 0o755) - - result = subprocess.run( - [str(script_path)], - cwd=str(PROJECT_ROOT), - capture_output=True, - text=True, - timeout=300 - ) - - if result.returncode == 0: - logger.info("Pixlet downloaded successfully") - return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout}) - else: - return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500 - - except subprocess.TimeoutExpired as err: - logger.exception("[Starlark] Pixlet download timed out") - return jsonify({'status': 'error', 'message': 'Download timed out', - 'details': describe_exception(err)}), 500 - except Exception as e: - logger.exception("[Starlark] install_pixlet failed") - return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500 - - -# The remaining eleven routes #330 dropped, restored the same way: apps CRUD, -# per-app config and toggle, render, manual .star upload, and the Tronbyte -# repository browse/categories/install the app store page is built on. Without -# these the store lists nothing and installing anything answers with the same -# generic 404 the Pixlet button did. - -def _starlark_github_token() -> Optional[str]: - """The GitHub token the Starlark store should authenticate with. - - These routes used to read `github_token` off config.json, a key that is - written nowhere and offered by no setting -- so the store always ran - unauthenticated at 60 requests/hour, on the same per-IP budget every - plugin update check spends, while the token the user had actually - configured sat in config_secrets.json raising the same budget to 5000. - The store going blank was that budget running out. - - Prefer the store manager's token, which is the one the settings UI - writes and validates; keep the config.json key as a fallback so a - hand-edited config still works. - """ - token = getattr(api_v3.plugin_store_manager, 'github_token', None) - if token: - return token - - try: - config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - return config.get('github_token') - except Exception: - logger.warning("[Starlark] Could not read config for a GitHub token", exc_info=True) - return None - - -def _get_tronbyte_repository_class() -> Type[Any]: - """Import TronbyteRepository from plugin-repos directory.""" - import importlib.util - import importlib - - module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py' - if not module_path.exists(): - raise ImportError(f"TronbyteRepository module not found at {module_path}") - - # If already imported, return cached class - if "tronbyte_repository" in sys.modules: - return sys.modules["tronbyte_repository"].TronbyteRepository - - spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path)) - if spec is None: - raise ImportError(f"Failed to create module spec for tronbyte_repository at {module_path}") - - module = importlib.util.module_from_spec(spec) - if module is None: - raise ImportError("Failed to create module from spec for tronbyte_repository") - - sys.modules["tronbyte_repository"] = module - try: - spec.loader.exec_module(module) - except BaseException: - # A module that failed to execute must not stay in sys.modules: the - # cache branch above would hand back the half-initialised object for - # the rest of the process, so one transient failure would disable - # this path permanently and surface as AttributeError, not ImportError. - sys.modules.pop("tronbyte_repository", None) - raise - return module.TronbyteRepository - -def _get_pixlet_renderer_class() -> Type[Any]: - """Import PixletRenderer from plugin-repos directory.""" - import importlib.util - import importlib - - module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'pixlet_renderer.py' - if not module_path.exists(): - raise ImportError(f"PixletRenderer module not found at {module_path}") - - # If already imported, return cached class - if "pixlet_renderer" in sys.modules: - return sys.modules["pixlet_renderer"].PixletRenderer - - spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path)) - if spec is None: - raise ImportError(f"Failed to create module spec for pixlet_renderer at {module_path}") - - module = importlib.util.module_from_spec(spec) - if module is None: - raise ImportError("Failed to create module from spec for pixlet_renderer") - - sys.modules["pixlet_renderer"] = module - try: - spec.loader.exec_module(module) - except BaseException: - # A module that failed to execute must not stay in sys.modules: the - # cache branch above would hand back the half-initialised object for - # the rest of the process, so one transient failure would disable - # this path permanently and surface as AttributeError, not ImportError. - sys.modules.pop("pixlet_renderer", None) - raise - return module.PixletRenderer - -def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: - """Validate and sanitize app_id to a safe slug.""" - if not app_id and fallback_source: - app_id = fallback_source - if not app_id: - return None, "app_id is required" - if '..' in app_id or '/' in app_id or '\\' in app_id: - return None, "app_id contains invalid characters" - - sanitized = re.sub(r'[^a-z0-9_]', '_', app_id.lower()).strip('_') - if not sanitized: - sanitized = f"app_{hashlib.sha256(app_id.encode()).hexdigest()[:12]}" - if sanitized[0].isdigit(): - sanitized = f"app_{sanitized}" - return sanitized, None - -def _validate_timing_value(value: Any, field_name: str, min_val: int = 1, max_val: int = 86400) -> Tuple[Optional[int], Optional[str]]: - """Validate and coerce timing values.""" - if value is None: - return None, None - try: - int_value = int(value) - except (ValueError, TypeError): - return None, f"{field_name} must be an integer" - if int_value < min_val: - return None, f"{field_name} must be at least {min_val}" - if int_value > max_val: - return None, f"{field_name} must be at most {max_val}" - return int_value, None - -def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[str]]: - """The app's directory, or an error if app_id could escape the base dir. - - Returns the *resolved* path rather than a boolean, and every caller uses - what it returns instead of re-joining ``_STARLARK_APPS_DIR / app_id`` - afterwards. The old shape validated in one place and rebuilt the path in - another, which is two things that have to stay in step -- and is why - CodeQL reported twenty-four path-injection alerts across these handlers - even though the guard was effective: a boolean is not a sanitiser it can - follow, and the value reaching the filesystem was the raw one. - - The name is unchanged so the call sites read the same. - """ - if not isinstance(app_id, str) or not app_id: - return None, "Invalid app_id" - - # Reject the traversal characters outright before touching the filesystem. - if '..' in app_id or '/' in app_id or '\\' in app_id: - return None, "Invalid app_id: contains path traversal characters" - - # os.path.basename strips any directory component, so what is joined below - # cannot carry one. The equality check means this rejects rather than - # silently truncates -- behaviour is identical to the character test above, - # and it is the sanitiser CodeQL's path-injection query actually follows. - # relative_to() alone is a check it cannot trace, which is why twenty-four - # of these stayed flagged after the value was threaded through properly. - safe_name = os.path.basename(app_id) - if safe_name != app_id or safe_name in ('', '.', '..'): - return None, "Invalid app_id: contains path traversal characters" - - try: - base_path = _STARLARK_APPS_DIR.resolve() - app_path = (base_path / safe_name).resolve() - try: - app_path.relative_to(base_path) - except ValueError: - return None, "Invalid app_id: path traversal attempt" - return app_path, None - except OSError as e: - logger.warning("Path validation error for app_id %r: %s", app_id, e) - return None, "Invalid app_id" - - -def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: - """Render a Starlark app via pixlet directly (no plugin required). - - Reads the .star file and config from starlark-apps/{app_id}/, runs pixlet, - and saves the output to cached_render.webp in the same directory. - This is the web-service fallback when starlark-apps plugin is not loaded. - - Returns (success, http_status_code, error_message). - """ - manifest = _read_starlark_manifest() - if not isinstance(manifest, dict): - return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" - apps = manifest.get('apps', {}) - if not isinstance(apps, dict): - return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" - app_data = apps.get(app_id) - if not app_data: - return False, 404, f"App not found: {app_id}" - - # Validated here as well as at the handler: this is reachable on its own, - # and a path built from app_id should never be assembled without it. - app_dir, path_error = _validate_starlark_app_path(app_id) - if path_error: - return False, 400, path_error - star_file = app_dir / app_data.get('star_file', f'{app_id}.star') - if not star_file.exists(): - return False, 404, f"Star file not found: {star_file}" - - full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} - plugin_config = full_config.get('starlark-apps', {}) - - pixlet_path = _find_pixlet_binary(plugin_config.get('pixlet_path')) - if not pixlet_path: - return False, 503, "Pixlet binary not found — install pixlet first" - - magnify = plugin_config.get('magnify') - if magnify is None: - hw = full_config.get('display', {}).get('hardware', {}) - cols = hw.get('cols', 64) - chain = hw.get('chain_length', 1) - rows = hw.get('rows', 32) - magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32)))) - else: - try: - magnify = max(1, min(8, int(magnify))) - except (ValueError, TypeError): - magnify = 1 - - config_file = app_dir / 'config.json' - app_config: Dict[str, Any] = {} - if config_file.exists(): - try: - with open(config_file) as f: - app_config = json.load(f) - except json.JSONDecodeError as e: - logger.warning("Invalid config.json for %r at %s: %s", app_id, config_file, e) - return False, 400, f"Invalid config.json for {app_id}" - except OSError as e: - logger.warning("Cannot read config.json for %r at %s: %s", app_id, config_file, e) - return False, 400, f"Cannot read config.json for {app_id}" - if not isinstance(app_config, dict): - return False, 400, ( - f"config.json for {app_id} must be a JSON object, " - f"got {type(app_config).__name__}" - ) - - INTERNAL_KEYS = {'render_interval', 'display_duration'} - pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} - - output_path = str(app_dir / 'cached_render.webp') - cmd = [pixlet_path, 'render', str(star_file)] - for key, value in pixlet_config.items(): - if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key): - continue - value_str = 'true' if value is True else 'false' if value is False else str(value) - if re.search(r'[`$|<>&;\x00]|\$\(', value_str): - continue - cmd.append(f'{key}={value_str}') - cmd.extend(['-o', output_path, '-m', str(magnify)]) - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(app_dir)) - if result.returncode == 0 and os.path.isfile(output_path): - return True, 200, None - return False, 502, f"Pixlet failed (exit {result.returncode}): {result.stderr.strip()}" - except subprocess.TimeoutExpired: - return False, 504, "Render timed out after 30s" - except Exception as e: - logger.exception("Starlark render failed for %r", app_id) - return False, 500, "Render error" - -def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool: - """Write the starlark-apps manifest.json to disk with atomic write.""" - temp_file = None - try: - _STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True) - - # Atomic write: unique temp file in the target directory, then rename. - # with_suffix('.tmp') gave every caller the same manifest.tmp, and - # Flask serves concurrently -- upload, uninstall, config, toggle and - # the plugin toggle all reach here. Two writers shared one file, - # interleaved their json.dump output, and both renamed it, so the - # rename was atomic over content that was a mix of two manifests. - fd, temp_name = tempfile.mkstemp( - dir=str(_STARLARK_APPS_DIR), prefix='.manifest.', suffix='.tmp') - temp_file = Path(temp_name) - with os.fdopen(fd, 'w') as f: - json.dump(manifest, f, indent=2) - f.flush() - os.fsync(f.fileno()) # Ensure data is written to disk - os.chmod(temp_name, 0o644) # mkstemp creates 0600; match a normal write - - # Atomic rename (overwrites destination) - temp_file.replace(_STARLARK_MANIFEST_FILE) - return True - except OSError as e: - logger.error(f"Error writing starlark manifest: {e}") - # Clean up temp file if it exists - if temp_file and temp_file.exists(): - try: - temp_file.unlink() - except Exception: - pass - return False - -def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any], assets_dir: Optional[str] = None) -> bool: - """Install a .star file and update the manifest (standalone, no plugin needed).""" - import shutil - import json - app_dir, path_error = _validate_starlark_app_path(app_id) - if path_error: - logger.warning("Refusing to install %r: %s", app_id, path_error) - return False - app_dir.mkdir(parents=True, exist_ok=True) - dest = app_dir / f"{app_id}.star" - shutil.copy2(star_file_path, str(dest)) - - # Copy asset directories if provided (images/, sources/, etc.) - if assets_dir and Path(assets_dir).exists(): - assets_path = Path(assets_dir) - for item in assets_path.iterdir(): - if item.is_dir(): - # Copy entire directory (e.g., images/, sources/) - dest_dir = app_dir / item.name - if dest_dir.exists(): - shutil.rmtree(dest_dir) - shutil.copytree(item, dest_dir) - logger.debug(f"Copied assets directory: {item.name}") - logger.info(f"Installed assets for {app_id}") - - # Try to extract schema using PixletRenderer - schema = None - try: - PixletRenderer = _get_pixlet_renderer_class() - pixlet = PixletRenderer() - if pixlet.is_available(): - _, schema, _ = pixlet.extract_schema(str(dest)) - if schema: - schema_path = app_dir / "schema.json" - with open(schema_path, 'w') as f: - json.dump(schema, f, indent=2) - logger.info(f"Extracted schema for {app_id}") - except Exception as e: - logger.warning(f"Failed to extract schema for {app_id}: {e}") - - # Create default config — pre-populate with schema defaults - default_config = {} - if schema: - fields = schema.get('fields') or schema.get('schema') or [] - for field in fields: - if isinstance(field, dict) and 'id' in field and 'default' in field: - default_config[field['id']] = field['default'] - - # Create config.json file - config_path = app_dir / "config.json" - with open(config_path, 'w') as f: - json.dump(default_config, f, indent=2) - - manifest = _read_starlark_manifest() - manifest.setdefault('apps', {})[app_id] = { - 'name': metadata.get('name', app_id), - 'enabled': True, - 'render_interval': metadata.get('render_interval', 300), - 'display_duration': metadata.get('display_duration', 15), - 'config': metadata.get('config', {}), - # The filename, not the full path. Readers join this to the app's own - # directory and fall back to a bare '.star', so an absolute - # value gave the key two meanings -- and Path.__truediv__ discards the - # left side when the right is absolute, which pinned the manifest to - # whatever PROJECT_ROOT installed it. Moving or redeploying the install - # then left the app unable to find its own file. - 'star_file': dest.name, - } - return _write_starlark_manifest(manifest) - -@api_v3.route('/starlark/apps', methods=['GET']) -def get_starlark_apps(): - """List all installed Starlark apps.""" - try: - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - apps_list = [] - for app_id, app_instance in starlark_plugin.apps.items(): - apps_list.append({ - 'id': app_id, - 'name': app_instance.manifest.get('name', app_id), - 'enabled': app_instance.is_enabled(), - 'has_frames': app_instance.frames is not None, - 'render_interval': app_instance.get_render_interval(), - 'display_duration': app_instance.get_display_duration(), - 'config': app_instance.config, - 'has_schema': app_instance.schema is not None, - 'last_render_time': app_instance.last_render_time - }) - return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) - - # Standalone: read manifest from disk - manifest = _read_starlark_manifest() - apps_list = [] - for app_id, app_data in manifest.get('apps', {}).items(): - apps_list.append({ - 'id': app_id, - 'name': app_data.get('name', app_id), - 'enabled': app_data.get('enabled', True), - 'has_frames': False, - 'render_interval': app_data.get('render_interval', 300), - 'display_duration': app_data.get('display_duration', 15), - 'config': app_data.get('config', {}), - 'has_schema': False, - 'last_render_time': None - }) - return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) - - except Exception as e: - logger.exception("[Starlark] get_starlark_apps failed") - return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/apps/', methods=['GET']) -def get_starlark_app(app_id): - """Get details for a specific Starlark app.""" - try: - # Validate app_id before any filesystem access - app_dir, error_msg = _validate_starlark_app_path(app_id) - if error_msg: - return jsonify({'status': 'error', 'message': error_msg}), 400 - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - return jsonify({ - 'status': 'success', - 'app': { - 'id': app_id, - 'name': app.manifest.get('name', app_id), - 'enabled': app.is_enabled(), - 'config': app.config, - 'schema': app.schema, - 'render_interval': app.get_render_interval(), - 'display_duration': app.get_display_duration(), - '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 - manifest = _read_starlark_manifest() - app_data = manifest.get('apps', {}).get(app_id) - if not app_data: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - # Load schema from schema.json if it exists (path already validated above) - schema = None - schema_file = app_dir / 'schema.json' - if schema_file.exists(): - try: - with open(schema_file, 'r') as f: - schema = json.load(f) - except (OSError, json.JSONDecodeError) as e: - logger.warning(f"Failed to load schema for {app_id}: {e}") - - return jsonify({ - 'status': 'success', - 'app': { - 'id': app_id, - 'name': app_data.get('name', app_id), - 'enabled': app_data.get('enabled', True), - 'config': app_data.get('config', {}), - 'schema': schema, - 'render_interval': app_data.get('render_interval', 300), - 'display_duration': app_data.get('display_duration', 15), - 'has_frames': False, - 'frame_count': 0, - 'last_render_time': None, - } - }) - - except Exception as e: - logger.exception("[Starlark] get_starlark_app failed") - return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/upload', methods=['POST']) -def upload_starlark_app(): - """Upload and install a new Starlark app.""" - try: - if 'file' not in request.files: - return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400 - - file = request.files['file'] - if not file.filename or not file.filename.endswith('.star'): - return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400 - - # Check file size (limit to 5MB for .star files) - file.seek(0, 2) # Seek to end - file_size = file.tell() - file.seek(0) # Reset to beginning - MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB - if file_size > MAX_STAR_SIZE: - return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400 - - app_name = request.form.get('name') - app_id_input = request.form.get('app_id') - filename_base = file.filename.replace('.star', '') if file.filename else None - app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base) - if app_id_error: - return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 - - render_interval_input = request.form.get('render_interval') - render_interval = 300 - if render_interval_input is not None: - render_interval, err = _validate_timing_value(render_interval_input, 'render_interval') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - render_interval = render_interval or 300 - - display_duration_input = request.form.get('display_duration') - display_duration = 15 - if display_duration_input is not None: - display_duration, err = _validate_timing_value(display_duration_input, 'display_duration') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - display_duration = display_duration or 15 - - import tempfile - with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: - file.save(tmp.name) - temp_path = tmp.name - - try: - metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration} - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - success = starlark_plugin.install_app(app_id, temp_path, metadata) - else: - success = _install_star_file(app_id, temp_path, metadata) - if success: - return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id}) - else: - return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 - finally: - try: - os.unlink(temp_path) - except OSError: - pass - - except (OSError, IOError) as err: - # This used to withhold the detail because it names absolute paths on - # the device. A full disk and a bad permission are indistinguishable - # without it, though, and describe_exception redacts credentials and - # truncates -- the same trade-off every other handler here makes. - logger.exception("[Starlark] File error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': 'File error during upload', - 'details': describe_exception(err)}), 500 - except ImportError as err: - logger.exception("[Starlark] Module load error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500 - except Exception as err: - logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err) - return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500 - -@api_v3.route('/starlark/apps/', methods=['DELETE']) -def uninstall_starlark_app(app_id): - """Uninstall a Starlark app.""" - try: - # Validate app_id before any filesystem access - app_dir, error_msg = _validate_starlark_app_path(app_id) - if error_msg: - return jsonify({'status': 'error', 'message': error_msg}), 400 - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - success = starlark_plugin.uninstall_app(app_id) - else: - # Standalone: remove app dir and manifest entry. app_dir is the - # path _validate_starlark_app_path checked, not a fresh join. - import shutil - if app_dir.exists(): - shutil.rmtree(app_dir) - manifest = _read_starlark_manifest() - manifest.get('apps', {}).pop(app_id, None) - success = _write_starlark_manifest(manifest) - - if success: - return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'}) - else: - return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500 - - except Exception as e: - logger.exception("[Starlark] uninstall_starlark_app failed") - return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/apps//config', methods=['GET']) -def get_starlark_app_config(app_id): - """Get configuration for a Starlark app.""" - try: - # Validate app_id before any filesystem access - app_dir, error_msg = _validate_starlark_app_path(app_id) - if error_msg: - return jsonify({'status': 'error', 'message': error_msg}), 400 - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema}) - - # Standalone: read from config.json. app_dir is the path - # _validate_starlark_app_path checked, not a fresh join. - config_file = app_dir / "config.json" - - if not app_dir.exists(): - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - config = {} - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - except (OSError, json.JSONDecodeError) as e: - logger.warning(f"Failed to load config for {app_id}: {e}") - - # Load schema from schema.json - schema = None - schema_file = app_dir / "schema.json" - if schema_file.exists(): - try: - with open(schema_file, 'r') as f: - schema = json.load(f) - except Exception as e: - logger.warning(f"Failed to load schema for {app_id}: {e}") - - return jsonify({'status': 'success', 'config': config, 'schema': schema}) - - except Exception as e: - logger.exception("[Starlark] get_starlark_app_config failed") - return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/apps//config', methods=['PUT']) -def update_starlark_app_config(app_id): - """Update configuration for a Starlark app.""" - try: - # Validate app_id before any filesystem access - app_dir, error_msg = _validate_starlark_app_path(app_id) - if error_msg: - return jsonify({'status': 'error', 'message': error_msg}), 400 - - data = request.get_json(silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400 - - if 'render_interval' in data: - val, err = _validate_timing_value(data['render_interval'], 'render_interval') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - data['render_interval'] = val - - if 'display_duration' in data: - val, err = _validate_timing_value(data['display_duration'], 'display_duration') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - data['display_duration'] = val - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - # Extract timing keys from data before updating config (they belong in manifest, not config) - render_interval = data.pop('render_interval', None) - display_duration = data.pop('display_duration', None) - - # Snapshot before mutating. save_config() can fail, and the route - # answers 500 below when it does -- but the loaded app kept the new - # values anyway, so a later GET returned configuration that was - # never persisted and the plugin rendered with it. - prev_config = dict(app.config) - prev_manifest = dict(app.manifest) - - # Update config with non-timing fields only - app.config.update(data) - - # Update manifest with timing fields - timing_changed = False - if render_interval is not None: - app.manifest['render_interval'] = render_interval - timing_changed = True - if display_duration is not None: - app.manifest['display_duration'] = display_duration - timing_changed = True - saved = app.save_config() - if not saved: - app.config.clear(); app.config.update(prev_config) - app.manifest.clear(); app.manifest.update(prev_manifest) - if saved: - # Persist manifest if timing changed (same pattern as toggle endpoint) - if timing_changed: - try: - # Use safe manifest update to prevent race conditions - timing_updates = {} - if render_interval is not None: - timing_updates['render_interval'] = render_interval - if display_duration is not None: - timing_updates['display_duration'] = display_duration - - def update_fn(manifest): - manifest['apps'][app_id].update(timing_updates) - starlark_plugin._update_manifest_safe(update_fn) - except Exception as e: - logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}") - starlark_plugin._render_app(app, force=True) - return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config}) - else: - return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500 - - # Standalone: update both config.json and manifest - manifest = _read_starlark_manifest() - app_data = manifest.get('apps', {}).get(app_id) - if not app_data: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - # Extract timing keys (they go in manifest, not config.json) - render_interval = data.pop('render_interval', None) - display_duration = data.pop('display_duration', None) - - # Update manifest with timing values - if render_interval is not None: - app_data['render_interval'] = render_interval - if display_duration is not None: - app_data['display_duration'] = display_duration - - # Load current config from config.json. app_dir is the path - # _validate_starlark_app_path checked, not a fresh join. - config_file = app_dir / "config.json" - current_config = {} - if config_file.exists(): - try: - with open(config_file, 'r') as f: - current_config = json.load(f) - except Exception as e: - logger.warning(f"Failed to load config for {app_id}: {e}") - - # Update config with new values (excluding timing keys) - current_config.update(data) - - # Write updated config to config.json - try: - with open(config_file, 'w') as f: - json.dump(current_config, f, indent=2) - except Exception as e: - logger.error(f"Failed to save config.json for {app_id}: {e}") - logger.exception("Failed to save Starlark configuration for %r", app_id) - return jsonify({'status': 'error', 'message': 'Failed to save configuration', - 'details': describe_exception(e)}), 500 - - # Also update manifest for backward compatibility - app_data.setdefault('config', {}).update(data) - - if _write_starlark_manifest(manifest): - return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config}) - else: - return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 - - except Exception as e: - logger.exception("[Starlark] update_starlark_app_config failed") - return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/apps//toggle', methods=['POST']) -def toggle_starlark_app(app_id): - """Enable or disable a Starlark app.""" - try: - data = request.get_json(silent=True) or {} - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - enabled = data.get('enabled') - if enabled is None: - enabled = not app.is_enabled() - app.manifest['enabled'] = enabled - # Use safe manifest update to prevent race conditions - def update_fn(manifest): - manifest['apps'][app_id]['enabled'] = enabled - starlark_plugin._update_manifest_safe(update_fn) - return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) - - # Standalone: update manifest directly - manifest = _read_starlark_manifest() - app_data = manifest.get('apps', {}).get(app_id) - if not app_data: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - enabled = data.get('enabled') - if enabled is None: - enabled = not app_data.get('enabled', True) - app_data['enabled'] = enabled - if _write_starlark_manifest(manifest): - return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) - else: - return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 - - except Exception as e: - logger.exception("[Starlark] toggle_starlark_app failed") - return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/apps//render', methods=['POST']) -def render_starlark_app(app_id): - """Force render a Starlark app.""" - try: - app_dir, err = _validate_starlark_app_path(app_id) - if err: - return jsonify({'status': 'error', 'message': err}), 400 - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - success = starlark_plugin._render_app(app, force=True) - if success: - return jsonify({'status': 'success', 'message': 'App rendered', - 'frame_count': len(app.frames) if app.frames else 0}) - return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500 - - # Web-service context: plugin not loaded, call pixlet directly - success, status_code, error = _standalone_render_starlark_app(app_id) - if success: - return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code - return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code - - except Exception as e: - logger.exception("[Starlark] render_starlark_app failed") - return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/repository/browse', methods=['GET']) -def browse_tronbyte_repository(): - """Browse all apps in the Tronbyte repository (bulk cached fetch). - - Returns ALL apps with metadata, categories, and authors. - Filtering/sorting/pagination is handled client-side. - Results are cached server-side for 2 hours. - """ - try: - TronbyteRepository = _get_tronbyte_repository_class() - - repo = TronbyteRepository(github_token=_starlark_github_token()) - - result = repo.list_all_apps_cached() - - rate_limit = repo.get_rate_limit_info() - - # An upstream failure used to arrive here as an empty app list and go - # out as 'success', so the store drew an empty grid and said nothing. - # 502: the request was fine, GitHub was not. - if result.get('error'): - return jsonify({ - 'status': 'error', - 'message': result['error'], - 'rate_limit': rate_limit, - }), 502 - - return jsonify({ - 'status': 'success', - 'apps': result['apps'], - 'categories': result['categories'], - 'authors': result['authors'], - 'count': result['count'], - 'cached': result['cached'], - 'rate_limit': rate_limit, - }) - - except Exception as e: - logger.exception("[Starlark] browse_tronbyte_repository failed") - return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/repository/install', methods=['POST']) -def install_from_tronbyte_repository(): - """Install an app from the Tronbyte repository.""" - try: - data = request.get_json(silent=True) - if not data or 'app_id' not in data: - return jsonify({'status': 'error', 'message': 'app_id is required'}), 400 - - app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id']) - if app_id_error: - return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 - - TronbyteRepository = _get_tronbyte_repository_class() - import tempfile - - repo = TronbyteRepository(github_token=_starlark_github_token()) - - success, metadata, error = repo.get_app_metadata(data['app_id']) - if not success: - return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404 - - with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: - temp_path = tmp.name - - try: - # Pass filename from metadata (e.g., "analog_clock.star" for analogclock app) - # Note: manifest uses 'fileName' (camelCase), not 'filename' - filename = metadata.get('fileName') if metadata else None - success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename) - if not success: - return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500 - - # Download assets (images, sources, etc.) to a temp directory - import tempfile - temp_assets_dir = tempfile.mkdtemp() - try: - success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir)) - # Asset download is non-critical - log warning but continue if it fails - if not success_assets: - logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}") - - render_interval = data.get('render_interval', 300) - ri, err = _validate_timing_value(render_interval, 'render_interval') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - render_interval = ri or 300 - - display_duration = data.get('display_duration', 15) - dd, err = _validate_timing_value(display_duration, 'display_duration') - if err: - return jsonify({'status': 'error', 'message': err}), 400 - display_duration = dd or 15 - - install_metadata = { - 'name': metadata.get('name', app_id) if metadata else app_id, - 'render_interval': render_interval, - 'display_duration': display_duration - } - - starlark_plugin = _get_starlark_plugin() - if starlark_plugin: - success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) - else: - success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) - finally: - # Clean up temp assets directory - import shutil - try: - shutil.rmtree(temp_assets_dir) - except OSError: - pass - - if success: - return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id}) - else: - return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 - finally: - try: - os.unlink(temp_path) - except OSError: - pass - - except Exception as e: - logger.exception("[Starlark] install_from_tronbyte_repository failed") - return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500 - -@api_v3.route('/starlark/repository/categories', methods=['GET']) -def get_tronbyte_categories(): - """Get list of available app categories (uses bulk cache).""" - try: - TronbyteRepository = _get_tronbyte_repository_class() - repo = TronbyteRepository(github_token=_starlark_github_token()) - - result = repo.list_all_apps_cached() - - if result.get('error'): - return jsonify({'status': 'error', 'message': result['error']}), 502 - - return jsonify({'status': 'success', 'categories': result['categories']}) - - except Exception as e: - logger.exception("[Starlark] get_tronbyte_categories failed") - return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500 - - -def _starlark_virtual_plugins() -> list: - """Installed Starlark apps, shaped like plugin entries. - - #253 surfaced these alongside real plugins so an installed .star app can - be seen, enabled and disabled from the same list as everything else; #330 - dropped it with the rest of the Starlark code, which is why an app - installs successfully and then appears nowhere. - - Reads the loaded plugin when there is one and the on-disk manifest - otherwise, so the list is right before starlark-apps has been loaded too. - """ - entries = [] - base = { - 'version': 'starlark', 'category': 'Starlark App', 'tags': ['starlark'], - 'verified': False, 'last_updated': None, 'last_commit': None, - 'last_commit_message': None, 'branch': None, 'web_ui_actions': [], - 'vegas_mode': 'fixed', 'vegas_content_type': 'multi', - 'is_starlark_app': True, - } - try: - plugin = _get_starlark_plugin() - if plugin is not None and hasattr(plugin, 'apps'): - for app_id, app in plugin.apps.items(): - m = getattr(app, 'manifest', {}) or {} - entries.append({**base, - 'id': f'starlark:{app_id}', - 'name': m.get('name', app_id), - 'author': m.get('author', 'Tronbyte Community'), - 'description': m.get('summary', 'Starlark app'), - 'enabled': app.is_enabled(), - 'loaded': True}) - return entries - - for app_id, data in (_read_starlark_manifest().get('apps', {}) or {}).items(): - entries.append({**base, - 'id': f'starlark:{app_id}', - 'name': data.get('name', app_id), - 'author': data.get('author', 'Tronbyte Community'), - 'description': data.get('summary', 'Starlark app'), - 'enabled': data.get('enabled', True), - 'loaded': False}) - except Exception: - # Never let a Starlark problem empty the whole plugins list. - logger.exception('Could not build Starlark virtual plugin entries') - return entries - - -def _toggle_starlark_app(app_id: str, enabled: bool): - """Enable or disable one Starlark app, loaded or not.""" - # Check for traversal, but toggle the key that was listed. - # _starlark_virtual_plugins publishes the raw manifest key, while - # _validate_and_sanitize_app_id lowercases it and rewrites every character - # outside [a-z0-9_]: an app stored as 'My-App' was offered to the UI as - # 'starlark:My-App' and looked up here as 'my_app', so toggling an app the - # page had just drawn answered 404. Keys written by _install_star_file are - # already sanitised; ones written by the plugin, or edited by hand, are - # not. _validate_starlark_app_path rejects traversal without rewriting. - _, err = _validate_starlark_app_path(app_id) - if err: - # err already names app_id; do not prefix it a second time. - return jsonify({'status': 'error', 'message': err}), 400 - safe_id = app_id - - plugin = _get_starlark_plugin() - if plugin is not None and safe_id in getattr(plugin, 'apps', {}): - def _update(manifest): - # setdefault rather than indexing: the app is loaded, but its - # on-disk entry need not exist, and _update_manifest_safe does not - # catch KeyError -- it would escape as a 500 rather than the error - # this returns. - manifest.setdefault('apps', {}).setdefault(safe_id, {})['enabled'] = enabled - - if plugin._update_manifest_safe(_update) is False: - return jsonify({'status': 'error', - 'message': 'Failed to save app state'}), 500 - # Only now is the in-memory copy allowed to disagree with disk. - plugin.apps[safe_id].manifest['enabled'] = enabled - else: - manifest = _read_starlark_manifest() - app_data = manifest.get('apps', {}).get(safe_id) - if not app_data: - return jsonify({'status': 'error', - 'message': f'Starlark app not found: {safe_id}'}), 404 - app_data['enabled'] = enabled - if not _write_starlark_manifest(manifest): - return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 - - return jsonify({'status': 'success', - 'message': f"Starlark app {'enabled' if enabled else 'disabled'}", - 'enabled': enabled}) diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py new file mode 100644 index 000000000..22b5388b1 --- /dev/null +++ b/web_interface/blueprints/api_v3/__init__.py @@ -0,0 +1,1787 @@ +"""The /api/v3 blueprint. + +This was one 10,469-line module. It is now a package, but deliberately not a +set of *separate* blueprints: every route module beside this one decorates the +single `api_v3` object defined here, so endpoint names stay `api_v3.` +and the URL map is unchanged. web_interface/app.py registers it as before. + +The shared imports, constants and helpers stay in this file rather than moving +to a _common submodule, because tests monkeypatch some of them by module +attribute -- `monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...)`. +Keeping them here means that keeps working exactly as it did. Route modules +read the mutable ones back through this module (see _pkg below) rather than +binding them by value, for the same reason. + +The route modules are imported at the *bottom*: they import names from here, so +everything they need has to exist first. +""" +from flask import Blueprint, request, jsonify, Response +import json +import os +import re +import stat +import sys +import shutil +import subprocess +import tempfile +import time +import hashlib +import uuid +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, Type +from urllib.parse import urlparse, urlunparse +logger = logging.getLogger(__name__) +# Import new infrastructure +from src.web_interface.api_helpers import success_response, error_response, validate_request_json +from src.web_interface.errors import ErrorCode +from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values, + merge_secrets, remove_empty_secrets, + separate_secrets, + strip_masked_values) +from src.web_interface.error_handler import describe_exception, redact_text +from src.plugin_system.operation_types import OperationType +from src.web_interface.validators import ( + validate_file_upload +) +from src.error_aggregator import get_error_aggregator +from src.common.permission_utils import install_requirements_file +_SUDO = shutil.which('sudo') +_JOURNALCTL = shutil.which('journalctl') +_GIT = shutil.which('git') +# Cap subprocess output returned to the browser — pip can produce MBs on build failures. +_MAX_OUTPUT_BYTES = 51_200 # 50 KB +def _truncate_output(stdout: str, stderr: str) -> str: + """Combine stdout+stderr and truncate to _MAX_OUTPUT_BYTES (keeping the tail).""" + combined = (stdout + stderr).strip() + if len(combined) > _MAX_OUTPUT_BYTES: + combined = '[...output truncated...]\n' + combined[-_MAX_OUTPUT_BYTES:] + return combined +def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess: + """Install a requirements.txt file, preferring the vetted sudo wrapper so + the packages are visible to root-run ledmatrix.service — not just to + whichever non-root user runs this web process. Falls back to installing + for the current process only if the wrapper isn't set up yet (i.e. the + admin hasn't run scripts/install/configure_web_sudo.sh since upgrading), + so the button still does *something* useful rather than hard-failing. + + Thin wrapper around the shared implementation in permission_utils so the + Plugin Store's own dependency installation (store_manager.py) follows the + exact same root-visible install path instead of a divergent one. + """ + return install_requirements_file(req_file, timeout=timeout) +def _scrub_git_remote_url(url: str) -> str: + """Strip embedded username/password from an HTTPS remote URL before returning it to the UI.""" + try: + p = urlparse(url) + if p.scheme in ('http', 'https') and (p.username or p.password): + netloc = p.hostname or '' + if p.port: + netloc += f':{p.port}' + return urlunparse(p._replace(netloc=netloc)) + except Exception: + pass + return url +# Will be initialized when blueprint is registered +# NOTE: the managers live on the blueprint object (app.py sets +# api_v3.config_manager / api_v3.plugin_manager). Deliberately not +# mirrored as module globals: a bare `config_manager` used to resolve to +# a None that was never assigned, which silently disabled the /health +# checks and made /display/current fall back to a hardcoded 128x64. +plugin_store_manager = None +saved_repositories_manager = None +cache_manager = None +schema_manager = None +operation_queue = None +plugin_state_manager = None +operation_history = None +sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available) +# Get project root directory (web_interface/../..) +# web_interface/blueprints/api_v3/_common.py -> up four to the project root. +# This was three levels when everything lived in web_interface/blueprints/api_v3.py; +# the split moved the file one directory deeper and silently pointed PROJECT_ROOT +# at web_interface/ instead. Nothing failed at import -- it surfaced as routes +# 404ing and "installation script not found", because every path built from it +# was wrong. Asserted in test_api_v3_url_map.py so the next move cannot repeat it. +PROJECT_ROOT = Path(__file__).resolve().parents[3] +# System fonts that cannot be deleted (used by catalog API and delete endpoint) +SYSTEM_FONTS = frozenset([ + 'pressstart2p-regular', 'pressstart2p', + '4x6-font', '4x6', + '5by7.regular', '5by7', '5x7', + '5x8', '6x9', '6x10', '6x12', '6x13', '6x13b', '6x13o', + '7x13', '7x13b', '7x13o', '7x14', '7x14b', + '8x13', '8x13b', '8x13o', + '9x15', '9x15b', '9x18', '9x18b', + '10x20', + 'matrixchunky8', 'matrixlight6', 'tom-thumb', + 'clr6x12', 'helvr12', 'texgyre-27' +]) +api_v3 = Blueprint('api_v3', __name__) +def _get_plugin_version(plugin_id: str) -> str: + """Read the installed version from a plugin's manifest.json. + + Returns the version string on success, or '' if the manifest + cannot be read (missing, corrupt, permission denied, etc.). + """ + manifest_path = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id / "manifest.json" + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + return manifest.get('version', '') + except (FileNotFoundError, PermissionError, OSError) as e: + logger.warning("[PluginVersion] Could not read manifest for %s at %s: %s", plugin_id, manifest_path, e) + except json.JSONDecodeError as e: + logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e) + return '' +def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool: + """Return True when the registry's ``latest_version`` is strictly newer + than the installed version. + + Thin alias for the shared comparator in + `src.plugin_system.compatibility.is_update_available` — the store's + `update_plugin` uses the same function, so the UI badge and the actual + reinstall decision can never disagree. + """ + from src.plugin_system.compatibility import is_update_available + return is_update_available(installed_version, latest_version) +def _ensure_cache_manager(): + """Ensure cache manager is initialized.""" + global cache_manager + if cache_manager is None: + from src.cache_manager import CacheManager + cache_manager = CacheManager() + return cache_manager +def _save_config_atomic(config_manager, config_data, create_backup=True): + """ + Save configuration using atomic save if available, fallback to regular save. + + Returns: + tuple: (success: bool, error_message: str or None) + """ + if hasattr(config_manager, 'save_config_atomic'): + result = config_manager.save_config_atomic(config_data, create_backup=create_backup) + if result.status.value != 'success': + return False, result.message + return True, None + else: + try: + config_manager.save_config(config_data) + return True, None + except Exception as e: + return False, str(e) +def _coerce_to_bool(value): + """ + Coerce a form value to a proper Python boolean. + + HTML checkboxes send string values like "true", "on", "1" when checked. + This ensures we store actual booleans in config JSON, not strings. + + Args: + value: The form value (string, bool, int, or None) + + Returns: + bool: True if value represents a truthy checkbox state, False otherwise + """ + if value is None: + return False + if isinstance(value, bool): + return value + if isinstance(value, int): + return value == 1 + if isinstance(value, str): + return value.lower() in ('true', 'on', '1', 'yes') + return False +def _get_display_service_status(): + """Return status information about the ledmatrix service.""" + try: + result = subprocess.run( + ['systemctl', 'is-active', 'ledmatrix'], + capture_output=True, + text=True, + timeout=3 + ) + return { + 'active': result.stdout.strip() == 'active', + 'returncode': result.returncode, + 'stdout': result.stdout.strip(), + 'stderr': result.stderr.strip() + } + except subprocess.TimeoutExpired: + return { + 'active': False, + 'returncode': -1, + 'stdout': '', + 'stderr': 'timeout' + } + except Exception as err: + return { + 'active': False, + 'returncode': -1, + 'stdout': '', + 'stderr': str(err) + } +def _run_systemctl_command(args): + """Run a systemctl command safely.""" + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=15 + ) + return { + 'returncode': result.returncode, + 'stdout': result.stdout, + 'stderr': result.stderr + } + except subprocess.TimeoutExpired: + return { + 'returncode': -1, + 'stdout': '', + 'stderr': 'timeout' + } + except Exception as err: + return { + 'returncode': -1, + 'stdout': '', + 'stderr': str(err) + } +def _ensure_display_service_running(): + """Ensure the ledmatrix display service is running.""" + status = _get_display_service_status() + if status.get('active'): + status['started'] = False + return status + result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']) + service_status = _get_display_service_status() + result['started'] = result.get('returncode') == 0 + result['active'] = service_status.get('active') + result['status'] = service_status + return result +def _stop_display_service(): + """Stop the ledmatrix display service.""" + result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']) + status = _get_display_service_status() + result['active'] = status.get('active') + result['status'] = status + return result +#: Field names whose value is a credential. Matched by name because this +#: endpoint returns the whole config, core keys included, and core config has +#: no schema to carry x-secret markers. +_CREDENTIAL_NAME_PARTS = ("password", "passwd", "secret", "token", "api_key", + "apikey", "access_key", "private_key", "client_secret") +def _looks_like_a_credential(name: str) -> bool: + lowered = name.lower() + return any(part in lowered for part in _CREDENTIAL_NAME_PARTS) +def _redact_credentials(value): + """A copy of `value` with credential-named fields blanked. + + /config/main returned the raw config to anyone who could reach the port, + and this interface has no authentication. On one rig that meant a 40-char + GitHub token, a 183-char Home Assistant token and five API keys were + readable by anything on the LAN. + + The x-secret masking used by the plugin config endpoints does not help + here: this endpoint never consults a schema, and core keys such as + github.api_token have no schema to mark. Matching on the field name is + blunt, but for a whole-config dump the right default is that anything + named like a credential does not leave the process. + + Blanked rather than removed, and safe to blank: POST /config/main merges + into the loaded config and only writes the keys it was given, so a client + that round-trips this response cannot erase a secret it never saw. + """ + if isinstance(value, dict): + return {k: ("" if _looks_like_a_credential(k) and not isinstance(v, (dict, list)) + else _redact_credentials(v)) + for k, v in value.items()} + if isinstance(value, list): + return [_redact_credentials(item) for item in value] + return value +def _validate_time_format(time_str): + """Validate time format is HH:MM""" + try: + datetime.strptime(time_str, '%H:%M') + return True, None + except (ValueError, TypeError): + return False, f"Invalid time format: {time_str}. Expected HH:MM format." +def _validate_time_range(start_time_str, end_time_str, allow_overnight=True): + """Validate time range. Returns (is_valid, error_message)""" + try: + start_time = datetime.strptime(start_time_str, '%H:%M').time() + end_time = datetime.strptime(end_time_str, '%H:%M').time() + + # Allow overnight schedules (start > end) or same-day schedules + if not allow_overnight and start_time >= end_time: + return False, f"Start time ({start_time_str}) must be before end time ({end_time_str}) for same-day schedules" + + return True, None + except (ValueError, TypeError) as e: + return False, f"Invalid time format: {str(e)}" +def _git_current_branch(project_dir): + """Current branch name, or '' when detached or git fails.""" + try: + r = subprocess.run(['git', 'branch', '--show-current'], + capture_output=True, text=True, timeout=10, cwd=str(project_dir)) + return r.stdout.strip() if r.returncode == 0 else '' + except (subprocess.TimeoutExpired, OSError): + return '' +def _git_upstream(project_dir): + """Configured upstream for the current branch (e.g. 'origin/main'), or ''.""" + try: + r = subprocess.run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + capture_output=True, text=True, timeout=10, cwd=str(project_dir)) + return r.stdout.strip() if r.returncode == 0 else '' + except (subprocess.TimeoutExpired, OSError): + return '' +def _git_remote_branch_exists(project_dir, branch): + """True when origin/ exists locally as a remote-tracking ref.""" + if not branch: + return False + try: + r = subprocess.run( + ['git', 'show-ref', '--verify', '--quiet', f'refs/remotes/origin/{branch}'], + capture_output=True, text=True, timeout=10, cwd=str(project_dir)) + return r.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False +def resolve_pull_command(project_dir): + """Work out how to pull, for branches with and without an upstream. + + A plain ``git pull --rebase`` fails outright on a branch that has no + upstream ("There is no tracking information for the current branch"), + which is easy to end up on: checking out a branch by name, restoring a + backup, or following an install guide that names one. The update button + then reports a failure the user cannot act on. + + ``--autostash`` is passed for the same reason. Rebase refuses to start + when any tracked file is modified, and on these installs something always + is: first_time_install.sh chmods five scripts that git tracked as 644, so + every machine that ran the installer carries five permanent mode changes + and the update button reports "cannot pull with rebase: You have unstaged + changes". Those modes are corrected in this commit, but a user cannot pull + the correction while the pull is what is blocked, and any other local edit + would reproduce it anyway. Autostash reapplies the changes afterwards. + + Returns ``(args, note, error)``. When ``origin/`` exists the pull + is made explicit against it, so the update proceeds and the branch is + given tracking information afterwards. + """ + upstream = _git_upstream(project_dir) + if upstream: + return ['git', 'pull', '--rebase', '--autostash'], '', None + + branch = _git_current_branch(project_dir) + if not branch: + return None, '', ( + "This checkout is in a detached HEAD state, so there is no branch " + "to update. Switch to a branch first (Tools -> Switch branch)." + ) + if _git_remote_branch_exists(project_dir, branch): + return ( + ['git', 'pull', '--rebase', '--autostash', 'origin', branch], + f"Branch '{branch}' had no upstream; pulled from origin/{branch} and set it as the upstream.", + None, + ) + return None, '', ( + f"Branch '{branch}' has no upstream and there is no origin/{branch} to " + f"pull from. Use Switch branch to move to a branch that exists on the " + f"remote, or push this one first." + ) +_BRANCH_NAME_RE = re.compile(r'[A-Za-z0-9._/-]{1,200}') +def is_valid_branch_name(name): + """Accept only plain branch names. + + This value becomes a subprocess argument, so anything exotic is refused + rather than escaped. '..' is excluded because it is range syntax to git. + """ + if not name or not _BRANCH_NAME_RE.fullmatch(name): + return False + return '..' not in name and not name.startswith('-') +def checkout_branch(project_dir, target, stash=False): + """Switch the checkout to `target`, returning (payload, http_status). + + Split out of the route so it can be tested against real repositories. + Attaches tracking when the branch exists on origin, so the next + Pull Latest is a plain `git pull` rather than the no-upstream fallback. + """ + target = (target or '').strip() + if not target: + return {'status': 'error', 'message': 'Branch name required'}, 400 + if not is_valid_branch_name(target): + return {'status': 'error', 'message': 'Invalid branch name'}, 400 + + try: + subprocess.run(['git', 'fetch', 'origin', '--prune'], + capture_output=True, text=True, timeout=60, cwd=project_dir) + + local_exists = subprocess.run( + ['git', 'show-ref', '--verify', '--quiet', f'refs/heads/{target}'], + capture_output=True, text=True, timeout=10, cwd=project_dir).returncode == 0 + remote_exists = _git_remote_branch_exists(project_dir, target) + if not local_exists and not remote_exists: + return {'status': 'error', + 'message': f"No branch '{target}' locally or on origin"}, 404 + + # Local edits block a checkout. Pull Latest already stashes for the + # same reason, so offer it here too -- but only when asked, never + # silently: putting someone's edits away unasked is worse than + # refusing the switch. + stash_note = '' + if stash: + stashed = subprocess.run(['git', 'stash', 'push', '-m', f'switch to {target}'], + capture_output=True, text=True, timeout=60, cwd=project_dir) + if stashed.returncode == 0 and 'No local changes' not in stashed.stdout: + stash_note = ' Local changes were stashed (recover them with git stash list).' + + if local_exists: + co = subprocess.run(['git', 'checkout', target], + capture_output=True, text=True, timeout=60, cwd=project_dir) + else: + # -B so a stale local ref does not block the checkout. + co = subprocess.run(['git', 'checkout', '-B', target, f'origin/{target}'], + capture_output=True, text=True, timeout=60, cwd=project_dir) + + if co.returncode != 0: + logger.warning("git checkout %s failed: %s", target, co.stderr) + return { + 'status': 'error', + 'message': f"Could not switch to '{target}'.", + # Keep git's full list of blocking files: naming them is the + # difference between an error the user can act on and one they + # cannot. + 'detail': (co.stderr or '').strip(), + 'can_retry_with_stash': 'would be overwritten by checkout' in (co.stderr or ''), + }, 200 + + if remote_exists: + subprocess.run(['git', 'branch', f'--set-upstream-to=origin/{target}', target], + capture_output=True, text=True, timeout=10, cwd=project_dir) + + logger.info("Switched checkout to branch %s", target) + return { + 'status': 'success', + 'message': f"Now on '{target}'.{stash_note} Use Pull Latest to fetch its newest code.", + }, 200 + except subprocess.TimeoutExpired: + return {'status': 'error', 'message': 'Timed out talking to git'}, 504 + except OSError as exc: + logger.error("checkout_branch failed: %s", exc, exc_info=True) + return {'status': 'error', 'message': 'Could not switch branch'}, 500 +def get_git_version(project_dir=None): + """Get git version information from the repository""" + if project_dir is None: + project_dir = PROJECT_ROOT + + try: + # Try to get tag description (e.g., v2.4-10-g123456) + result = subprocess.run( + ['git', 'describe', '--tags', '--dirty'], + capture_output=True, + text=True, + timeout=5, + cwd=str(project_dir) + ) + + if result.returncode == 0: + version_str = result.stdout.strip() + if re.match(r'^[a-zA-Z0-9._\-]+$', version_str): + return version_str + + # Fallback to short commit hash + result = subprocess.run( + ['git', 'rev-parse', '--short', 'HEAD'], + capture_output=True, + text=True, + timeout=5, + cwd=str(project_dir) + ) + + if result.returncode == 0: + version_str = result.stdout.strip() + if re.match(r'^[a-zA-Z0-9._\-]+$', version_str): + return version_str + + return 'Unknown' + except Exception: + return 'Unknown' +_update_check_cache: Dict[str, Any] = {'result': None, 'ts': 0.0} +_UPDATE_CHECK_TTL = 300 # 5 minutes — avoids a git fetch on every page load +def _update_check_failed(detail: str) -> Dict[str, Any]: + """A check that could not run is not the same as being up to date. + + Reporting update_available=False on a git failure hides the banner, and + the banner is the only route to the update button -- so a checkout git + refuses to touch looks exactly like a current one, permanently. The most + common cause is an install performed as root: git then reports "dubious + ownership" and every command fails, including the fetch here. + """ + return {'update_available': False, 'remote_sha': 'unknown', + 'commits_behind': 0, 'check_failed': True, 'error': detail} +def _describe_git_failure(stderr: str) -> str: + """Turn git's stderr into something the user can act on.""" + text = (stderr or '').strip() + if 'dubious ownership' in text or 'detected dubious ownership' in text: + return ("This checkout is owned by a different user than the one " + "running the web interface, so git refuses to use it. It is " + "usually the result of installing as root. Fix the ownership " + "and the update will work: sudo chown -R $USER:$USER " + + str(PROJECT_ROOT)) + if 'could not resolve host' in text.lower() or 'network is unreachable' in text.lower(): + return "Could not reach GitHub to check for updates." + return "Could not check for updates: " + (text.splitlines()[0] if text else "git failed") +def _installed_plugin_ids(): + """Best-effort list of installed plugin IDs for the web process. + + Health/metrics state is written by the separate display service to the + shared on-disk cache, so the tracker's in-memory set is empty here. We + enumerate the installed plugins and read each one's persisted summary by ID + instead of relying on the tracker's in-memory `get_all_*` view. + """ + pm = api_v3.plugin_manager + manifests = getattr(pm, 'plugin_manifests', None) + if not manifests: + # Only pay for a discovery scan when we haven't discovered anything yet; + # subsequent polls reuse the already-populated manifest map. + try: + pm.discover_plugins() + except Exception: + logger.debug('discover_plugins failed while listing plugin ids', exc_info=True) + manifests = getattr(pm, 'plugin_manifests', None) + try: + return list(manifests.keys()) if manifests else [] + except Exception: + logger.debug('listing plugin_manifests failed while building plugin ids', exc_info=True) + return [] +def _do_transactional_uninstall(plugin_id, preserve_config): + """Execute an uninstall with snapshot-based rollback. + + Order of operations: + 1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors). + 2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files). + 3. Unload plugin from runtime if loaded (rollback + 500 if this raises). + 4. Remove plugin files (rollback + 500 if this returns False or raises). + 5. Finish (remove state, invalidate caches). + + Rollback restores the config snapshot and, if the plugin had been + loaded before unload, calls load_plugin to restore runtime state. + + Returns (True, None) on success or (False, error_message) on failure. + """ + from src.exceptions import ConfigError + + # --- Step 1: snapshot main + secrets --- + main_snapshot = None + secrets_snapshot = None + try: + main_snapshot = api_v3.config_manager.get_raw_file_content('main') + except (OSError, ConfigError): + pass # Proceed without snapshot; narrow catch preserves TypeError/AttributeError + try: + secrets_snapshot = api_v3.config_manager.get_raw_file_content('secrets') + except (OSError, ConfigError): + pass + + # --- Step 2: cleanup config first (abort before touching filesystem) --- + if not preserve_config: + api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True) + + # Record whether the plugin was running before we touch anything. + was_loaded = ( + api_v3.plugin_manager is not None + and plugin_id in api_v3.plugin_manager.plugins + ) + + def _rollback(reload_plugin): + if main_snapshot is not None: + try: + api_v3.config_manager.save_raw_file_content('main', main_snapshot) + except Exception as restore_err: + logger.error("Failed to restore main config snapshot for %s: %s", plugin_id, restore_err) + if secrets_snapshot is not None: + try: + api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot) + except Exception as restore_err: + logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err) + if reload_plugin and api_v3.plugin_manager is not None: + try: + api_v3.plugin_manager.load_plugin(plugin_id) + except Exception as reload_err: + logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err) + + # --- Step 3: unload --- + if was_loaded: + try: + api_v3.plugin_manager.unload_plugin(plugin_id) + except Exception as unload_err: + _rollback(reload_plugin=False) # unload failed — runtime state unchanged + return False, f"Failed to unload plugin {plugin_id}: {unload_err}" + + # --- Step 4: remove files --- + try: + success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id) + except Exception as remove_err: + _rollback(reload_plugin=was_loaded) + return False, f"Failed to remove plugin {plugin_id}: {remove_err}" + + if not success: + _rollback(reload_plugin=was_loaded) + return False, f"Failed to uninstall plugin {plugin_id}" + + # --- Step 5: finish --- + if api_v3.schema_manager: + api_v3.schema_manager.invalidate_cache(plugin_id) + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.remove_plugin_state(plugin_id) + # Persistently record the uninstall so a later core `git pull` update + # cannot resurrect a built-in plugin (committed under plugin-repos/) that + # the user removed. Best-effort: never fail the uninstall over this. + try: + api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id) + except Exception as record_err: + logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err) + return True, None +def deep_merge(base_dict, update_dict): + """ + Deep merge update_dict into base_dict. + For nested dicts, recursively merge. For other types, update_dict takes precedence. + + Lists are intentionally REPLACED wholesale, never index-merged: form posts + carry complete arrays, and index-merging would resurrect items the user + deleted. This also applies to the parallel secrets lists produced by + separate_secrets — a newly saved secrets list is authoritative. + """ + result = base_dict.copy() + for key, value in update_dict.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + # Recursively merge nested dicts + result[key] = deep_merge(result[key], value) + else: + # For non-dict values or new keys, use the update value + result[key] = value + return result +def _parse_form_value(value): + """ + Parse a form value into the appropriate Python type. + Handles booleans, numbers, JSON arrays/objects, and strings. + """ + import json + + if value is None: + return None + + # Handle string values + if isinstance(value, str): + stripped = value.strip() + + # Check for boolean strings + if stripped.lower() == 'true': + return True + if stripped.lower() == 'false': + return False + if stripped.lower() in ('null', 'none') or stripped == '': + return None + + # Try parsing as JSON (for arrays and objects) - do this BEFORE number parsing + # This handles RGB arrays like "[255, 0, 0]" correctly + if stripped.startswith('[') or stripped.startswith('{'): + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + + # Try parsing as number + try: + if '.' in stripped: + return float(stripped) + return int(stripped) + except ValueError: + pass + + # Return as string (original value, not stripped) + return value + + return value +def _get_schema_property(schema, key_path): + """ + Get the schema property for a given key path (supports dot notation). + + Args: + schema: The JSON schema dict + key_path: Dot-separated path like "customization.time_text.font" + + Returns: + The property schema dict or None if not found + """ + if not schema or 'properties' not in schema: + return None + + parts = key_path.split('.') + current = schema['properties'] + i = 0 + + while i < len(parts): + # Try progressively longer candidates, longest first, so schema keys that + # themselves contain dots (e.g. league keys like "fifa.world") are matched + # instead of being mistaken for nested "fifa" -> "world" objects. + matched = False + for j in range(len(parts), i, -1): + candidate = '.'.join(parts[i:j]) + if isinstance(current, dict) and candidate in current: + prop = current[candidate] + # Consumed all remaining parts — this is the target property. + if j == len(parts): + return prop + # Navigate deeper through an object with properties. + if isinstance(prop, dict) and 'properties' in prop: + current = prop['properties'] + i = j + matched = True + break + # Matched a non-object before consuming the path — can't go deeper. + return None + if not matched: + return None + + return None +def _is_field_required(key_path, schema): + """ + Check if a field is required according to the schema. + + Args: + key_path: Dot-separated path like "mqtt.username" + schema: The JSON schema dict + + Returns: + True if field is required, False otherwise + """ + if not schema or 'properties' not in schema: + return False + + parts = key_path.split('.') + if len(parts) == 1: + # Top-level field + required = schema.get('required', []) + return parts[0] in required + else: + # Nested field - navigate to parent object + parent_path = '.'.join(parts[:-1]) + field_name = parts[-1] + + # Get parent property + parent_prop = _get_schema_property(schema, parent_path) + if not parent_prop or 'properties' not in parent_prop: + return False + + # Check if field is required in parent + required = parent_prop.get('required', []) + return field_name in required +# Sentinel object to indicate a field should be skipped (not set in config) +_SKIP_FIELD = object() +def _parse_form_value_with_schema(value, key_path, schema): + """ + Parse a form value using schema information to determine correct type. + Handles arrays (comma-separated strings), objects, and other types. + + Args: + value: The form value (usually a string) + key_path: Dot-separated path like "category_order" or "customization.time_text.font" + schema: The plugin's JSON schema + + Returns: + Parsed value with correct type, or _SKIP_FIELD to indicate the field should not be set + """ + import json + + # Get the schema property for this field + prop = _get_schema_property(schema, key_path) + + # Handle None/empty values + if value is None or (isinstance(value, str) and value.strip() == ''): + # If schema says it's an array, return empty array instead of None + if prop and prop.get('type') == 'array': + return [] + # If schema says it's an object, return empty dict instead of None + if prop and prop.get('type') == 'object': + return {} + # If it's an optional string field, preserve empty string instead of None + if prop and prop.get('type') == 'string': + if not _is_field_required(key_path, schema): + return "" # Return empty string for optional string fields + # For number/integer fields, check if they have defaults or are required + if prop: + prop_type = prop.get('type') + if prop_type in ('number', 'integer'): + # If field has a default, use it + if 'default' in prop: + return prop['default'] + # If field is not required and has no default, skip setting it + if not _is_field_required(key_path, schema): + return _SKIP_FIELD + # If field is required but empty, return None (validation will fail, which is correct) + return None + return None + + # Handle string values + if isinstance(value, str): + stripped = value.strip() + + # Check for boolean strings + if stripped.lower() == 'true': + return True + if stripped.lower() == 'false': + return False + # "on"/"off" come from HTML checkboxes — only coerce when schema says boolean + if prop and prop.get('type') == 'boolean': + if stripped.lower() == 'on': + return True + if stripped.lower() == 'off': + return False + + # Handle arrays based on schema + if prop and prop.get('type') == 'array': + # Try parsing as JSON first (handles "[1,2,3]" format) + if stripped.startswith('['): + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + + # Otherwise, treat as comma-separated string + if stripped: + # Split by comma and strip each item + items = [item.strip() for item in stripped.split(',') if item.strip()] + # Try to convert items to numbers if schema items are numbers + items_schema = prop.get('items', {}) + if items_schema.get('type') in ('number', 'integer'): + try: + return [int(item) if '.' not in item else float(item) for item in items] + except ValueError: + pass + return items + return [] + + # Handle objects based on schema + if prop and prop.get('type') == 'object': + # Try parsing as JSON + if stripped.startswith('{'): + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + # If it's not JSON, return empty dict (form shouldn't send objects as strings) + return {} + + # Try parsing as JSON (for arrays and objects) - do this BEFORE number parsing + if stripped.startswith('[') or stripped.startswith('{'): + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + + # Handle numbers based on schema + if prop: + prop_type = prop.get('type') + if prop_type == 'integer': + try: + return int(stripped) + except ValueError: + return prop.get('default', 0) + elif prop_type == 'number': + try: + return float(stripped) + except ValueError: + return prop.get('default', 0.0) + + # Try parsing as number (fallback) — skip when schema explicitly says string + if not (prop and prop.get('type') == 'string'): + try: + if '.' in stripped: + return float(stripped) + return int(stripped) + except ValueError: + pass + + # Return as string + return value + + return value +def _resolve_key_segments(key_path, config): + """Split a dot-notation path into segments, greedily preserving keys that + themselves contain dots (e.g. league keys like "fifa.world"). + + At each level the longest candidate that matches a key already present in the + config wins; otherwise the path splits on the next dot (the normal + nested-create case). Because dotted keys such as ``leagues."fifa.world"`` + always exist in the saved config being updated, this routes the value to the + real league object instead of fabricating a ``leagues.fifa.world`` tree. + """ + parts = key_path.split('.') + segments = [] + node = config + i = 0 + while i < len(parts): + matched = False + if isinstance(node, dict): + for j in range(len(parts), i, -1): + candidate = '.'.join(parts[i:j]) + if candidate in node: + segments.append(candidate) + node = node[candidate] + i = j + matched = True + break + if not matched: + part = parts[i] + segments.append(part) + node = node.get(part) if isinstance(node, dict) else None + i += 1 + return segments +def _set_nested_value(config, key_path, value): + """ + Set a value in a nested dict using dot notation path. + Handles existing nested dicts correctly by merging instead of replacing. + Keys containing dots (e.g. league keys like "fifa.world") are preserved when + they already exist in the config rather than being split into nested objects. + + Args: + config: The config dict to modify + key_path: Dot-separated path (e.g., "customization.period_text.font") + value: The value to set (or _SKIP_FIELD to skip setting) + """ + # Skip setting if value is the sentinel + if value is _SKIP_FIELD: + return + + segments = _resolve_key_segments(key_path, config) + current = config + + # Navigate/create intermediate dicts + for seg in segments[:-1]: + if seg not in current: + current[seg] = {} + elif not isinstance(current[seg], dict): + # If the existing value is not a dict, replace it with a dict + current[seg] = {} + current = current[seg] + + # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) + if value is not None or segments[-1] not in current: + current[segments[-1]] = value +def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): + """Walk schema and set missing boolean form fields to False. + + HTML checkboxes don't submit values when unchecked. When saving plugin config, + the backend starts from existing config (to support partial form updates), which + means an unchecked checkbox's old ``True`` value persists. This function detects + boolean schema properties not present in the form submission and explicitly sets + them to ``False``. + + The top-level ``enabled`` field is excluded because it has its own preservation + logic in the save endpoint. + + Handles boolean fields inside nested objects and inside arrays of objects + (e.g. ``feeds.custom_feeds.0.enabled``). + + Args: + config: The root plugin config dict (used for pure-dict paths) + schema_props: Schema ``properties`` dict at the current nesting level + form_keys: Set of form field names that were submitted + prefix: Dot-notation prefix for the current nesting level + config_node: The current config subtree when inside an array item (avoids + using _set_nested_value which corrupts lists) + """ + # Determine which config node to operate on + node = config_node if config_node is not None else config + + for prop_name, prop_schema in schema_props.items(): + if not isinstance(prop_schema, dict): + continue + + full_path = f"{prefix}.{prop_name}" if prefix else prop_name + prop_type = prop_schema.get('type') + + if prop_type == 'boolean' and full_path != 'enabled': + # If this boolean wasn't submitted in the form, it's an unchecked checkbox + if full_path not in form_keys: + if config_node is not None: + # Inside an array item — set directly on the item dict + node[prop_name] = False + else: + # Pure dict path — use helper + _set_nested_value(config, full_path, False) + + elif prop_type == 'object' and 'properties' in prop_schema: + # Recurse into nested objects + if config_node is not None: + # Inside an array item — ensure nested dict exists in item + if prop_name not in node or not isinstance(node[prop_name], dict): + node[prop_name] = {} + _set_missing_booleans_to_false( + config, prop_schema['properties'], form_keys, full_path, + config_node=node[prop_name] + ) + else: + _set_missing_booleans_to_false( + config, prop_schema['properties'], form_keys, full_path + ) + + elif prop_type == 'array': + # Handle arrays of objects that may contain boolean fields + # Form keys use indexed notation: "path.0.field", "path.1.field" + items_schema = prop_schema.get('items', {}) + if isinstance(items_schema, dict) and items_schema.get('type') == 'object' and 'properties' in items_schema: + array_prefix = f"{full_path}." + # Collect unique item indices from submitted form keys + indices = set() + for k in form_keys: + if k.startswith(array_prefix): + # Extract index: "path.0.field" -> "0" + rest = k[len(array_prefix):] + idx = rest.split('.', 1)[0] + if idx.isdigit(): + indices.add(int(idx)) + + if not indices: + continue + + # Navigate to the array in the config (create if missing) + if config_node is not None: + if prop_name not in node or not isinstance(node[prop_name], list): + node[prop_name] = [] + array_list = node[prop_name] + else: + # Navigate from root config through dict keys to get the list + parts = full_path.split('.') + current = config + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + arr_key = parts[-1] + if arr_key not in current or not isinstance(current[arr_key], list): + current[arr_key] = [] + array_list = current[arr_key] + + # Recurse into each array item so its missing booleans get set to False + for idx in indices: + # Ensure list is long enough and item is a dict + while len(array_list) <= idx: + array_list.append({}) + if not isinstance(array_list[idx], dict): + array_list[idx] = {} + item_prefix = f"{full_path}.{idx}" + _set_missing_booleans_to_false( + config, items_schema['properties'], form_keys, item_prefix, + config_node=array_list[idx] + ) +def _enhance_schema_with_core_properties(schema): + """ + Enhance schema with core plugin properties (enabled, display_duration, live_priority). + These properties are system-managed and should always be allowed even if not in the plugin's schema. + + Args: + schema: The original JSON schema dict + + Returns: + Enhanced schema dict with core properties injected + """ + import copy + + if not schema: + return schema + + # Core plugin properties that should always be allowed + # These match the definitions in SchemaManager.validate_config_against_schema() + core_properties = { + "enabled": { + "type": "boolean", + "default": True, + "description": "Enable or disable this plugin" + }, + "display_duration": { + "type": "number", + "default": 15, + "minimum": 1, + "maximum": 300, + "description": "How long to display this plugin in seconds" + }, + "live_priority": { + "type": "boolean", + "default": False, + "description": "Enable live priority takeover when plugin has live content" + } + } + + # Create a deep copy of the schema to modify (to avoid mutating the original) + enhanced_schema = copy.deepcopy(schema) + if "properties" not in enhanced_schema: + enhanced_schema["properties"] = {} + + # Inject core properties if they're not already defined in the schema + for prop_name, prop_def in core_properties.items(): + if prop_name not in enhanced_schema["properties"]: + enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def) + + return enhanced_schema +def _filter_config_by_schema(config, schema, prefix=''): + """ + Filter config to only include fields defined in the schema. + Removes fields not in schema, especially important when additionalProperties is false. + + Args: + config: The config dict to filter + schema: The JSON schema dict + prefix: Prefix for nested paths (used recursively) + + Returns: + Filtered config dict containing only schema-defined fields + """ + if not schema or 'properties' not in schema: + return config + + filtered = {} + schema_props = schema.get('properties', {}) + + for key, value in config.items(): + if key not in schema_props: + # Field not in schema, skip it + continue + + prop_schema = schema_props[key] + + # Handle nested objects recursively + if isinstance(value, dict) and prop_schema.get('type') == 'object' and 'properties' in prop_schema: + filtered[key] = _filter_config_by_schema(value, prop_schema, f"{prefix}.{key}" if prefix else key) + else: + # Keep the value as-is for non-object types + filtered[key] = value + + return filtered +_MAX_CREDENTIAL_BACKUPS = 5 +def _prune_credential_backups(plugin_dir: Path) -> None: + """Keep only the newest _MAX_CREDENTIAL_BACKUPS credential backups. + + Every re-upload copies the previous credentials.json aside. Without + pruning those accumulate for the life of the install — each one a + complete set of OAuth client credentials sitting in the plugin + directory. + """ + backups = sorted( + plugin_dir.glob('credentials.json.backup.*'), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for stale in backups[_MAX_CREDENTIAL_BACKUPS:]: + try: + stale.unlink() + except OSError: + logger.warning("Could not remove old credential backup %s", stale.name) +# calendarList.list pages at 250 entries maximum. Ten pages is far past any +# real account and exists only so a malformed nextPageToken cannot spin here. +_CALENDAR_LIST_MAX_PAGES = 10 +def _calendar_plugin_dir() -> Optional[Path]: + """Where the calendar plugin is installed, or None if it is not.""" + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar') + else: + plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar' + if not plugin_dir: + return None + plugin_dir = Path(plugin_dir) + return plugin_dir if plugin_dir.exists() else None +def _run_calendar_registration(plugin_dir: Path, stdin_payload: str): + """Run the plugin's OAuth script and return the JSON object it prints. + + The script decides between web and terminal mode by whether stdin is a + tty, so it must be given a pipe. It emits one JSON object on stdout; the + last parsable line is taken, because an import warning or a library's + stderr redirection can land in front of it. + + Returns (payload, error_message). Exactly one is None. + """ + script = plugin_dir / 'calendar_registration.py' + if not script.exists(): + return None, 'Authentication script not found in the calendar plugin' + + try: + result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir + [sys.executable, str(script)], + input=stdin_payload, + capture_output=True, + text=True, + timeout=120, + cwd=str(plugin_dir), + ) + except subprocess.TimeoutExpired: + return None, 'Authentication timed out after 120s' + except OSError as e: + logger.error('Could not run calendar_registration.py', exc_info=True) + return None, 'Could not run the authentication script: %s' % describe_exception(e) + + for line in reversed((result.stdout or '').splitlines()): + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload, None + + raw = (result.stderr or result.stdout or '').strip() + # The unredacted text goes to the log, where it is worth having in full. + # What comes back over HTTP is redacted: this is a script that handles + # OAuth client secrets, and its stderr can quote them. + if raw: + logger.error('calendar_registration.py failed (exit %s): %s', + result.returncode, raw) + return None, 'Authentication script produced no result%s' % ( + ': %s' % redact_text(raw) if raw else '') +def _resolve_backup_export_dir() -> Path: + """Where exported backups live: beside the install, not inside it. + + They used to be written to ``/config/backups/exports``. That is + inside the directory a reinstall deletes, so the documented recovery path + -- export a backup, then reinstall -- destroyed the backup it had just + told the user to make. Anyone who downloaded the ZIP was fine; anyone + relying on the on-device copy was not. + + Falls back to the old location when the parent directory is not writable, + so an unusual layout degrades to previous behaviour instead of failing to + export at all. + """ + preferred = PROJECT_ROOT.parent / "ledmatrix-backups" + fallback = PROJECT_ROOT / "config" / "backups" / "exports" + try: + preferred.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=preferred, prefix=".writetest-"): + pass + return preferred + except OSError as e: + logger.warning( + f"[Backup] Export dir {preferred} is not writable ({e}); " + f"falling back to {fallback}, which a reinstall will delete" + ) + return fallback +_BACKUP_EXPORT_DIR = _resolve_backup_export_dir() +def _safe_backup_path(filename: str) -> Path: + """Resolve a filename to an absolute path inside the export dir, + rejecting any traversal attempts. Returns None if unsafe.""" + # Use basename first (CodeQL-recognized sanitizer) then validate format + filename = os.path.basename(filename or '') + if not filename or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$', filename): + return None + path = (_BACKUP_EXPORT_DIR / filename).resolve() + try: + path.relative_to(_BACKUP_EXPORT_DIR.resolve()) + except ValueError: + return None + return path +_STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps' +_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json' +def _get_starlark_plugin() -> Optional[Any]: + """Get the starlark-apps plugin instance, or None.""" + if not api_v3.plugin_manager: + return None + return api_v3.plugin_manager.get_plugin('starlark-apps') +def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: + """Find pixlet binary: explicit path → bundled binary → system PATH.""" + import platform + if explicit_path and os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK): + return explicit_path + bin_dir = PROJECT_ROOT / "bin" / "pixlet" + system = platform.system().lower() + machine = platform.machine().lower() + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + name = "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + name = "pixlet-linux-amd64" + else: + name = None + elif system == "darwin": + name = "pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64" + else: + name = None + if name: + bundled = bin_dir / name + if bundled.is_file(): + if os.access(str(bundled), os.X_OK): + return str(bundled) + try: + bundled.chmod(0o755) + except OSError: + logger.warning("Could not make pixlet bundled binary executable (%s); falling back to PATH", bundled) + else: + if os.access(str(bundled), os.X_OK): + return str(bundled) + logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) + return shutil.which("pixlet") +def _read_starlark_manifest() -> Dict[str, Any]: + """Read the starlark-apps manifest.json directly from disk.""" + try: + if _STARLARK_MANIFEST_FILE.exists(): + with open(_STARLARK_MANIFEST_FILE, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Error reading starlark manifest: {e}") + return {'apps': {}} +def _starlark_github_token() -> Optional[str]: + """The GitHub token the Starlark store should authenticate with. + + These routes used to read `github_token` off config.json, a key that is + written nowhere and offered by no setting -- so the store always ran + unauthenticated at 60 requests/hour, on the same per-IP budget every + plugin update check spends, while the token the user had actually + configured sat in config_secrets.json raising the same budget to 5000. + The store going blank was that budget running out. + + Prefer the store manager's token, which is the one the settings UI + writes and validates; keep the config.json key as a fallback so a + hand-edited config still works. + """ + token = getattr(api_v3.plugin_store_manager, 'github_token', None) + if token: + return token + + try: + config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + return config.get('github_token') + except Exception: + logger.warning("[Starlark] Could not read config for a GitHub token", exc_info=True) + return None +def _get_tronbyte_repository_class() -> Type[Any]: + """Import TronbyteRepository from plugin-repos directory.""" + import importlib.util + import importlib + + module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py' + if not module_path.exists(): + raise ImportError(f"TronbyteRepository module not found at {module_path}") + + # If already imported, return cached class + if "tronbyte_repository" in sys.modules: + return sys.modules["tronbyte_repository"].TronbyteRepository + + spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path)) + if spec is None: + raise ImportError(f"Failed to create module spec for tronbyte_repository at {module_path}") + + module = importlib.util.module_from_spec(spec) + if module is None: + raise ImportError("Failed to create module from spec for tronbyte_repository") + + sys.modules["tronbyte_repository"] = module + try: + spec.loader.exec_module(module) + except BaseException: + # A module that failed to execute must not stay in sys.modules: the + # cache branch above would hand back the half-initialised object for + # the rest of the process, so one transient failure would disable + # this path permanently and surface as AttributeError, not ImportError. + sys.modules.pop("tronbyte_repository", None) + raise + return module.TronbyteRepository +def _get_pixlet_renderer_class() -> Type[Any]: + """Import PixletRenderer from plugin-repos directory.""" + import importlib.util + import importlib + + module_path = PROJECT_ROOT / 'plugin-repos' / 'starlark-apps' / 'pixlet_renderer.py' + if not module_path.exists(): + raise ImportError(f"PixletRenderer module not found at {module_path}") + + # If already imported, return cached class + if "pixlet_renderer" in sys.modules: + return sys.modules["pixlet_renderer"].PixletRenderer + + spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path)) + if spec is None: + raise ImportError(f"Failed to create module spec for pixlet_renderer at {module_path}") + + module = importlib.util.module_from_spec(spec) + if module is None: + raise ImportError("Failed to create module from spec for pixlet_renderer") + + sys.modules["pixlet_renderer"] = module + try: + spec.loader.exec_module(module) + except BaseException: + # A module that failed to execute must not stay in sys.modules: the + # cache branch above would hand back the half-initialised object for + # the rest of the process, so one transient failure would disable + # this path permanently and surface as AttributeError, not ImportError. + sys.modules.pop("pixlet_renderer", None) + raise + return module.PixletRenderer +def _validate_and_sanitize_app_id(app_id: Optional[str], fallback_source: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: + """Validate and sanitize app_id to a safe slug.""" + if not app_id and fallback_source: + app_id = fallback_source + if not app_id: + return None, "app_id is required" + if '..' in app_id or '/' in app_id or '\\' in app_id: + return None, "app_id contains invalid characters" + + sanitized = re.sub(r'[^a-z0-9_]', '_', app_id.lower()).strip('_') + if not sanitized: + sanitized = f"app_{hashlib.sha256(app_id.encode()).hexdigest()[:12]}" + if sanitized[0].isdigit(): + sanitized = f"app_{sanitized}" + return sanitized, None +def _validate_timing_value(value: Any, field_name: str, min_val: int = 1, max_val: int = 86400) -> Tuple[Optional[int], Optional[str]]: + """Validate and coerce timing values.""" + if value is None: + return None, None + try: + int_value = int(value) + except (ValueError, TypeError): + return None, f"{field_name} must be an integer" + if int_value < min_val: + return None, f"{field_name} must be at least {min_val}" + if int_value > max_val: + return None, f"{field_name} must be at most {max_val}" + return int_value, None +def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[str]]: + """The app's directory, or an error if app_id could escape the base dir. + + Returns the *resolved* path rather than a boolean, and every caller uses + what it returns instead of re-joining ``_STARLARK_APPS_DIR / app_id`` + afterwards. The old shape validated in one place and rebuilt the path in + another, which is two things that have to stay in step -- and is why + CodeQL reported twenty-four path-injection alerts across these handlers + even though the guard was effective: a boolean is not a sanitiser it can + follow, and the value reaching the filesystem was the raw one. + + The name is unchanged so the call sites read the same. + """ + if not isinstance(app_id, str) or not app_id: + return None, "Invalid app_id" + + # Reject the traversal characters outright before touching the filesystem. + if '..' in app_id or '/' in app_id or '\\' in app_id: + return None, "Invalid app_id: contains path traversal characters" + + # os.path.basename strips any directory component, so what is joined below + # cannot carry one. The equality check means this rejects rather than + # silently truncates -- behaviour is identical to the character test above, + # and it is the sanitiser CodeQL's path-injection query actually follows. + # relative_to() alone is a check it cannot trace, which is why twenty-four + # of these stayed flagged after the value was threaded through properly. + safe_name = os.path.basename(app_id) + if safe_name != app_id or safe_name in ('', '.', '..'): + return None, "Invalid app_id: contains path traversal characters" + + try: + base_path = _STARLARK_APPS_DIR.resolve() + app_path = (base_path / safe_name).resolve() + try: + app_path.relative_to(base_path) + except ValueError: + return None, "Invalid app_id: path traversal attempt" + return app_path, None + except OSError as e: + logger.warning("Path validation error for app_id %r: %s", app_id, e) + return None, "Invalid app_id" +def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: + """Render a Starlark app via pixlet directly (no plugin required). + + Reads the .star file and config from starlark-apps/{app_id}/, runs pixlet, + and saves the output to cached_render.webp in the same directory. + This is the web-service fallback when starlark-apps plugin is not loaded. + + Returns (success, http_status_code, error_message). + """ + manifest = _read_starlark_manifest() + if not isinstance(manifest, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + apps = manifest.get('apps', {}) + if not isinstance(apps, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + app_data = apps.get(app_id) + if not app_data: + return False, 404, f"App not found: {app_id}" + + # Validated here as well as at the handler: this is reachable on its own, + # and a path built from app_id should never be assembled without it. + app_dir, path_error = _validate_starlark_app_path(app_id) + if path_error: + return False, 400, path_error + star_file = app_dir / app_data.get('star_file', f'{app_id}.star') + if not star_file.exists(): + return False, 404, f"Star file not found: {star_file}" + + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + plugin_config = full_config.get('starlark-apps', {}) + + pixlet_path = _find_pixlet_binary(plugin_config.get('pixlet_path')) + if not pixlet_path: + return False, 503, "Pixlet binary not found — install pixlet first" + + magnify = plugin_config.get('magnify') + if magnify is None: + hw = full_config.get('display', {}).get('hardware', {}) + cols = hw.get('cols', 64) + chain = hw.get('chain_length', 1) + rows = hw.get('rows', 32) + magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32)))) + else: + try: + magnify = max(1, min(8, int(magnify))) + except (ValueError, TypeError): + magnify = 1 + + config_file = app_dir / 'config.json' + app_config: Dict[str, Any] = {} + if config_file.exists(): + try: + with open(config_file) as f: + app_config = json.load(f) + except json.JSONDecodeError as e: + logger.warning("Invalid config.json for %r at %s: %s", app_id, config_file, e) + return False, 400, f"Invalid config.json for {app_id}" + except OSError as e: + logger.warning("Cannot read config.json for %r at %s: %s", app_id, config_file, e) + return False, 400, f"Cannot read config.json for {app_id}" + if not isinstance(app_config, dict): + return False, 400, ( + f"config.json for {app_id} must be a JSON object, " + f"got {type(app_config).__name__}" + ) + + INTERNAL_KEYS = {'render_interval', 'display_duration'} + pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} + + output_path = str(app_dir / 'cached_render.webp') + cmd = [pixlet_path, 'render', str(star_file)] + for key, value in pixlet_config.items(): + if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key): + continue + value_str = 'true' if value is True else 'false' if value is False else str(value) + if re.search(r'[`$|<>&;\x00]|\$\(', value_str): + continue + cmd.append(f'{key}={value_str}') + cmd.extend(['-o', output_path, '-m', str(magnify)]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(app_dir)) + if result.returncode == 0 and os.path.isfile(output_path): + return True, 200, None + return False, 502, f"Pixlet failed (exit {result.returncode}): {result.stderr.strip()}" + except subprocess.TimeoutExpired: + return False, 504, "Render timed out after 30s" + except Exception as e: + logger.exception("Starlark render failed for %r", app_id) + return False, 500, "Render error" +def _write_starlark_manifest(manifest: Dict[str, Any]) -> bool: + """Write the starlark-apps manifest.json to disk with atomic write.""" + temp_file = None + try: + _STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True) + + # Atomic write: unique temp file in the target directory, then rename. + # with_suffix('.tmp') gave every caller the same manifest.tmp, and + # Flask serves concurrently -- upload, uninstall, config, toggle and + # the plugin toggle all reach here. Two writers shared one file, + # interleaved their json.dump output, and both renamed it, so the + # rename was atomic over content that was a mix of two manifests. + fd, temp_name = tempfile.mkstemp( + dir=str(_STARLARK_APPS_DIR), prefix='.manifest.', suffix='.tmp') + temp_file = Path(temp_name) + with os.fdopen(fd, 'w') as f: + json.dump(manifest, f, indent=2) + f.flush() + os.fsync(f.fileno()) # Ensure data is written to disk + os.chmod(temp_name, 0o644) # mkstemp creates 0600; match a normal write + + # Atomic rename (overwrites destination) + temp_file.replace(_STARLARK_MANIFEST_FILE) + return True + except OSError as e: + logger.error(f"Error writing starlark manifest: {e}") + # Clean up temp file if it exists + if temp_file and temp_file.exists(): + try: + temp_file.unlink() + except Exception: + pass + return False +def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any], assets_dir: Optional[str] = None) -> bool: + """Install a .star file and update the manifest (standalone, no plugin needed).""" + import shutil + import json + app_dir, path_error = _validate_starlark_app_path(app_id) + if path_error: + logger.warning("Refusing to install %r: %s", app_id, path_error) + return False + app_dir.mkdir(parents=True, exist_ok=True) + dest = app_dir / f"{app_id}.star" + shutil.copy2(star_file_path, str(dest)) + + # Copy asset directories if provided (images/, sources/, etc.) + if assets_dir and Path(assets_dir).exists(): + assets_path = Path(assets_dir) + for item in assets_path.iterdir(): + if item.is_dir(): + # Copy entire directory (e.g., images/, sources/) + dest_dir = app_dir / item.name + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(item, dest_dir) + logger.debug(f"Copied assets directory: {item.name}") + logger.info(f"Installed assets for {app_id}") + + # Try to extract schema using PixletRenderer + schema = None + try: + PixletRenderer = _get_pixlet_renderer_class() + pixlet = PixletRenderer() + if pixlet.is_available(): + _, schema, _ = pixlet.extract_schema(str(dest)) + if schema: + schema_path = app_dir / "schema.json" + with open(schema_path, 'w') as f: + json.dump(schema, f, indent=2) + logger.info(f"Extracted schema for {app_id}") + except Exception as e: + logger.warning(f"Failed to extract schema for {app_id}: {e}") + + # Create default config — pre-populate with schema defaults + default_config = {} + if schema: + fields = schema.get('fields') or schema.get('schema') or [] + for field in fields: + if isinstance(field, dict) and 'id' in field and 'default' in field: + default_config[field['id']] = field['default'] + + # Create config.json file + config_path = app_dir / "config.json" + with open(config_path, 'w') as f: + json.dump(default_config, f, indent=2) + + manifest = _read_starlark_manifest() + manifest.setdefault('apps', {})[app_id] = { + 'name': metadata.get('name', app_id), + 'enabled': True, + 'render_interval': metadata.get('render_interval', 300), + 'display_duration': metadata.get('display_duration', 15), + 'config': metadata.get('config', {}), + # The filename, not the full path. Readers join this to the app's own + # directory and fall back to a bare '.star', so an absolute + # value gave the key two meanings -- and Path.__truediv__ discards the + # left side when the right is absolute, which pinned the manifest to + # whatever PROJECT_ROOT installed it. Moving or redeploying the install + # then left the app unable to find its own file. + 'star_file': dest.name, + } + return _write_starlark_manifest(manifest) +def _starlark_virtual_plugins() -> list: + """Installed Starlark apps, shaped like plugin entries. + + #253 surfaced these alongside real plugins so an installed .star app can + be seen, enabled and disabled from the same list as everything else; #330 + dropped it with the rest of the Starlark code, which is why an app + installs successfully and then appears nowhere. + + Reads the loaded plugin when there is one and the on-disk manifest + otherwise, so the list is right before starlark-apps has been loaded too. + """ + entries = [] + base = { + 'version': 'starlark', 'category': 'Starlark App', 'tags': ['starlark'], + 'verified': False, 'last_updated': None, 'last_commit': None, + 'last_commit_message': None, 'branch': None, 'web_ui_actions': [], + 'vegas_mode': 'fixed', 'vegas_content_type': 'multi', + 'is_starlark_app': True, + } + try: + plugin = _get_starlark_plugin() + if plugin is not None and hasattr(plugin, 'apps'): + for app_id, app in plugin.apps.items(): + m = getattr(app, 'manifest', {}) or {} + entries.append({**base, + 'id': f'starlark:{app_id}', + 'name': m.get('name', app_id), + 'author': m.get('author', 'Tronbyte Community'), + 'description': m.get('summary', 'Starlark app'), + 'enabled': app.is_enabled(), + 'loaded': True}) + return entries + + for app_id, data in (_read_starlark_manifest().get('apps', {}) or {}).items(): + entries.append({**base, + 'id': f'starlark:{app_id}', + 'name': data.get('name', app_id), + 'author': data.get('author', 'Tronbyte Community'), + 'description': data.get('summary', 'Starlark app'), + 'enabled': data.get('enabled', True), + 'loaded': False}) + except Exception: + # Never let a Starlark problem empty the whole plugins list. + logger.exception('Could not build Starlark virtual plugin entries') + return entries +def _toggle_starlark_app(app_id: str, enabled: bool): + """Enable or disable one Starlark app, loaded or not.""" + # Check for traversal, but toggle the key that was listed. + # _starlark_virtual_plugins publishes the raw manifest key, while + # _validate_and_sanitize_app_id lowercases it and rewrites every character + # outside [a-z0-9_]: an app stored as 'My-App' was offered to the UI as + # 'starlark:My-App' and looked up here as 'my_app', so toggling an app the + # page had just drawn answered 404. Keys written by _install_star_file are + # already sanitised; ones written by the plugin, or edited by hand, are + # not. _validate_starlark_app_path rejects traversal without rewriting. + _, err = _validate_starlark_app_path(app_id) + if err: + # err already names app_id; do not prefix it a second time. + return jsonify({'status': 'error', 'message': err}), 400 + safe_id = app_id + + plugin = _get_starlark_plugin() + if plugin is not None and safe_id in getattr(plugin, 'apps', {}): + def _update(manifest): + # setdefault rather than indexing: the app is loaded, but its + # on-disk entry need not exist, and _update_manifest_safe does not + # catch KeyError -- it would escape as a 500 rather than the error + # this returns. + manifest.setdefault('apps', {}).setdefault(safe_id, {})['enabled'] = enabled + + if plugin._update_manifest_safe(_update) is False: + return jsonify({'status': 'error', + 'message': 'Failed to save app state'}), 500 + # Only now is the in-memory copy allowed to disagree with disk. + plugin.apps[safe_id].manifest['enabled'] = enabled + else: + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(safe_id) + if not app_data: + return jsonify({'status': 'error', + 'message': f'Starlark app not found: {safe_id}'}), 404 + app_data['enabled'] = enabled + if not _write_starlark_manifest(manifest): + return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 + + return jsonify({'status': 'success', + 'message': f"Starlark app {'enabled' if enabled else 'disabled'}", + 'enabled': enabled}) + + +# Imported last, and for their side effect: each registers its routes on +# api_v3. They import names from this module, so this module must be fully +# executed before they run. +from web_interface.blueprints.api_v3 import ( # noqa: E402,F401 + backup, + config, + display, + fonts, + misc, + plugins, + starlark, + system, + wifi, +) + +__all__ = ["api_v3"] diff --git a/web_interface/blueprints/api_v3/backup.py b/web_interface/blueprints/api_v3/backup.py new file mode 100644 index 000000000..12add9db3 --- /dev/null +++ b/web_interface/blueprints/api_v3/backup.py @@ -0,0 +1,212 @@ +"""Backup creation, listing and restore. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + PROJECT_ROOT, Path, _safe_backup_path, api_v3, + datetime, json, jsonify, logger, os, plugin_store_manager, 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. + + +@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 +@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 +@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 +@api_v3.route('/backup/validate', methods=['POST']) +def backup_validate(): + """Validate an uploaded backup ZIP and return its manifest.""" + 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) + 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 +@api_v3.route('/backup/restore', methods=['POST']) +def backup_restore(): + """Restore a backup ZIP with optional RestoreOptions.""" + 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', '{}') + 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 + options = RestoreOptions( + restore_config=bool(opts_dict.get('restore_config', True)), + restore_secrets=bool(opts_dict.get('restore_secrets', True)), + restore_wifi=bool(opts_dict.get('restore_wifi', True)), + restore_fonts=bool(opts_dict.get('restore_fonts', True)), + restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)), + reinstall_plugins=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 + + # 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) or plugin_store_manager + 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'}) + 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}) + 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 +@api_v3.route('/backup/download/', 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/', 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 diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py new file mode 100644 index 000000000..ab6ea270a --- /dev/null +++ b/web_interface/blueprints/api_v3/config.py @@ -0,0 +1,1178 @@ +"""Reading and writing configuration, including schedules. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + ErrorCode, Optional, PROJECT_ROOT, Path, _coerce_to_bool, + _redact_credentials, _validate_time_format, api_v3, + deep_merge, describe_exception, error_response, find_secret_fields, json, + jsonify, logger, logging, mask_all_secret_values, merge_secrets, os, + remove_empty_secrets, request, separate_secrets, strip_masked_values, + success_response, +) +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. + + +@api_v3.route('/config/main', methods=['GET']) +def get_main_config(): + """Get main configuration, with credentials redacted.""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + config = api_v3.config_manager.load_config() + return jsonify({'status': 'success', 'data': _redact_credentials(config)}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/config/schedule', methods=['GET']) +def get_schedule_config(): + """Get current schedule configuration""" + try: + if not api_v3.config_manager: + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + 'Config manager not initialized', + status_code=500 + ) + + config = api_v3.config_manager.load_config() + schedule_config = config.get('schedule', {}) + + return success_response(data=schedule_config) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "An error occurred; see logs for details", + details=describe_exception(e), + status_code=500 + ) +@api_v3.route('/config/schedule', methods=['POST']) +def save_schedule_config(): + """Save schedule configuration""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Load current config + current_config = api_v3.config_manager.load_config() + + # Build schedule configuration + # Handle enabled checkbox - can be True, False, or 'on' + enabled_value = data.get('enabled', False) + if isinstance(enabled_value, str): + enabled_value = enabled_value.lower() in ('true', 'on', '1') + schedule_config = { + 'enabled': enabled_value + } + + mode = data.get('mode', 'global') + schedule_config['mode'] = mode + + if mode == 'global': + # Simple global schedule + start_time = data.get('start_time', '07:00') + end_time = data.get('end_time', '23:00') + + # Validate _pkg.time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + schedule_config['start_time'] = start_time + schedule_config['end_time'] = end_time + # Remove days config when switching to global mode + schedule_config.pop('days', None) + else: + # Per-day schedule + schedule_config['days'] = {} + # Remove global times when switching to per-day mode + schedule_config.pop('start_time', None) + schedule_config.pop('end_time', None) + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + enabled_days_count = 0 + + for day in days: + day_config = {} + enabled_key = f'{day}_enabled' + start_key = f'{day}_start' + end_key = f'{day}_end' + + # Check if day is enabled + if enabled_key in data: + enabled_val = data[enabled_key] + # Handle checkbox values that may come as 'on', True, or False + if isinstance(enabled_val, str): + day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') + else: + day_config['enabled'] = bool(enabled_val) + else: + # Default to enabled if not specified + day_config['enabled'] = True + + # Only add times if day is enabled + if day_config.get('enabled', True): + enabled_days_count += 1 + start_time = None + end_time = None + + if start_key in data and data[start_key]: + start_time = data[start_key] + else: + start_time = '07:00' + + if end_key in data and data[end_key]: + end_time = data[end_key] + else: + end_time = '23:00' + + # Validate _pkg.time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid start _pkg.time for {day}: {error_msg}", + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid end _pkg.time for {day}: {error_msg}", + status_code=400 + ) + + day_config['start_time'] = start_time + day_config['end_time'] = end_time + + schedule_config['days'][day] = day_config + + # Validate that at least one day is enabled in per-day mode + if enabled_days_count == 0: + return error_response( + ErrorCode.VALIDATION_ERROR, + "At least one day must be enabled in per-day schedule mode", + status_code=400 + ) + + # Update and save config using atomic save + current_config['schedule'] = schedule_config + success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save schedule configuration: {error_msg}", + status_code=500 + ) + + # Invalidate cache on config change + try: + from web_interface.cache import invalidate_cache + invalidate_cache() + except ImportError: + pass + + return success_response(message='Schedule configuration saved successfully') + except Exception as e: + import logging + logger.error("Error saving schedule config", exc_info=True) + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + "An error occurred; see logs for details", + + status_code=500, details=describe_exception(e) + ) +@api_v3.route('/config/dim-schedule', methods=['GET']) +def get_dim_schedule_config(): + """Get current dim schedule configuration""" + import logging + import json + + if not api_v3.config_manager: + logging.error("[DIM SCHEDULE] Config manager not initialized") + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + 'Config manager not initialized', + status_code=500 + ) + + try: + config = api_v3.config_manager.load_config() + dim_schedule_config = config.get('dim_schedule', { + 'enabled': False, + 'dim_brightness': 30, + 'mode': 'global', + 'start_time': '20:00', + 'end_time': '07:00', + 'days': {} + }) + + return success_response(data=dim_schedule_config) + except FileNotFoundError as e: + logging.error(f"[DIM SCHEDULE] Config file not found: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "Configuration file not found", + status_code=500 + ) + except json.JSONDecodeError as e: + logging.error(f"[DIM SCHEDULE] Invalid JSON in config file: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "Configuration file contains invalid JSON", + status_code=500 + ) + except (IOError, OSError) as e: + logging.error(f"[DIM SCHEDULE] Error reading config file: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "An error occurred; see logs for details", + status_code=500, details=describe_exception(e) + ) + except Exception as e: + logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True) + return error_response( + ErrorCode.CONFIG_LOAD_FAILED, + "An error occurred; see logs for details", + status_code=500, details=describe_exception(e) + ) +@api_v3.route('/config/dim-schedule', methods=['POST']) +def save_dim_schedule_config(): + """Save dim schedule configuration""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Load current config + current_config = api_v3.config_manager.load_config() + + # Build dim schedule configuration + enabled_value = data.get('enabled', False) + if isinstance(enabled_value, str): + enabled_value = enabled_value.lower() in ('true', 'on', '1') + + # Validate and get dim_brightness + dim_brightness_raw = data.get('dim_brightness', 30) + try: + # Handle empty string or None + if dim_brightness_raw is None or dim_brightness_raw == '': + dim_brightness = 30 + else: + dim_brightness = int(dim_brightness_raw) + except (ValueError, TypeError, OverflowError): + return error_response( + ErrorCode.VALIDATION_ERROR, + "dim_brightness must be an integer between 0 and 100", + status_code=400 + ) + + if not 0 <= dim_brightness <= 100: + return error_response( + ErrorCode.VALIDATION_ERROR, + "dim_brightness must be between 0 and 100", + status_code=400 + ) + + dim_schedule_config = { + 'enabled': enabled_value, + 'dim_brightness': dim_brightness + } + + mode = data.get('mode', 'global') + dim_schedule_config['mode'] = mode + + if mode == 'global': + # Simple global schedule + start_time = data.get('start_time', '20:00') + end_time = data.get('end_time', '07:00') + + # Validate _pkg.time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + error_msg, + status_code=400 + ) + + dim_schedule_config['start_time'] = start_time + dim_schedule_config['end_time'] = end_time + # Remove days config when switching to global mode + dim_schedule_config.pop('days', None) + else: + # Per-day schedule + dim_schedule_config['days'] = {} + # Remove global times when switching to per-day mode + dim_schedule_config.pop('start_time', None) + dim_schedule_config.pop('end_time', None) + days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + enabled_days_count = 0 + + for day in days: + day_config = {} + enabled_key = f'{day}_enabled' + start_key = f'{day}_start' + end_key = f'{day}_end' + + # Check if day is enabled + if enabled_key in data: + enabled_val = data[enabled_key] + if isinstance(enabled_val, str): + day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1') + else: + day_config['enabled'] = bool(enabled_val) + else: + day_config['enabled'] = True + + # Only add times if day is enabled + if day_config.get('enabled', True): + enabled_days_count += 1 + start_time = data.get(start_key) or '20:00' + end_time = data.get(end_key) or '07:00' + + # Validate _pkg.time formats + is_valid, error_msg = _validate_time_format(start_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid start _pkg.time for {day}: {error_msg}", + status_code=400 + ) + + is_valid, error_msg = _validate_time_format(end_time) + if not is_valid: + return error_response( + ErrorCode.VALIDATION_ERROR, + f"Invalid end _pkg.time for {day}: {error_msg}", + status_code=400 + ) + + day_config['start_time'] = start_time + day_config['end_time'] = end_time + + dim_schedule_config['days'][day] = day_config + + # Validate that at least one day is enabled in per-day mode + if enabled_days_count == 0: + return error_response( + ErrorCode.VALIDATION_ERROR, + "At least one day must be enabled in per-day dim schedule mode", + status_code=400 + ) + + # Update and save config using atomic save + current_config['dim_schedule'] = dim_schedule_config + success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save dim schedule configuration: {error_msg}", + status_code=500 + ) + + # Invalidate cache on config change + try: + from web_interface.cache import invalidate_cache + invalidate_cache() + except ImportError: + pass + + return success_response(message='Dim schedule configuration saved successfully') + except Exception as e: + import logging + logger.error("Error saving dim schedule config", exc_info=True) + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + "An error occurred; see logs for details", + + status_code=500, details=describe_exception(e) + ) +@api_v3.route('/config/main', methods=['POST']) +def save_main_config(): + """Save main configuration""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + # Try to get JSON data first, fallback to form data + data = None + if request.content_type == 'application/json': + data = request.get_json() + else: + # Handle form data + data = request.form.to_dict() + # Convert checkbox values + for key in ['web_display_autostart']: + if key in data: + data[key] = data[key] == 'on' + + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # What arrives here is the config itself, and the headers carry the + # session cookie -- neither belongs in the journal, least of all at + # ERROR on every save. The shape of the request is the part with + # diagnostic value, so log that, at the level it deserves. + logger.debug("save_main_config: %s, %d top-level key(s)", + request.content_type or 'no content-type', len(data)) + + # Merge with existing config (similar to original implementation) + current_config = api_v3.config_manager.load_config() + + # Handle general settings + # Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings + # If any general setting is present, we're updating the general tab + is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart', + 'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory']) + + if is_general_update: + # For checkbox: if not present in data during general update, it means unchecked + current_config['web_display_autostart'] = _coerce_to_bool(data.get('web_display_autostart')) + + if 'timezone' in data: + current_config['timezone'] = data['timezone'] + + # Device-wide scroll frame rate, read by plugins via + # BasePlugin.global_config. Bounds match ScrollHelper.set_target_fps, + # which clamps silently -- rejecting here instead means a value that + # would have been quietly altered is reported rather than appearing to + # save and then behaving differently. + if 'target_fps' in data and data['target_fps'] not in ('', None): + raw_target_fps = data['target_fps'] + # A JSON body can carry real floats and bools, where int() would + # silently truncate: 90.5 would save as 90, and true as 1. Reject + # them rather than storing a value the user did not ask for. Form + # posts arrive as strings, so '90.5' still fails in int() below. + if isinstance(raw_target_fps, (bool, float)): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for target_fps: must be an integer" + }), 400 + try: + target_fps = int(raw_target_fps) + except (ValueError, TypeError, OverflowError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for target_fps: must be an integer" + }), 400 + if not (30 <= target_fps <= 200): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for target_fps: must be between 30 and 200" + }), 400 + current_config['target_fps'] = target_fps + + # Handle location settings + if 'city' in data or 'state' in data or 'country' in data: + if 'location' not in current_config: + current_config['location'] = {} + if 'city' in data: + current_config['location']['city'] = data['city'] + if 'state' in data: + current_config['location']['state'] = data['state'] + if 'country' in data: + current_config['location']['country'] = data['country'] + + # Handle plugin system settings + if 'auto_discover' in data or 'auto_load_enabled' in data or 'development_mode' in data or 'plugins_directory' in data: + if 'plugin_system' not in current_config: + current_config['plugin_system'] = {} + + # Handle plugin system checkboxes - always set to handle unchecked state + # HTML checkboxes omit the key when unchecked, so missing key = unchecked = False + for checkbox in ['auto_discover', 'auto_load_enabled', 'development_mode']: + current_config['plugin_system'][checkbox] = _coerce_to_bool(data.get(checkbox)) + + # Handle plugins_directory + if 'plugins_directory' in data: + current_config['plugin_system']['plugins_directory'] = data['plugins_directory'] + + # Handle display settings + display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', + 'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', + 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format', + 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type', + 'row_address_type', 'pixel_mapper_config', 'orientation'] + + if any(k in data for k in display_fields): + if 'display' not in current_config: + current_config['display'] = {} + if 'hardware' not in current_config['display']: + current_config['display']['hardware'] = {} + if 'runtime' not in current_config['display']: + current_config['display']['runtime'] = {} + + # Allowed values for validated string fields + LED_RGB_ALLOWED = {'RGB', 'RBG', 'GRB', 'GBR', 'BRG', 'BGR'} + PANEL_TYPE_ALLOWED = {'', 'FM6126A', 'FM6127'} + + # Validate led_rgb_sequence + if 'led_rgb_sequence' in data and data['led_rgb_sequence'] not in LED_RGB_ALLOWED: + return jsonify({'status': 'error', 'message': f"Invalid LED RGB sequence '{data['led_rgb_sequence']}'. Allowed values: {', '.join(sorted(LED_RGB_ALLOWED))}"}), 400 + + # Validate panel_type + if 'panel_type' in data and data['panel_type'] not in PANEL_TYPE_ALLOWED: + return jsonify({'status': 'error', 'message': f"Invalid panel type '{data['panel_type']}'. Allowed values: Standard (empty), FM6126A, FM6127"}), 400 + + # Validate multiplexing + if 'multiplexing' in data: + try: + mux_val = int(data['multiplexing']) + if mux_val < 0 or mux_val > 22: + return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 + + # Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90") + if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str): + return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400 + + # Validate orientation (physical mounting rotation; composed onto pixel_mapper_config at runtime) + ORIENTATION_ALLOWED = {'normal', '180'} + if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED: + return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400 + + # Validate row_address_type + if 'row_address_type' in data: + try: + rat_val = int(data['row_address_type']) + if rat_val < 0 or rat_val > 4: + return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 + + # Handle hardware settings + for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', + 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', + 'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type', + 'pixel_mapper_config', 'orientation']: + if field in data: + if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode', + 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', + 'multiplexing', 'row_address_type']: + current_config['display']['hardware'][field] = int(data[field]) + else: + current_config['display']['hardware'][field] = data[field] + + # Handle runtime settings + if 'gpio_slowdown' in data: + current_config['display']['runtime']['gpio_slowdown'] = int(data['gpio_slowdown']) + if 'rp1_rio' in data: + try: + rp1_val = int(data['rp1_rio']) + if rp1_val not in (0, 1): + return jsonify({'status': 'error', 'message': "rp1_rio must be 0 (PIO) or 1 (RIO)"}), 400 + current_config['display']['runtime']['rp1_rio'] = rp1_val + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', 'message': "rp1_rio must be 0 or 1"}), 400 + + # Handle checkboxes - coerce to bool to ensure proper JSON types + for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']: + current_config['display']['hardware'][checkbox] = _coerce_to_bool(data.get(checkbox)) + + # Handle display-level checkboxes (always set to handle unchecked state) + current_config['display']['use_short_date_format'] = _coerce_to_bool(data.get('use_short_date_format')) + + # Handle dynamic duration settings + if 'max_dynamic_duration_seconds' in data: + if 'dynamic_duration' not in current_config['display']: + current_config['display']['dynamic_duration'] = {} + current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds']) + + # Handle double-sided display settings + double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis'] + if any(k in data for k in double_sided_fields): + if 'display' not in current_config: + current_config['display'] = {} + if 'double_sided' not in current_config['display']: + current_config['display']['double_sided'] = {} + ds_config = current_config['display']['double_sided'] + + # Enabled checkbox: omitted from the form when unchecked. + # The Display form posts copies/axis on every save regardless of this + # checkbox, so when the feature is off we accept the values without + # rejecting the whole save — otherwise a stale copies/chain_length + # mismatch locks the user out of every other display setting. + enabled = _coerce_to_bool(data.get('double_sided_enabled')) + ds_config['enabled'] = enabled + + def _copies_fits_hardware(copies: int) -> Optional[str]: + """Error message if copies doesn't divide the panel evenly, else None.""" + # Use axis from this request if provided, else from stored config. + hw = current_config.get('display', {}).get('hardware', {}) + effective_axis = (data.get('double_sided_axis') + or current_config.get('display', {}).get('double_sided', {}).get('axis', 'horizontal')) + if effective_axis == 'horizontal': + chain_length = int(hw.get('chain_length', 2) or 2) + if chain_length % copies != 0: + return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly" + elif effective_axis == 'vertical': + parallel = int(hw.get('parallel', 1) or 1) + if parallel % copies != 0: + return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly" + return None + + if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None): + copies = None + try: + copies = int(data['double_sided_copies']) + except (ValueError, TypeError, OverflowError): + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400 + if copies is not None and not (2 <= copies <= 8): + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400 + # Disabled: leave the stored value alone rather than writing junk. + copies = None + if copies is not None: + # Divisibility is a hardware-relational check — only meaningful + # when the feature is actually on. + if enabled: + fit_error = _copies_fits_hardware(copies) + if fit_error: + return jsonify({'status': 'error', 'message': fit_error}), 400 + ds_config['copies'] = copies + + if 'double_sided_axis' in data: + axis = data['double_sided_axis'] + if axis not in ('horizontal', 'vertical'): + if enabled: + return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400 + else: + ds_config['axis'] = axis + + # Handle Vegas scroll mode settings + vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', + 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins', + 'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding', + 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', + 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', + 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', + 'vegas_intra_plugin_gap', 'vegas_render_width_pct', + 'vegas_min_content_separation', 'vegas_min_cut_gap', + 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', + 'vegas_smooth_scroll', 'vegas_overflow_mode'] + + if any(k in data for k in vegas_fields): + if 'display' not in current_config: + current_config['display'] = {} + if 'vegas_scroll' not in current_config['display']: + current_config['display']['vegas_scroll'] = {} + + vegas_config = current_config['display']['vegas_scroll'] + + # Handle enabled checkbox + # HTML checkboxes omit the key entirely when unchecked, so if the form + # was submitted (any vegas field present) but enabled key is missing, + # the checkbox was unchecked and we should set enabled=False + vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) + vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) + vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( + data.get('vegas_dynamic_duration_enabled')) + vegas_config['continuous_scroll'] = _coerce_to_bool( + data.get('vegas_continuous_scroll')) + vegas_config['smooth_scroll'] = _coerce_to_bool( + data.get('vegas_smooth_scroll')) + + # max_plugin_width_ratio is the one fractional setting, so it is + # handled outside the integer loop below. + if data.get('vegas_overflow_mode') not in ('', None): + mode = str(data['vegas_overflow_mode']).strip().lower() + if mode not in ('rotate', 'truncate'): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_overflow_mode: " + "must be 'rotate' or 'truncate'" + }), 400 + vegas_config['overflow_mode'] = mode + + if data.get('vegas_extend_threshold_screens') not in ('', None): + try: + screens = float(data['vegas_extend_threshold_screens']) + except (ValueError, TypeError, OverflowError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be a number" + }), 400 + if not (1.0 <= screens <= 10.0): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be between 1.0 and 10.0" + }), 400 + vegas_config['extend_threshold_screens'] = screens + + if data.get('vegas_max_plugin_width_ratio') not in ('', None): + try: + ratio = float(data['vegas_max_plugin_width_ratio']) + except (ValueError, TypeError, OverflowError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_max_plugin_width_ratio: " + "must be a number" + }), 400 + if not (0 <= ratio <= 20): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_max_plugin_width_ratio: " + "must be between 0 and 20 (0 disables the cap)" + }), 400 + vegas_config['max_plugin_width_ratio'] = ratio + + # Handle numeric settings with validation. + # + # These bounds must match VegasModeConfig.validate(), which is what + # actually gates Vegas starting. Where they were looser, a value + # saved with a 200 and then made VegasModeCoordinator.start() bail + # out with only a log line, so the ticker silently never ran. + # Where they were tighter (scroll_speed capped at 100 against a + # slider that goes to 200), a legitimate value was rejected with a + # 400. See test_vegas_api_bounds_match_validate. + numeric_fields = { + 'vegas_scroll_speed': ('scroll_speed', 1, 200), + 'vegas_separator_width': ('separator_width', 0, 128), + 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), + 'vegas_render_width_pct': ('render_width_pct', 10, 100), + 'vegas_min_content_separation': ('min_content_separation', 0, 256), + 'vegas_min_cut_gap': ('min_cut_gap', 1, 128), + 'vegas_target_fps': ('target_fps', 30, 200), + 'vegas_buffer_ahead': ('buffer_ahead', 1, 5), + 'vegas_trim_threshold': ('trim_threshold', 0, 254), + 'vegas_content_padding': ('content_padding', 0, 128), + 'vegas_min_plugin_width': ('min_plugin_width', 0, 512), + 'vegas_lead_in_width': ('lead_in_width', 0, 2048), + 'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50), + 'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600), + 'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600), + } + for field_name, (config_key, min_val, max_val) in numeric_fields.items(): + if field_name in data: + raw_value = data[field_name] + # Skip empty strings (treat as "not provided") + if raw_value == '' or raw_value is None: + continue + try: + int_value = int(raw_value) + except (ValueError, TypeError, OverflowError): + return jsonify({ + 'status': 'error', + 'message': f"Invalid value for {field_name}: must be an integer" + }), 400 + if not (min_val <= int_value <= max_val): + return jsonify({ + 'status': 'error', + 'message': f"Invalid value for {field_name}: must be between {min_val} and {max_val}" + }), 400 + vegas_config[config_key] = int_value + + # Handle plugin order and exclusions (JSON arrays) + if 'vegas_plugin_order' in data: + try: + if isinstance(data['vegas_plugin_order'], str): + parsed = json.loads(data['vegas_plugin_order']) + else: + parsed = data['vegas_plugin_order'] + # Ensure result is a list + vegas_config['plugin_order'] = list(parsed) if isinstance(parsed, (list, tuple)) else [] + except (json.JSONDecodeError, TypeError, ValueError): + vegas_config['plugin_order'] = [] + + if 'vegas_excluded_plugins' in data: + try: + if isinstance(data['vegas_excluded_plugins'], str): + parsed = json.loads(data['vegas_excluded_plugins']) + else: + parsed = data['vegas_excluded_plugins'] + # Ensure result is a list + vegas_config['excluded_plugins'] = list(parsed) if isinstance(parsed, (list, tuple)) else [] + except (json.JSONDecodeError, TypeError, ValueError): + vegas_config['excluded_plugins'] = [] + + # Handle multi-display sync settings + sync_fields = ["sync_role", "sync_port", "sync_follower_position"] + if any(k in data for k in sync_fields): + if 'sync' not in current_config: + current_config['sync'] = {} + SYNC_ROLE_ALLOWED = {'standalone', 'leader', 'follower'} + if 'sync_role' in data: + role_val = str(data['sync_role']).lower() + if role_val not in SYNC_ROLE_ALLOWED: + return jsonify({'status': 'error', 'message': f"Invalid sync role '{role_val}'. Must be one of: standalone, leader, follower"}), 400 + current_config['sync']['role'] = role_val + if 'sync_port' in data: + try: + port_val = int(data['sync_port']) + if not (1024 <= port_val <= 65535): + return jsonify({'status': 'error', 'message': "sync_port must be between 1024 and 65535"}), 400 + current_config['sync']['port'] = port_val + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', 'message': "sync_port must be an integer"}), 400 + + if "sync_follower_position" in data: + pos_val = str(data["sync_follower_position"]).lower() + if pos_val not in {"left", "right"}: + return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400 + current_config["sync"]["follower_position"] = pos_val + + # Handle primary rotation order: must be a JSON array of plugin-id + # strings. Reject anything else with a 400 rather than silently + # coercing, so a buggy client can't clear or corrupt the saved order. + if 'plugin_rotation_order' in data: + raw_order = data.pop('plugin_rotation_order') + try: + parsed = json.loads(raw_order) if isinstance(raw_order, str) else raw_order + except (json.JSONDecodeError, TypeError, ValueError): + return jsonify({'status': 'error', + 'message': 'plugin_rotation_order must be valid JSON'}), 400 + if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed): + return jsonify({'status': 'error', + 'message': 'plugin_rotation_order must be a list of plugin-id strings'}), 400 + if 'display' not in current_config: + current_config['display'] = {} + current_config['display']['plugin_rotation_order'] = parsed + + # Handle display durations. Popped from `data` (not just read) so + # they can never also fall through to the generic "remaining keys" + # merge near the end of this function, which would otherwise write + # them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30 + # sitting at config root alongside the correct + # display.display_durations.clock_duration). + duration_fields = [k for k in list(data.keys()) + if k.endswith('_duration') or k in ('default_duration', 'transition_duration')] + if duration_fields: + if 'display' not in current_config: + current_config['display'] = {} + if 'display_durations' not in current_config['display']: + current_config['display']['display_durations'] = {} + + for field in duration_fields: + raw_value = data.pop(field) + try: + int_value = int(raw_value) + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', + 'message': f"Invalid duration for {field}: must be an integer"}), 400 + current_config['display']['display_durations'][field] = int_value + + # Per-mode durations from the Rotation & Durations page, posted as + # duration__ (mode keys are arbitrary plugin mode names, so + # they can't use the suffix convention above). Same pop-and-validate + # treatment, for the same reason. + mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')] + if mode_duration_fields: + if 'display' not in current_config: + current_config['display'] = {} + if 'display_durations' not in current_config['display']: + current_config['display']['display_durations'] = {} + + for field in mode_duration_fields: + raw_value = data.pop(field) + mode_key = field[len('duration__'):] + if not mode_key: + continue + try: + int_value = int(raw_value) + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', + 'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400 + current_config['display']['display_durations'][mode_key] = int_value + + # Handle plugin configurations dynamically + # Any key that matches a plugin ID should be saved as plugin config + # This includes proper secret field handling from schema + plugin_keys_to_remove = [] + for key in data: + # Check if this key is a plugin ID + if api_v3.plugin_manager and key in api_v3.plugin_manager.plugin_manifests: + plugin_id = key + plugin_config = data[key] + + # Load plugin schema to identify secret fields (same logic as save_plugin_config) + secret_fields = set() + if api_v3.plugin_manager: + plugins_dir = api_v3.plugin_manager.plugins_dir + else: + plugin_system_config = current_config.get('plugin_system', {}) + plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos') + if os.path.isabs(plugins_dir_name): + plugins_dir = Path(plugins_dir_name) + else: + plugins_dir = PROJECT_ROOT / plugins_dir_name + schema_path = plugins_dir / plugin_id / 'config_schema.json' + + if schema_path.exists(): + try: + with open(schema_path, 'r', encoding='utf-8') as f: + schema = json.load(f) + if 'properties' in schema: + secret_fields = find_secret_fields(schema['properties']) + except Exception as e: + logger.debug("Error reading schema for secret detection: %s", e) + + # Separate secrets from regular config (same logic as save_plugin_config) + regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) + # The config form renders secrets masked, so every save posts + # them back blank. Without this the blank is merged over the + # stored value and the credential is destroyed by the act of + # changing an unrelated setting. A blank means "unchanged". + secrets_config = remove_empty_secrets(secrets_config) + + # PRE-PROCESSING: Preserve 'enabled' state if not in regular_config + # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle + if 'enabled' not in regular_config: + try: + if plugin_id in current_config and 'enabled' in current_config[plugin_id]: + regular_config['enabled'] = current_config[plugin_id]['enabled'] + elif api_v3.plugin_manager: + # Fallback to plugin instance if config doesn't have it + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin_instance: + regular_config['enabled'] = plugin_instance.enabled + # Final fallback: default to True if plugin is loaded (matches BasePlugin default) + if 'enabled' not in regular_config: + regular_config['enabled'] = True + except Exception as e: + logger.debug("Error preserving enabled state: %s", e) + # Default to True on error to avoid disabling plugins + regular_config['enabled'] = True + + # Get current secrets config + current_secrets = api_v3.config_manager.get_raw_file_content('secrets') + + # Deep merge regular config into main config + if plugin_id not in current_config: + current_config[plugin_id] = {} + current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) + + # Deep merge secrets into secrets config + if secrets_config: + if plugin_id not in current_secrets: + current_secrets[plugin_id] = {} + # Lists merge by replacement, so deep_merge here wrote a + # blanked array straight over the stored credentials. + current_secrets[plugin_id] = merge_secrets( + current_secrets[plugin_id], secrets_config) + # Save secrets file + api_v3.config_manager.save_raw_file_content('secrets', current_secrets) + + # Mark for removal from data dict (already processed) + plugin_keys_to_remove.append(key) + + # Notify plugin of config change if loaded (with merged config including secrets) + try: + if api_v3.plugin_manager: + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin_instance: + # Reload merged config (includes secrets) and pass the plugin-specific section + merged_config = api_v3.config_manager.load_config() + plugin_full_config = merged_config.get(plugin_id, {}) + if hasattr(plugin_instance, 'on_config_change'): + plugin_instance.on_config_change(plugin_full_config) + except Exception as hook_err: + # Don't fail the save if hook fails + logger.warning("on_config_change failed: %s", hook_err) + + # Remove processed plugin keys from data (they're already in current_config) + for key in plugin_keys_to_remove: + del data[key] + + # Handle any remaining config keys + # System settings (timezone, city, etc.) are already handled above + # Plugin configs should use /api/v3/plugins/config endpoint, but we'll handle them here too for flexibility + for key in data: + # Skip system settings that are already handled above + if key in ['timezone', 'city', 'state', 'country', + 'web_display_autostart', 'auto_discover', + 'auto_load_enabled', 'development_mode', + 'plugins_directory', 'target_fps']: + continue + # Skip fields that are already handled above in their own named sections. + # Without this, every form field name lands as a top-level config key too. + if key in display_fields: + continue + if key in sync_fields: + continue + if key in vegas_fields: + continue + if key in double_sided_fields: + continue + # For any remaining keys (including plugin keys), use deep merge to preserve existing settings + if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict): + # Deep merge to preserve existing settings + current_config[key] = deep_merge(current_config[key], data[key]) + else: + current_config[key] = data[key] + + # Save the merged config using atomic save + success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save configuration: {error_msg}", + status_code=500 + ) + + # Invalidate cache on config change + try: + from web_interface.cache import invalidate_cache + invalidate_cache() + except ImportError: + pass + + return success_response(message='Configuration saved successfully') + except Exception as e: + logger.error("Error saving config", exc_info=True) + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + "An error occurred; see logs for details", + status_code=500, details=describe_exception(e) + ) +@api_v3.route('/config/secrets', methods=['GET']) +def get_secrets_config(): + """Get secrets configuration""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + config = api_v3.config_manager.get_raw_file_content('secrets') + # This interface has no authentication, and this file is nothing but + # credentials. It was handing all of them to anyone who could reach + # the port. Values are masked; empty and YOUR_* placeholders are left + # alone so a client can still tell "set" from "not set". + return jsonify({'status': 'success', + 'data': mask_all_secret_values(config)}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/config/raw/main', methods=['POST']) +def save_raw_main_config(): + """Save raw main configuration JSON""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + # silent=True so a malformed body returns None instead of raising + # Werkzeug's own BadRequest, which would answer in a different + # shape than this API's. Distinguish the two causes: a body that + # was sent but does not parse is a different mistake from no body. + data = request.get_json(silent=True) + if data is None and request.get_data(): + return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400 + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Save the raw config file + api_v3.config_manager.save_raw_file_content('main', data) + + return jsonify({'status': 'success', 'message': 'Main configuration saved successfully'}) + 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 + ) +@api_v3.route('/config/raw/secrets', methods=['POST']) +def save_raw_secrets_config(): + """Save raw secrets configuration JSON""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + # See save_raw_main_config: silent parsing, with a sent-but-broken + # body reported separately from a missing one. + data = request.get_json(silent=True) + if data is None and request.get_data(): + return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400 + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # The GET above masks what it returns, and this endpoint's only client + # reads the whole file, edits one field and posts all of it back. So + # most of what arrives here is the mask, echoed rather than changed -- + # storing it verbatim would replace every untouched credential with + # eight bullets. Strip those, then merge onto what is already stored, + # which makes "unchanged" mean unchanged. + # + # The cost is that a secret can no longer be cleared by blanking it. + # That needs its own affordance; a control that erases credentials as + # a side effect of saving an unrelated one is not it. + current = api_v3.config_manager.get_raw_file_content('secrets') or {} + merged = deep_merge(current, strip_masked_values(data)) + api_v3.config_manager.save_raw_file_content('secrets', merged) + + # Reload GitHub token in plugin store manager if it exists + if api_v3.plugin_store_manager: + api_v3.plugin_store_manager.github_token = api_v3.plugin_store_manager._load_github_token() + + 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 diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py new file mode 100644 index 000000000..3f449bff9 --- /dev/null +++ b/web_interface/blueprints/api_v3/display.py @@ -0,0 +1,326 @@ +"""Display control, on-demand playback and preview. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + _ensure_cache_manager, _ensure_display_service_running, + _get_display_service_status, _stop_display_service, api_v3, + describe_exception, jsonify, logger, os, request, uuid, +) +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. + + +@api_v3.route('/display/current', methods=['GET']) +def get_display_current(): + """Get current display state""" + try: + import base64 + from PIL import Image + import io + + snapshot_path = "/tmp/led_matrix_preview.png" + + # Get display dimensions from config + try: + if api_v3.config_manager: + main_config = api_v3.config_manager.load_config() + hardware_config = main_config.get('display', {}).get('hardware', {}) + cols = hardware_config.get('cols', 64) + chain_length = hardware_config.get('chain_length', 2) + rows = hardware_config.get('rows', 32) + parallel = hardware_config.get('parallel', 1) + width = cols * chain_length + height = rows * parallel + else: + width = 128 + height = 64 + except Exception: + width = 128 + height = 64 + + # Try to read snapshot file + image_data = None + if os.path.exists(snapshot_path): + try: + with Image.open(snapshot_path) as img: + # Convert to PNG and encode as base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + image_data = base64.b64encode(buffer.getvalue()).decode('utf-8') + except Exception as img_err: + # File might be being written or corrupted, return None + pass + + display_data = { + 'timestamp': _pkg.time.time(), + 'width': width, + 'height': height, + 'image': image_data # Base64 encoded image data or None if unavailable + } + return jsonify({'status': 'success', 'data': display_data}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/display/modes', methods=['GET']) +def get_display_modes(): + """Every display mode that can be requested on-demand, with its plugin. + + /plugins/installed carries no mode information, so anything driving the + display from outside the web UI (the Home Assistant MQTT bridge, a script) + had to read each plugin's manifest.json off disk and reimplement the + fallbacks in PluginManager.get_plugin_display_modes to do it. This is the + same list the force-display dialog offers, from the source that owns it. + + Knowing each mode's plugin_id also matters because /display/on-demand/start + falls back to find_plugin_for_mode when plugin_id is omitted, and that + lookup only sees modes declared in a static manifest -- a plugin whose + modes are generated (each installed Starlark app is one) 404s there. + Sending the plugin_id from this list skips the lookup entirely. + + Query params: + include_disabled: '1' to list modes of disabled plugins too. They can + still be requested on-demand -- the controller enables the plugin + for the duration -- so they are reported with enabled: false + rather than omitted. + """ + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Discovery is lazy and normally triggered by whichever endpoint runs + # first, which is a person opening the dashboard. A caller that never + # visits it would otherwise see an empty list. + api_v3.plugin_manager.discover_plugins() + + include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True') + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + + modes = [] + for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()): + # A hand-edited or migrated config.json can hold a non-dict under a + # plugin id; DisplayController._reconcile guards the same shape, so + # it happens in practice. Without this, .get() raises AttributeError, + # the loop aborts and the endpoint answers 500 with no modes at all + # -- one bad section would blank every entity the MQTT bridge builds + # from this list. + plugin_config = full_config.get(plugin_id) + if not isinstance(plugin_config, dict): + if plugin_config is not None: + logger.warning( + "Config for plugin %r is %s, not an object; treating it as disabled", + plugin_id, type(plugin_config).__name__) + plugin_config = {} + enabled = bool(plugin_config.get('enabled', False)) + if not enabled and not include_disabled: + continue + plugin_name = (manifest or {}).get('name') or plugin_id + plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id] + for mode in plugin_modes: + # A single-mode plugin's mode is the plugin, so its own name is + # the readable label. Multi-mode plugins have no per-mode name + # anywhere, so the raw mode string is the only thing to show. + modes.append({ + 'mode': mode, + 'plugin_id': plugin_id, + 'plugin_name': plugin_name, + 'name': plugin_name if len(plugin_modes) == 1 else mode, + 'enabled': enabled, + }) + + return jsonify({'status': 'success', 'data': {'modes': modes}}) + except Exception as exc: + # describe_exception, not a bare message: test_web_error_detail.py + # enforces that every handler here returns it, because a device whose + # storage is failing otherwise answers "see logs for details" from the + # log viewer too. It redacts credentials out of the exception text. + # CodeQL flags this as stack-trace exposure across all ~75 handlers; + # it is the project's deliberate, reviewed trade-off. + logger.error('Error in get_display_modes', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 +@api_v3.route('/display/on-demand/status', methods=['GET']) +def get_on_demand_status(): + """Return the current on-demand display state.""" + try: + cache = _ensure_cache_manager() + state = cache.get('display_on_demand_state', max_age=120) + if state is None: + state = { + 'active': False, + 'status': 'idle', + 'last_updated': None + } + service_status = _get_display_service_status() + return jsonify({ + 'status': 'success', + 'data': { + 'state': state, + 'service': service_status + } + }) + except Exception as exc: + logger.error('Error in get_on_demand_status', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 +@api_v3.route('/display/on-demand/start', methods=['POST']) +def start_on_demand_display(): + """Request the display controller to run a specific plugin on-demand.""" + try: + data = request.get_json(silent=True) or {} + 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) + + if not plugin_id and not mode: + return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400 + + resolved_plugin = plugin_id + resolved_mode = mode + + if api_v3.plugin_manager: + if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests: + return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404 + + if resolved_plugin and not resolved_mode: + modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin) + resolved_mode = modes[0] if modes else resolved_plugin + elif resolved_mode and not resolved_plugin: + resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode) + if not resolved_plugin: + return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 + + # Note: On-demand can work with disabled plugins - the display controller + # will temporarily enable them during initialization if needed + # We don't block the request here, but log it for debugging + if api_v3.config_manager and resolved_plugin: + config = api_v3.config_manager.load_config() + plugin_config = config.get(resolved_plugin, {}) + if 'enabled' in plugin_config and not plugin_config.get('enabled', False): + logger.info( + "On-demand request for disabled plugin '%s' - will be temporarily enabled", + resolved_plugin, + ) + + # Set the on-demand request in cache FIRST (before starting service) + # This ensures the request is available when the service starts/restarts + cache = _ensure_cache_manager() + request_id = data.get('request_id') or str(uuid.uuid4()) + request_payload = { + 'request_id': request_id, + 'action': 'start', + 'plugin_id': resolved_plugin, + 'mode': resolved_mode, + 'duration': duration, + 'pinned': pinned, + 'timestamp': _pkg.time.time() + } + cache.set('display_on_demand_request', request_payload) + + # Check if display service is running (or will be started) + service_status = _get_display_service_status() + service_was_running = service_status.get('active', False) + + # Stop the display service first to ensure clean state when we will restart it + if service_was_running and start_service: + import _pkg.time as time_module + logger.debug("Stopping display service before starting on-demand mode") + _stop_display_service() + # Wait a brief moment for the service to fully stop + time_module.sleep(1.5) + logger.debug("Display service stopped, now starting with on-demand request") + + if not service_status.get('active') and not start_service: + return jsonify({ + 'status': 'error', + 'message': 'Display service is not running. Please start the display service or enable "Start Service" option.', + 'service_status': service_status + }), 400 + + service_result = None + if start_service: + service_result = _ensure_display_service_running() + # Check if service actually started + if service_result and not service_result.get('active'): + return jsonify({ + 'status': 'error', + 'message': 'Failed to start display service. Please check service logs or start it manually.', + 'service_result': service_result + }), 500 + + # Service was restarted (or started fresh) with on-demand request in cache + # The display controller will read the request during initialization or when it polls + + response_data = { + 'request_id': request_id, + 'plugin_id': resolved_plugin, + 'mode': resolved_mode, + 'duration': duration, + 'pinned': pinned, + 'service': service_result + } + return jsonify({'status': 'success', 'data': response_data}) + except Exception as exc: + logger.error('Error in start_on_demand_display', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 +@api_v3.route('/display/on-demand/stop', methods=['POST']) +def stop_on_demand_display(): + """Request the display controller to stop on-demand mode.""" + try: + data = request.get_json(silent=True) or {} + stop_service = data.get('stop_service', False) + + # Set the stop request in cache FIRST + # The display controller will poll this and restart without the on-demand filter + cache = _ensure_cache_manager() + request_id = data.get('request_id') or str(uuid.uuid4()) + request_payload = { + 'request_id': request_id, + 'action': 'stop', + 'timestamp': _pkg.time.time() + } + cache.set('display_on_demand_request', request_payload) + + # Note: The display controller's _clear_on_demand() will handle the restart + # to restore normal operation with all plugins + + service_result = None + if stop_service: + service_result = _stop_display_service() + + return jsonify({ + 'status': 'success', + 'data': { + 'request_id': request_id, + 'service': service_result + } + }) + except Exception as exc: + logger.error('Error in stop_on_demand_display', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 +@api_v3.route('/display/current-status', methods=['GET']) +def get_current_display_status(): + """Return the display mode/plugin currently intended to be shown. + + Published by the display process (display_controller._publish_current_mode_state) + to the shared cache whenever the active mode changes, so the web UI (e.g. the + System Logs page) can show what's on screen without querying the display + process directly. + """ + try: + cache = _ensure_cache_manager() + state = cache.get('display_current_state', max_age=120) + if state is None: + state = { + 'mode': None, + 'plugin_id': None, + 'last_updated': None, + } + return jsonify({'status': 'success', 'data': state}) + except Exception as e: + logger.error('Error in get_current_display_status', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/api_v3/fonts.py b/web_interface/blueprints/api_v3/fonts.py new file mode 100644 index 000000000..897d04d57 --- /dev/null +++ b/web_interface/blueprints/api_v3/fonts.py @@ -0,0 +1,452 @@ +"""Font catalogue, upload, preview and deletion. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception, + jsonify, logger, os, re, request, validate_file_upload, +) + + +@api_v3.route('/fonts/catalog', methods=['GET']) +def get_fonts_catalog(): + """Get fonts catalog""" + try: + # Check cache first (5 minute TTL) + try: + from web_interface.cache import get_cached, set_cached + cached_result = get_cached('fonts_catalog', ttl_seconds=300) + if cached_result is not None: + return jsonify({'status': 'success', 'data': {'catalog': cached_result}}) + except ImportError: + # Cache not available, continue without caching + get_cached = None + set_cached = None + + # Try to import freetype, but continue without it if unavailable + try: + import freetype + freetype_available = True + except ImportError: + freetype_available = False + + # Scan assets/fonts directory for actual font files + fonts_dir = PROJECT_ROOT / "assets" / "fonts" + catalog = {} + + if fonts_dir.exists() and fonts_dir.is_dir(): + for filename in os.listdir(fonts_dir): + if filename.endswith(('.ttf', '.otf', '.bdf')): + filepath = fonts_dir / filename + # Generate family name from filename (without extension) + family_name = os.path.splitext(filename)[0] + + # Try to get font metadata using freetype (for TTF/OTF) + metadata = {} + if filename.endswith(('.ttf', '.otf')) and freetype_available: + try: + face = freetype.Face(str(filepath)) + if face.valid: + # Get font family name from font file + family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name + metadata = { + 'family': family_name_from_font, + 'style': face.style_name.decode('utf-8') if face.style_name else 'Regular', + 'num_glyphs': face.num_glyphs, + 'units_per_em': face.units_per_EM + } + # Use font's family name if available + if family_name_from_font: + family_name = family_name_from_font + except Exception: + # If freetype fails, use filename-based name + pass + + # Store relative path from project root + relative_path = str(filepath.relative_to(PROJECT_ROOT)) + font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf' + + # Generate human-readable display name from family_name + display_name = family_name.replace('-', ' ').replace('_', ' ') + # Add space before capital letters for camelCase names + display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name) + # Add space before numbers that follow letters + display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name) + # Clean up multiple spaces + display_name = ' '.join(display_name.split()) + + # Use filename (without extension) as unique key to avoid collisions + # when multiple files share the same family_name from font metadata + catalog_key = os.path.splitext(filename)[0] + + # Check if this is a system font (cannot be deleted) + is_system = catalog_key.lower() in SYSTEM_FONTS + + catalog[catalog_key] = { + 'filename': filename, + 'family_name': family_name, + 'display_name': display_name, + 'path': relative_path, + 'type': font_type, + 'is_system': is_system, + 'metadata': metadata if metadata else None + } + + # Cache the result (5 minute TTL) if available + if set_cached: + try: + set_cached('fonts_catalog', catalog, ttl_seconds=300) + except Exception: + logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True) + + return jsonify({'status': 'success', 'data': {'catalog': catalog}}) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/tokens', methods=['GET']) +def get_font_tokens(): + """Get font size tokens""" + try: + # This would integrate with the actual font system + # For now, return sample tokens + tokens = { + 'xs': 6, + 'sm': 8, + 'md': 10, + 'lg': 12, + 'xl': 14, + 'xxl': 16 + } + return jsonify({'status': 'success', 'data': {'tokens': tokens}}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/overrides', methods=['GET']) +def get_fonts_overrides(): + """Get font overrides""" + try: + # This would integrate with the actual font system + # For now, return empty overrides + overrides = {} + return jsonify({'status': 'success', 'data': {'overrides': overrides}}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/overrides', methods=['POST']) +def save_fonts_overrides(): + """Save font overrides""" + try: + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # This would integrate with the actual font system + return jsonify({'status': 'success', 'message': 'Font overrides saved'}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/overrides/', methods=['DELETE']) +def delete_font_override(element_key): + """Delete font override""" + try: + # This would integrate with the actual font system + return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/upload', methods=['POST']) +def upload_font(): + """Upload font file""" + try: + if 'font_file' not in request.files: + return jsonify({'status': 'error', 'message': 'No font file provided'}), 400 + + font_file = request.files['font_file'] + if font_file.filename == '': + return jsonify({'status': 'error', 'message': 'No file selected'}), 400 + + # Validate filename + is_valid, error_msg = validate_file_upload( + font_file.filename, + max_size_mb=10, + allowed_extensions=['.ttf', '.otf', '.bdf'] + ) + if not is_valid: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + font_family = request.form.get('font_family', '') + + if not font_family: + return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400 + + # Validate font family name + if not font_family.replace('_', '').replace('-', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400 + + # Save the font file to assets/fonts directory + fonts_dir = PROJECT_ROOT / "assets" / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + + # Create filename from family name + original_ext = os.path.splitext(font_file.filename)[1].lower() + safe_filename = f"{font_family}{original_ext}" + filepath = fonts_dir / safe_filename + + # Check if file already exists + if filepath.exists(): + return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400 + + # Save the file + font_file.save(str(filepath)) + + # Clear font catalog cache + try: + from web_interface.cache import delete_cached + delete_cached('fonts_catalog') + except ImportError as e: + logger.warning("[FontUpload] Cache module not available: %s", e) + except Exception: + logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True) + + return jsonify({ + 'status': 'success', + 'message': f'Font {font_family} uploaded successfully', + 'font_family': font_family, + 'filename': safe_filename, + 'path': f'assets/fonts/{safe_filename}' + }) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/preview', methods=['GET']) +def get_font_preview() -> tuple[Response, int] | Response: + """Generate a preview image of text rendered with a specific font""" + try: + from PIL import Image, ImageDraw, ImageFont + import io + import base64 + + # Limits to prevent DoS via large image generation on constrained devices + MAX_TEXT_CHARS = 100 + MAX_TEXT_LINES = 3 + MAX_DIM = 1024 # Max width or height in pixels + MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700) + + font_filename = request.args.get('font', '') + text = request.args.get('text', 'Sample Text 123') + bg_color = request.args.get('bg', '000000') + fg_color = request.args.get('fg', 'ffffff') + + # Validate text length and line count early + if len(text) > MAX_TEXT_CHARS: + return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400 + if text.count('\n') >= MAX_TEXT_LINES: + return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400 + + # Safe integer parsing for size + try: + size = int(request.args.get('size', 12)) + except (ValueError, TypeError, OverflowError): + return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400 + + if not font_filename: + return jsonify({'status': 'error', 'message': 'Font filename required'}), 400 + + # Validate size + if size < 4 or size > 72: + return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400 + + # Security: Validate font_filename to prevent path traversal + # Only allow alphanumeric, hyphen, underscore, and dot (for extension) + safe_name = Path(font_filename).name # Strip any directory components + if safe_name != font_filename or '..' in font_filename: + return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400 + + # Validate extension + allowed_extensions = ['.ttf', '.otf', '.bdf'] + has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions) + name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name + + # Find the font file + fonts_dir = PROJECT_ROOT / "assets" / "fonts" + if not fonts_dir.exists(): + return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404 + + font_path = fonts_dir / safe_name + + if not font_path.exists() and not has_valid_ext: + # Try finding by family name (without extension) + for ext in allowed_extensions: + potential_path = fonts_dir / f"{name_without_ext}{ext}" + if potential_path.exists(): + font_path = potential_path + break + + # Final security check: ensure path is within fonts_dir + try: + font_path.resolve().relative_to(fonts_dir.resolve()) + except ValueError: + return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400 + + if not font_path.exists(): + return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404 + + # Parse colors + try: + bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4)) + fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4)) + except (ValueError, IndexError): + bg_rgb = (0, 0, 0) + fg_rgb = (255, 255, 255) + + # 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 + else: + # TTF/OTF fonts + try: + font = ImageFont.truetype(str(font_path), size) + except (IOError, OSError) as e: + # IOError/OSError raised for invalid/corrupt font files + 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] + + # Create image with padding + padding = 10 + img_width = max(text_width + padding * 2, 100) + img_height = max(text_height + padding * 2, 30) + + # Validate resulting image size to prevent memory/CPU spikes + if img_width > MAX_DIM or img_height > MAX_DIM: + return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400 + if img_width * img_height > MAX_PIXELS: + return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400 + + img = Image.new('RGB', (img_width, img_height), bg_rgb) + draw = ImageDraw.Draw(img) + + # Center text + x = (img_width - text_width) // 2 + y = (img_height - text_height) // 2 + + draw.text((x, y), text, font=font, fill=fg_rgb) + + # Convert to base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + buffer.seek(0) + img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return jsonify({ + 'status': 'success', + 'data': { + 'image': f'data:image/png;base64,{img_base64}', + 'width': img_width, + 'height': img_height + } + }) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/fonts/', methods=['DELETE']) +def delete_font(font_family: str) -> tuple[Response, int] | Response: + """Delete a user-uploaded font file""" + try: + # Security: Validate font_family to prevent path traversal + # Reject if it contains path separators or .. + if '..' in font_family or '/' in font_family or '\\' in font_family: + return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400 + + # Only allow safe characters: alphanumeric, hyphen, underscore, dot + if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family): + return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400 + + # Check if this is a system font (uses module-level SYSTEM_FONTS frozenset) + if font_family.lower() in SYSTEM_FONTS: + return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403 + + # Find and delete the font file + fonts_dir = PROJECT_ROOT / "assets" / "fonts" + + # Ensure fonts directory exists + if not fonts_dir.exists() or not fonts_dir.is_dir(): + return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404 + + deleted = False + deleted_filename = None + + # Only try valid font extensions (no empty string to avoid matching directories) + for ext in ['.ttf', '.otf', '.bdf']: + potential_path = fonts_dir / f"{font_family}{ext}" + + # Security: Verify path is within fonts_dir + try: + potential_path.resolve().relative_to(fonts_dir.resolve()) + except ValueError: + continue # Path escapes fonts_dir, skip + + if potential_path.exists() and potential_path.is_file(): + potential_path.unlink() + deleted = True + deleted_filename = f"{font_family}{ext}" + break + + if not deleted: + # Try case-insensitive match within fonts directory + font_family_lower = font_family.lower() + for filename in os.listdir(fonts_dir): + # Only consider files with valid font extensions + if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']): + continue + + name_without_ext = os.path.splitext(filename)[0] + if name_without_ext.lower() == font_family_lower: + filepath = fonts_dir / filename + + # Security: Verify path is within fonts_dir + try: + filepath.resolve().relative_to(fonts_dir.resolve()) + except ValueError: + continue # Path escapes fonts_dir, skip + + if filepath.is_file(): + filepath.unlink() + deleted = True + deleted_filename = filename + break + + if not deleted: + return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404 + + # Clear font catalog cache + try: + from web_interface.cache import delete_cached + delete_cached('fonts_catalog') + except ImportError as e: + logger.warning("[FontDelete] Cache module not available: %s", e) + except Exception: + logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True) + + return jsonify({ + 'status': 'success', + 'message': f'Font {deleted_filename} deleted successfully' + }) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/api_v3/misc.py b/web_interface/blueprints/api_v3/misc.py new file mode 100644 index 000000000..094ddeccf --- /dev/null +++ b/web_interface/blueprints/api_v3/misc.py @@ -0,0 +1,398 @@ +"""Routes with no larger group of their own: errors, integrations, +cache, sync, skins, logs, health and hardware. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + ErrorCode, Path, _JOURNALCTL, _SUDO, _get_display_service_status, api_v3, + describe_exception, error_response, get_error_aggregator, json, jsonify, + logger, os, request, subprocess, success_response, +) +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. + + +@api_v3.route('/health', methods=['GET']) +def get_health(): + """Get system health status""" + try: + health_status = { + 'status': 'healthy', + 'timestamp': _pkg.time.time(), + 'services': {}, + 'checks': {} + } + + # Check web interface service + # Stamp the start _pkg.time before measuring against it -- reading it with a + # fallback of _pkg.time.time() and only assigning afterwards made the very + # first call subtract two separate clock reads, reporting a small + # negative uptime. + if not hasattr(get_health, '_start_time'): + get_health._start_time = _pkg.time.time() + health_status['services']['web_interface'] = { + 'status': 'running', + 'uptime_seconds': _pkg.time.time() - get_health._start_time + } + + # Check display service + display_service_status = _get_display_service_status() + health_status['services']['display_service'] = { + 'status': 'active' if display_service_status.get('active') else 'inactive', + 'details': display_service_status + } + + # Check config file accessibility + try: + if api_v3.config_manager: + test_config = api_v3.config_manager.load_config() + health_status['checks']['config_file'] = { + 'status': 'accessible', + 'readable': True + } + else: + health_status['checks']['config_file'] = { + 'status': 'unknown', + 'readable': False + } + except Exception as e: + health_status['checks']['config_file'] = { + 'status': 'error', + 'readable': False, + 'error': 'see logs for details' + } + + # Check plugin system + try: + if api_v3.plugin_manager: + # Try to discover plugins (lightweight check) + plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0 + health_status['checks']['plugin_system'] = { + 'status': 'operational', + 'plugin_count': plugin_count + } + else: + health_status['checks']['plugin_system'] = { + 'status': 'not_initialized' + } + except Exception as e: + health_status['checks']['plugin_system'] = { + 'status': 'error', + 'error': 'see logs for details' + } + + # Check hardware connectivity (if display manager available) + try: + snapshot_path = "/tmp/led_matrix_preview.png" + if os.path.exists(snapshot_path): + # Check if snapshot is recent (updated in last 60 seconds) + mtime = os.path.getmtime(snapshot_path) + age_seconds = _pkg.time.time() - mtime + health_status['checks']['hardware'] = { + 'status': 'connected' if age_seconds < 60 else 'stale', + 'snapshot_age_seconds': round(age_seconds, 1) + } + else: + health_status['checks']['hardware'] = { + 'status': 'no_snapshot', + 'note': 'Display service may not be running' + } + except Exception as e: + health_status['checks']['hardware'] = { + 'status': 'unknown', + 'error': 'see logs for details' + } + + # Determine overall health + all_healthy = all( + check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active'] + for check in health_status['checks'].values() + ) + + if not all_healthy: + health_status['status'] = 'degraded' + + return jsonify({'status': 'success', 'data': health_status}) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e), + 'data': {'status': 'unhealthy'} + }), 500 +@api_v3.route('/hardware/status', methods=['GET']) +def get_hardware_status(): + """Return LED matrix hardware initialization status written by display_manager at startup.""" + status_path = "/tmp/led_matrix_hw_status.json" # nosec B108 + try: + with open(status_path) as f: + hw_data = json.load(f) + return jsonify({"status": "success", "data": hw_data}) + except FileNotFoundError: + return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}}) + except PermissionError: + logger.warning("Permission denied reading hardware status file; display service may be running as a different user") + return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}}) + except json.JSONDecodeError: + logger.error("Failed to parse hardware status file", exc_info=True) + return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}}) + except Exception: + logger.error("Unexpected error reading hardware status", exc_info=True) + return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500 +@api_v3.route('/skins', methods=['GET']) +def list_skins(): + """List installed visual skins (docs/SKIN_SYSTEM.md). + + Optional ?plugin_id=... filters to skins matching that plugin. + """ + try: + from src.skin_system import skin_runtime + + plugin_id = request.args.get('plugin_id') + if plugin_id: + skins = skin_runtime.skins_for_plugin(plugin_id) + else: + # The discovery cache self-invalidates on directory/manifest + # mtime changes, so no force_refresh — keeps Pi disk I/O down. + skins = skin_runtime.discover_skins() + + payload = [] + for skin_id, manifest in sorted(skins.items()): + skin_dir = Path(manifest['_skin_dir']) + preview = manifest.get('preview') + payload.append({ + 'id': skin_id, + 'name': manifest.get('name', skin_id), + 'version': manifest.get('version'), + 'author': manifest.get('author'), + 'description': manifest.get('description', ''), + 'skin_api_version': manifest.get('skin_api_version'), + 'targets': manifest.get('targets', {}), + 'modes': manifest.get('modes', []), + 'has_preview': bool(preview and (skin_dir / preview).is_file()), + }) + return jsonify({'status': 'success', 'data': {'skins': payload}}) + except Exception as e: + logger.error('Error in list_skins', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/logs', methods=['GET']) +def get_logs(): + """Get system logs from journalctl""" + try: + if not _JOURNALCTL: + return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503 + # Get recent logs from journalctl + _cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [ + '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service', + '-n', '100', '--no-pager', '--output=short-iso'] + result = subprocess.run( + _cmd, + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + logs_text = result.stdout.strip() + return jsonify({ + 'status': 'success', + 'data': { + 'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service' + } + }) + else: + return jsonify({ + 'status': 'error', + 'message': f'Failed to get logs: {result.stderr}' + }), 500 + + except subprocess.TimeoutExpired: + return jsonify({ + 'status': 'error', + 'message': 'Timeout while fetching logs' + }), 500 + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +# Multi-Display Sync Endpoints +@api_v3.route('/sync/status', methods=['GET']) +def get_sync_status(): + """Return live multi-display sync status written by the display process.""" + import os as _os + status_file = "/tmp/led_matrix_sync_status.json" + # Also surface config so the UI can show the configured role even before + # the display process has written a status file. + cfg_role = "standalone" + cfg_port = 5765 + if api_v3.config_manager: + try: + cfg = api_v3.config_manager.load_config().get("sync", {}) + cfg_role = cfg.get("role", "standalone") + cfg_port = int(cfg.get("port", 5765)) + except Exception: + pass + + if _os.path.exists(status_file): + try: + with open(status_file) as f: + live = json.load(f) + return jsonify({"status": "success", "data": live}) + except Exception: + pass + + # Status file not yet written — return config-only placeholder + return jsonify({ + "status": "success", + "data": { + "role": cfg_role, + "port": cfg_port, + "state": "starting", + } + }) +@api_v3.route('/cache/list', methods=['GET']) +def list_cache_files(): + """List all cache files with metadata""" + try: + if not api_v3.cache_manager: + # Initialize cache manager if not already initialized + from src.cache_manager import CacheManager + api_v3.cache_manager = CacheManager() + + cache_files = api_v3.cache_manager.list_cache_files() + cache_dir = api_v3.cache_manager.get_cache_dir() + + return jsonify({ + 'status': 'success', + 'data': { + 'cache_files': cache_files, + 'cache_dir': cache_dir, + 'total_files': len(cache_files) + } + }) + except Exception as e: + logger.error('Error in list_cache_files', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/cache/delete', methods=['POST']) +def delete_cache_file(): + """Delete a specific cache file by key""" + try: + if not api_v3.cache_manager: + # Initialize cache manager if not already initialized + from src.cache_manager import CacheManager + api_v3.cache_manager = CacheManager() + + data = request.get_json(silent=True) + if not data or 'key' not in data: + return jsonify({'status': 'error', 'message': 'cache key is required'}), 400 + + cache_key = data['key'] + + # Delete the cache file + api_v3.cache_manager.clear_cache(cache_key) + + return jsonify({ + 'status': 'success', + 'message': f'Cache file for key "{cache_key}" deleted successfully' + }) + except Exception as e: + logger.error('Error in delete_cache_file', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/errors/summary', methods=['GET']) +def get_error_summary(): + """ + Get summary of all errors for monitoring and debugging. + + Returns error counts, detected patterns, and recent errors. + """ + try: + aggregator = get_error_aggregator() + summary = aggregator.get_error_summary() + return success_response(data=summary, message="Error summary retrieved") + except Exception as e: + logger.error(f"Error getting error summary: {e}", exc_info=True) + return error_response( + error_code=ErrorCode.SYSTEM_ERROR, + message="Failed to retrieve error summary", + status_code=500 + ) +@api_v3.route('/errors/plugin/', methods=['GET']) +def get_plugin_errors(plugin_id): + """ + Get error health status for a specific plugin. + + Args: + plugin_id: Plugin identifier + + Returns health status and error statistics for the plugin. + """ + try: + aggregator = get_error_aggregator() + health = aggregator.get_plugin_health(plugin_id) + return success_response(data=health, message="Plugin health retrieved") + except Exception as e: + logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True) + return error_response( + error_code=ErrorCode.SYSTEM_ERROR, + message=f"Failed to retrieve health for plugin {plugin_id}", + status_code=500 + ) +@api_v3.route('/errors/clear', methods=['POST']) +def clear_old_errors(): + """ + Clear error records older than specified age. + + Request body (optional): + max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year) + """ + try: + data = request.get_json(silent=True) or {} + raw_max_age = data.get('max_age_hours', 24) + + # Validate and coerce max_age_hours + try: + max_age_hours = int(raw_max_age) + if max_age_hours < 1: + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours must be at least 1", + context={'provided_value': raw_max_age}, + status_code=400 + ) + if max_age_hours > 8760: # 1 year max + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours cannot exceed 8760 (1 year)", + context={'provided_value': raw_max_age}, + status_code=400 + ) + except (ValueError, TypeError, OverflowError): + return error_response( + error_code=ErrorCode.INVALID_INPUT, + message="max_age_hours must be a valid integer", + context={'provided_value': str(raw_max_age)}, + status_code=400 + ) + + aggregator = get_error_aggregator() + cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours) + + return success_response( + data={'cleared_count': cleared_count}, + message=f"Cleared {cleared_count} error records older than {max_age_hours} hours" + ) + except Exception as e: + logger.error(f"Error clearing old errors: {e}", exc_info=True) + return error_response( + error_code=ErrorCode.SYSTEM_ERROR, + message="Failed to clear old errors", + status_code=500 + ) diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py new file mode 100644 index 000000000..945bbcd07 --- /dev/null +++ b/web_interface/blueprints/api_v3/plugins.py @@ -0,0 +1,3867 @@ +"""Plugin install, update, enable/disable, config and store routes. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + ErrorCode, OperationType, PROJECT_ROOT, Path, Response, + _CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD, + _coerce_to_bool, _do_transactional_uninstall, + _enhance_schema_with_core_properties, _filter_config_by_schema, + _get_plugin_version, _get_schema_property, _installed_plugin_ids, + _is_plugin_update_available, _parse_form_value_with_schema, + _prune_credential_backups, _run_calendar_registration, _set_missing_booleans_to_false, _set_nested_value, + _starlark_virtual_plugins, _toggle_starlark_app, api_v3, datetime, + deep_merge, describe_exception, error_response, find_secret_fields, + hashlib, json, jsonify, logger, logging, merge_secrets, os, redact_text, + remove_empty_secrets, request, separate_secrets, shutil, stat, subprocess, + success_response, sys, tempfile, uuid, validate_request_json, +) +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. + + +@api_v3.route('/plugins/installed', methods=['GET']) +def get_installed_plugins(): + """Get installed plugins""" + try: + if not api_v3.plugin_manager or not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin managers not initialized'}), 500 + + import json + from pathlib import Path + + # Re-discover plugins to ensure we have the latest list + # This handles cases where plugins are added/removed after app startup + api_v3.plugin_manager.discover_plugins() + + # Get all installed plugin info from the plugin manager + all_plugin_info = api_v3.plugin_manager.get_all_plugin_info() + + # Load config once before the loop (not per-plugin) + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + + def _build_plugin_entry(plugin_info): + plugin_id = plugin_info.get('id') + try: + return _build_plugin_entry_inner(plugin_info, plugin_id) + except Exception: + logger.exception("Error building plugin entry for %s — skipping", plugin_id) + return None + + def _build_plugin_entry_inner(plugin_info, plugin_id): + # Capture runtime state (state machine + error context) before the + # manifest merge below can shadow the 'state' key. get_all_plugin_info + # attaches this via PluginStateManager.get_state_info(); surfacing it + # lets the UI show *why* a plugin isn't running instead of just + # 'loaded: false'. + state_info = plugin_info.get('state') + plugin_state = None + plugin_error_info = None + if isinstance(state_info, dict): + 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(): + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + fresh_manifest = json.load(f) + if isinstance(fresh_manifest, dict): + plugin_info.update(fresh_manifest) + else: + logger.debug("Manifest for %s is not a dict (%s) — skipping merge", + plugin_id, type(fresh_manifest).__name__) + except (FileNotFoundError, PermissionError, json.JSONDecodeError) as e: + logger.debug("Could not read fresh manifest for %s: %s", plugin_id, e) + + # Enabled status: config is source of truth, fall back to instance + enabled = None + plugin_config = full_config.get(plugin_id, {}) + if 'enabled' in plugin_config: + enabled = bool(plugin_config['enabled']) + + # Single get_plugin() call shared for both enabled fallback and Vegas mode + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if enabled is None: + enabled = plugin_instance.enabled if plugin_instance else True + + # Verified + latest published version from registry (no network call) + store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id) + verified = store_info.get('verified', False) if store_info else False + latest_version = store_info.get('latest_version', '') if store_info else '' + installed_version = plugin_info.get('version', '') + 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 + + if local_git_info: + sha = local_git_info.get('sha', '') + last_commit = local_git_info.get('short_sha') or (sha[:7] if sha else None) + branch = local_git_info.get('branch') + last_updated = local_git_info.get('date_iso') or local_git_info.get('date') + else: + 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') + + last_commit_message = plugin_info.get('last_commit_message') + if store_info and not last_commit_message: + last_commit_message = store_info.get('last_commit_message') + + # Vegas mode from instance, overridden by explicit config value + vegas_mode = None + vegas_content_type = None + if plugin_instance: + try: + if hasattr(plugin_instance, 'get_vegas_display_mode'): + mode = plugin_instance.get_vegas_display_mode() + vegas_mode = mode.value if hasattr(mode, 'value') else str(mode) + except (AttributeError, TypeError, ValueError) as e: + logger.debug("[%s] Failed to get vegas_display_mode: %s", plugin_id, e) + try: + if hasattr(plugin_instance, 'get_vegas_content_type'): + vegas_content_type = plugin_instance.get_vegas_content_type() + except (AttributeError, TypeError, ValueError) as e: + logger.debug("[%s] Failed to get vegas_content_type: %s", plugin_id, e) + + if 'vegas_mode' in plugin_config: + vegas_mode = plugin_config['vegas_mode'] + + return { + 'id': plugin_id, + 'name': plugin_info.get('name', plugin_id), + 'version': plugin_info.get('version', ''), + 'latest_version': latest_version, + 'update_available': update_available, + '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), + 'state': plugin_state, + 'error_info': plugin_error_info, + 'last_updated': last_updated, + 'last_commit': last_commit, + 'last_commit_message': last_commit_message, + 'branch': branch, + 'web_ui_actions': plugin_info.get('web_ui_actions', []), + 'vegas_mode': vegas_mode, + 'vegas_content_type': vegas_content_type, + } + + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(_build_plugin_entry, all_plugin_info)) + plugins = [r for r in results if r is not None] + plugins.extend(_starlark_virtual_plugins()) + + return jsonify({'status': 'success', 'data': {'plugins': plugins}}) + except Exception as e: + logger.error('Error in get_installed_plugins', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/health', methods=['GET']) +def get_plugin_health(): + """Get health metrics for all plugins""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if health tracker is available + if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: + return jsonify({ + 'status': 'success', + 'data': {}, + 'message': 'Health tracking not available' + }) + + tracker = api_v3.plugin_manager.health_tracker + # Build per-plugin summaries by ID so persisted (cross-process) health + # is included, then fold in any in-memory-only entries. + health_summaries = {} + for pid in _installed_plugin_ids(): + try: + # force_reload: this process only reads; bypass the in-memory + # snapshot so each poll reflects the display service's latest + # persisted state. + health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True) + except Exception: + logger.debug('Could not read health summary for %s', pid, exc_info=True) + try: + for pid, summary in tracker.get_all_health_summaries().items(): + health_summaries.setdefault(pid, summary) + except Exception: + logger.debug('get_all_health_summaries failed', exc_info=True) + + return jsonify({ + 'status': 'success', + 'data': health_summaries + }) + except Exception as e: + logger.error('Error in get_plugin_health', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/health/', methods=['GET']) +def get_plugin_health_single(plugin_id): + """Get health metrics for a specific plugin""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if health tracker is available + if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: + return jsonify({ + 'status': 'error', + 'message': 'Health tracking not available' + }), 503 + + # Get health summary for specific plugin + health_summary = api_v3.plugin_manager.health_tracker.get_health_summary(plugin_id) + + return jsonify({ + 'status': 'success', + 'data': health_summary + }) + except Exception as e: + logger.error('Error in get_plugin_health_single', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/health//reset', methods=['POST']) +def reset_plugin_health(plugin_id): + """Reset health state for a plugin (manual recovery)""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if health tracker is available + if not hasattr(api_v3.plugin_manager, 'health_tracker') or not api_v3.plugin_manager.health_tracker: + return jsonify({ + 'status': 'error', + 'message': 'Health tracking not available' + }), 503 + + # Reset health state + api_v3.plugin_manager.health_tracker.reset_health(plugin_id) + + return jsonify({ + 'status': 'success', + 'message': f'Health state reset for plugin {plugin_id}' + }) + except Exception as e: + logger.error('Error in reset_plugin_health', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/metrics', methods=['GET']) +def get_plugin_metrics(): + """Get resource metrics for all plugins""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if resource monitor is available + if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: + return jsonify({ + 'status': 'success', + 'data': {}, + 'message': 'Resource monitoring not available' + }) + + monitor = api_v3.plugin_manager.resource_monitor + # Build per-plugin summaries by ID so persisted (cross-process) metrics + # are included, then fold in any in-memory-only entries. + metrics_summaries = {} + for pid in _installed_plugin_ids(): + try: + # force_reload: read-only path — bypass the in-memory snapshot so + # each poll reflects the display service's latest persisted metrics. + metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True) + except Exception: + logger.debug('Could not read metrics summary for %s', pid, exc_info=True) + try: + for pid, summary in monitor.get_all_metrics_summaries().items(): + metrics_summaries.setdefault(pid, summary) + except Exception: + logger.debug('get_all_metrics_summaries failed', exc_info=True) + + return jsonify({ + 'status': 'success', + 'data': metrics_summaries + }) + except Exception as e: + logger.error('Error in get_plugin_metrics', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/metrics/', methods=['GET']) +def get_plugin_metrics_single(plugin_id): + """Get resource metrics for a specific plugin""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if resource monitor is available + if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: + return jsonify({ + 'status': 'error', + 'message': 'Resource monitoring not available' + }), 503 + + # Get metrics summary for specific plugin + metrics_summary = api_v3.plugin_manager.resource_monitor.get_metrics_summary(plugin_id) + + return jsonify({ + 'status': 'success', + 'data': metrics_summary + }) + except Exception as e: + logger.error('Error in get_plugin_metrics_single', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/metrics//reset', methods=['POST']) +def reset_plugin_metrics(plugin_id): + """Reset metrics for a plugin""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if resource monitor is available + if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: + return jsonify({ + 'status': 'error', + 'message': 'Resource monitoring not available' + }), 503 + + # Reset metrics + api_v3.plugin_manager.resource_monitor.reset_metrics(plugin_id) + + return jsonify({ + 'status': 'success', + 'message': f'Metrics reset for plugin {plugin_id}' + }) + except Exception as e: + logger.error('Error in reset_plugin_metrics', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/limits/', methods=['GET', 'POST']) +def manage_plugin_limits(plugin_id): + """Get or set resource limits for a plugin""" + try: + if not api_v3.plugin_manager: + return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500 + + # Check if resource monitor is available + if not hasattr(api_v3.plugin_manager, 'resource_monitor') or not api_v3.plugin_manager.resource_monitor: + return jsonify({ + 'status': 'error', + 'message': 'Resource monitoring not available' + }), 503 + + if request.method == 'GET': + # Get limits + limits = api_v3.plugin_manager.resource_monitor.get_limits(plugin_id) + if limits: + return jsonify({ + 'status': 'success', + 'data': { + 'max_memory_mb': limits.max_memory_mb, + 'max_cpu_percent': limits.max_cpu_percent, + 'max_execution_time': limits.max_execution_time, + 'warning_threshold': limits.warning_threshold + } + }) + else: + return jsonify({ + 'status': 'success', + 'data': None, + 'message': 'No limits configured for this plugin' + }) + else: + # POST - Set limits + data = request.get_json(silent=True) or {} + from src.plugin_system.resource_monitor import ResourceLimits + + limits = ResourceLimits( + max_memory_mb=data.get('max_memory_mb'), + max_cpu_percent=data.get('max_cpu_percent'), + max_execution_time=data.get('max_execution_time'), + warning_threshold=data.get('warning_threshold', 0.8) + ) + + api_v3.plugin_manager.resource_monitor.set_limits(plugin_id, limits) + + return jsonify({ + 'status': 'success', + 'message': f'Resource limits updated for plugin {plugin_id}' + }) + except Exception as e: + logger.error('Error in manage_plugin_limits', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/toggle', methods=['POST']) +def toggle_plugin(): + """Toggle plugin enabled/disabled""" + try: + if not api_v3.plugin_manager or not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Plugin or config manager not initialized'}), 500 + + # Support both JSON and form data (for HTMX submissions) + content_type = request.content_type or '' + + if 'application/json' in content_type: + data = request.get_json(silent=True) + 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'] + else: + # Form data or query string (HTMX submission) + plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') + if not plugin_id: + return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 + + # For checkbox toggle, if form was submitted, the checkbox was checked (enabled) + # If using HTMX with hx-trigger="change", we need to check if checkbox is checked + # The checkbox value or 'enabled' form field indicates the state + enabled_str = request.form.get('enabled', request.args.get('enabled', '')) + + # Handle various truthy/falsy values + if enabled_str.lower() in ('true', '1', 'on', 'yes'): + enabled = True + elif enabled_str.lower() in ('false', '0', 'off', 'no', ''): + # Empty string means checkbox was unchecked (toggle off) + enabled = False + else: + # Default: toggle based on current state + config = api_v3.config_manager.load_config() + current_enabled = config.get(plugin_id, {}).get('enabled', False) + enabled = not current_enabled + + # A Starlark app is not a plugin in plugin_manager's sense -- it is an + # entry in starlark-apps' own manifest -- so its enable/disable is + # handled here rather than falling through to the check below, which + # would answer "Plugin not found". + if plugin_id.startswith('starlark:'): + return _toggle_starlark_app(plugin_id[len('starlark:'):], enabled) + + # Check if plugin exists in manifests (discovered but may not be loaded) + if plugin_id not in api_v3.plugin_manager.plugin_manifests: + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Update config (this is what the display controller reads) + config = api_v3.config_manager.load_config() + if plugin_id not in config: + config[plugin_id] = {} + config[plugin_id]['enabled'] = enabled + + # Use atomic save if available + if hasattr(api_v3.config_manager, 'save_config_atomic'): + result = api_v3.config_manager.save_config_atomic(config, create_backup=True) + if result.status.value != 'success': + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save configuration: {result.message}", + status_code=500 + ) + else: + api_v3.config_manager.save_config(config) + + # Update state manager if available + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled) + + # Log operation + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "enable" if enabled else "disable", + plugin_id=plugin_id, + status="success" + ) + + # If plugin is loaded, also call its lifecycle methods + # Wrap in try/except to prevent lifecycle errors from failing the toggle + plugin = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin: + try: + if enabled: + if hasattr(plugin, 'on_enable'): + plugin.on_enable() + else: + if hasattr(plugin, 'on_disable'): + plugin.on_disable() + except Exception as lifecycle_error: + # Log the error but don't fail the toggle - config is already saved + import logging + logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True) + + return success_response( + message=f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} successfully" + ) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_OPERATION_CONFLICT) + if api_v3.operation_history: + toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable" + api_v3.operation_history.record_operation( + toggle_type, + plugin_id=data.get('plugin_id') if 'data' in locals() else None, + status="failed", + error=str(e) + ) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/operation/', methods=['GET']) +def get_operation_status(operation_id): + """Get status of a plugin operation""" + try: + if not api_v3.operation_queue: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Operation queue not initialized', + status_code=500 + ) + + operation = api_v3.operation_queue.get_operation_status(operation_id) + if not operation: + return error_response( + ErrorCode.PLUGIN_NOT_FOUND, + f'Operation {operation_id} not found', + status_code=404 + ) + + return success_response(data=operation.to_dict()) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) + return error_response( + error.error_code, + error.message, + details=error.details, + status_code=500 + ) +@api_v3.route('/plugins/operation/history', methods=['GET']) +def get_operation_history() -> Response: + """Get operation history from the audit log.""" + if not api_v3.operation_history: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Operation history not initialized', + status_code=500 + ) + + try: + limit = request.args.get('limit', 50, type=int) + plugin_id = request.args.get('plugin_id') + operation_type = request.args.get('operation_type') + except (ValueError, TypeError) as e: + return error_response(ErrorCode.INVALID_INPUT, f'Invalid query parameter: {e}', status_code=400) + + try: + history = api_v3.operation_history.get_history( + limit=limit, + plugin_id=plugin_id, + operation_type=operation_type + ) + except (AttributeError, RuntimeError) as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) + return error_response(error.error_code, error.message, details=error.details, status_code=500) + + return success_response(data=[record.to_dict() for record in history]) +@api_v3.route('/plugins/operation/history', methods=['DELETE']) +def clear_operation_history() -> Response: + """Clear operation history.""" + if not api_v3.operation_history: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Operation history not initialized', + status_code=500 + ) + + try: + api_v3.operation_history.clear_history() + except (OSError, RuntimeError) as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) + return error_response(error.error_code, error.message, details=error.details, status_code=500) + + return success_response(message='Operation history cleared') +@api_v3.route('/plugins/state', methods=['GET']) +def get_plugin_state(): + """Get plugin state from state manager""" + try: + if not api_v3.plugin_state_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'State manager not initialized', + status_code=500 + ) + + plugin_id = request.args.get('plugin_id') + + if plugin_id: + # Get state for specific plugin + state = api_v3.plugin_state_manager.get_plugin_state(plugin_id) + if not state: + return error_response( + ErrorCode.PLUGIN_NOT_FOUND, + f'Plugin {plugin_id} not found in state manager', + context={'plugin_id': plugin_id}, + status_code=404 + ) + return success_response(data=state.to_dict()) + else: + # Get all plugin states + all_states = api_v3.plugin_state_manager.get_all_states() + return success_response(data={ + plugin_id: state.to_dict() + for plugin_id, state in all_states.items() + }) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/state/reconcile', methods=['POST']) +def reconcile_plugin_state(): + """Reconcile plugin state across all sources""" + try: + if not api_v3.plugin_state_manager or not api_v3.plugin_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'State manager or plugin manager not initialized', + status_code=500 + ) + + from src.plugin_system.state_reconciliation import StateReconciliation + + # Parse optional `force` flag from request body, guarding against + # non-dict bodies (bare string, array, null) that would raise AttributeError. + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + payload = {} + force = _coerce_to_bool(payload.get('force', False)) + + reconciler = StateReconciliation( + state_manager=api_v3.plugin_state_manager, + config_manager=api_v3.config_manager, + plugin_manager=api_v3.plugin_manager, + plugins_dir=Path(api_v3.plugin_manager.plugins_dir) + ) + + result = reconciler.reconcile_state(force=force) + + return success_response( + data={ + 'inconsistencies_found': len(result.inconsistencies_found), + 'inconsistencies_fixed': len(result.inconsistencies_fixed), + 'inconsistencies_manual': len(result.inconsistencies_manual), + 'inconsistencies': [ + { + 'plugin_id': inc.plugin_id, + 'type': inc.inconsistency_type.value, + 'description': inc.description, + 'fix_action': inc.fix_action.value + } + for inc in result.inconsistencies_found + ], + 'fixed': [ + { + 'plugin_id': inc.plugin_id, + 'type': inc.inconsistency_type.value, + 'description': inc.description + } + for inc in result.inconsistencies_fixed + ], + 'manual_fix_required': [ + { + 'plugin_id': inc.plugin_id, + 'type': inc.inconsistency_type.value, + 'description': inc.description + } + for inc in result.inconsistencies_manual + ] + }, + message=result.message + ) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.SYSTEM_ERROR) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/reconciliation-status', methods=['GET']) +def get_reconciliation_status(): + """Return the result of the last startup reconciliation from /tmp status file.""" + _recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json") + try: + st = os.lstat(_recon_path) + except FileNotFoundError: + return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) + if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode): + logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path) + return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) + try: + with open(_recon_path) as _f: + data = json.load(_f) + return jsonify({'status': 'success', 'data': data}) + except json.JSONDecodeError: + logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path) + return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) + except PermissionError: + logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path) + return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}}) +@api_v3.route('/plugins/config', methods=['GET']) +def get_plugin_config(): + """Get plugin configuration""" + try: + if not api_v3.config_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Config manager not initialized', + status_code=500 + ) + + plugin_id = request.args.get('plugin_id') + if not plugin_id: + return error_response( + ErrorCode.INVALID_INPUT, + 'plugin_id required', + context={'missing_params': ['plugin_id']}, + status_code=400 + ) + + # Get plugin configuration from config manager + main_config = api_v3.config_manager.load_config() + plugin_config = main_config.get(plugin_id, {}) + + # Merge with defaults from schema so form shows default values for missing fields + schema_mgr = api_v3.schema_manager + if schema_mgr: + try: + defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) + plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) + except Exception as e: + # Log but don't fail - defaults merge is best effort + import logging + logging.warning(f"Could not merge defaults for {plugin_id}: {e}") + + # Special handling for of-the-day plugin: populate uploaded_files and categories from disk + if plugin_id == 'of-the-day' or plugin_id == 'ledmatrix-of-the-day': + # Get plugin directory - plugin_id in manifest is 'of-the-day', but directory is 'ledmatrix-of-the-day' + plugin_dir_name = 'ledmatrix-of-the-day' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_dir_name) + # If not found, try with the plugin_id + if not plugin_dir or not Path(plugin_dir).exists(): + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_dir_name + if not plugin_dir.exists(): + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if plugin_dir and Path(plugin_dir).exists(): + data_dir = Path(plugin_dir) / 'of_the_day' + if data_dir.exists(): + # Scan for JSON files + uploaded_files = [] + categories_from_files = {} + + for json_file in data_dir.glob('*.json'): + try: + # Get file stats + stat = json_file.stat() + + # Read JSON to count entries + with open(json_file, 'r', encoding='utf-8') as f: + json_data = json.load(f) + entry_count = len(json_data) if isinstance(json_data, dict) else 0 + + # Extract category name from filename + category_name = json_file.stem + filename = json_file.name + + # Create file entry + file_entry = { + 'id': category_name, + 'category_name': category_name, + 'filename': filename, + 'original_filename': filename, + 'path': f'of_the_day/{filename}', + 'size': stat.st_size, + 'uploaded_at': datetime.fromtimestamp(stat.st_mtime).isoformat() + 'Z', + 'entry_count': entry_count + } + uploaded_files.append(file_entry) + + # Create/update category entry if not in config + if category_name not in plugin_config.get('categories', {}): + display_name = category_name.replace('_', ' ').title() + categories_from_files[category_name] = { + 'enabled': False, # Default to disabled, user can enable + 'data_file': f'of_the_day/{filename}', + 'display_name': display_name + } + else: + # Update with file info if needed + categories_from_files[category_name] = plugin_config['categories'][category_name] + # Ensure data_file is correct + categories_from_files[category_name]['data_file'] = f'of_the_day/{filename}' + + except Exception as e: + logger.debug("Could not read json file: %s", e) + continue + + # Update plugin_config with scanned files + if uploaded_files: + plugin_config['uploaded_files'] = uploaded_files + + # Merge categories from files with existing config + # Start with existing categories (preserve user settings like enabled/disabled) + existing_categories = plugin_config.get('categories', {}).copy() + + # Update existing categories with file info, add new ones from files + for cat_name, cat_data in categories_from_files.items(): + if cat_name in existing_categories: + # Preserve existing enabled state and display_name, but update data_file path + existing_categories[cat_name]['data_file'] = cat_data['data_file'] + if 'display_name' not in existing_categories[cat_name] or not existing_categories[cat_name]['display_name']: + existing_categories[cat_name]['display_name'] = cat_data['display_name'] + else: + # Add new category from file (default to disabled) + existing_categories[cat_name] = cat_data + + if existing_categories: + plugin_config['categories'] = existing_categories + + # Update category_order to include all categories + category_order = plugin_config.get('category_order', []).copy() + all_category_names = set(existing_categories.keys()) + for cat_name in all_category_names: + if cat_name not in category_order: + category_order.append(cat_name) + if category_order: + plugin_config['category_order'] = category_order + + # If no config exists, return defaults + if not plugin_config: + plugin_config = { + 'enabled': True, + 'display_duration': 30 + } + + return success_response(data=plugin_config) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_LOAD_FAILED) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/update', methods=['POST']) +def update_plugin(): + """Update plugin""" + try: + # Support both JSON and form data + content_type = request.content_type or '' + + if 'application/json' in content_type: + # JSON request + data, error = validate_request_json(['plugin_id']) + if error: + logger.debug("[UPDATE] JSON validation failed. Content-Type: %s", content_type) + return error + else: + # Form data or query string + plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') + if not plugin_id: + logger.debug("[UPDATE] Missing plugin_id. Content-Type: %s", content_type) + return error_response( + ErrorCode.INVALID_INPUT, + 'plugin_id required', + status_code=400 + ) + data = {'plugin_id': plugin_id} + + if not api_v3.plugin_store_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Plugin store manager not initialized', + status_code=500 + ) + + plugin_id = data['plugin_id'] + + # Always do direct updates (they're fast git pull operations) + # Operation queue is reserved for longer operations like install/uninstall + plugin_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id + manifest_path = plugin_dir / "manifest.json" + + current_last_updated = None + current_commit = None + current_branch = None + + if manifest_path.exists(): + try: + import json + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + current_last_updated = manifest.get('last_updated') + if manifest.get('local_only'): + logger.debug("Skipping update for local-only plugin: %s", plugin_id) + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "update", + plugin_id=plugin_id, + status="skipped", + details={"reason": "local_only"} + ) + return success_response(message=f'Plugin {plugin_id} is managed locally and does not receive registry updates') + 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_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id + 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") + + 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 + remote_branch = remote_info.get('branch') if remote_info else None + + # Update the plugin + success = api_v3.plugin_store_manager.update_plugin(plugin_id) + + if success: + updated_last_updated = current_last_updated + try: + if manifest_path.exists(): + import json + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + updated_last_updated = manifest.get('last_updated', current_last_updated) + except Exception as e: + logger.debug("Could not read updated manifest after update: %s", e) + + 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 + + message = f'Plugin {plugin_id} updated successfully' + if current_commit and updated_commit and current_commit == updated_commit: + message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})' + elif updated_commit: + message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}' + if updated_branch: + message += f' on branch {updated_branch}' + elif updated_last_updated and updated_last_updated != current_last_updated: + message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})' + + remote_commit_short = remote_commit[:7] if remote_commit else None + if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]: + message += f' (remote latest {remote_commit_short})' + + # Invalidate schema cache + if api_v3.schema_manager: + api_v3.schema_manager.invalidate_cache(plugin_id) + + # Rediscover plugins + if api_v3.plugin_manager: + api_v3.plugin_manager.discover_plugins() + if plugin_id in api_v3.plugin_manager.plugins: + api_v3.plugin_manager.reload_plugin(plugin_id) + + # Update state and history + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.update_plugin_state( + plugin_id, + {'last_updated': datetime.now()} + ) + if api_v3.operation_history: + version = _get_plugin_version(plugin_id) + api_v3.operation_history.record_operation( + "update", + plugin_id=plugin_id, + status="success", + details={ + "version": version, + "previous_commit": current_commit[:7] if current_commit else None, + "commit": updated_commit[:7] if updated_commit else None, + "branch": updated_branch + } + ) + + return success_response( + data={ + 'last_updated': updated_last_updated, + 'commit': updated_commit + }, + message=message + ) + else: + plugin_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id + if not plugin_path_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) + if not git_info: + plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) + if not plugin_info: + client_msg = 'Plugin update failed: not found in registry' + else: + client_msg = 'Plugin update failed; check logs for details' + else: + client_msg = 'Plugin update failed; check logs for details' + logger.error("update_plugin failed for plugin_id=%s: %s", plugin_id, client_msg) + + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "update", + plugin_id=plugin_id, + status="failed", + error=client_msg, + details={ + "previous_commit": current_commit[:7] if current_commit else None, + "branch": current_branch + } + ) + + return error_response( + ErrorCode.PLUGIN_UPDATE_FAILED, + client_msg, + status_code=500 + ) + + except Exception as e: + logger.error("Unhandled exception in update endpoint", exc_info=True) + + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UPDATE_FAILED) + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "update", + plugin_id=data.get('plugin_id') if 'data' in locals() else None, + status="failed", + error=str(e) + ) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/uninstall', methods=['POST']) +def uninstall_plugin(): + """Uninstall plugin""" + try: + # Validate request + data, error = validate_request_json(['plugin_id']) + if error: + return error + + if not api_v3.plugin_store_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Plugin store manager not initialized', + status_code=500 + ) + + plugin_id = data['plugin_id'] + preserve_config = data.get('preserve_config', False) + + # Both queued and direct paths use the same transactional helper so + # snapshot/rollback behaviour is consistent regardless of deployment. + if api_v3.operation_queue: + def uninstall_callback(operation): + """Callback to execute plugin uninstallation via transactional helper.""" + success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) + if not success: + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "uninstall", + plugin_id=plugin_id, + status="failed", + error=error_msg + ) + raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}') + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "uninstall", + plugin_id=plugin_id, + status="success", + details={"preserve_config": preserve_config} + ) + return {'success': True, 'message': 'Plugin uninstalled successfully'} + + # Enqueue operation + operation_id = api_v3.operation_queue.enqueue_operation( + OperationType.UNINSTALL, + plugin_id, + operation_callback=uninstall_callback + ) + + return success_response( + data={'operation_id': operation_id}, + message='Plugin uninstallation queued' + ) + else: + # Direct (non-queued) transactional uninstall + success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) + + if success: + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "uninstall", + plugin_id=plugin_id, + status="success", + details={"preserve_config": preserve_config} + ) + return success_response(message='Plugin uninstalled successfully') + else: + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "uninstall", + plugin_id=plugin_id, + status="failed", + error=error_msg + ) + return error_response( + ErrorCode.PLUGIN_UNINSTALL_FAILED, + error_msg or 'Plugin uninstall failed', + status_code=500 + ) + + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.PLUGIN_UNINSTALL_FAILED) + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "uninstall", + plugin_id=data.get('plugin_id') if 'data' in locals() else None, + status="failed", + error=str(e) + ) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/install', methods=['POST']) +def install_plugin(): + """Install plugin from store""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data or 'plugin_id' not in data: + return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 + + plugin_id = data['plugin_id'] + branch = data.get('branch') # Optional branch parameter + + # Install the plugin + # Log the plugins directory being used for debugging + plugins_dir = api_v3.plugin_store_manager.plugins_dir + branch_info = f" (branch: {branch})" if branch else "" + logger.info("Installing plugin to directory: %s", plugins_dir) + + # Use operation queue if available + if api_v3.operation_queue: + def install_callback(operation): + """Callback to execute plugin installation.""" + success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) + + if success: + # Invalidate schema cache + if api_v3.schema_manager: + api_v3.schema_manager.invalidate_cache(plugin_id) + + # Discover and load the new plugin + if api_v3.plugin_manager: + api_v3.plugin_manager.discover_plugins() + api_v3.plugin_manager.load_plugin(plugin_id) + + # Update state manager + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.set_plugin_installed(plugin_id) + + # Record in history + if api_v3.operation_history: + version = _get_plugin_version(plugin_id) + api_v3.operation_history.record_operation( + "install", + plugin_id=plugin_id, + status="success", + details={"version": version, "branch": branch} + ) + + branch_msg = f" (branch: {branch})" if branch else "" + return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'} + else: + error_msg = f'Failed to install plugin {plugin_id}' + if branch: + error_msg += f' (branch: {branch})' + plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) + if not plugin_info: + error_msg += ' (plugin not found in registry)' + + # Record failure in history + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "install", + plugin_id=plugin_id, + status="failed", + error=error_msg, + details={"branch": branch} + ) + + raise Exception(error_msg) + + # Enqueue operation + operation_id = api_v3.operation_queue.enqueue_operation( + OperationType.INSTALL, + plugin_id, + operation_callback=install_callback + ) + + branch_msg = f" (branch: {branch})" if branch else "" + return success_response( + data={'operation_id': operation_id}, + message=f'Plugin {plugin_id} installation queued{branch_msg}' + ) + else: + # Fallback to direct installation + success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) + + if success: + if api_v3.schema_manager: + api_v3.schema_manager.invalidate_cache(plugin_id) + if api_v3.plugin_manager: + api_v3.plugin_manager.discover_plugins() + api_v3.plugin_manager.load_plugin(plugin_id) + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.set_plugin_installed(plugin_id) + if api_v3.operation_history: + version = _get_plugin_version(plugin_id) + api_v3.operation_history.record_operation( + "install", + plugin_id=plugin_id, + status="success", + details={"version": version, "branch": branch} + ) + + branch_msg = f" (branch: {branch})" if branch else "" + return success_response(message=f'Plugin installed successfully{branch_msg}') + else: + error_msg = f'Failed to install plugin {plugin_id}' + if branch: + error_msg += f' (branch: {branch})' + plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) + if not plugin_info: + error_msg += ' (plugin not found in registry)' + + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "install", + plugin_id=plugin_id, + status="failed", + error=error_msg, + details={"branch": branch} + ) + + return error_response( + ErrorCode.PLUGIN_INSTALL_FAILED, + error_msg, + status_code=500 + ) + + except Exception as e: + logger.error('Error in install_plugin', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/install-from-url', methods=['POST']) +def install_plugin_from_url(): + """Install plugin from custom GitHub URL""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data or 'repo_url' not in data: + return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 + + # A non-string repo_url is a client mistake, not a server fault: + # .strip() would raise and the catch-all would report it as a 500. + if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): + return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 + + repo_url = data['repo_url'].strip() + plugin_id = data.get('plugin_id') # Optional, for monorepo installations + plugin_path = data.get('plugin_path') # Optional, for monorepo subdirectory + branch = data.get('branch') # Optional branch parameter + + # Install the plugin + result = api_v3.plugin_store_manager.install_from_url( + repo_url=repo_url, + plugin_id=plugin_id, + plugin_path=plugin_path, + branch=branch + ) + + if result.get('success'): + # Invalidate schema cache for the installed plugin + installed_plugin_id = result.get('plugin_id') + if api_v3.schema_manager and installed_plugin_id: + api_v3.schema_manager.invalidate_cache(installed_plugin_id) + + # Discover and load the new plugin + if api_v3.plugin_manager and installed_plugin_id: + api_v3.plugin_manager.discover_plugins() + api_v3.plugin_manager.load_plugin(installed_plugin_id) + + branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else "" + response_data = { + 'status': 'success', + 'message': f"Plugin {installed_plugin_id} installed successfully{branch_msg}", + 'plugin_id': installed_plugin_id, + 'name': result.get('name') + } + if result.get('branch'): + response_data['branch'] = result.get('branch') + return jsonify(response_data) + else: + return jsonify({ + 'status': 'error', + 'message': result.get('error', 'Failed to install plugin from URL') + }), 500 + + except Exception as e: + logger.error('Error in install_plugin_from_url', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/registry-from-url', methods=['POST']) +def get_registry_from_url(): + """Get plugin list from a registry-style monorepo URL""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data or 'repo_url' not in data: + return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 + + # A non-string repo_url is a client mistake, not a server fault: + # .strip() would raise and the catch-all would report it as a 500. + if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): + return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 + + repo_url = data['repo_url'].strip() + + # Get registry from the URL + registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) + + if registry: + return jsonify({ + 'status': 'success', + 'plugins': registry.get('plugins', []), + 'registry_url': repo_url + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to fetch registry from URL or URL does not contain a valid registry' + }), 400 + + except Exception as e: + logger.error('Error in get_registry_from_url', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/saved-repositories', methods=['GET']) +def get_saved_repositories(): + """Get all saved repositories""" + try: + if not api_v3.saved_repositories_manager: + return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 + + repositories = api_v3.saved_repositories_manager.get_all() + return jsonify({'status': 'success', 'data': {'repositories': repositories}}) + except Exception as e: + logger.error('Error in get_saved_repositories', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/saved-repositories', methods=['POST']) +def add_saved_repository(): + """Add a repository to saved list""" + try: + if not api_v3.saved_repositories_manager: + return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data or 'repo_url' not in data: + return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 + + # A non-string repo_url is a client mistake, not a server fault: + # .strip() would raise and the catch-all would report it as a 500. + if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): + return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 + + repo_url = data['repo_url'].strip() + name = data.get('name') + + success = api_v3.saved_repositories_manager.add(repo_url, name) + + if success: + return jsonify({ + 'status': 'success', + 'message': 'Repository saved successfully', + 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Repository already exists or failed to save' + }), 400 + except Exception as e: + logger.error('Error in add_saved_repository', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/saved-repositories', methods=['DELETE']) +def remove_saved_repository(): + """Remove a repository from saved list""" + try: + if not api_v3.saved_repositories_manager: + return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 + + data = request.get_json(silent=True) + if not data or 'repo_url' not in data: + return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 + + repo_url = data['repo_url'] + + success = api_v3.saved_repositories_manager.remove(repo_url) + + if success: + return jsonify({ + 'status': 'success', + 'message': 'Repository removed successfully', + 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Repository not found' + }), 404 + except Exception as e: + logger.error('Error in remove_saved_repository', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/store/list', methods=['GET']) +def list_plugin_store(): + """Search plugin store""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + query = request.args.get('query', '') + category = request.args.get('category', '') + tags = request.args.getlist('tags') + # Default to fetching commit metadata to ensure accurate commit timestamps + fetch_commit_param = request.args.get('fetch_commit_info', request.args.get('fetch_latest_versions', '')).lower() + fetch_commit = fetch_commit_param != 'false' + + # Search plugins from the registry (including saved repositories) + plugins = api_v3.plugin_store_manager.search_plugins( + query=query, + category=category, + tags=tags, + fetch_commit_info=fetch_commit, + include_saved_repos=True, + saved_repositories_manager=api_v3.saved_repositories_manager + ) + + # Format plugins for the web interface + formatted_plugins = [] + for plugin in plugins: + formatted_plugins.append({ + 'id': plugin.get('id'), + 'name': plugin.get('name'), + 'author': plugin.get('author'), + 'category': plugin.get('category'), + 'description': plugin.get('description'), + 'tags': plugin.get('tags', []), + 'stars': plugin.get('stars', 0), + 'verified': plugin.get('verified', False), + 'repo': plugin.get('repo', ''), + 'last_updated': plugin.get('last_updated') or plugin.get('last_updated_iso', ''), + 'last_updated_iso': plugin.get('last_updated_iso', ''), + 'last_commit': plugin.get('last_commit') or plugin.get('last_commit_sha'), + 'last_commit_message': plugin.get('last_commit_message'), + 'last_commit_author': plugin.get('last_commit_author'), + 'version': plugin.get('latest_version') or plugin.get('version', ''), + 'branch': plugin.get('branch') or plugin.get('default_branch'), + 'default_branch': plugin.get('default_branch'), + 'plugin_path': plugin.get('plugin_path', '') + }) + + return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) + except Exception as e: + logger.error('Error in list_plugin_store', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/store/github-status', methods=['GET']) +def get_github_auth_status(): + """Check if GitHub authentication is configured and validate token""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + token = api_v3.plugin_store_manager.github_token + + # Check if GitHub token is configured + if not token or len(token) == 0: + return jsonify({ + 'status': 'success', + 'data': { + 'token_status': 'none', + 'authenticated': False, + 'rate_limit': 60, + 'message': 'No GitHub token configured', + 'error': None + } + }) + + # Validate the token + is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token) + + if is_valid: + return jsonify({ + 'status': 'success', + 'data': { + 'token_status': 'valid', + 'authenticated': True, + 'rate_limit': 5000, + 'message': 'GitHub API authenticated', + 'error': None + } + }) + else: + return jsonify({ + 'status': 'success', + 'data': { + 'token_status': 'invalid', + 'authenticated': False, + 'rate_limit': 60, + 'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid', + 'error': error_message + } + }) + except Exception as e: + logger.error('Error in get_github_auth_status', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/store/refresh', methods=['POST']) +def refresh_plugin_store(): + """Refresh plugin store repository""" + try: + if not api_v3.plugin_store_manager: + return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 + + data = request.get_json(silent=True) or {} + fetch_commit_info = data.get('fetch_commit_info', data.get('fetch_latest_versions', False)) + + # Force refresh the registry + registry = api_v3.plugin_store_manager.fetch_registry(force_refresh=True) + plugin_count = len(registry.get('plugins', [])) + + message = 'Plugin store refreshed' + if fetch_commit_info: + message += ' (with refreshed commit metadata from GitHub)' + + return jsonify({ + 'status': 'success', + 'message': message, + 'plugin_count': plugin_count + }) + except Exception as e: + logger.error('Error in refresh_plugin_store', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/config', methods=['POST']) +def save_plugin_config(): + """Save plugin configuration, separating secrets from regular config""" + try: + if not api_v3.config_manager: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Config manager not initialized', + status_code=500 + ) + + # Support both JSON and form data (for HTMX submissions) + content_type = request.content_type or '' + + if 'application/json' in content_type: + # JSON request + data, error = validate_request_json(['plugin_id']) + if error: + return error + plugin_id = data['plugin_id'] + plugin_config = data.get('config', {}) + else: + # Form data (HTMX submission) + # plugin_id comes from query string, config from form fields + plugin_id = request.args.get('plugin_id') + if not plugin_id: + return error_response( + ErrorCode.INVALID_INPUT, + 'plugin_id required in query string', + status_code=400 + ) + + # Load existing config as base (partial form updates should merge, not replace) + existing_config = {} + if api_v3.config_manager: + full_config = api_v3.config_manager.load_config() + existing_config = full_config.get(plugin_id, {}).copy() + + # Get schema manager instance (needed for type conversion) + schema_mgr = api_v3.schema_manager + if not schema_mgr: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Schema manager not initialized', + status_code=500 + ) + + # Load plugin schema BEFORE processing form data (needed for type conversion) + schema = schema_mgr.load_schema(plugin_id, use_cache=False) + + # Start with existing config and apply form updates + plugin_config = existing_config + + # Convert form data to config dict + # Form fields can use dot notation for nested values (e.g., "transition.type") + form_data = request.form.to_dict() + + # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) + # These fields use getlist() to preserve all values, then replace in form_data + # Sentinel empty value ("") allows clearing array to [] when all checkboxes unchecked + bracket_array_fields = {} # Maps base field path to list of values + for key in request.form.keys(): + # Check if key ends with "[]" (bracket notation for array fields) + if key.endswith('[]'): + base_path = key[:-2] # Remove "[]" suffix + values = request.form.getlist(key) + # Filter out sentinel empty string - if only sentinel present, array should be [] + # If sentinel + values present, use the actual values + filtered_values = [v for v in values if v and v.strip()] + # If no non-empty values but key exists, it means all checkboxes unchecked (empty array) + bracket_array_fields[base_path] = filtered_values + # Remove the bracket notation key from form_data if present + if key in form_data: + del form_data[key] + + # Process bracket notation fields and set directly in plugin_config + # Use JSON encoding instead of comma-join to handle values containing commas + import json + for base_path, values in bracket_array_fields.items(): + # Get schema property to verify it's an array + base_prop = _get_schema_property(schema, base_path) + if base_prop and base_prop.get('type') == 'array': + # Filter out empty values and sentinel empty strings + filtered_values = [v for v in values if v and v.strip()] + # Set directly in plugin_config (values are already strings, no need to parse) + # Empty array (all unchecked) is represented as [] + _set_nested_value(plugin_config, base_path, filtered_values) + logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {filtered_values}") + # Remove from form_data to avoid double processing + if base_path in form_data: + del form_data[base_path] + + # Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) + # This handles cases where forms send array fields as indexed inputs + array_fields = {} # Maps base field path to list of (index, value) tuples + processed_keys = set() + indexed_base_paths = set() # Track which base paths have indexed fields + + for key, value in form_data.items(): + # Check if this looks like an array index field (ends with .0, .1, .2, etc.) + if '.' in key: + parts = key.rsplit('.', 1) # Split on last dot + if len(parts) == 2: + base_path, last_part = parts + # Check if last part is a numeric string (array index) + if last_part.isdigit(): + # Get schema property for the base path to verify it's an array + base_prop = _get_schema_property(schema, base_path) + if base_prop and base_prop.get('type') == 'array': + # This is an array index field + index = int(last_part) + if base_path not in array_fields: + array_fields[base_path] = [] + array_fields[base_path].append((index, value)) + processed_keys.add(key) + indexed_base_paths.add(base_path) + continue + + # Process combined array fields + for base_path, index_values in array_fields.items(): + # Sort by index and extract values + index_values.sort(key=lambda x: x[0]) + values = [v for _, v in index_values] + # Combine values into comma-separated string for parsing + combined_value = ', '.join(str(v) for v in values) + # Parse as array using schema + parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema) + # Debug logging + logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}") + # Only set if not skipped + if parsed_value is not _SKIP_FIELD: + _set_nested_value(plugin_config, base_path, parsed_value) + + # Process remaining (non-indexed) fields + # Skip any base paths that were processed as indexed arrays + for key, value in form_data.items(): + if key not in processed_keys: + # Skip if this key is a base path that was processed as indexed array + # (to avoid overwriting the combined array with a single value) + if key not in indexed_base_paths: + # Parse value using schema to determine correct type + parsed_value = _parse_form_value_with_schema(value, key, schema) + # Debug logging for array fields + if schema: + prop = _get_schema_property(schema, key) + if prop and prop.get('type') == 'array': + logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}") + # Use helper to set nested values correctly (skips if _SKIP_FIELD) + if parsed_value is not _SKIP_FIELD: + _set_nested_value(plugin_config, key, parsed_value) + + # Post-process: Fix array fields that might have been incorrectly structured + # This handles cases where array fields are stored as dicts (e.g., from indexed form fields) + def fix_array_structures(config_dict, schema_props, prefix=''): + """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" + for prop_key, prop_schema in schema_props.items(): + prop_type = prop_schema.get('type') + + if prop_type == 'array': + # Navigate to the field location + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + + if parent is not None and isinstance(parent, dict) and prop_key in parent: + current_value = parent[prop_key] + # If it's a dict with numeric string keys, convert to array + if isinstance(current_value, dict) and not isinstance(current_value, list): + try: + # Check if all keys are numeric strings (array indices) + keys = [k for k in current_value.keys()] + if all(k.isdigit() for k in keys): + # Convert to sorted array by index + sorted_keys = sorted(keys, key=int) + array_value = [current_value[k] for k in sorted_keys] + # Convert array elements to correct types based on schema + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in array_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError, OverflowError): + converted_array.append(v) + else: + converted_array.append(v) + array_value = converted_array + parent[prop_key] = array_value + current_value = array_value # Update for length check below + except (ValueError, KeyError, TypeError): + # Conversion failed, check if we should use default + pass + + # If it's an array, ensure correct types and check minItems + if isinstance(current_value, list): + # First, ensure array elements are correct types + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in current_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError, OverflowError): + converted_array.append(v) + else: + converted_array.append(v) + parent[prop_key] = converted_array + current_value = converted_array + + # Then check minItems + min_items = prop_schema.get('minItems') + if min_items is not None and len(current_value) < min_items: + # Use default if available, otherwise keep as-is (validation will catch it) + default = prop_schema.get('default') + if default and isinstance(default, list) and len(default) >= min_items: + parent[prop_key] = default + else: + # Top-level field + if prop_key in config_dict: + current_value = config_dict[prop_key] + # If it's a dict with numeric string keys, convert to array + if isinstance(current_value, dict) and not isinstance(current_value, list): + try: + keys = list(current_value.keys()) + if keys and all(str(k).isdigit() for k in keys): + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + array_value = [current_value[k] for k in sorted_keys] + # Convert array elements to correct types based on schema + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in array_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError, OverflowError): + converted_array.append(v) + else: + converted_array.append(v) + array_value = converted_array + config_dict[prop_key] = array_value + current_value = array_value # Update for length check below + except (ValueError, KeyError, TypeError) as e: + logger.debug(f"Failed to convert {prop_key} to array: {e}") + + # If it's an array, ensure correct types and check minItems + if isinstance(current_value, list): + # First, ensure array elements are correct types + items_schema = prop_schema.get('items', {}) + item_type = items_schema.get('type') + if item_type in ('number', 'integer'): + converted_array = [] + for v in current_value: + if isinstance(v, str): + try: + if item_type == 'integer': + converted_array.append(int(v)) + else: + converted_array.append(float(v)) + except (ValueError, TypeError, OverflowError): + converted_array.append(v) + else: + converted_array.append(v) + config_dict[prop_key] = converted_array + current_value = converted_array + + # Then check minItems + min_items = prop_schema.get('minItems') + if min_items is not None and len(current_value) < min_items: + default = prop_schema.get('default') + if default and isinstance(default, list) and len(default) >= min_items: + config_dict[prop_key] = default + + # Recurse into nested objects + elif prop_type == 'object' and 'properties' in prop_schema: + nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None + else: + nested_dict = config_dict.get(prop_key) + + if isinstance(nested_dict, dict): + # Pass no prefix: config_dict is already the navigated sub-dict, + # so path segments from the parent would mis-navigate it. + fix_array_structures(nested_dict, prop_schema['properties']) + + # Also ensure array fields that are None get converted to empty arrays + def ensure_array_defaults(config_dict, schema_props, prefix=''): + """Recursively ensure array fields have defaults if None""" + for prop_key, prop_schema in schema_props.items(): + prop_type = prop_schema.get('type') + + if prop_type == 'array': + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + + if parent is not None and isinstance(parent, dict): + if prop_key not in parent or parent[prop_key] is None: + default = prop_schema.get('default', []) + parent[prop_key] = default if default else [] + else: + if prop_key not in config_dict or config_dict[prop_key] is None: + default = prop_schema.get('default', []) + config_dict[prop_key] = default if default else [] + + elif prop_type == 'object' and 'properties' in prop_schema: + nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + parent = None + break + nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None + else: + nested_dict = config_dict.get(prop_key) + + if nested_dict is None: + if prefix: + parent_parts = prefix.split('.') + parent = config_dict + for part in parent_parts: + if part not in parent: + parent[part] = {} + parent = parent[part] + if prop_key not in parent: + parent[prop_key] = {} + nested_dict = parent[prop_key] + else: + if prop_key not in config_dict: + config_dict[prop_key] = {} + nested_dict = config_dict[prop_key] + + if isinstance(nested_dict, dict): + # Pass no prefix: config_dict is already navigated. + ensure_array_defaults(nested_dict, prop_schema['properties']) + + if schema and 'properties' in schema: + # First, fix any dict structures that should be arrays + # This must be called BEFORE validation to convert dicts with numeric keys to arrays + fix_array_structures(plugin_config, schema['properties']) + # Then, ensure None arrays get defaults + ensure_array_defaults(plugin_config, schema['properties']) + + # Debug: Log the structure after fixing + if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): + custom_feeds = plugin_config['feeds']['custom_feeds'] + logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") + + # Force fix for feeds.custom_feeds if it's still a dict (fallback) + if 'feeds' in plugin_config: + feeds_config = plugin_config.get('feeds') or {} + if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): + custom_feeds_dict = feeds_config['custom_feeds'] + # Check if all keys are numeric + keys = list(custom_feeds_dict.keys()) + if keys and all(str(k).isdigit() for k in keys): + # Convert to array + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys] + logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items") + + # Fix unchecked boolean checkboxes: HTML checkboxes don't submit values + # when unchecked, so the existing config value (potentially True) persists. + # Walk the schema and set any boolean fields missing from form data to False. + if schema and 'properties' in schema: + form_keys = set(request.form.keys()) + _set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) + + # Get schema manager instance (for JSON requests) + schema_mgr = api_v3.schema_manager + if not schema_mgr: + return error_response( + ErrorCode.SYSTEM_ERROR, + 'Schema manager not initialized', + status_code=500 + ) + + # Load plugin schema using SchemaManager (force refresh to get latest schema) + # For JSON requests, schema wasn't loaded yet + if 'application/json' in content_type: + schema = schema_mgr.load_schema(plugin_id, use_cache=False) + + # JSON path: fix numeric-keyed dicts that should be arrays. + # JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}} + # instead of [{name:...}]. The form-data path has fix_array_structures for this; + # mirror that logic here for JSON submissions. + if 'application/json' in content_type and schema and 'properties' in schema: + def _fix_json_arrays(cfg, props): + for k, ps in props.items(): + if not isinstance(cfg, dict) or k not in cfg: + continue + pt = ps.get('type') + val = cfg[k] + if pt == 'array': + items_schema = ps.get('items', {}) + item_type = items_schema.get('type') + if isinstance(val, dict): + keys = list(val.keys()) + if keys and all(str(x).isdigit() for x in keys): + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + arr = [val[sk] for sk in sorted_keys] + if item_type in ('integer', 'number'): + converted = [] + for v in arr: + if isinstance(v, str): + try: + converted.append(int(v) if item_type == 'integer' else float(v)) + except (ValueError, TypeError, OverflowError): + converted.append(v) + else: + converted.append(v) + arr = converted + cfg[k] = arr + elif not keys: + cfg[k] = [] + # Recurse into each element when items are objects with properties, + # covering both freshly-converted and already-list values. + if item_type == 'object' and 'properties' in items_schema: + for elem in (cfg[k] if isinstance(cfg[k], list) else []): + if isinstance(elem, dict): + _fix_json_arrays(elem, items_schema['properties']) + elif pt == 'object' and 'properties' in ps and isinstance(val, dict): + _fix_json_arrays(val, ps['properties']) + _fix_json_arrays(plugin_config, schema['properties']) + + # PRE-PROCESSING: Preserve 'enabled' state if not in request + # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle + if 'enabled' not in plugin_config: + try: + current_config = api_v3.config_manager.load_config() + if plugin_id in current_config and 'enabled' in current_config[plugin_id]: + plugin_config['enabled'] = current_config[plugin_id]['enabled'] + # logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}") + elif api_v3.plugin_manager: + # Fallback to plugin instance if config doesn't have it + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin_instance: + plugin_config['enabled'] = plugin_instance.enabled + # Final fallback: default to True if plugin is loaded (matches BasePlugin default) + if 'enabled' not in plugin_config: + plugin_config['enabled'] = True + except Exception as e: + logger.debug("Error preserving enabled state: %s", e) + # Default to True on error to avoid disabling plugins + plugin_config['enabled'] = True + + # Find secret fields (supports nested schemas and array-item secrets) + secret_fields = set() + + if schema and 'properties' in schema: + secret_fields = find_secret_fields(schema['properties']) + + # Apply defaults from schema to config BEFORE validation + # This ensures required fields with defaults are present before validation + # Store preserved enabled value before merge to protect it from defaults + preserved_enabled = None + if 'enabled' in plugin_config: + preserved_enabled = plugin_config['enabled'] + + if schema: + defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) + plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults) + + # After merging defaults, replace any None array values with their schema defaults. + # merge_with_defaults gives user config higher priority, so a None submitted by + # the client can survive the merge — this pass cleans those up. + def _fix_none_arrays(cfg, props): + for k, pschema in props.items(): + if pschema.get('type') == 'array': + if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None): + cfg[k] = pschema.get('default', []) + elif pschema.get('type') == 'object' and 'properties' in pschema: + if isinstance(cfg, dict) and isinstance(cfg.get(k), dict): + _fix_none_arrays(cfg[k], pschema['properties']) + + if schema and 'properties' in schema and isinstance(plugin_config, dict): + _fix_none_arrays(plugin_config, schema['properties']) + + # Ensure enabled state is preserved after defaults merge + # Defaults should not overwrite an explicitly preserved enabled value + if preserved_enabled is not None: + # Restore preserved value if it was changed by defaults merge + if plugin_config.get('enabled') != preserved_enabled: + plugin_config['enabled'] = preserved_enabled + + # Normalize config data: convert string numbers to integers/floats where schema expects numbers + # This handles form data which sends everything as strings + def normalize_config_values(config, schema_props, prefix=''): + """Recursively normalize config values based on schema types""" + if not isinstance(config, dict) or not isinstance(schema_props, dict): + return config + + normalized = {} + for key, value in config.items(): + field_path = f"{prefix}.{key}" if prefix else key + + if key not in schema_props: + # Field not in schema, keep as-is (will be caught by additionalProperties check if needed) + normalized[key] = value + continue + + prop_schema = schema_props[key] + prop_type = prop_schema.get('type') + + # Handle union types (e.g., ["integer", "null"]) + if isinstance(prop_type, list): + # Check if null is allowed and value is empty/null + if 'null' in prop_type: + # Handle various representations of null/empty + if value is None: + normalized[key] = None + continue + elif isinstance(value, str): + # Strip whitespace and check for null representations + value_stripped = value.strip() + if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'): + normalized[key] = None + continue + + # Try to normalize based on non-null types in the union + # Check integer first (more specific than number) + if 'integer' in prop_type: + if isinstance(value, str): + value_stripped = value.strip() + if value_stripped == '': + # Empty string with null allowed - already handled above, but double-check + if 'null' in prop_type: + normalized[key] = None + continue + try: + normalized[key] = int(value_stripped) + continue + except (ValueError, TypeError, OverflowError): + pass + elif isinstance(value, (int, float)): + normalized[key] = int(value) + continue + + # Check number (less specific, but handles floats) + if 'number' in prop_type: + if isinstance(value, str): + value_stripped = value.strip() + if value_stripped == '': + # Empty string with null allowed - already handled above, but double-check + if 'null' in prop_type: + normalized[key] = None + continue + try: + normalized[key] = float(value_stripped) + continue + except (ValueError, TypeError, OverflowError): + pass + elif isinstance(value, (int, float)): + normalized[key] = float(value) + continue + + # Check boolean + if 'boolean' in prop_type: + if isinstance(value, str): + normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes') + continue + + # If no conversion worked and null is allowed, try to set to None + # This handles cases where the value is an empty string or can't be converted + if 'null' in prop_type: + if isinstance(value, str): + value_stripped = value.strip() + if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'): + normalized[key] = None + continue + # If it's already None, keep it + if value is None: + normalized[key] = None + continue + + # If no conversion worked, keep original value (will fail validation, but that's expected) + # Log a warning for debugging + logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}") + normalized[key] = value + continue + + if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema: + # Recursively normalize nested objects + normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path) + elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema: + # Normalize array items + items_schema = prop_schema['items'] + item_type = items_schema.get('type') + + # Handle union types in array items + if isinstance(item_type, list): + normalized_array = [] + for v in value: + # Check if null is allowed + if 'null' in item_type: + if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')): + normalized_array.append(None) + continue + + # Try to normalize based on non-null types + if 'integer' in item_type: + if isinstance(v, str): + try: + normalized_array.append(int(v)) + continue + except (ValueError, TypeError, OverflowError): + pass + elif isinstance(v, (int, float)): + normalized_array.append(int(v)) + continue + elif 'number' in item_type: + if isinstance(v, str): + try: + normalized_array.append(float(v)) + continue + except (ValueError, TypeError, OverflowError): + pass + elif isinstance(v, (int, float)): + normalized_array.append(float(v)) + continue + + # If no conversion worked, keep original value + normalized_array.append(v) + normalized[key] = normalized_array + elif item_type == 'integer': + # Convert string numbers to integers + normalized_array = [] + for v in value: + if isinstance(v, str): + try: + normalized_array.append(int(v)) + except (ValueError, TypeError, OverflowError): + normalized_array.append(v) + elif isinstance(v, (int, float)): + normalized_array.append(int(v)) + else: + normalized_array.append(v) + normalized[key] = normalized_array + elif item_type == 'number': + # Convert string numbers to floats + normalized_array = [] + for v in value: + if isinstance(v, str): + try: + normalized_array.append(float(v)) + except (ValueError, TypeError, OverflowError): + normalized_array.append(v) + else: + normalized_array.append(v) + normalized[key] = normalized_array + elif item_type == 'object' and 'properties' in items_schema: + # Recursively normalize array of objects + normalized_array = [] + for v in value: + if isinstance(v, dict): + normalized_array.append( + normalize_config_values(v, items_schema['properties'], f"{field_path}[]") + ) + else: + normalized_array.append(v) + normalized[key] = normalized_array + else: + normalized[key] = value + elif prop_type == 'integer': + # Convert string to integer + if isinstance(value, str): + try: + normalized[key] = int(value) + except (ValueError, TypeError, OverflowError): + normalized[key] = value + else: + normalized[key] = value + elif prop_type == 'number': + # Convert string to float + if isinstance(value, str): + try: + normalized[key] = float(value) + except (ValueError, TypeError, OverflowError): + normalized[key] = value + else: + normalized[key] = value + elif prop_type == 'boolean': + # Convert string booleans + if isinstance(value, str): + normalized[key] = value.lower() in ('true', '1', 'on', 'yes') + else: + normalized[key] = value + else: + normalized[key] = value + + return normalized + + # Normalize config before validation + if schema and 'properties' in schema: + plugin_config = normalize_config_values(plugin_config, schema['properties']) + + # Filter config to only include schema-defined fields (important when additionalProperties is false) + # Use enhanced schema with core properties to ensure core properties are preserved during filtering + if schema and 'properties' in schema: + enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema) + plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering) + + # Debug logging for union type fields (temporary) + if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}): + seed_value = plugin_config['rotation_settings']['random_seed'] + logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}") + + # Validate configuration against schema before saving + if schema: + # Log what we're validating for debugging + logger.info(f"Validating config for {plugin_id}") + # Only the shape. plugin_config still holds the submitted secret + # values at this point -- separate_secrets does not run until + # below -- so logging it wrote live credentials to the journal. + logger.info(f"Config keys being validated: {list(plugin_config.keys())}") + + # Get enhanced schema keys (including injected core properties) + # We need to create an enhanced schema to get the actual allowed keys + import copy + enhanced_schema = copy.deepcopy(schema) + if "properties" not in enhanced_schema: + enhanced_schema["properties"] = {} + + # Core properties that are always injected during validation + core_properties = ["enabled", "display_duration", "live_priority"] + for prop_name in core_properties: + if prop_name not in enhanced_schema["properties"]: + # Add placeholder to get the full list of allowed keys + enhanced_schema["properties"][prop_name] = {"type": "any"} + + is_valid, validation_errors = schema_mgr.validate_config_against_schema( + plugin_config, schema, plugin_id + ) + if not is_valid: + # Log validation errors for debugging + logger.error(f"Config validation failed for {plugin_id}") + logger.error(f"Validation errors: {validation_errors}") + # Keys only, for the same reason as above. + logger.error(f"Config keys that failed: {list(plugin_config.keys())}") + logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}") + + # Also print to console for immediate visibility + import json + logger.warning("Config validation failed for plugin (see debug logs)") + + # Log raw form data if this was a form submission + if 'application/json' not in (request.content_type or ''): + form_data = request.form.to_dict() + return error_response( + ErrorCode.CONFIG_VALIDATION_FAILED, + 'Configuration validation failed', + details='; '.join(validation_errors) if validation_errors else 'Unknown validation error', + context={ + 'plugin_id': plugin_id, + 'validation_errors': validation_errors, + 'config_keys': list(plugin_config.keys()), + 'schema_keys': list(enhanced_schema.get('properties', {}).keys()) + }, + suggested_fixes=[ + 'Review validation errors above', + 'Check config against schema', + 'Verify all required fields are present' + ], + status_code=400 + ) + + # Separate secrets from regular config (handles nested configs and + # array-item secrets — see src/web_interface/secret_helpers.py) + regular_config, secrets_config = separate_secrets(plugin_config, secret_fields) + # The config form renders secrets masked, so every save posts + # them back blank. Without this the blank is merged over the + # stored value and the credential is destroyed by the act of + # changing an unrelated setting. A blank means "unchanged". + secrets_config = remove_empty_secrets(secrets_config) + + # Get current configs + current_config = api_v3.config_manager.load_config() + current_secrets = api_v3.config_manager.get_raw_file_content('secrets') + + # Deep merge plugin configuration in main config (preserves nested structures) + if plugin_id not in current_config: + current_config[plugin_id] = {} + + current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config) + + # Deep merge plugin secrets in secrets config + if secrets_config: + if plugin_id not in current_secrets: + current_secrets[plugin_id] = {} + # See above -- secrets lists must merge element-wise. + current_secrets[plugin_id] = merge_secrets( + current_secrets[plugin_id], secrets_config) + # Save secrets file + try: + api_v3.config_manager.save_raw_file_content('secrets', current_secrets) + except PermissionError as e: + # Log the error with more details + import os + secrets_path = api_v3.config_manager.secrets_path + secrets_dir = os.path.dirname(secrets_path) if secrets_path else None + + # Check permissions + dir_readable = os.access(secrets_dir, os.R_OK) if secrets_dir and os.path.exists(secrets_dir) else False + dir_writable = os.access(secrets_dir, os.W_OK) if secrets_dir and os.path.exists(secrets_dir) else False + file_writable = os.access(secrets_path, os.W_OK) if secrets_path and os.path.exists(secrets_path) else False + + logger.error( + f"Permission error saving secrets config for {plugin_id}: {e}\n" + f"Secrets path: {secrets_path}\n" + f"Directory readable: {dir_readable}, writable: {dir_writable}\n" + f"File writable: {file_writable}", + exc_info=True + ) + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save secrets configuration: Permission denied. Check file permissions on {secrets_path}", + status_code=500 + ) + except Exception as e: + # Log the error but don't fail the entire config save + import os + secrets_path = api_v3.config_manager.secrets_path + logger.error("Error saving secrets config for %s (path=%s)", plugin_id, secrets_path, exc_info=True) + # Return error response with more context + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + "Failed to save secrets configuration; see logs for details", + status_code=500 + ) + + # Save the updated main config using atomic save + success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True) + if not success: + return error_response( + ErrorCode.CONFIG_SAVE_FAILED, + f"Failed to save configuration: {error_msg}", + status_code=500 + ) + + # If the plugin is loaded, notify it of the config change with merged config + try: + if api_v3.plugin_manager: + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin_instance: + # Reload merged config (includes secrets) and pass the plugin-specific section + merged_config = api_v3.config_manager.load_config() + plugin_full_config = merged_config.get(plugin_id, {}) + if hasattr(plugin_instance, 'on_config_change'): + plugin_instance.on_config_change(plugin_full_config) + + # Update plugin state manager and call lifecycle methods based on enabled state + # This ensures the plugin state is synchronized with the config + enabled = plugin_full_config.get('enabled', plugin_instance.enabled) + + # Update state manager if available + if api_v3.plugin_state_manager: + api_v3.plugin_state_manager.set_plugin_enabled(plugin_id, enabled) + + # Call lifecycle methods to ensure plugin state matches config + try: + if enabled: + if hasattr(plugin_instance, 'on_enable'): + plugin_instance.on_enable() + else: + if hasattr(plugin_instance, 'on_disable'): + plugin_instance.on_disable() + except Exception as lifecycle_error: + # Log the error but don't fail the save - config is already saved + import logging + logging.warning(f"Lifecycle method error for {plugin_id}: {lifecycle_error}", exc_info=True) + except Exception as hook_err: + # Do not fail the save if hook fails; just log + logger.warning("on_config_change failed: %s", hook_err) + + secret_count = len(secrets_config) + message = f'Plugin {plugin_id} configuration saved successfully' + if secret_count > 0: + message += f' ({secret_count} secret field(s) saved to config_secrets.json)' + + return success_response(message=message) + except Exception as e: + from src.web_interface.errors import WebInterfaceError + error = WebInterfaceError.from_exception(e, ErrorCode.CONFIG_SAVE_FAILED) + if api_v3.operation_history: + api_v3.operation_history.record_operation( + "configure", + plugin_id=data.get('plugin_id') if 'data' in locals() else None, + status="failed", + error=str(e) + ) + return error_response( + error.error_code, + error.message, + details=error.details, + context=error.context, + status_code=500 + ) +@api_v3.route('/plugins/schema', methods=['GET']) +def get_plugin_schema(): + """Get plugin configuration schema""" + try: + plugin_id = request.args.get('plugin_id') + if not plugin_id: + return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 + + # Get schema manager instance + schema_mgr = api_v3.schema_manager + if not schema_mgr: + return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500 + + # Load schema using SchemaManager (uses caching) + schema = schema_mgr.load_schema(plugin_id, use_cache=True) + + if schema: + # Offer installed visual skins as a dropdown (returns a copy; + # the cached schema and validation are never enum-restricted) + try: + current_skin = None + if api_v3.config_manager: + config = api_v3.config_manager.load_config() + current_skin = config.get(plugin_id, {}).get('skin') + injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin) + if isinstance(injected, dict): + schema = injected + except Exception: + logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True) + return jsonify({'status': 'success', 'data': {'schema': schema}}) + + # Return a simple default schema if file not found + default_schema = { + 'type': 'object', + 'properties': { + 'enabled': { + 'type': 'boolean', + 'title': 'Enable Plugin', + 'description': 'Enable or disable this plugin', + 'default': True + }, + 'display_duration': { + 'type': 'integer', + 'title': 'Display Duration', + 'description': 'How long to show content (seconds)', + 'minimum': 5, + 'maximum': 300, + 'default': 30 + } + } + } + + return jsonify({'status': 'success', 'data': {'schema': default_schema}}) + except Exception as e: + logger.error('Error in get_plugin_schema', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/config/reset', methods=['POST']) +def reset_plugin_config(): + """Reset plugin configuration to schema defaults""" + try: + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + + data = request.get_json(silent=True) or {} + plugin_id = data.get('plugin_id') + preserve_secrets = data.get('preserve_secrets', True) + + if not plugin_id: + return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 + + # Get schema manager instance + schema_mgr = api_v3.schema_manager + if not schema_mgr: + return jsonify({'status': 'error', 'message': 'Schema manager not initialized'}), 500 + + # Generate defaults from schema + defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True) + + # Get current configs + current_config = api_v3.config_manager.load_config() + current_secrets = api_v3.config_manager.get_raw_file_content('secrets') + + # Load schema to identify secret fields + schema = schema_mgr.load_schema(plugin_id, use_cache=True) + secret_fields = set() + + if schema and 'properties' in schema: + secret_fields = find_secret_fields(schema['properties']) + + # Separate defaults into regular and secret configs + default_regular, default_secrets = separate_secrets(defaults, secret_fields) + + # Update main config with defaults + current_config[plugin_id] = default_regular + + # Update secrets config (preserve existing secrets if preserve_secrets=True) + if preserve_secrets: + # Keep existing secrets for this plugin + if plugin_id in current_secrets: + # Merge defaults with existing secrets + existing_secrets = current_secrets[plugin_id] + for key, value in default_secrets.items(): + if key not in existing_secrets or not existing_secrets[key]: + existing_secrets[key] = value + else: + current_secrets[plugin_id] = default_secrets + else: + # Replace all secrets with defaults + current_secrets[plugin_id] = default_secrets + + # Save updated configs + api_v3.config_manager.save_config(current_config) + if default_secrets or not preserve_secrets: + api_v3.config_manager.save_raw_file_content('secrets', current_secrets) + + # Notify plugin of config change if loaded + try: + if api_v3.plugin_manager: + plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id) + if plugin_instance: + merged_config = api_v3.config_manager.load_config() + plugin_full_config = merged_config.get(plugin_id, {}) + if hasattr(plugin_instance, 'on_config_change'): + plugin_instance.on_config_change(plugin_full_config) + except Exception as hook_err: + logger.warning("on_config_change failed: %s", hook_err) + + return jsonify({ + 'status': 'success', + 'message': f'Plugin {plugin_id} configuration reset to defaults', + 'data': {'config': defaults} + }) + except Exception as e: + logger.error('Error in reset_plugin_config', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/action', methods=['POST']) +def execute_plugin_action(): + """Execute a plugin-defined action (e.g., authentication)""" + try: + # Try to get JSON data, with better error handling + try: + data = request.get_json(force=True) or {} + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error parsing JSON in execute_plugin_action: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Invalid JSON in request body', + 'content_type': request.content_type }), 400 + + plugin_id = data.get('plugin_id') + action_id = data.get('action_id') + action_params = data.get('params', {}) + + if not plugin_id or not action_id: + return jsonify({ + 'status': 'error', + 'message': 'plugin_id and action_id required', + 'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)} + }), 400 + + # Get plugin directory + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Load manifest to get action definition + manifest_path = Path(plugin_dir) / 'manifest.json' + if not manifest_path.exists(): + return jsonify({'status': 'error', 'message': 'Plugin manifest not found'}), 404 + + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + + web_ui_actions = manifest.get('web_ui_actions', []) + action_def = None + for action in web_ui_actions: + if action.get('id') == action_id: + action_def = action + break + + if not action_def: + return jsonify({'status': 'error', 'message': f'Action {action_id} not found in plugin manifest'}), 404 + + # Set LEDMATRIX_ROOT environment variable + env = os.environ.copy() + env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) + + # Execute action based on type + action_type = action_def.get('type', 'script') + + if action_type == 'script': + # Execute a Python script + script_path = action_def.get('script') + if not script_path: + return jsonify({'status': 'error', 'message': 'Script path not defined for action'}), 400 + + script_file = Path(plugin_dir) / script_path + if not script_file.exists(): + return jsonify({'status': 'error', 'message': f'Script not found: {script_path}'}), 404 + + # Handle multi-step actions (like Spotify OAuth) + step = action_params.get('step') + + if step == '2' and action_params.get('redirect_url'): + # Step 2: Complete authentication with redirect URL + redirect_url = action_params.get('redirect_url') + import tempfile + import json as json_lib + + redirect_url_escaped = json_lib.dumps(redirect_url) + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: + wrapper.write(f'''import sys +import subprocess +import os + +# Set LEDMATRIX_ROOT +os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" + +# Run the script and provide redirect URL +proc = subprocess.Popen( + [sys.executable, r"{script_file}"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=os.environ +) + +# Send redirect URL to stdin +redirect_url = {redirect_url_escaped} +stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) +print(stdout) +sys.exit(proc.returncode) +''') + wrapper_path = wrapper.name + + try: + result = subprocess.run( + ['python3', wrapper_path], + capture_output=True, + text=True, + timeout=120, + env=env + ) + os.unlink(wrapper_path) + + if result.returncode == 0: + return jsonify({ + 'status': 'success', + 'message': action_def.get('success_message', 'Action completed successfully'), + 'output': result.stdout + }) + else: + return jsonify({ + 'status': 'error', + 'message': action_def.get('error_message', 'Action failed'), + 'output': result.stdout + result.stderr + }), 400 + except subprocess.TimeoutExpired: + if os.path.exists(wrapper_path): + os.unlink(wrapper_path) + return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 + else: + # Regular script execution - pass params via stdin if provided + if action_params: + # Pass params as JSON via stdin + import tempfile + import json as json_lib + + params_json = json_lib.dumps(action_params) + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: + wrapper.write(f'''import sys +import subprocess +import os +import json + +# Set LEDMATRIX_ROOT +os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" + +# Run the script and provide params as JSON via stdin +proc = subprocess.Popen( + [sys.executable, r"{script_file}"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=os.environ +) + +# Send params as JSON to stdin +params = {params_json} +stdout, _ = proc.communicate(input=json.dumps(params), timeout=120) +print(stdout) +sys.exit(proc.returncode) +''') + wrapper_path = wrapper.name + + try: + result = subprocess.run( + ['python3', wrapper_path], + capture_output=True, + text=True, + timeout=120, + env=env + ) + os.unlink(wrapper_path) + + # Try to parse output as JSON + try: + output_data = json.loads(result.stdout) + if result.returncode == 0: + return jsonify(output_data) + else: + return jsonify({ + 'status': 'error', + 'message': output_data.get('message', action_def.get('error_message', 'Action failed')), + 'output': result.stdout + result.stderr + }), 400 + except json.JSONDecodeError: + # Output is not JSON, return as text + if result.returncode == 0: + return jsonify({ + 'status': 'success', + 'message': action_def.get('success_message', 'Action completed successfully'), + 'output': result.stdout + }) + else: + return jsonify({ + 'status': 'error', + 'message': action_def.get('error_message', 'Action failed'), + 'output': result.stdout + result.stderr + }), 400 + except subprocess.TimeoutExpired: + if os.path.exists(wrapper_path): + os.unlink(wrapper_path) + return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 + else: + # No params - check for OAuth flow first, then run script normally + # Step 1: Get initial data (like auth URL) + # For OAuth flows, we might need to import the script as a module + if action_def.get('oauth_flow'): + # Import script as module to get auth URL + import sys + import importlib.util + + spec = importlib.util.spec_from_file_location("plugin_action", script_file) + action_module = importlib.util.module_from_spec(spec) + sys.modules["plugin_action"] = action_module + + try: + spec.loader.exec_module(action_module) + + # Try to get auth URL using common patterns + auth_url = None + if hasattr(action_module, 'get_auth_url'): + auth_url = action_module.get_auth_url() + elif hasattr(action_module, 'load_spotify_credentials'): + # Spotify-specific pattern + client_id, client_secret, redirect_uri = action_module.load_spotify_credentials() + if all([client_id, client_secret, redirect_uri]): + from spotipy.oauth2 import SpotifyOAuth + sp_oauth = SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=getattr(action_module, 'SCOPE', ''), + cache_path=getattr(action_module, 'SPOTIFY_AUTH_CACHE_PATH', None), + open_browser=False + ) + auth_url = sp_oauth.get_authorize_url() + + if auth_url: + return jsonify({ + 'status': 'success', + 'message': action_def.get('step1_message', 'Authorization URL generated'), + 'auth_url': auth_url, + 'requires_step2': True + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Could not generate authorization URL' + }), 400 + except Exception as e: + logger.error("Error executing action step 1", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 + else: + # Simple script execution + result = subprocess.run( + ['python3', str(script_file)], + capture_output=True, + text=True, + timeout=60, + env=env + ) + + # Try to parse output as JSON + try: + import json as json_module + output_data = json_module.loads(result.stdout) + if result.returncode == 0: + return jsonify(output_data) + else: + return jsonify({ + 'status': 'error', + 'message': output_data.get('message', action_def.get('error_message', 'Action failed')), + 'output': result.stdout + result.stderr + }), 400 + except json.JSONDecodeError: + # Output is not JSON, return as text + if result.returncode == 0: + return jsonify({ + 'status': 'success', + 'message': action_def.get('success_message', 'Action completed successfully'), + 'output': result.stdout + }) + else: + return jsonify({ + 'status': 'error', + 'message': action_def.get('error_message', 'Action failed'), + 'output': result.stdout + result.stderr + }), 400 + + elif action_type == 'endpoint': + # Call a plugin-defined HTTP endpoint (future feature) + return jsonify({'status': 'error', 'message': 'Endpoint actions not yet implemented'}), 501 + + else: + return jsonify({'status': 'error', 'message': f'Unknown action type: {action_type}'}), 400 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 + except Exception as e: + logger.error('Error in execute_plugin_action', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/authenticate/spotify', methods=['POST']) +def authenticate_spotify(): + """Run Spotify authentication script""" + try: + data = request.get_json(silent=True) or {} + redirect_url = data.get('redirect_url', '').strip() + + # Get plugin directory + plugin_id = 'ledmatrix-music' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + auth_script = Path(plugin_dir) / 'authenticate_spotify.py' + if not auth_script.exists(): + return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 + + # Set LEDMATRIX_ROOT environment variable + env = os.environ.copy() + env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) + + if redirect_url: + # Step 2: Complete authentication with redirect URL + # Create a wrapper script that provides the redirect URL as input + import tempfile + + # Create a wrapper script that provides the redirect URL + import json + redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: + wrapper.write(f'''import sys +import subprocess +import os + +# Set LEDMATRIX_ROOT +os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" + +# Run the auth script and provide redirect URL +proc = subprocess.Popen( + [sys.executable, r"{auth_script}"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=os.environ +) + +# Send redirect URL to stdin +redirect_url = {redirect_url_escaped} +stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) +print(stdout) +sys.exit(proc.returncode) +''') + wrapper_path = wrapper.name + + try: + result = subprocess.run( + ['python3', wrapper_path], + capture_output=True, + text=True, + timeout=120, + env=env + ) + + if result.returncode == 0: + return jsonify({ + 'status': 'success', + 'message': 'Spotify authentication completed successfully', + 'output': result.stdout + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Spotify authentication failed', + 'output': result.stdout + result.stderr + }), 400 + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 + finally: + # The wrapper carries the user's redirect URL, so it must not + # survive the request on any path — including a failure to + # launch, which the previous per-branch unlinks missed. + if os.path.exists(wrapper_path): + os.unlink(wrapper_path) + else: + # Step 1: Get authorization URL + # Import the script's functions directly to get the auth URL + import sys + import importlib.util + + # Load the authentication script as a module + spec = importlib.util.spec_from_file_location("auth_spotify", auth_script) + auth_module = importlib.util.module_from_spec(spec) + sys.modules["auth_spotify"] = auth_module + + # Set LEDMATRIX_ROOT before loading + os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) + + try: + spec.loader.exec_module(auth_module) + + # Get credentials and create OAuth object + client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials() + if not all([client_id, client_secret, redirect_uri]): + return jsonify({ + 'status': 'error', + 'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.' + }), 400 + + from spotipy.oauth2 import SpotifyOAuth + sp_oauth = SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=auth_module.SCOPE, + cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH, + open_browser=False + ) + + auth_url = sp_oauth.get_authorize_url() + + return jsonify({ + 'status': 'success', + 'message': 'Authorization URL generated', + 'auth_url': auth_url + }) + except Exception as e: + logger.error("Error getting Spotify auth URL", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 + + except Exception as e: + logger.error('Error in authenticate_spotify', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/authenticate/ytm', methods=['POST']) +def authenticate_ytm(): + """Run YouTube Music authentication script""" + try: + # Get plugin directory + plugin_id = 'ledmatrix-music' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + auth_script = Path(plugin_dir) / 'authenticate_ytm.py' + if not auth_script.exists(): + return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 + + # Set LEDMATRIX_ROOT environment variable + env = os.environ.copy() + env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) + + # Run the authentication script + result = subprocess.run( + ['python3', str(auth_script)], + capture_output=True, + text=True, + timeout=60, + env=env + ) + + if result.returncode == 0: + return jsonify({ + 'status': 'success', + 'message': 'YouTube Music authentication completed successfully', + 'output': result.stdout + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'YouTube Music authentication failed', + 'output': result.stdout + result.stderr + }), 400 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 + except Exception as e: + logger.error('Error in authenticate_ytm', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/assets/upload', methods=['POST']) +def upload_plugin_asset(): + """Upload asset files for a plugin""" + try: + plugin_id = request.form.get('plugin_id') + if not plugin_id: + return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 + + if 'files' not in request.files: + return jsonify({'status': 'error', 'message': 'No files provided'}), 400 + + files = request.files.getlist('files') + if not files or all(not f.filename for f in files): + return jsonify({'status': 'error', 'message': 'No files provided'}), 400 + + # Validate file count + if len(files) > 10: + return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400 + + # Setup plugin assets directory + assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' + assets_dir.mkdir(parents=True, exist_ok=True) + + # Load metadata file + metadata_file = assets_dir / '.metadata.json' + if metadata_file.exists(): + with open(metadata_file, 'r') as f: + metadata = json.load(f) + else: + metadata = {} + + uploaded_files = [] + total_size = 0 + max_size_per_file = 5 * 1024 * 1024 # 5MB + max_total_size = 50 * 1024 * 1024 # 50MB + + # Calculate current total size + for entry in metadata.values(): + if 'size' in entry: + total_size += entry.get('size', 0) + + for file in files: + if not file.filename: + continue + + # Validate file type + allowed_extensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif'] + file_ext = '.' + file.filename.lower().split('.')[-1] + if file_ext not in allowed_extensions: + return jsonify({ + 'status': 'error', + 'message': f'Invalid file type: {file_ext}. Allowed: {allowed_extensions}' + }), 400 + + # Read file to check size and validate + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > max_size_per_file: + return jsonify({ + 'status': 'error', + 'message': f'File {file.filename} exceeds 5MB limit' + }), 400 + + if total_size + file_size > max_total_size: + return jsonify({ + 'status': 'error', + 'message': f'Upload would exceed 50MB total storage limit' + }), 400 + + # Validate file is actually an image (check magic bytes) + file_content = file.read(8) + file.seek(0) + is_valid_image = False + if file_content.startswith(b'\x89PNG\r\n\x1a\n'): # PNG + is_valid_image = True + elif file_content[:2] == b'\xff\xd8': # JPEG + is_valid_image = True + elif file_content[:2] == b'BM': # BMP + is_valid_image = True + elif file_content[:6] in [b'GIF87a', b'GIF89a']: # GIF + is_valid_image = True + + if not is_valid_image: + return jsonify({ + 'status': 'error', + 'message': f'File {file.filename} is not a valid image file' + }), 400 + + # Generate unique filename + timestamp = int(_pkg.time.time()) + file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8] + safe_filename = f"image_{timestamp}_{file_hash}{file_ext}" + file_path = assets_dir / safe_filename + + # Ensure filename is unique + counter = 1 + while file_path.exists(): + safe_filename = f"image_{timestamp}_{file_hash}_{counter}{file_ext}" + file_path = assets_dir / safe_filename + counter += 1 + + # Save file + file.save(str(file_path)) + + # Make file readable + os.chmod(file_path, 0o644) + + # Generate unique ID + image_id = str(uuid.uuid4()) + + # Store metadata + relative_path = f"assets/plugins/{plugin_id}/uploads/{safe_filename}" + metadata[image_id] = { + 'id': image_id, + 'filename': safe_filename, + 'path': relative_path, + 'size': file_size, + 'uploaded_at': datetime.utcnow().isoformat() + 'Z', + 'original_filename': file.filename + } + + uploaded_files.append({ + 'id': image_id, + 'filename': safe_filename, + 'path': relative_path, + 'size': file_size, + '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) + + return jsonify({ + 'status': 'success', + 'uploaded_files': uploaded_files, + 'total_files': len(metadata) + }) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) +def upload_of_the_day_json(): + """Upload JSON files for of-the-day plugin""" + try: + if 'files' not in request.files: + return jsonify({'status': 'error', 'message': 'No files provided'}), 400 + + files = request.files.getlist('files') + if not files or all(not f.filename for f in files): + return jsonify({'status': 'error', 'message': 'No files provided'}), 400 + + # Get plugin directory + plugin_id = 'ledmatrix-of-the-day' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Setup of_the_day directory + data_dir = Path(plugin_dir) / 'of_the_day' + data_dir.mkdir(parents=True, exist_ok=True) + + uploaded_files = [] + max_size_per_file = 5 * 1024 * 1024 # 5MB + + for file in files: + if not file.filename: + continue + + # Validate file extension + if not file.filename.lower().endswith('.json'): + return jsonify({ + 'status': 'error', + 'message': f'File {file.filename} must be a JSON file (.json)' + }), 400 + + # Read and validate file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > max_size_per_file: + return jsonify({ + 'status': 'error', + 'message': f'File {file.filename} exceeds 5MB limit' + }), 400 + + # Read and validate JSON content + try: + file_content = file.read().decode('utf-8') + json_data = json.loads(file_content) + except json.JSONDecodeError as e: + return jsonify({ + 'status': 'error', + 'message': 'Invalid JSON in request body' + }), 400 + except UnicodeDecodeError: + return jsonify({ + 'status': 'error', + 'message': f'File {file.filename} is not valid UTF-8 text' + }), 400 + + # Validate JSON structure (must be object with day number keys) + if not isinstance(json_data, dict): + return jsonify({ + 'status': 'error', + 'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys' + }), 400 + + # Check if keys are valid day numbers + for key in json_data.keys(): + try: + day_num = int(key) + if day_num < 1 or day_num > 365: + return jsonify({ + 'status': 'error', + 'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)' + }), 400 + except ValueError: + return jsonify({ + 'status': 'error', + 'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)' + }), 400 + + # Generate safe filename from original (preserve user's filename) + original_filename = file.filename + safe_filename = original_filename.lower().replace(' ', '_') + # Ensure it's a valid filename + safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-') + if not safe_filename.endswith('.json'): + safe_filename += '.json' + + file_path = data_dir / safe_filename + + # If file exists, add counter + counter = 1 + base_name = safe_filename.replace('.json', '') + while file_path.exists(): + safe_filename = f"{base_name}_{counter}.json" + file_path = data_dir / safe_filename + counter += 1 + + # Save file + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(json_data, f, indent=2, ensure_ascii=False) + + # Make file readable + os.chmod(file_path, 0o644) + + # Extract category name from filename (remove .json extension) + category_name = safe_filename.replace('.json', '') + display_name = category_name.replace('_', ' ').title() + + # Update plugin config to add category + try: + sys.path.insert(0, str(plugin_dir)) + from scripts.update_config import add_category_to_config + add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name) + except Exception as e: + logger.warning("Could not update config: %s", e) + # Continue anyway - file is uploaded + + # Generate file ID (use category name as ID for simplicity) + file_id = category_name + + uploaded_files.append({ + 'id': file_id, + 'filename': safe_filename, + 'original_filename': original_filename, + 'path': f'of_the_day/{safe_filename}', + 'size': file_size, + 'uploaded_at': datetime.utcnow().isoformat() + 'Z', + 'category_name': category_name, + 'display_name': display_name, + 'entry_count': len(json_data) + }) + + return jsonify({ + 'status': 'success', + 'uploaded_files': uploaded_files, + 'total_files': len(uploaded_files) + }) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) +def delete_of_the_day_json(): + """Delete a JSON file from of-the-day plugin""" + try: + data = request.get_json(silent=True) or {} + file_id = data.get('file_id') # This is the category_name + + if not file_id: + return jsonify({'status': 'error', 'message': 'file_id is required'}), 400 + + # Get plugin directory + plugin_id = 'ledmatrix-of-the-day' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + data_dir = Path(plugin_dir) / 'of_the_day' + filename = f"{file_id}.json" + file_path = data_dir / filename + + if not file_path.exists(): + return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404 + + # Delete file + file_path.unlink() + + # Update config to remove category + try: + sys.path.insert(0, str(plugin_dir)) + from scripts.update_config import remove_category_from_config + remove_category_from_config(file_id) + except Exception as e: + logger.warning("Could not update config: %s", e) + + return jsonify({ + 'status': 'success', + 'message': f'File {filename} deleted successfully' + }) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins//static/', methods=['GET']) +def serve_plugin_static(plugin_id, file_path): + """Serve static files from plugin directory""" + try: + # Get plugin directory + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Resolve file path (prevent directory traversal) + plugin_dir = Path(plugin_dir).resolve() + requested_file = (plugin_dir / file_path).resolve() + + # Security check: ensure file is within plugin directory + if not str(requested_file).startswith(str(plugin_dir)): + return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403 + + # Check if file exists + if not requested_file.exists() or not requested_file.is_file(): + return jsonify({'status': 'error', 'message': 'File not found'}), 404 + + # Determine content type + content_type = 'text/plain' + if file_path.endswith('.html'): + content_type = 'text/html' + elif file_path.endswith('.js'): + content_type = 'application/javascript' + elif file_path.endswith('.css'): + content_type = 'text/css' + elif file_path.endswith('.json'): + content_type = 'application/json' + + # Read and return file + with open(requested_file, 'r', encoding='utf-8') as f: + content = f.read() + + return Response(content, mimetype=content_type) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) +def upload_calendar_credentials(): + """Upload credentials.json file for calendar plugin""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file provided'}), 400 + + file = request.files['file'] + if not file or not file.filename: + return jsonify({'status': 'error', 'message': 'No file provided'}), 400 + + # Validate file extension + if not file.filename.lower().endswith('.json'): + return jsonify({'status': 'error', 'message': 'File must be a JSON file (.json)'}), 400 + + # Validate file size (max 1MB for credentials) + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + + if file_size > 1024 * 1024: # 1MB + return jsonify({'status': 'error', 'message': 'File exceeds 1MB limit'}), 400 + + # Validate it's valid JSON + try: + file_content = file.read() + file.seek(0) + creds_data = json.loads(file_content) + except json.JSONDecodeError: + return jsonify({'status': 'error', 'message': 'File is not valid JSON'}), 400 + + # Validate it looks like Google OAuth credentials. A bare scalar, a + # list, true/null — all valid JSON, none of them credentials. Reject + # rather than save: a file written as credentials.json but unusable + # as credentials only fails later, somewhere less obvious. + if not isinstance(creds_data, dict) or not ( + 'installed' in creds_data or 'web' in creds_data): + return jsonify({ + 'status': 'error', + 'message': 'File does not appear to be a valid Google OAuth credentials file' + }), 400 + + # Get plugin directory + plugin_id = 'calendar' + if api_v3.plugin_manager: + plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) + else: + plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id + + if not plugin_dir or not Path(plugin_dir).exists(): + return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 + + # Save file to plugin directory + credentials_path = Path(plugin_dir) / 'credentials.json' + + # Backup existing file if it exists + if credentials_path.exists(): + backup_path = Path(plugin_dir) / f'credentials.json.backup.{int(_pkg.time.time())}' + import shutil + 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 + + return jsonify({ + 'status': 'success', + 'message': 'Credentials file uploaded successfully', + 'path': str(credentials_path) + }) + + except Exception as e: + logger.error('Error in upload_calendar_credentials', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/calendar/authenticate', methods=['POST']) +def authenticate_calendar(): + """Google OAuth for the calendar plugin, in the two steps it requires. + + Step 1 (no body) returns the consent URL to open. Step 2 posts back the + URL Google redirected to -- it fails to load, because the redirect points + at a loopback address nothing is listening on, but the address bar carries + the authorization code -- and the script exchanges it for a token. + + Two calls rather than one because the user has to visit Google in between. + The script persists the PKCE verifier from step 1 for step 2 to reuse; the + exchange fails with "Missing code verifier" otherwise. + """ + try: + plugin_dir = _pkg._calendar_plugin_dir() + if plugin_dir is None: + return jsonify({ + 'status': 'error', + 'message': 'The calendar plugin is not installed' + }), 404 + + if not (plugin_dir / 'credentials.json').exists(): + return jsonify({ + 'status': 'error', + 'message': ('No credentials.json yet. Upload your Google OAuth ' + 'client file first (Step 1).') + }), 400 + + data = request.get_json(silent=True) or {} + redirect_url = (data.get('redirect_url') or data.get('code') or '').strip() + + payload, error = _run_calendar_registration(plugin_dir, redirect_url) + if error: + return jsonify({'status': 'error', 'message': error}), 500 + if payload.get('status') != 'success': + # The script's own diagnosis is more useful than anything that + # could be reconstructed here -- but it interpolates exceptions + # into its messages, so it reaches the client redacted and the + # original goes to the log. + logger.error('calendar authentication failed: %s', payload) + safe = dict(payload) + safe['message'] = redact_text(str(payload.get('message', '') + or 'Authentication failed')) + return jsonify(safe), 400 + return jsonify(payload) + + except Exception as e: + logger.error('Error in authenticate_calendar', exc_info=True) + return jsonify({'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/calendar/list-calendars', methods=['GET']) +def list_calendar_calendars(): + """The calendars this account can see, for the config picker. + + Reads the token the OAuth flow wrote rather than shelling out again: the + picker is used interactively and a subprocess per click is slower than the + API call it would be wrapping. + """ + try: + plugin_dir = _pkg._calendar_plugin_dir() + if plugin_dir is None: + return jsonify({ + 'status': 'error', + 'message': 'The calendar plugin is not installed' + }), 404 + + token_file = plugin_dir / 'token.pickle' + if not token_file.exists(): + return jsonify({ + 'status': 'error', + 'message': ('Not authenticated with Google yet. Complete Step 2 ' + 'first, then load your calendars.') + }), 400 + + try: + import pickle + from google.auth.transport.requests import Request as GoogleRequest + from googleapiclient.discovery import build as build_google_service + except ImportError as e: + return jsonify({ + 'status': 'error', + # The name of the missing module is the whole diagnosis, but it + # arrives as an exception, so it goes through the redactor like + # any other -- an ImportError can quote a path. + 'message': ('The Google API libraries are not installed. Install ' + "the calendar plugin's requirements.txt. (%s)" + % describe_exception(e)) + }), 500 + + with open(token_file, 'rb') as handle: + # Written only by this plugin's own OAuth flow, into its own + # directory, and read here exactly as the plugin itself reads it. + creds = pickle.load(handle) # nosec B301 - locally generated token + + if creds and creds.expired and creds.refresh_token: + creds.refresh(GoogleRequest()) + with open(token_file, 'wb') as handle: + pickle.dump(creds, handle) + os.chmod(token_file, 0o600) + + if not creds or not creds.valid: + return jsonify({ + 'status': 'error', + 'message': ('Stored Google credentials are no longer valid. ' + 'Run Step 2 again to re-authenticate.') + }), 400 + + service = build_google_service('calendar', 'v3', credentials=creds) + + # calendarList.list returns 100 entries per page by default and caps at + # 250, handing back a nextPageToken when there are more. Taking only + # the first page would silently hide calendars from the picker, and the + # user would have no way to tell the list was truncated. + entries = [] + page_token = None + for _ in range(_CALENDAR_LIST_MAX_PAGES): + response = service.calendarList().list( + maxResults=250, pageToken=page_token).execute() + entries.extend(response.get('items', [])) + page_token = response.get('nextPageToken') + if not page_token: + break + else: + # 2500 calendars in, something is wrong with the account or the + # token is looping; show what was collected rather than spin. + logger.warning( + 'calendarList paging stopped at %d pages with more remaining', + _CALENDAR_LIST_MAX_PAGES) + + calendars = [{ + 'id': entry.get('id'), + # The picker labels each row with summary and falls back to the id + # only in its own display, so send something either way. + 'summary': entry.get('summary') or entry.get('id'), + 'primary': bool(entry.get('primary', False)), + } for entry in entries if entry.get('id')] + + # Primary first, then alphabetically: the list is usually short but the + # one the user wants is almost always their own calendar. + calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower())) + + return jsonify({'status': 'success', 'calendars': calendars}) + + except Exception as e: + logger.error('Error in list_calendar_calendars', exc_info=True) + return jsonify({'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/assets/delete', methods=['POST']) +def delete_plugin_asset(): + """Delete an asset file for a plugin""" + try: + data = request.get_json() + plugin_id = data.get('plugin_id') + image_id = data.get('image_id') + + if not plugin_id or not image_id: + return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400 + + # Get asset directory + assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' + metadata_file = assets_dir / '.metadata.json' + + if not metadata_file.exists(): + return jsonify({'status': 'error', 'message': 'Metadata file not found'}), 404 + + # Load metadata + with open(metadata_file, 'r') as f: + metadata = json.load(f) + + if image_id not in metadata: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + # Delete file + file_path = PROJECT_ROOT / metadata[image_id]['path'] + if file_path.exists(): + file_path.unlink() + + # Remove from metadata + del metadata[image_id] + + # Save metadata + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + + return jsonify({'status': 'success', 'message': 'Image deleted successfully'}) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/plugins/assets/list', methods=['GET']) +def list_plugin_assets(): + """List asset files for a plugin""" + try: + plugin_id = request.args.get('plugin_id') + if not plugin_id: + return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400 + + # Get asset directory + assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads' + metadata_file = assets_dir / '.metadata.json' + + if not metadata_file.exists(): + return jsonify({'status': 'success', 'data': {'assets': []}}) + + # Load metadata + with open(metadata_file, 'r') as f: + metadata = json.load(f) + + # Convert to list + assets = list(metadata.values()) + + return jsonify({'status': 'success', 'data': {'assets': assets}}) + + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/api_v3/starlark.py b/web_interface/blueprints/api_v3/starlark.py new file mode 100644 index 000000000..801aea9da --- /dev/null +++ b/web_interface/blueprints/api_v3/starlark.py @@ -0,0 +1,692 @@ +"""Starlark / Tronbyte app management routes. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + PROJECT_ROOT, Path, _find_pixlet_binary, _install_star_file, _standalone_render_starlark_app, + _starlark_github_token, _validate_and_sanitize_app_id, + _validate_starlark_app_path, _validate_timing_value, api_v3, describe_exception, json, jsonify, + logger, os, request, shutil, subprocess, 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. + + +@api_v3.route('/starlark/status', methods=['GET']) +def get_starlark_status(): + """Get Starlark plugin status and Pixlet availability.""" + try: + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + info = starlark_plugin.get_info() + magnify_info = starlark_plugin.get_magnify_recommendation() + return jsonify({ + 'status': 'success', + 'pixlet_available': info.get('pixlet_available', False), + 'pixlet_version': info.get('pixlet_version'), + 'installed_apps': info.get('installed_apps', 0), + 'enabled_apps': info.get('enabled_apps', 0), + 'current_app': info.get('current_app'), + 'plugin_enabled': starlark_plugin.enabled, + 'display_info': magnify_info + }) + + # Plugin not loaded - check Pixlet availability via shared resolver + # (respects user-configured pixlet_path, bundled binary, and system PATH) + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path')) + pixlet_available = pixlet_path is not None + + # Read app counts from manifest + manifest = _pkg._read_starlark_manifest() + apps = manifest.get('apps', {}) + installed_count = len(apps) + enabled_count = sum(1 for a in apps.values() if a.get('enabled', True)) + + return jsonify({ + 'status': 'success', + 'pixlet_available': pixlet_available, + 'pixlet_version': None, + 'installed_apps': installed_count, + 'enabled_apps': enabled_count, + 'plugin_enabled': True, + 'plugin_loaded': False, + 'display_info': {} + }) + + except Exception as e: + logger.exception("[Starlark] get_starlark_status failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/install-pixlet', methods=['POST']) +def install_pixlet(): + """Download and install Pixlet binary.""" + try: + script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh' + if not script_path.exists(): + return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404 + + os.chmod(script_path, 0o755) + + result = subprocess.run( + [str(script_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + logger.info("Pixlet downloaded successfully") + return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout}) + else: + return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500 + + except subprocess.TimeoutExpired as err: + logger.exception("[Starlark] Pixlet download timed out") + return jsonify({'status': 'error', 'message': 'Download timed out', + 'details': describe_exception(err)}), 500 + except Exception as e: + logger.exception("[Starlark] install_pixlet failed") + return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps', methods=['GET']) +def get_starlark_apps(): + """List all installed Starlark apps.""" + try: + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + apps_list = [] + for app_id, app_instance in starlark_plugin.apps.items(): + apps_list.append({ + 'id': app_id, + 'name': app_instance.manifest.get('name', app_id), + 'enabled': app_instance.is_enabled(), + 'has_frames': app_instance.frames is not None, + 'render_interval': app_instance.get_render_interval(), + 'display_duration': app_instance.get_display_duration(), + 'config': app_instance.config, + 'has_schema': app_instance.schema is not None, + 'last_render_time': app_instance.last_render_time + }) + return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) + + # Standalone: read manifest from disk + manifest = _pkg._read_starlark_manifest() + apps_list = [] + for app_id, app_data in manifest.get('apps', {}).items(): + apps_list.append({ + 'id': app_id, + 'name': app_data.get('name', app_id), + 'enabled': app_data.get('enabled', True), + 'has_frames': False, + 'render_interval': app_data.get('render_interval', 300), + 'display_duration': app_data.get('display_duration', 15), + 'config': app_data.get('config', {}), + 'has_schema': False, + 'last_render_time': None + }) + return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)}) + + except Exception as e: + logger.exception("[Starlark] get_starlark_apps failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps/', methods=['GET']) +def get_starlark_app(app_id): + """Get details for a specific Starlark app.""" + try: + # Validate app_id before any filesystem access + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + return jsonify({ + 'status': 'success', + 'app': { + 'id': app_id, + 'name': app.manifest.get('name', app_id), + 'enabled': app.is_enabled(), + 'config': app.config, + 'schema': app.schema, + 'render_interval': app.get_render_interval(), + 'display_duration': app.get_display_duration(), + '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 + manifest = _pkg._read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Load schema from schema.json if it exists (path already validated above) + schema = None + schema_file = app_dir / 'schema.json' + if schema_file.exists(): + try: + with open(schema_file, 'r') as f: + schema = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to load schema for {app_id}: {e}") + + return jsonify({ + 'status': 'success', + 'app': { + 'id': app_id, + 'name': app_data.get('name', app_id), + 'enabled': app_data.get('enabled', True), + 'config': app_data.get('config', {}), + 'schema': schema, + 'render_interval': app_data.get('render_interval', 300), + 'display_duration': app_data.get('display_duration', 15), + 'has_frames': False, + 'frame_count': 0, + 'last_render_time': None, + } + }) + + except Exception as e: + logger.exception("[Starlark] get_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/upload', methods=['POST']) +def upload_starlark_app(): + """Upload and install a new Starlark app.""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400 + + file = request.files['file'] + if not file.filename or not file.filename.endswith('.star'): + return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400 + + # Check file size (limit to 5MB for .star files) + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(0) # Reset to beginning + MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB + if file_size > MAX_STAR_SIZE: + return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400 + + app_name = request.form.get('name') + app_id_input = request.form.get('app_id') + filename_base = file.filename.replace('.star', '') if file.filename else None + app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base) + if app_id_error: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 + + render_interval_input = request.form.get('render_interval') + render_interval = 300 + if render_interval_input is not None: + render_interval, err = _validate_timing_value(render_interval_input, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = render_interval or 300 + + display_duration_input = request.form.get('display_duration') + display_duration = 15 + if display_duration_input is not None: + display_duration, err = _validate_timing_value(display_duration_input, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = display_duration or 15 + + import tempfile + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + file.save(tmp.name) + temp_path = tmp.name + + try: + metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration} + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.install_app(app_id, temp_path, metadata) + else: + success = _install_star_file(app_id, temp_path, metadata) + if success: + return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + except (OSError, IOError) as err: + # This used to withhold the detail because it names absolute paths on + # the device. A full disk and a bad permission are indistinguishable + # without it, though, and describe_exception redacts credentials and + # truncates -- the same trade-off every other handler here makes. + logger.exception("[Starlark] File error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': 'File error during upload', + 'details': describe_exception(err)}), 500 + except ImportError as err: + logger.exception("[Starlark] Module load error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500 + except Exception as err: + logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err) + return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500 +@api_v3.route('/starlark/apps/', methods=['DELETE']) +def uninstall_starlark_app(app_id): + """Uninstall a Starlark app.""" + try: + # Validate app_id before any filesystem access + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.uninstall_app(app_id) + else: + # Standalone: remove app dir and manifest entry. app_dir is the + # path _validate_starlark_app_path checked, not a fresh join. + import shutil + if app_dir.exists(): + shutil.rmtree(app_dir) + manifest = _pkg._read_starlark_manifest() + manifest.get('apps', {}).pop(app_id, None) + success = _pkg._write_starlark_manifest(manifest) + + if success: + return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'}) + else: + return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500 + + except Exception as e: + logger.exception("[Starlark] uninstall_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps//config', methods=['GET']) +def get_starlark_app_config(app_id): + """Get configuration for a Starlark app.""" + try: + # Validate app_id before any filesystem access + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema}) + + # Standalone: read from config.json. app_dir is the path + # _validate_starlark_app_path checked, not a fresh join. + config_file = app_dir / "config.json" + + if not app_dir.exists(): + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + config = {} + if config_file.exists(): + try: + with open(config_file, 'r') as f: + config = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to load config for {app_id}: {e}") + + # Load schema from schema.json + schema = None + schema_file = app_dir / "schema.json" + if schema_file.exists(): + try: + with open(schema_file, 'r') as f: + schema = json.load(f) + except Exception as e: + logger.warning(f"Failed to load schema for {app_id}: {e}") + + return jsonify({'status': 'success', 'config': config, 'schema': schema}) + + except Exception as e: + logger.exception("[Starlark] get_starlark_app_config failed") + return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps//config', methods=['PUT']) +def update_starlark_app_config(app_id): + """Update configuration for a Starlark app.""" + try: + # Validate app_id before any filesystem access + app_dir, error_msg = _validate_starlark_app_path(app_id) + if error_msg: + return jsonify({'status': 'error', 'message': error_msg}), 400 + + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400 + + if 'render_interval' in data: + val, err = _validate_timing_value(data['render_interval'], 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['render_interval'] = val + + if 'display_duration' in data: + val, err = _validate_timing_value(data['display_duration'], 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + data['display_duration'] = val + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Extract timing keys from data before updating config (they belong in manifest, not config) + render_interval = data.pop('render_interval', None) + display_duration = data.pop('display_duration', None) + + # Snapshot before mutating. save_config() can fail, and the route + # answers 500 below when it does -- but the loaded app kept the new + # values anyway, so a later GET returned configuration that was + # never persisted and the plugin rendered with it. + prev_config = dict(app.config) + prev_manifest = dict(app.manifest) + + # Update config with non-timing fields only + app.config.update(data) + + # Update manifest with timing fields + timing_changed = False + if render_interval is not None: + app.manifest['render_interval'] = render_interval + timing_changed = True + if display_duration is not None: + app.manifest['display_duration'] = display_duration + timing_changed = True + saved = app.save_config() + if not saved: + app.config.clear(); app.config.update(prev_config) + app.manifest.clear(); app.manifest.update(prev_manifest) + if saved: + # Persist manifest if timing changed (same pattern as toggle endpoint) + if timing_changed: + try: + # Use safe manifest update to prevent race conditions + timing_updates = {} + if render_interval is not None: + timing_updates['render_interval'] = render_interval + if display_duration is not None: + timing_updates['display_duration'] = display_duration + + def update_fn(manifest): + manifest['apps'][app_id].update(timing_updates) + starlark_plugin._update_manifest_safe(update_fn) + except Exception as e: + logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}") + starlark_plugin._render_app(app, force=True) + return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500 + + # Standalone: update both config.json and manifest + manifest = _pkg._read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + # Extract timing keys (they go in manifest, not config.json) + render_interval = data.pop('render_interval', None) + display_duration = data.pop('display_duration', None) + + # Update manifest with timing values + if render_interval is not None: + app_data['render_interval'] = render_interval + if display_duration is not None: + app_data['display_duration'] = display_duration + + # Load current config from config.json. app_dir is the path + # _validate_starlark_app_path checked, not a fresh join. + config_file = app_dir / "config.json" + current_config = {} + if config_file.exists(): + try: + with open(config_file, 'r') as f: + current_config = json.load(f) + except Exception as e: + logger.warning(f"Failed to load config for {app_id}: {e}") + + # Update config with new values (excluding timing keys) + current_config.update(data) + + # Write updated config to config.json + try: + with open(config_file, 'w') as f: + json.dump(current_config, f, indent=2) + except Exception as e: + logger.error(f"Failed to save config.json for {app_id}: {e}") + logger.exception("Failed to save Starlark configuration for %r", app_id) + return jsonify({'status': 'error', 'message': 'Failed to save configuration', + 'details': describe_exception(e)}), 500 + + # Also update manifest for backward compatibility + app_data.setdefault('config', {}).update(data) + + if _pkg._write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 + + except Exception as e: + logger.exception("[Starlark] update_starlark_app_config failed") + return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps//toggle', methods=['POST']) +def toggle_starlark_app(app_id): + """Enable or disable a Starlark app.""" + try: + data = request.get_json(silent=True) or {} + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + enabled = data.get('enabled') + if enabled is None: + enabled = not app.is_enabled() + app.manifest['enabled'] = enabled + # Use safe manifest update to prevent race conditions + def update_fn(manifest): + manifest['apps'][app_id]['enabled'] = enabled + starlark_plugin._update_manifest_safe(update_fn) + return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) + + # Standalone: update manifest directly + manifest = _pkg._read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + + enabled = data.get('enabled') + if enabled is None: + enabled = not app_data.get('enabled', True) + app_data['enabled'] = enabled + if _pkg._write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 + + except Exception as e: + logger.exception("[Starlark] toggle_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/apps//render', methods=['POST']) +def render_starlark_app(app_id): + """Force render a Starlark app.""" + try: + app_dir, err = _validate_starlark_app_path(app_id) + if err: + return jsonify({'status': 'error', 'message': err}), 400 + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + success = starlark_plugin._render_app(app, force=True) + if success: + return jsonify({'status': 'success', 'message': 'App rendered', + 'frame_count': len(app.frames) if app.frames else 0}) + return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500 + + # Web-service context: plugin not loaded, call pixlet directly + success, status_code, error = _standalone_render_starlark_app(app_id) + if success: + return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code + return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code + + except Exception as e: + logger.exception("[Starlark] render_starlark_app failed") + return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/repository/browse', methods=['GET']) +def browse_tronbyte_repository(): + """Browse all apps in the Tronbyte repository (bulk cached fetch). + + Returns ALL apps with metadata, categories, and authors. + Filtering/sorting/pagination is handled client-side. + Results are cached server-side for 2 hours. + """ + try: + TronbyteRepository = _pkg._get_tronbyte_repository_class() + + repo = TronbyteRepository(github_token=_starlark_github_token()) + + result = repo.list_all_apps_cached() + + rate_limit = repo.get_rate_limit_info() + + # An upstream failure used to arrive here as an empty app list and go + # out as 'success', so the store drew an empty grid and said nothing. + # 502: the request was fine, GitHub was not. + if result.get('error'): + return jsonify({ + 'status': 'error', + 'message': result['error'], + 'rate_limit': rate_limit, + }), 502 + + return jsonify({ + 'status': 'success', + 'apps': result['apps'], + 'categories': result['categories'], + 'authors': result['authors'], + 'count': result['count'], + 'cached': result['cached'], + 'rate_limit': rate_limit, + }) + + except Exception as e: + logger.exception("[Starlark] browse_tronbyte_repository failed") + return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/repository/install', methods=['POST']) +def install_from_tronbyte_repository(): + """Install an app from the Tronbyte repository.""" + try: + data = request.get_json(silent=True) + if not data or 'app_id' not in data: + return jsonify({'status': 'error', 'message': 'app_id is required'}), 400 + + app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id']) + if app_id_error: + return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400 + + TronbyteRepository = _pkg._get_tronbyte_repository_class() + import tempfile + + repo = TronbyteRepository(github_token=_starlark_github_token()) + + success, metadata, error = repo.get_app_metadata(data['app_id']) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404 + + with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp: + temp_path = tmp.name + + try: + # Pass filename from metadata (e.g., "analog_clock.star" for analogclock app) + # Note: manifest uses 'fileName' (camelCase), not 'filename' + filename = metadata.get('fileName') if metadata else None + success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename) + if not success: + return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500 + + # Download assets (images, sources, etc.) to a temp directory + import tempfile + temp_assets_dir = tempfile.mkdtemp() + try: + success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir)) + # Asset download is non-critical - log warning but continue if it fails + if not success_assets: + logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}") + + render_interval = data.get('render_interval', 300) + ri, err = _validate_timing_value(render_interval, 'render_interval') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + render_interval = ri or 300 + + display_duration = data.get('display_duration', 15) + dd, err = _validate_timing_value(display_duration, 'display_duration') + if err: + return jsonify({'status': 'error', 'message': err}), 400 + display_duration = dd or 15 + + install_metadata = { + 'name': metadata.get('name', app_id) if metadata else app_id, + 'render_interval': render_interval, + 'display_duration': display_duration + } + + starlark_plugin = _pkg._get_starlark_plugin() + if starlark_plugin: + success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) + else: + success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir) + finally: + # Clean up temp assets directory + import shutil + try: + shutil.rmtree(temp_assets_dir) + except OSError: + pass + + if success: + return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id}) + else: + return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500 + finally: + try: + os.unlink(temp_path) + except OSError: + pass + + except Exception as e: + logger.exception("[Starlark] install_from_tronbyte_repository failed") + return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500 +@api_v3.route('/starlark/repository/categories', methods=['GET']) +def get_tronbyte_categories(): + """Get list of available app categories (uses bulk cache).""" + try: + TronbyteRepository = _pkg._get_tronbyte_repository_class() + repo = TronbyteRepository(github_token=_starlark_github_token()) + + result = repo.list_all_apps_cached() + + if result.get('error'): + return jsonify({'status': 'error', 'message': result['error']}), 502 + + return jsonify({'status': 'success', 'categories': result['categories']}) + + except Exception as e: + logger.exception("[Starlark] get_tronbyte_categories failed") + return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/api_v3/system.py b/web_interface/blueprints/api_v3/system.py new file mode 100644 index 000000000..c991cdde7 --- /dev/null +++ b/web_interface/blueprints/api_v3/system.py @@ -0,0 +1,619 @@ +"""Service control, updates, versions and system status. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL, + _describe_git_failure, _get_display_service_status, _git_current_branch, + _git_remote_branch_exists, _git_upstream, _pip_install_requirements, + _scrub_git_remote_url, _truncate_output, _update_check_cache, + _update_check_failed, api_v3, checkout_branch, describe_exception, + get_git_version, jsonify, logger, os, request, resolve_pull_command, + shutil, subprocess, +) +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. + + +@api_v3.route('/system/status', methods=['GET']) +def get_system_status(): + """Get system status""" + try: + # Check cache first (10 second TTL for system status) + try: + from web_interface.cache import get_cached, set_cached + cached_result = get_cached('system_status', ttl_seconds=10) + if cached_result is not None: + return jsonify({'status': 'success', 'data': cached_result}) + except ImportError: + # Cache not available, continue without caching + get_cached = None + set_cached = None + + # Import psutil for system monitoring + try: + import psutil + except ImportError: + # Fallback if psutil not available + return jsonify({ + 'status': 'error', + 'message': 'psutil not available for system monitoring' + }), 503 + + # Get system metrics using psutil + cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness + memory = psutil.virtual_memory() + memory_percent = memory.percent + disk = psutil.disk_usage('/') + disk_percent = disk.percent + + # Calculate uptime + boot_time = psutil.boot_time() + uptime_seconds = _pkg.time.time() - boot_time + uptime_hours = uptime_seconds / 3600 + uptime_days = uptime_hours / 24 + + # Format uptime string + if uptime_days >= 1: + uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h" + elif uptime_hours >= 1: + uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m" + else: + uptime_str = f"{int(uptime_seconds / 60)}m" + + # Get CPU temperature (Raspberry Pi) + cpu_temp = None + try: + temp_file = '/sys/class/thermal/thermal_zone0/temp' + if os.path.exists(temp_file): + with open(temp_file, 'r') as f: + temp_millidegrees = int(f.read().strip()) + cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius + except (IOError, ValueError, OSError): + # Temperature sensor not available or error reading + cpu_temp = None + + # Get display service status + service_status = _get_display_service_status() + + status = { + 'timestamp': _pkg.time.time(), + 'uptime': uptime_str, + 'uptime_seconds': int(uptime_seconds), + 'service_active': service_status.get('active', False), + 'cpu_percent': round(cpu_percent, 1), + 'memory_used_percent': round(memory_percent, 1), + 'memory_total_mb': round(memory.total / (1024 * 1024), 1), + 'memory_used_mb': round(memory.used / (1024 * 1024), 1), + # MemAvailable, not total-minus-used: it accounts for reclaimable + # page cache, so it is what actually predicts memory trouble. A + # board can read 70% "used" and be fine, or read the same and be + # about to fail fork(), and only this number tells them apart. + 'memory_available_mb': round(memory.available / (1024 * 1024), 1), + 'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None, + 'disk_used_percent': round(disk_percent, 1), + 'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1), + 'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1) + } + + # Cache the result if available + if set_cached: + try: + set_cached('system_status', status, ttl_seconds=10) + except Exception: + pass # Cache write failed, but continue + + return jsonify({'status': 'success', 'data': status}) + except Exception as e: + logger.error('Unhandled exception', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 +@api_v3.route('/system/version', methods=['GET']) +def get_system_version(): + """Get LEDMatrix repository version""" + try: + version = get_git_version() + return jsonify({'status': 'success', 'data': {'version': version}}) + except Exception as e: + logger.error("get_system_version failed: %s", e, exc_info=True) + return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500 +@api_v3.route('/system/check-update', methods=['GET']) +def check_for_update(): + """Check whether a newer LEDMatrix commit is available on origin/main.""" + now = _pkg.time.time() + if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL: + return jsonify(_update_check_cache['result']) + + _safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0} + try: + cwd = str(PROJECT_ROOT) + fetch_result = subprocess.run( + ['git', 'fetch', 'origin', 'main', '--quiet'], + capture_output=True, timeout=10, cwd=cwd, + ) + if fetch_result.returncode != 0: + stderr = fetch_result.stderr.decode(errors='replace').strip() + logger.warning("check-update: git fetch failed (rc=%d): %s", + fetch_result.returncode, stderr) + failed = _update_check_failed(_describe_git_failure(stderr)) + _update_check_cache['result'] = failed + _update_check_cache['ts'] = now + return jsonify(failed) + local = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + capture_output=True, text=True, timeout=5, cwd=cwd, + ).stdout.strip() + remote = subprocess.run( + ['git', 'rev-parse', 'origin/main'], + capture_output=True, text=True, timeout=5, cwd=cwd, + ).stdout.strip() + + if not local or not remote: + return jsonify(_safe) + + if local == remote: + result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0} + else: + count_str = subprocess.run( + ['git', 'rev-list', 'HEAD..origin/main', '--count'], + capture_output=True, text=True, timeout=5, cwd=cwd, + ).stdout.strip() + count = int(count_str) if count_str.isdigit() else 0 + result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count} + + _update_check_cache['result'] = result + _update_check_cache['ts'] = now + return jsonify(result) + except Exception as e: + logger.warning("check-update failed: %s", e) + return jsonify(_update_check_failed( + "Could not check for updates; see logs for details.")) +@api_v3.route('/system/action', methods=['POST']) +def execute_system_action(): + """Execute system actions (start/stop/reboot/etc)""" + try: + # HTMX sends data as form data, not JSON + data = request.get_json(silent=True) or {} + if not data: + # Try to get from form data if JSON fails + data = { + 'action': request.form.get('action'), + 'mode': request.form.get('mode') + } + + if not data or 'action' not in data: + return jsonify({'status': 'error', 'message': 'Action required'}), 400 + + action = data['action'] + mode = data.get('mode') # For on-demand modes + + # Map actions to subprocess calls (similar to original implementation) + if action == 'start_display': + if mode: + # For on-demand modes, we would need to integrate with the display controller + # For now, just start the display service + try: + result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + except subprocess.TimeoutExpired as e: + logger.error("start_display (%s) timed out: %s", mode, e) + return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'}) + logger.info("start_display (%s) returned code %d", mode, result.returncode) + if result.returncode != 0 and result.stderr: + logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip()) + resp = { + 'status': 'success' if result.returncode == 0 else 'error', + 'message': 'Display started' if result.returncode == 0 else 'Failed to start display', + } + if result.returncode != 0: + resp['returncode'] = result.returncode + resp['stderr'] = result.stderr.strip() + return jsonify(resp) + else: + result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + elif action == 'stop_display': + result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + elif action == 'enable_autostart': + result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + elif action == 'disable_autostart': + result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + elif action == 'reboot_system': + result = subprocess.run(['sudo', 'reboot'], + capture_output=True, text=True, timeout=10) + elif action == 'shutdown_system': + result = subprocess.run(['sudo', 'poweroff'], + capture_output=True, text=True, timeout=10) + elif action == 'git_pull': + # Use PROJECT_ROOT instead of hardcoded path + project_dir = str(PROJECT_ROOT) + + # Decide how to pull BEFORE stashing. If this checkout cannot be + # updated at all, stashing first would put the user's local changes + # away for an update that was never going to run. + pull_args, upstream_note, pull_error = resolve_pull_command(project_dir) + if pull_error: + logger.warning("git pull not attempted: %s", pull_error) + return jsonify({'status': 'error', 'message': pull_error}) + + # Check if there are local changes that need to be stashed + # Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project + # Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins) + try: + status_result = subprocess.run( + ['git', 'status', '--porcelain', '--untracked-files=no'], + capture_output=True, + text=True, + timeout=30, + cwd=project_dir + ) + # Filter out any changes in plugins directory - plugins are separate repositories + # Git status format: XY filename (where X is status of index, Y is status of work tree) + status_lines = [line for line in status_result.stdout.strip().split('\n') + if line.strip() and 'plugins/' not in line] + has_changes = bool('\n'.join(status_lines).strip()) + except subprocess.TimeoutExpired: + # If status check times out, assume there might be changes and proceed + # This is safer than failing the update + has_changes = True + status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})() + + stash_info = "" + + # Stash local changes if they exist (excluding plugins) + # Plugins are separate repositories and shouldn't be stashed with base project updates + if has_changes: + try: + # Use pathspec to exclude plugins directory from stash + stash_result = subprocess.run( + ['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'], + capture_output=True, + text=True, + timeout=30, + cwd=project_dir + ) + if stash_result.returncode == 0: + logger.debug("git stash: stashed local changes before pull") + stash_info = " Local changes were stashed." + else: + logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode) + except subprocess.TimeoutExpired: + logger.warning("git stash timed out, proceeding with pull") + + # Record HEAD before the pull so dependency changes can be detected + old_head = None + try: + _pre = subprocess.run(['git', 'rev-parse', 'HEAD'], + capture_output=True, text=True, timeout=10, cwd=project_dir) + if _pre.returncode == 0: + old_head = _pre.stdout.strip() + except subprocess.TimeoutExpired: + logger.warning("git rev-parse timed out before pull") + + # Whether the pull actually brought new code in. "Already up to + # date" is a success too, and prompting for a restart then would + # train users to ignore the prompt. + code_changed = False + + # Perform the git pull. Branches without an upstream were given + # an explicit "origin " above so the update still works. + result = subprocess.run( + pull_args, + capture_output=True, + text=True, + timeout=60, + cwd=project_dir + ) + + # Give the branch tracking information so the next pull is a plain + # `git pull` — otherwise every update repeats the fallback. + if result.returncode == 0 and upstream_note: + branch = _git_current_branch(project_dir) + if branch: + try: + subprocess.run( + ['git', 'branch', f'--set-upstream-to=origin/{branch}', branch], + capture_output=True, text=True, timeout=10, cwd=project_dir) + except (subprocess.TimeoutExpired, OSError) as exc: + logger.debug("could not set upstream for %s: %s", branch, exc) + + # Return custom response for git_pull + if result.returncode == 0: + pull_message = "Code updated successfully." + if has_changes: + pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" + if result.stdout and "Already up to date" not in result.stdout: + pull_message = f"Code updated successfully.{stash_info}" + if upstream_note: + pull_message = f"{pull_message} {upstream_note}" + + # Keep Python dependencies in sync automatically: if the pull + # changed a requirements file, install it now — users updating + # from the web UI (most of them) never SSH in to pip install. + # Installs go through the same root-visible path as the + # Tools-tab buttons (_pip_install_requirements). + dep_notes = [] + try: + _post = subprocess.run(['git', 'rev-parse', 'HEAD'], + capture_output=True, text=True, timeout=10, cwd=project_dir) + new_head = _post.stdout.strip() if _post.returncode == 0 else None + if old_head and new_head and old_head != new_head: + code_changed = True + diff = subprocess.run( + ['git', 'diff', '--name-only', f'{old_head}..{new_head}'], + capture_output=True, text=True, timeout=15, cwd=project_dir) + changed = set(diff.stdout.split()) if diff.returncode == 0 else set() + for rel in ('requirements.txt', 'web_interface/requirements.txt'): + req_path = PROJECT_ROOT / rel + if rel not in changed or not req_path.exists(): + continue + # Each file's install is isolated: a timeout or + # OSError (e.g. the sudo wrapper/interpreter + # missing) on one file must not abort the other. + try: + r = _pip_install_requirements(req_path, timeout=180) + if r.returncode == 0: + dep_notes.append(f"Dependencies from {rel} updated.") + else: + dep_notes.append( + f"Dependency install from {rel} failed — " + "run Install Base Requirements from the Tools tab.") + logger.warning("post-update pip install failed for %s: %s", + rel, _truncate_output(r.stdout, r.stderr)) + except subprocess.TimeoutExpired: + dep_notes.append( + f"Dependency install from {rel} timed out — " + "run Install Base Requirements from the Tools tab.") + logger.warning("post-update pip install timed out for %s", rel) + except OSError as install_err: + dep_notes.append( + f"Dependency install from {rel} failed — " + "run Install Base Requirements from the Tools tab.") + logger.warning("post-update pip install errored for %s: %s", + rel, install_err) + except subprocess.TimeoutExpired: + logger.warning("post-update dependency sync timed out") + if dep_notes: + pull_message += " " + " ".join(dep_notes) + # A `git pull` restores built-in plugins (committed under + # plugin-repos/) even if the user uninstalled them. Re-remove + # any the user previously uninstalled so the update doesn't + # resurrect them. + if api_v3.plugin_store_manager: + try: + purged = api_v3.plugin_store_manager.purge_uninstalled_plugins() + if purged: + logger.info( + "Re-removed %d uninstalled plugin(s) restored by update: %s", + len(purged), ", ".join(purged), + ) + except (OSError, RuntimeError) as purge_err: + logger.warning("Post-update plugin purge failed: %s", purge_err) + else: + logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr) + # Show git's own first line: "check logs" leaves the user with + # nothing to act on, and these failures are usually actionable + # (conflicting local commits, no upstream, network). + detail = next((ln.strip() for ln in (result.stderr or '').splitlines() + if ln.strip()), '') + pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details" + + # Nothing here restarts anything: the pull replaces files on + # disk while the display and web services keep running the code + # they loaded at boot. Without this the user is told the update + # succeeded and sees no change until they happen to reboot. + return jsonify({ + 'status': 'success' if result.returncode == 0 else 'error', + 'message': pull_message, + 'restart_required': bool(result.returncode == 0 and code_changed), + }) + elif action == 'checkout_branch': + # Switch branches from the Tools tab. Needed because a checkout + # that predates tracking (or a restored backup) can leave the pi + # on a branch the update button cannot pull. + result_payload, http_status = checkout_branch( + str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash'))) + return jsonify(result_payload), http_status + + elif action == 'restart_display_service': + result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'], + capture_output=True, text=True, timeout=10) + elif action == 'restart_web_service': + # Try to restart the web service (assuming it's ledmatrix-web.service) + result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'], + capture_output=True, text=True, timeout=10) + elif action == 'install_base_requirements': + # Base + web interface requirements: flask-compress and friends + # live in web_interface/requirements.txt, not the root file. + req_files = [f for f in (PROJECT_ROOT / 'requirements.txt', + PROJECT_ROOT / 'web_interface' / 'requirements.txt') + if f.exists()] + if not req_files: + return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'}) + outputs = [] + all_ok = True + for req_file in req_files: + label = req_file.relative_to(PROJECT_ROOT) + # Isolate each file's install: a timeout or OSError on one + # (e.g. requirements.txt) must not abort the rest of the + # loop (e.g. web_interface/requirements.txt never attempted). + try: + result = _pip_install_requirements(req_file, timeout=120) + all_ok = all_ok and result.returncode == 0 + outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr)) + except subprocess.TimeoutExpired: + all_ok = False + outputs.append(f"== {label} ==\nTimed out after 120s") + logger.warning("install_base_requirements timed out for %s", label) + except OSError as install_err: + all_ok = False + outputs.append(f"== {label} ==\nFailed: {install_err}") + logger.warning("install_base_requirements errored for %s: %s", label, install_err) + return jsonify({ + 'status': 'success' if all_ok else 'error', + 'message': 'Base requirements installed successfully' if all_ok else 'pip install failed', + 'output': "\n".join(outputs) + }) + elif action == 'install_plugin_requirements': + active_pm = getattr(api_v3, 'plugin_manager', None) + if active_pm: + plugins_dir = Path(active_pm.plugins_dir) + else: + _cm = getattr(api_v3, 'config_manager', None) + _cfg = _cm.load_config() if _cm else {} + _dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos') + plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name + results = [] + if plugins_dir.exists(): + for p in sorted(plugins_dir.iterdir()): + req = p / 'requirements.txt' + if p.is_dir() and req.exists(): + try: + r = _pip_install_requirements(req, timeout=60) + results.append({ + 'plugin': p.name, + 'ok': r.returncode == 0, + 'output': _truncate_output(r.stdout, r.stderr) + }) + except subprocess.TimeoutExpired: + results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'}) + except OSError as exc: + results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'}) + ok_count = sum(1 for r in results if r['ok']) + all_ok = all(r['ok'] for r in results) if results else True + return jsonify({ + 'status': 'success' if all_ok else 'error', + 'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found', + 'details': results + }) + elif action == 'force_git_reset': + if not _GIT: + return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 + project_dir = str(PROJECT_ROOT) + fetch = subprocess.run( + [_GIT, 'fetch', 'origin'], + capture_output=True, text=True, timeout=30, cwd=project_dir + ) + if fetch.returncode != 0: + return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()}) + reset = subprocess.run( + [_GIT, 'reset', '--hard', 'origin/main'], + capture_output=True, text=True, timeout=30, cwd=project_dir + ) + return jsonify({ + 'status': 'success' if reset.returncode == 0 else 'error', + 'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed', + 'output': (reset.stdout + reset.stderr).strip() + }) + elif action == 'clear_pycache': + cleared = 0 + failed = 0 + for d in PROJECT_ROOT.rglob('__pycache__'): + if d.is_dir(): + try: + shutil.rmtree(d) + cleared += 1 + except OSError: + failed += 1 + msg = f'Cleared {cleared} __pycache__ directories' + if failed: + msg += f' ({failed} could not be removed)' + return jsonify({'status': 'success', 'message': msg}) + else: + return jsonify({'status': 'error', 'message': 'Unknown action'}), 400 + + logger.info("system action '%s' returncode=%d", action, result.returncode) + if result.returncode != 0 and result.stderr: + logger.error("system action '%s' stderr: %s", action, result.stderr.strip()) + resp = { + 'status': 'success' if result.returncode == 0 else 'error', + 'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details', + } + if result.returncode != 0: + resp['returncode'] = result.returncode + resp['stderr'] = result.stderr.strip() + return jsonify(resp) + + except subprocess.TimeoutExpired as e: + logger.error("system action '%s' timed out: %s", action, e) + return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'}) + except Exception as e: + logger.error("execute_system_action failed: %s", e, exc_info=True) + return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500 +@api_v3.route('/system/git-info', methods=['GET']) +def get_git_info(): + """Return branch, dirty state, recent commits and remote URL for the Tools tab.""" + if not _GIT: + return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 + d = str(PROJECT_ROOT) + try: + branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d) + if branch.returncode != 0: + return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500 + + status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d) + if status.returncode != 0: + return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500 + + log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d) + remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d) + branch_name = branch.stdout.strip() + upstream = _git_upstream(d) + return jsonify({ + 'branch': branch_name, + 'dirty': bool(status.stdout.strip()), + 'status': status.stdout.strip(), + 'recent_commits': log.stdout.strip() if log.returncode == 0 else '', + 'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '', + # Surfaced so the Tools tab can warn before the user clicks Pull + # Latest, rather than after it fails. + 'upstream': upstream, + 'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name), + }) + except Exception as e: + logger.error("get_git_info failed: %s", e, exc_info=True) + return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500 +@api_v3.route('/system/git-branches', methods=['GET']) +def get_git_branches(): + """List branches available to switch to, for the Tools tab picker.""" + if not _GIT: + return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503 + d = str(PROJECT_ROOT) + try: + # Refresh remote refs so a branch created since the last fetch shows up. + subprocess.run([_GIT, 'fetch', 'origin', '--prune'], + capture_output=True, text=True, timeout=60, cwd=d) + + local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'], + capture_output=True, text=True, timeout=15, cwd=d) + remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'], + capture_output=True, text=True, timeout=15, cwd=d) + if local.returncode != 0: + return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500 + + local_names = [b for b in local.stdout.split() if b] + remote_names = [] + for ref in remote.stdout.split() if remote.returncode == 0 else []: + name = ref.split('origin/', 1)[-1] + # origin/HEAD is a symbolic alias, not a branch a user can pick. + if name and name != 'HEAD' and name not in local_names: + remote_names.append(name) + + return jsonify({ + 'status': 'success', + 'current': _git_current_branch(d), + 'upstream': _git_upstream(d), + 'local': sorted(local_names), + 'remote_only': sorted(remote_names), + }) + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504 + except OSError as e: + logger.error("get_git_branches failed: %s", e, exc_info=True) + return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500 diff --git a/web_interface/blueprints/api_v3/wifi.py b/web_interface/blueprints/api_v3/wifi.py new file mode 100644 index 000000000..a3b9c013d --- /dev/null +++ b/web_interface/blueprints/api_v3/wifi.py @@ -0,0 +1,361 @@ +"""Wi-Fi scanning, connection and status routes. + +Routes decorate the shared `api_v3` Blueprint from ._common, so their +endpoint names are unchanged by living here. +""" +from web_interface.blueprints.api_v3 import ( + api_v3, describe_exception, jsonify, logger, request, +) + + +# WiFi Management Endpoints +@api_v3.route('/wifi/status', methods=['GET']) +def get_wifi_status(): + """Get current WiFi connection status""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + status = wifi_manager.get_wifi_status() + + # Get auto-enable setting from config + auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period) + + return jsonify({ + 'status': 'success', + 'data': { + 'connected': status.connected, + 'ssid': status.ssid, + 'ip_address': status.ip_address, + 'signal': status.signal, + 'ap_mode_active': status.ap_mode_active, + 'auto_enable_ap_mode': auto_enable_ap + } + }) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/scan', methods=['GET']) +def scan_wifi_networks(): + """Scan for available WiFi networks + + If AP mode is active, it will be temporarily disabled during scanning + and automatically re-enabled afterward. Users connected to the AP will + be briefly disconnected during this process. + """ + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + + # Check if AP mode is active before scanning (for user notification) + ap_was_active = wifi_manager._is_ap_mode_active() + + # Perform the scan (this will handle AP mode disabling/enabling internally) + networks, _was_cached = wifi_manager.scan_networks() + + # Convert to dict format + networks_data = [ + { + 'ssid': net.ssid, + 'signal': net.signal, + 'security': net.security, + 'frequency': net.frequency + } + for net in networks + ] + + response_data = { + 'status': 'success', + 'data': networks_data + } + + # Inform user if AP mode was temporarily disabled + if ap_was_active: + response_data['message'] = ( + f'Found {len(networks_data)} networks. ' + 'Note: AP mode was temporarily disabled during scanning and has been re-enabled. ' + 'If you were connected to the setup network, you may need to reconnect.' + ) + + return jsonify(response_data) + except Exception as e: + logger.error("Error scanning WiFi networks", exc_info=True) + error_message = 'An error occurred while scanning WiFi networks; see logs for details' + + # Provide more specific error messages for common issues + error_str = str(e).lower() + if 'permission' in error_str or 'sudo' in error_str: + error_message = ( + 'Permission error while scanning. ' + 'The WiFi scan requires appropriate permissions. ' + 'Please ensure the application has necessary privileges.' + ) + elif 'timeout' in error_str: + error_message = ( + 'WiFi scan timed out. ' + 'The scan took too long to complete. ' + 'This may happen if the WiFi interface is busy or in use.' + ) + elif 'no wifi' in error_str or 'not available' in error_str: + error_message = ( + 'WiFi scanning tools are not available. ' + 'Please ensure NetworkManager (nmcli) or iwlist is installed.' + ) + + return jsonify({ + 'status': 'error', + 'message': error_message + }), 500 +@api_v3.route('/wifi/connect', methods=['POST']) +def connect_wifi(): + """Connect to a WiFi network""" + try: + from src.wifi_manager import WiFiManager + + data = request.get_json(silent=True) + if not data: + return jsonify({ + 'status': 'error', + 'message': 'Request body is required' + }), 400 + + if 'ssid' not in data: + return jsonify({ + 'status': 'error', + 'message': 'SSID is required' + }), 400 + + ssid = data['ssid'] + if not ssid or not ssid.strip(): + return jsonify({ + 'status': 'error', + 'message': 'SSID cannot be empty' + }), 400 + + ssid = ssid.strip() + password = data.get('password', '') or '' + + wifi_manager = WiFiManager() + success, message = wifi_manager.connect_to_network(ssid, password) + + if success: + return jsonify({ + 'status': 'success', + 'message': message + }) + else: + return jsonify({ + 'status': 'error', + 'message': message or 'Failed to connect to network' + }), 400 + except Exception as e: + logger.error("Error connecting to WiFi", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/disconnect', methods=['POST']) +def disconnect_wifi(): + """Disconnect from the current WiFi network""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + success, message = wifi_manager.disconnect_from_network() + + if success: + return jsonify({ + 'status': 'success', + 'message': message + }) + else: + return jsonify({ + 'status': 'error', + 'message': message or 'Failed to disconnect from network' + }), 400 + except Exception as e: + logger.error("Error disconnecting from WiFi", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/ap/enable', methods=['POST']) +def enable_ap_mode(): + """Enable access point mode""" + try: + 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')) + success, message = wifi_manager.enable_ap_mode(force=force) + + if success: + return jsonify({ + 'status': 'success', + 'message': message + }) + else: + return jsonify({ + 'status': 'error', + 'message': message + }), 400 + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/ap/disable', methods=['POST']) +def disable_ap_mode(): + """Disable access point mode""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + success, message = wifi_manager.disable_ap_mode() + + if success: + return jsonify({ + 'status': 'success', + 'message': message + }) + else: + return jsonify({ + 'status': 'error', + 'message': message + }), 400 + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/ap/auto-enable', methods=['GET']) +def get_auto_enable_ap_mode(): + """Get auto-enable AP mode setting""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period) + + return jsonify({ + 'status': 'success', + 'data': { + 'auto_enable_ap_mode': auto_enable + } + }) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/ap/auto-enable', methods=['POST']) +def set_auto_enable_ap_mode(): + """Set auto-enable AP mode setting""" + try: + from src.wifi_manager import WiFiManager + + data = request.get_json(silent=True) + if data is None or 'auto_enable_ap_mode' not in data: + return jsonify({ + 'status': 'error', + 'message': 'auto_enable_ap_mode is required' + }), 400 + + auto_enable = bool(data['auto_enable_ap_mode']) + + wifi_manager = WiFiManager() + wifi_manager.config["auto_enable_ap_mode"] = auto_enable + wifi_manager._save_config() + + return jsonify({ + 'status': 'success', + 'message': f'Auto-enable AP mode set to {auto_enable}', + 'data': { + 'auto_enable_ap_mode': auto_enable + } + }) + except Exception as e: + logger.error("%s failed", request.path, exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', + 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/radio', methods=['GET']) +def get_wifi_radio(): + """Get current WiFi radio state (enabled/disabled) and wired-fallback status.""" + try: + from src.wifi_manager import WiFiManager + + wifi_manager = WiFiManager() + state = wifi_manager.get_wifi_radio_state() + + return jsonify({ + 'status': 'success', + 'data': state + }) + except Exception as e: + logger.error("Error getting WiFi radio state", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 +@api_v3.route('/wifi/radio', methods=['POST']) +def set_wifi_radio(): + """Turn the WiFi radio on or off. + + Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused + unless Ethernet is connected or force=True, to avoid locking the user out + of this web interface. + """ + try: + from src.wifi_manager import WiFiManager + + data = request.get_json(silent=True) or {} + if 'enabled' not in data: + return jsonify({ + 'status': 'error', + 'message': 'enabled is required' + }), 400 + + # Parse defensively: bool("false") is True, so mirror the string-aware + # coercion used for `force` — the endpoint is a public contract, not just + # the shipped UI (which always sends real JSON booleans). + _enabled_raw = data['enabled'] + enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes')) + _force_raw = data.get('force', False) + force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes')) + + wifi_manager = WiFiManager() + success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force) + + if success: + return jsonify({ + 'status': 'success', + 'message': message, + 'data': wifi_manager.get_wifi_radio_state() + }) + else: + return jsonify({ + 'status': 'error', + 'message': message, + 'reason': reason + }), 400 + except Exception as e: + logger.error("Error setting WiFi radio state", exc_info=True) + return jsonify({ + 'status': 'error', + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) + }), 500 From 4e91cf1fef1af3b7110256a68de956af38978784 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 02:40:46 +0000 Subject: [PATCH 2/2] fix(api-v3): address CodeRabbit findings from the blueprint-split review Fixes to the api_v3 package split (PR #553), one per finding verified against the actual code: - __init__.py: _redact_credentials only blanked scalar values under a credential-named key; a bare list of secrets under such a key (e.g. tokens: ["a", "b"]) passed through untouched, since the list branch recursed with no memory that its key looked like a credential. Nested dicts still walk normally (a documented, tested behaviour -- a container like secrets: {api_key: ..., note: ...} is a section name, not a value to blank outright), but any value reached under a credential-shaped key is now actually blanked. - __init__.py: the OAuth helper script's raw stderr/stdout went to logger.error unredacted (CWE-532) right next to a comment claiming this was deliberate; the HTTP response already used the existing redact_text helper. Routed the log line through the same helper. - __init__.py / starlark.py: the standalone Starlark manifest fallback (used when the plugin instance isn't loaded) read-modified-wrote manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py), which already holds an flock for the same file when the plugin is loaded. Added _starlark_manifest_lock, mirroring that pattern, and wrapped every standalone read-modify-write call site in it. The app-config update route also wrote config.json and the manifest as two separate, non-transactional writes (a second, distinct finding at the same call site); config.json is now rolled back if the manifest write that follows it fails. - backup.py: restore options used bare bool() on values from the request, so {"restore_secrets": "false"} restored secrets anyway (bool("false") is True). Switched to the existing _coerce_to_bool helper already used for this exact purpose elsewhere in the package. - config.py: an automated import-rewrite mangled four user-facing validation strings and their neighbouring comments -- "Invalid start time" had become "Invalid start _pkg.time" (and likewise for "end time") in both the schedule and dim-schedule per-day validation paths. - display.py: `import _pkg.time as time_module` -- _pkg is a local alias for the package, not a real importable module, so this raised ModuleNotFoundError whenever a caller restarted an already-running display service via /display/on-demand/start, after the on-demand request was already written to cache. Fixed to `import time`. Audited the rest of the package for the same `_pkg.` import mistake; every other `_pkg.` reference is a legitimate attribute read-through (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken import statement. - fonts.py: validate_file_upload's max_size_mb parameter is silently unused by that helper (it only checks filename/extension) -- the font upload route saved arbitrarily large files as a result. Added the same seek-and-check pattern already used for the sibling .star upload. - wifi.py: two ad hoc, inconsistent bool coercions. POST /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled it. POST /wifi/radio's enabled/force parsing recognized real bool and some strings but not int 1/0 (1 is True is False in Python). Factored one small _parse_bool_ish helper local to this file and used it at all three sites. Not changed: the "unknown/misspelled restore option keys default to True" half of the backup.py finding -- the file's own comment documents that a missing key deliberately means "restore everything," matching the already-existing JSON-parse-failure guard a few lines above it; only the bool-coercion defect was a real bug. Added or extended regression tests for every fix, following each area's existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed on both this branch and origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change) -- no new failures. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5 --- test/test_api_v3_font_upload_size.py | 60 ++++++++ test/test_api_v3_on_demand_restart.py | 93 +++++++++++++ test/test_api_v3_schedule_error_messages.py | 54 ++++++++ test/test_api_v3_wifi_endpoints.py | 55 ++++++++ test/test_config_main_redacts_secrets.py | 37 +++++ .../test_api_v3_backup_restore.py | 35 +++++ .../test_calendar_oauth_endpoints.py | 16 +++ .../test_starlark_pixlet_routes.py | 126 +++++++++++++++++ web_interface/blueprints/api_v3/__init__.py | 105 ++++++++++---- web_interface/blueprints/api_v3/backup.py | 18 ++- web_interface/blueprints/api_v3/config.py | 16 +-- web_interface/blueprints/api_v3/display.py | 2 +- web_interface/blueprints/api_v3/fonts.py | 19 ++- web_interface/blueprints/api_v3/starlark.py | 130 ++++++++++-------- web_interface/blueprints/api_v3/wifi.py | 45 ++++-- 15 files changed, 701 insertions(+), 110 deletions(-) create mode 100644 test/test_api_v3_font_upload_size.py create mode 100644 test/test_api_v3_on_demand_restart.py create mode 100644 test/test_api_v3_schedule_error_messages.py diff --git a/test/test_api_v3_font_upload_size.py b/test/test_api_v3_font_upload_size.py new file mode 100644 index 000000000..cd1b731de --- /dev/null +++ b/test/test_api_v3_font_upload_size.py @@ -0,0 +1,60 @@ +"""Regression test: POST /fonts/upload must enforce its own stated size limit. + +validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads +like it checks the upload's size, but it only ever validated the filename +(traversal characters, extension) -- max_size_mb was accepted and silently +ignored. Nothing else in the handler checked the actual upload size either, +so it saved whatever was posted to assets/fonts/ regardless of +size, unlike the sibling .star and plugin-asset upload routes, which check +`file.tell()` against a stated limit before saving. +""" + +import io +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + +URL = "/api/v3/fonts/upload" +TEN_MB = 10 * 1024 * 1024 + + +@pytest.fixture +def fonts_root(tmp_path): + # PROJECT_ROOT is bound by value in fonts.py, so it is patched there. + with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path): + yield tmp_path + + +def upload(client, content, filename="myfont.ttf", family="myfont"): + data = { + "font_file": (io.BytesIO(content), filename), + "font_family": family, + } + return client.post(URL, data=data, content_type="multipart/form-data") + + +class TestFontUploadSizeLimit: + def test_oversized_font_is_rejected(self, api_v3_client, fonts_root): + response = upload(api_v3_client, b"x" * (TEN_MB + 1)) + assert response.status_code == 400 + assert "too large" in response.get_json()["message"].lower() + assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), ( + "an oversized font was saved to disk before being rejected") + + def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root): + response = upload(api_v3_client, b"x" * TEN_MB, + filename="atlimit.ttf", family="atlimit") + assert response.status_code == 200, response.get_json() + assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists() + + def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root): + response = upload(api_v3_client, b"fake font bytes", + filename="small.ttf", family="small") + assert response.status_code == 200, response.get_json() + assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes" diff --git a/test/test_api_v3_on_demand_restart.py b/test/test_api_v3_on_demand_restart.py new file mode 100644 index 000000000..c203fbe4b --- /dev/null +++ b/test/test_api_v3_on_demand_restart.py @@ -0,0 +1,93 @@ +"""Regression test: POST /display/on-demand/start restarting a running +service must not import a name that does not exist. + +display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads +mutable, test-patched attributes back through it (`_pkg.time.time()`, +`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per +the package's own docstring. One spot went further and wrote a genuine +`import` *statement* against that alias -- + + import _pkg.time as time_module + +-- but `_pkg` is a local name bound by `import ... as _pkg` in this module, +not a real top-level package, so `import _pkg.time` is not something Python +can resolve; it raises ModuleNotFoundError. That line only runs when the +display service is already running and the caller also asked to (re)start +it, so this endpoint failed on exactly the restart path -- the one where a +cache write recording the new on-demand request had already happened. + +The route wraps its body in `except Exception`, so the failure reached the +caller as a handled 500 with a generic message, not an unhandled crash -- +but a 500 all the same on a request that should have restarted the service +and reported success. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + +URL = "/api/v3/display/on-demand/start" + + +@pytest.fixture +def restart_path(api_v3_module): + """Force the `service_was_running and start_service` branch. + + plugin_manager and config_manager are set to None so the route takes + the simplest path to that branch rather than tripping over unrelated + MagicMock plumbing; _ensure_cache_manager, _get_display_service_status, + _stop_display_service and _ensure_display_service_running are bound by + value in display.py (see its own docstring), so they are patched on + that submodule rather than on the package. + """ + api_v3_module.api_v3.plugin_manager = None + api_v3_module.api_v3.config_manager = None + + with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \ + patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \ + patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \ + patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running: + ensure_cache.return_value = MagicMock() + # Active before the request: service_was_running becomes True. + get_status.return_value = {"active": True} + ensure_running.return_value = {"active": True} + yield { + "ensure_cache": ensure_cache, + "get_status": get_status, + "stop_service": stop_service, + "ensure_running": ensure_running, + } + + +class TestRestartingARunningService: + def test_it_does_not_500(self, api_v3_client, restart_path): + response = api_v3_client.post( + URL, json={"plugin_id": "weather", "start_service": True}) + body = response.get_json() + assert response.status_code == 200, body + assert body["status"] == "success", body + + def test_the_service_is_actually_stopped_and_restarted( + self, api_v3_client, restart_path): + api_v3_client.post( + URL, json={"plugin_id": "weather", "start_service": True}) + restart_path["stop_service"].assert_called_once() + restart_path["ensure_running"].assert_called_once() + + def test_a_service_that_was_not_running_is_not_stopped_first( + self, api_v3_client, restart_path): + # The buggy import sits inside `if service_was_running and + # start_service`, so it only ever fired on the restart path -- + # this is the other side of that branch, unaffected either way, + # kept here so the branch condition itself stays covered. + restart_path["get_status"].return_value = {"active": False} + response = api_v3_client.post( + URL, json={"plugin_id": "weather", "start_service": True}) + assert response.status_code == 200, response.get_json() + restart_path["stop_service"].assert_not_called() diff --git a/test/test_api_v3_schedule_error_messages.py b/test/test_api_v3_schedule_error_messages.py new file mode 100644 index 000000000..383ec1721 --- /dev/null +++ b/test/test_api_v3_schedule_error_messages.py @@ -0,0 +1,54 @@ +"""Regression test: per-day schedule validation errors must say "time", not +"_pkg.time". + +The split into a package rewrote every bare `time` reference that needed to +read through the shared module as `_pkg.time` (see the package's own +docstring on why -- tests patch it, so it has to be read back live rather +than bound by value). That rewrite was mechanical and matched the substring +"time" inside string literals and comments too, so the user-facing message + + "Invalid start time for {day}: ..." + +came out as + + "Invalid start _pkg.time for {day}: ..." + +in both POST /config/schedule and POST /config/dim-schedule, for both the +start and end time of a per-day entry. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + + +@pytest.mark.parametrize("url", [ + "/api/v3/config/schedule", + "/api/v3/config/dim-schedule", +]) +class TestPerDayTimeErrorsAreNotCorrupted: + def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url): + response = api_v3_client.post(url, json={ + "mode": "per_day", + "monday_start": "not-a-time", + }) + assert response.status_code == 400 + message = response.get_json()["message"] + assert "_pkg" not in message, message + assert message.startswith("Invalid start time for monday:"), message + + def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url): + response = api_v3_client.post(url, json={ + "mode": "per_day", + "monday_start": "07:00", + "monday_end": "not-a-time", + }) + assert response.status_code == 400 + message = response.get_json()["message"] + assert "_pkg" not in message, message + assert message.startswith("Invalid end time for monday:"), message diff --git a/test/test_api_v3_wifi_endpoints.py b/test/test_api_v3_wifi_endpoints.py index 0c713ae22..0b4a81eac 100644 --- a/test/test_api_v3_wifi_endpoints.py +++ b/test/test_api_v3_wifi_endpoints.py @@ -229,6 +229,61 @@ def test_exception_is_a_500(self, api_v3_client, wifi_manager): assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500 +class TestAutoEnableApMode: + URL = "/api/v3/wifi/ap/auto-enable" + + def test_requires_the_field(self, api_v3_client, wifi_manager): + response = api_v3_client.post(self.URL, json={}) + assert response.status_code == 400 + + @pytest.mark.parametrize("raw,expected", [ + (True, True), (False, False), + ("true", True), ("True", True), ("1", True), ("yes", True), + ("false", False), ("False", False), ("0", False), ("no", False), + (1, True), (0, False), + ]) + def test_value_is_coerced_not_just_truthy( + self, api_v3_client, wifi_manager, raw, expected): + # Regression: bool(data['auto_enable_ap_mode']) meant a caller who + # sent the JSON string "false" got it stored as True — bool("false") + # is True, since any non-empty string is truthy. + wifi_manager.config = {} + response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw}) + assert response.status_code == 200, response.get_json() + assert response.get_json()["data"]["auto_enable_ap_mode"] is expected + assert wifi_manager.config["auto_enable_ap_mode"] is expected + + def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager): + # The exact shape of the bug. + wifi_manager.config = {} + api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"}) + assert wifi_manager.config["auto_enable_ap_mode"] is False + + +class TestRadioEnabledAndForceAcceptIntegers: + """`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old + coercion was `raw is True or (isinstance(raw, str) and ...)`, and + `1 is True` is False in Python -- an int is never the `True` singleton + even though it equals it -- so a plain integer fell through to False + regardless of its value. + """ + URL = "/api/v3/wifi/radio" + + @pytest.mark.parametrize("raw,expected", [(1, True), (0, False)]) + def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected): + wifi_manager.set_wifi_radio.return_value = (True, "ok", None) + wifi_manager.get_wifi_radio_state.return_value = {} + api_v3_client.post(self.URL, json={"enabled": raw}) + wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False) + + @pytest.mark.parametrize("raw,expected", [(1, True), (0, False)]) + def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected): + wifi_manager.set_wifi_radio.return_value = (True, "ok", None) + wifi_manager.get_wifi_radio_state.return_value = {} + api_v3_client.post(self.URL, json={"enabled": True, "force": raw}) + wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected) + + class TestNoRealNetworking: def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client): # Guard against a future refactor moving the import to module level, diff --git a/test/test_config_main_redacts_secrets.py b/test/test_config_main_redacts_secrets.py index c33ab9627..191f8d202 100644 --- a/test/test_config_main_redacts_secrets.py +++ b/test/test_config_main_redacts_secrets.py @@ -103,6 +103,43 @@ def test_non_dict_input_passes_through(): assert _redact_credentials(None) is None +def test_a_list_of_bare_credentials_is_redacted(): + """A credential-named key whose value is a list of scalars, not a list + of named-field dicts, used to pass through untouched: the dict branch + recursed into `v` on its own value only, so a bare string list item had + no field name to test and fell through the base case unredacted. + """ + config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"} + out = _redact_credentials(config) + assert out["tokens"] == ["", ""] + assert out["timezone"] == "America/New_York" + + +def test_a_list_nested_inside_a_credential_named_container_is_also_blanked(): + """The list fix applies at any depth under a credential-named key, not + only when the list is the key's direct value. + """ + config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}} + out = _redact_credentials(config) + assert out["auth"]["backup_tokens"] == ["", ""] + assert out["auth"]["note"] == "keep" + + +def test_credential_shaped_container_with_named_fields_still_only_blanks_those(): + """Unchanged behaviour for the case the container test above already + covers: a dict whose sub-keys are semantically named fields is walked + normally, not blanket-blanked, so an ordinary field next to a + credential-named one survives. + """ + config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}} + out = _redact_credentials(config) + assert out["secrets"]["api_key"] == "" + assert out["secrets"]["note"] == "keep" + # "list" isn't itself credential-named, so it is walked, not blanked -- + # but it holds no credential-named field either, so it survives whole. + assert out["secrets"]["list"] == ["a", "b"] + + def test_the_endpoint_itself_redacts(): """Through the view function, not the helper. diff --git a/test/web_interface/test_api_v3_backup_restore.py b/test/web_interface/test_api_v3_backup_restore.py index 1ea8d4cc1..52758fbaa 100644 --- a/test/web_interface/test_api_v3_backup_restore.py +++ b/test/web_interface/test_api_v3_backup_restore.py @@ -140,6 +140,41 @@ def test_empty_object_is_accepted_as_all_defaults(self, client, restore): assert restore.call_args[0][2].restore_config is True +class TestOptionsAreBooleanAware: + """Regression: bool("false") is True in Python. + + Every restore flag used bare bool() coercion, so a caller that sends its + options as JSON strings rather than real booleans -- a form field, a + hand-built request -- had `{"restore_secrets": "false"}` restore secrets + anyway, the opposite of what was asked. Fixed with the same + string-aware `_coerce_to_bool` already used for checkbox-style config + fields elsewhere in this package (config.py, plugins.py). + """ + + @pytest.mark.parametrize("raw,expected", [ + ("false", False), ("False", False), ("FALSE", False), + ("0", False), + ("true", True), ("True", True), ("1", True), + ]) + def test_string_valued_flags_are_parsed_not_just_truthy( + self, client, restore, raw, expected): + post(client, options=json.dumps({"restore_secrets": raw})) + assert restore.call_args[0][2].restore_secrets is expected + + def test_a_string_false_does_not_restore_secrets(self, client, restore): + # The exact shape of the bug: a truthy non-empty string coerced by + # bare bool() to True regardless of its contents. + post(client, options=json.dumps({"restore_secrets": "false"})) + assert restore.call_args[0][2].restore_secrets is False + + def test_real_json_booleans_still_work(self, client, restore): + post(client, options=json.dumps({"restore_secrets": False, + "restore_config": True})) + options = restore.call_args[0][2] + assert options.restore_secrets is False + assert options.restore_config is True + + class TestSuccess: def test_success_returns_the_result(self, client, restore): restore.return_value = FakeResult(success=True, restored=["config", "secrets"]) diff --git a/test/web_interface/test_calendar_oauth_endpoints.py b/test/web_interface/test_calendar_oauth_endpoints.py index b0293a28a..ef18b413c 100644 --- a/test/web_interface/test_calendar_oauth_endpoints.py +++ b/test/web_interface/test_calendar_oauth_endpoints.py @@ -363,6 +363,22 @@ def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path): assert 'hunter2' not in error, error assert '' in error, error + def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog): + # Regression: the return value went through redact_text (asserted + # above), but the logger.error call right next to it logged `raw` + # verbatim -- a script that handles OAuth client secrets and can + # quote them in its stderr, landing unredacted in the log (CWE-532). + script = tmp_path / 'calendar_registration.py' + script.write_text( + 'import sys\n' + 'sys.stderr.write("boom client_secret=hunter2 more\\n")\n', + encoding='utf-8') + with caplog.at_level('ERROR', logger=mod.logger.name): + mod._run_calendar_registration(tmp_path, '') + logged = '\n'.join(r.getMessage() for r in caplog.records) + assert 'hunter2' not in logged, logged + assert '' in logged, logged + def test_a_failing_script_payload_is_redacted(self, client): (client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8') (client.plugin_dir / 'calendar_registration.py').write_text( diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 55f53f9b4..e1c09a86b 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -395,6 +395,132 @@ def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_p assert self._install(tmp_path)['star_file'] == 'demo.star' +class TestManifestLockPreventsLostUpdates: + """The standalone manifest fallback (no plugin instance loaded) reads, + mutates and writes manifest.json with no coordination across requests. + Each write is atomic on its own (temp file + rename), but two concurrent + read-modify-write cycles can still race: both read the same starting + manifest, and the second write silently discards whatever the first one + added. _starlark_manifest_lock closes that window -- mirrors + StarlarkAppsPlugin._update_manifest_safe, which already does this when + the plugin instance is loaded. + """ + + @pytest.fixture + def starlark_dir(self, tmp_path, monkeypatch): + from web_interface.blueprints import api_v3 as module + apps_dir = tmp_path / "starlark-apps" + apps_dir.mkdir() + monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir) + monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json') + module._write_starlark_manifest({'apps': {}}) + return apps_dir + + def test_two_concurrent_updates_are_both_kept(self, starlark_dir): + import threading + import time as _time + + from web_interface.blueprints import api_v3 as module + + def add_app(app_id): + with module._starlark_manifest_lock(): + manifest = module._read_starlark_manifest() + # Widen the window between read and write. Without the lock + # both threads read here before either writes, and whichever + # writes second overwrites the other's addition; with the + # lock, the second thread cannot even start its read until + # the first has written and released. + _time.sleep(0.05) + manifest.setdefault('apps', {})[app_id] = {'enabled': True} + module._write_starlark_manifest(manifest) + + threads = [threading.Thread(target=add_app, args=(app_id,)) + for app_id in ('a', 'b')] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5) + + manifest = module._read_starlark_manifest() + assert set(manifest['apps']) == {'a', 'b'}, ( + "a concurrent update was lost: %r" % (manifest,)) + + def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir): + """Not reentrant within one thread -- just that using it twice in a + row (the ordinary case: one request, then the next) works cleanly + and does not leak the lock file descriptor or leave it locked.""" + from web_interface.blueprints import api_v3 as module + + for app_id in ('first', 'second'): + with module._starlark_manifest_lock(): + manifest = module._read_starlark_manifest() + manifest.setdefault('apps', {})[app_id] = {'enabled': True} + module._write_starlark_manifest(manifest) + + manifest = module._read_starlark_manifest() + assert set(manifest['apps']) == {'first', 'second'} + + +class TestConfigAndManifestStayInSync: + """Standalone-mode PUT /starlark/apps//config (no plugin instance + loaded) writes config.json and then the manifest. If the manifest write + fails after config.json was already written, the two disagree about + what was saved unless config.json is rolled back. + """ + + @pytest.fixture + def app_dir(self, tmp_path, monkeypatch): + from web_interface.blueprints import api_v3 as module + apps_dir = tmp_path / "starlark-apps" + apps_dir.mkdir() + monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir) + monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json') + one_app_dir = apps_dir / 'demo' + one_app_dir.mkdir() + module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}}) + return one_app_dir + + def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir): + config_file = app_dir / 'config.json' + config_file.write_text(json.dumps({'existing': 'value'})) + + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False): + resp = client.put('/api/v3/starlark/apps/demo/config', + json={'new_field': 'x'}) + + assert resp.status_code == 500 + assert json.loads(config_file.read_text()) == {'existing': 'value'}, ( + "config.json kept the new value even though the manifest write " + "that was supposed to follow it failed") + + def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir): + config_file = app_dir / 'config.json' + assert not config_file.exists() + + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \ + patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False): + resp = client.put('/api/v3/starlark/apps/demo/config', + json={'new_field': 'x'}) + + assert resp.status_code == 500 + assert not config_file.exists(), ( + "config.json was left behind even though the manifest write " + "that was supposed to follow it failed") + + def test_success_updates_both_config_and_manifest(self, client, app_dir): + from web_interface.blueprints import api_v3 as module + + with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None): + resp = client.put('/api/v3/starlark/apps/demo/config', + json={'new_field': 'x'}) + + assert resp.status_code == 200, resp.get_json() + assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x' + manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text()) + assert manifest['apps']['demo']['config']['new_field'] == 'x' + + # --------------------------------------------------------------------------- # The store loaded, then stopped loading, and nothing anywhere said why. # diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 22b5388b1..d51d5609f 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -16,6 +16,8 @@ everything they need has to exist first. """ from flask import Blueprint, request, jsonify, Response +import contextlib +import fcntl import json import os import re @@ -294,12 +296,31 @@ def _redact_credentials(value): that round-trips this response cannot erase a secret it never saw. """ if isinstance(value, dict): - return {k: ("" if _looks_like_a_credential(k) and not isinstance(v, (dict, list)) + return {k: (_blank_credential_value(v) if _looks_like_a_credential(k) else _redact_credentials(v)) for k, v in value.items()} if isinstance(value, list): return [_redact_credentials(item) for item in value] return value +def _blank_credential_value(value): + """Blank a value that sits under a credential-shaped key. + + A dict is still walked -- `secrets: {api_key: ..., note: ...}` is a + section name, not a value to blank in one go, so sub-fields that are not + themselves credential-named survive (see + test_a_credential_shaped_container_is_still_walked). A scalar list item + has no field name to test, though: `_redact_credentials` previously + recursed into a list the same way it recurses into a dict, and a scalar + is returned unchanged by the base case, so a credential-named key whose + value was a bare list of secrets (e.g. `tokens: ["a", "b"]`) passed + through with nothing redacted. Blank every scalar reached under this key, + at any depth, instead. + """ + if isinstance(value, dict): + return _redact_credentials(value) + if isinstance(value, list): + return [_blank_credential_value(item) for item in value] + return "" def _validate_time_format(time_str): """Validate time format is HH:MM""" try: @@ -1233,12 +1254,11 @@ def _run_calendar_registration(plugin_dir: Path, stdin_payload: str): return payload, None raw = (result.stderr or result.stdout or '').strip() - # The unredacted text goes to the log, where it is worth having in full. - # What comes back over HTTP is redacted: this is a script that handles - # OAuth client secrets, and its stderr can quote them. + # Redacted in the log too: this is a script that handles OAuth client + # secrets, and its stderr can quote them verbatim (CWE-532). if raw: logger.error('calendar_registration.py failed (exit %s): %s', - result.returncode, raw) + result.returncode, redact_text(raw)) return None, 'Authentication script produced no result%s' % ( ': %s' % redact_text(raw) if raw else '') def _resolve_backup_export_dir() -> Path: @@ -1321,6 +1341,31 @@ def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: return str(bundled) logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) return shutil.which("pixlet") +@contextlib.contextmanager +def _starlark_manifest_lock(): + """Hold an exclusive lock across a standalone starlark-manifest read-modify-write. + + StarlarkAppsPlugin._update_manifest_safe (plugin-repos/starlark-apps/manager.py) + already does this -- fcntl.flock held for the whole read-modify-write cycle -- + when the plugin instance is loaded. These routes fall back to reading and + writing manifest.json directly when it is not, and did so with no lock: each + _write_starlark_manifest() call is atomic on its own (temp file + rename), but + two concurrent requests can each read the manifest, mutate their own copy, and + write it back, and the second write silently discards the first's change. + + Callers should do their read, mutation and _write_starlark_manifest() call + entirely inside the `with` block, mirroring the plugin's lock scope. + """ + _STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True) + lock_fd = os.open(str(_STARLARK_MANIFEST_FILE), os.O_CREAT | os.O_RDWR, 0o644) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) def _read_starlark_manifest() -> Dict[str, Any]: """Read the starlark-apps manifest.json directly from disk.""" try: @@ -1663,22 +1708,23 @@ def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any with open(config_path, 'w') as f: json.dump(default_config, f, indent=2) - manifest = _read_starlark_manifest() - manifest.setdefault('apps', {})[app_id] = { - 'name': metadata.get('name', app_id), - 'enabled': True, - 'render_interval': metadata.get('render_interval', 300), - 'display_duration': metadata.get('display_duration', 15), - 'config': metadata.get('config', {}), - # The filename, not the full path. Readers join this to the app's own - # directory and fall back to a bare '.star', so an absolute - # value gave the key two meanings -- and Path.__truediv__ discards the - # left side when the right is absolute, which pinned the manifest to - # whatever PROJECT_ROOT installed it. Moving or redeploying the install - # then left the app unable to find its own file. - 'star_file': dest.name, - } - return _write_starlark_manifest(manifest) + with _starlark_manifest_lock(): + manifest = _read_starlark_manifest() + manifest.setdefault('apps', {})[app_id] = { + 'name': metadata.get('name', app_id), + 'enabled': True, + 'render_interval': metadata.get('render_interval', 300), + 'display_duration': metadata.get('display_duration', 15), + 'config': metadata.get('config', {}), + # The filename, not the full path. Readers join this to the app's own + # directory and fall back to a bare '.star', so an absolute + # value gave the key two meanings -- and Path.__truediv__ discards the + # left side when the right is absolute, which pinned the manifest to + # whatever PROJECT_ROOT installed it. Moving or redeploying the install + # then left the app unable to find its own file. + 'star_file': dest.name, + } + return _write_starlark_manifest(manifest) def _starlark_virtual_plugins() -> list: """Installed Starlark apps, shaped like plugin entries. @@ -1755,14 +1801,15 @@ def _update(manifest): # Only now is the in-memory copy allowed to disagree with disk. plugin.apps[safe_id].manifest['enabled'] = enabled else: - manifest = _read_starlark_manifest() - app_data = manifest.get('apps', {}).get(safe_id) - if not app_data: - return jsonify({'status': 'error', - 'message': f'Starlark app not found: {safe_id}'}), 404 - app_data['enabled'] = enabled - if not _write_starlark_manifest(manifest): - return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 + with _starlark_manifest_lock(): + manifest = _read_starlark_manifest() + app_data = manifest.get('apps', {}).get(safe_id) + if not app_data: + return jsonify({'status': 'error', + 'message': f'Starlark app not found: {safe_id}'}), 404 + app_data['enabled'] = enabled + if not _write_starlark_manifest(manifest): + return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 return jsonify({'status': 'success', 'message': f"Starlark app {'enabled' if enabled else 'disabled'}", diff --git a/web_interface/blueprints/api_v3/backup.py b/web_interface/blueprints/api_v3/backup.py index 12add9db3..f971f0e75 100644 --- a/web_interface/blueprints/api_v3/backup.py +++ b/web_interface/blueprints/api_v3/backup.py @@ -4,7 +4,7 @@ endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( - PROJECT_ROOT, Path, _safe_backup_path, api_v3, + PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3, datetime, json, jsonify, logger, os, plugin_store_manager, request, tempfile, ) @@ -112,13 +112,17 @@ def backup_restore(): 'status': 'error', 'message': 'Invalid options: expected a JSON object', }), 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=bool(opts_dict.get('restore_config', True)), - restore_secrets=bool(opts_dict.get('restore_secrets', True)), - restore_wifi=bool(opts_dict.get('restore_wifi', True)), - restore_fonts=bool(opts_dict.get('restore_fonts', True)), - restore_plugin_uploads=bool(opts_dict.get('restore_plugin_uploads', True)), - reinstall_plugins=bool(opts_dict.get('reinstall_plugins', True)), + 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 diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index ab6ea270a..9a7742dd6 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -84,7 +84,7 @@ def save_schedule_config(): start_time = data.get('start_time', '07:00') end_time = data.get('end_time', '23:00') - # Validate _pkg.time formats + # Validate time formats is_valid, error_msg = _validate_time_format(start_time) if not is_valid: return error_response( @@ -148,12 +148,12 @@ def save_schedule_config(): else: end_time = '23:00' - # Validate _pkg.time formats + # Validate time formats is_valid, error_msg = _validate_time_format(start_time) if not is_valid: return error_response( ErrorCode.VALIDATION_ERROR, - f"Invalid start _pkg.time for {day}: {error_msg}", + f"Invalid start time for {day}: {error_msg}", status_code=400 ) @@ -161,7 +161,7 @@ def save_schedule_config(): if not is_valid: return error_response( ErrorCode.VALIDATION_ERROR, - f"Invalid end _pkg.time for {day}: {error_msg}", + f"Invalid end time for {day}: {error_msg}", status_code=400 ) @@ -313,7 +313,7 @@ def save_dim_schedule_config(): start_time = data.get('start_time', '20:00') end_time = data.get('end_time', '07:00') - # Validate _pkg.time formats + # Validate time formats is_valid, error_msg = _validate_time_format(start_time) if not is_valid: return error_response( @@ -365,12 +365,12 @@ def save_dim_schedule_config(): start_time = data.get(start_key) or '20:00' end_time = data.get(end_key) or '07:00' - # Validate _pkg.time formats + # Validate time formats is_valid, error_msg = _validate_time_format(start_time) if not is_valid: return error_response( ErrorCode.VALIDATION_ERROR, - f"Invalid start _pkg.time for {day}: {error_msg}", + f"Invalid start time for {day}: {error_msg}", status_code=400 ) @@ -378,7 +378,7 @@ def save_dim_schedule_config(): if not is_valid: return error_response( ErrorCode.VALIDATION_ERROR, - f"Invalid end _pkg.time for {day}: {error_msg}", + f"Invalid end time for {day}: {error_msg}", status_code=400 ) diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index 3f449bff9..ac3efc0d3 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -227,7 +227,7 @@ def start_on_demand_display(): # Stop the display service first to ensure clean state when we will restart it if service_was_running and start_service: - import _pkg.time as time_module + import time as time_module logger.debug("Stopping display service before starting on-demand mode") _stop_display_service() # Wait a brief moment for the service to fully stop diff --git a/web_interface/blueprints/api_v3/fonts.py b/web_interface/blueprints/api_v3/fonts.py index 897d04d57..ad966ae64 100644 --- a/web_interface/blueprints/api_v3/fonts.py +++ b/web_interface/blueprints/api_v3/fonts.py @@ -168,15 +168,30 @@ def upload_font(): if font_file.filename == '': return jsonify({'status': 'error', 'message': 'No file selected'}), 400 - # Validate filename + # Validate filename. validate_file_upload takes max_size_mb but only + # checks the filename/extension with it -- it never looks at the + # actual upload size, so the size limit below is enforced separately + # before the file is saved (same pattern as the .star upload above). + MAX_FONT_SIZE_MB = 10 is_valid, error_msg = validate_file_upload( font_file.filename, - max_size_mb=10, + max_size_mb=MAX_FONT_SIZE_MB, allowed_extensions=['.ttf', '.otf', '.bdf'] ) if not is_valid: return jsonify({'status': 'error', 'message': error_msg}), 400 + # Check file size (stated limit is MAX_FONT_SIZE_MB) + font_file.seek(0, 2) # Seek to end + file_size = font_file.tell() + font_file.seek(0) # Reset to beginning + max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024 + if file_size > max_font_size_bytes: + return jsonify({ + 'status': 'error', + 'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)' + }), 400 + font_family = request.form.get('font_family', '') if not font_family: diff --git a/web_interface/blueprints/api_v3/starlark.py b/web_interface/blueprints/api_v3/starlark.py index 801aea9da..39c406686 100644 --- a/web_interface/blueprints/api_v3/starlark.py +++ b/web_interface/blueprints/api_v3/starlark.py @@ -5,7 +5,7 @@ """ from web_interface.blueprints.api_v3 import ( PROJECT_ROOT, Path, _find_pixlet_binary, _install_star_file, _standalone_render_starlark_app, - _starlark_github_token, _validate_and_sanitize_app_id, + _starlark_github_token, _starlark_manifest_lock, _validate_and_sanitize_app_id, _validate_starlark_app_path, _validate_timing_value, api_v3, describe_exception, json, jsonify, logger, os, request, shutil, subprocess, tempfile, ) @@ -294,9 +294,10 @@ def uninstall_starlark_app(app_id): import shutil if app_dir.exists(): shutil.rmtree(app_dir) - manifest = _pkg._read_starlark_manifest() - manifest.get('apps', {}).pop(app_id, None) - success = _pkg._write_starlark_manifest(manifest) + with _starlark_manifest_lock(): + manifest = _pkg._read_starlark_manifest() + manifest.get('apps', {}).pop(app_id, None) + success = _pkg._write_starlark_manifest(manifest) if success: return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'}) @@ -430,52 +431,70 @@ def update_fn(manifest): else: return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500 - # Standalone: update both config.json and manifest - manifest = _pkg._read_starlark_manifest() - app_data = manifest.get('apps', {}).get(app_id) - if not app_data: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + # Standalone: update both config.json and manifest. Both live under + # the manifest lock (serialized against every other standalone + # manifest read-modify-write -- see _starlark_manifest_lock), and if + # the manifest write fails after config.json was already written, + # config.json is rolled back so the two do not end up out of sync. + with _starlark_manifest_lock(): + manifest = _pkg._read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - # Extract timing keys (they go in manifest, not config.json) - render_interval = data.pop('render_interval', None) - display_duration = data.pop('display_duration', None) + # Extract timing keys (they go in manifest, not config.json) + render_interval = data.pop('render_interval', None) + display_duration = data.pop('display_duration', None) - # Update manifest with timing values - if render_interval is not None: - app_data['render_interval'] = render_interval - if display_duration is not None: - app_data['display_duration'] = display_duration + # Update manifest with timing values + if render_interval is not None: + app_data['render_interval'] = render_interval + if display_duration is not None: + app_data['display_duration'] = display_duration + + # Load current config from config.json. app_dir is the path + # _validate_starlark_app_path checked, not a fresh join. + config_file = app_dir / "config.json" + existed_before = config_file.exists() + previous_config_bytes = None + current_config = {} + if existed_before: + try: + previous_config_bytes = config_file.read_bytes() + current_config = json.loads(previous_config_bytes) + except Exception as e: + logger.warning(f"Failed to load config for {app_id}: {e}") - # Load current config from config.json. app_dir is the path - # _validate_starlark_app_path checked, not a fresh join. - config_file = app_dir / "config.json" - current_config = {} - if config_file.exists(): + # Update config with new values (excluding timing keys) + current_config.update(data) + + # Write updated config to config.json try: - with open(config_file, 'r') as f: - current_config = json.load(f) + with open(config_file, 'w') as f: + json.dump(current_config, f, indent=2) except Exception as e: - logger.warning(f"Failed to load config for {app_id}: {e}") + logger.error(f"Failed to save config.json for {app_id}: {e}") + logger.exception("Failed to save Starlark configuration for %r", app_id) + return jsonify({'status': 'error', 'message': 'Failed to save configuration', + 'details': describe_exception(e)}), 500 - # Update config with new values (excluding timing keys) - current_config.update(data) + # Also update manifest for backward compatibility + app_data.setdefault('config', {}).update(data) - # Write updated config to config.json - try: - with open(config_file, 'w') as f: - json.dump(current_config, f, indent=2) - except Exception as e: - logger.error(f"Failed to save config.json for {app_id}: {e}") - logger.exception("Failed to save Starlark configuration for %r", app_id) - return jsonify({'status': 'error', 'message': 'Failed to save configuration', - 'details': describe_exception(e)}), 500 - - # Also update manifest for backward compatibility - app_data.setdefault('config', {}).update(data) - - if _pkg._write_starlark_manifest(manifest): - return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config}) - else: + if _pkg._write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config}) + + # The manifest write failed after config.json was already + # written -- roll config.json back rather than leave the two + # disagreeing about what was saved. + try: + if existed_before and previous_config_bytes is not None: + config_file.write_bytes(previous_config_bytes) + elif not existed_before: + config_file.unlink(missing_ok=True) + except OSError: + logger.exception( + "Failed to roll back config.json for %r after manifest write failure", app_id) return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500 except Exception as e: @@ -503,19 +522,20 @@ def update_fn(manifest): return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) # Standalone: update manifest directly - manifest = _pkg._read_starlark_manifest() - app_data = manifest.get('apps', {}).get(app_id) - if not app_data: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + with _starlark_manifest_lock(): + manifest = _pkg._read_starlark_manifest() + app_data = manifest.get('apps', {}).get(app_id) + if not app_data: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - enabled = data.get('enabled') - if enabled is None: - enabled = not app_data.get('enabled', True) - app_data['enabled'] = enabled - if _pkg._write_starlark_manifest(manifest): - return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) - else: - return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 + enabled = data.get('enabled') + if enabled is None: + enabled = not app_data.get('enabled', True) + app_data['enabled'] = enabled + if _pkg._write_starlark_manifest(manifest): + return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled}) + else: + return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 except Exception as e: logger.exception("[Starlark] toggle_starlark_app failed") diff --git a/web_interface/blueprints/api_v3/wifi.py b/web_interface/blueprints/api_v3/wifi.py index a3b9c013d..7827347e9 100644 --- a/web_interface/blueprints/api_v3/wifi.py +++ b/web_interface/blueprints/api_v3/wifi.py @@ -8,6 +8,36 @@ ) +def _parse_bool_ish(value, default=False): + """Coerce a JSON value that is supposed to be a boolean. + + A JSON boolean arrives as a real Python bool, but these routes are a + public HTTP contract and not every caller sends one. `bool(value)` gets + two common cases wrong: `bool("false")` is True (a non-empty string is + always truthy), and a plain int does not match an `is True` check + (`1 is True` is False, since `True` is a distinct singleton from the int + `1`) -- so a caller sending `{"enabled": 1}` was silently treated as + False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no" + case-insensitively, and int 1/0; anything else falls back to `default`. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ('true', '1', 'yes'): + return True + if lowered in ('false', '0', 'no'): + return False + return default + if isinstance(value, int): + if value == 1: + return True + if value == 0: + return False + return default + return default + + # WiFi Management Endpoints @api_v3.route('/wifi/status', methods=['GET']) def get_wifi_status(): @@ -273,7 +303,7 @@ def set_auto_enable_ap_mode(): 'message': 'auto_enable_ap_mode is required' }), 400 - auto_enable = bool(data['auto_enable_ap_mode']) + auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'], default=False) wifi_manager = WiFiManager() wifi_manager.config["auto_enable_ap_mode"] = auto_enable @@ -330,13 +360,12 @@ def set_wifi_radio(): 'message': 'enabled is required' }), 400 - # Parse defensively: bool("false") is True, so mirror the string-aware - # coercion used for `force` — the endpoint is a public contract, not just - # the shipped UI (which always sends real JSON booleans). - _enabled_raw = data['enabled'] - enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes')) - _force_raw = data.get('force', False) - force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes')) + # Parse defensively: bool("false") is True and a plain int never + # matches `is True`, so `_parse_bool_ish` handles bool, string and + # int 1/0 — the endpoint is a public contract, not just the shipped + # UI (which always sends real JSON booleans). + enabled = _parse_bool_ish(data['enabled'], default=False) + force = _parse_bool_ish(data.get('force', False), default=False) wifi_manager = WiFiManager() success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)