diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json new file mode 100644 index 00000000..f2557fd9 --- /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_font_upload_size.py b/test/test_api_v3_font_upload_size.py new file mode 100644 index 00000000..cd1b731d --- /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 00000000..c203fbe4 --- /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_optional_body.py b/test/test_api_v3_optional_body.py index 4b7eb133..fe1abc5e 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_schedule_error_messages.py b/test/test_api_v3_schedule_error_messages.py new file mode 100644 index 00000000..383ec172 --- /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_url_map.py b/test/test_api_v3_url_map.py new file mode 100644 index 00000000..157ab8a1 --- /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_api_v3_wifi_endpoints.py b/test/test_api_v3_wifi_endpoints.py index 0c713ae2..0b4a81ea 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 15172df2..191f8d20 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. @@ -130,7 +167,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 cd6db2bc..2338421f 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 8cadf922..679bfd84 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 55f81db8..6324bc01 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 7b67ca16..52758fba 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"]) @@ -230,7 +265,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 9520be5f..ef18b413 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 @@ -360,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_config_logging_omits_secrets.py b/test/web_interface/test_config_logging_omits_secrets.py index 031eb4f1..250b215c 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 91367234..9486715e 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 24fab51e..e1c09a86 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' @@ -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/test/web_interface/test_systemctl_sudoers_alignment.py b/test/web_interface/test_systemctl_sudoers_alignment.py index f29691f0..0013eccf 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 865789f0..00000000 --- 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 00000000..d51d5609 --- /dev/null +++ b/web_interface/blueprints/api_v3/__init__.py @@ -0,0 +1,1834 @@ +"""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 contextlib +import fcntl +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: (_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: + 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() + # 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, redact_text(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") +@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: + 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) + + 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. + + #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: + 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'}", + '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 00000000..f971f0e7 --- /dev/null +++ b/web_interface/blueprints/api_v3/backup.py @@ -0,0 +1,216 @@ +"""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, _coerce_to_bool, _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 + # _coerce_to_bool (not bare bool()) because a request can send these + # as JSON strings: bool("false") is True in Python, so a caller who + # explicitly asked to skip secrets would have had them restored + # anyway. + options = RestoreOptions( + restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)), + restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)), + restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)), + restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)), + restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)), + reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)), + ) + with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp: + tmp_path = tmp.name + f.save(tmp_path) + try: + result = restore_backup(Path(tmp_path), PROJECT_ROOT, options) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + # 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 00000000..9a7742dd --- /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 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 = _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 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 = _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 00000000..ac3efc0d --- /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 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 00000000..ad966ae6 --- /dev/null +++ b/web_interface/blueprints/api_v3/fonts.py @@ -0,0 +1,467 @@ +"""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. 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=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: + 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 00000000..094ddecc --- /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 00000000..945bbcd0 --- /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 00000000..39c40668 --- /dev/null +++ b/web_interface/blueprints/api_v3/starlark.py @@ -0,0 +1,712 @@ +"""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, _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, +) +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) + 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}'}) + 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. 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) + + # 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}") + + # 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}) + + # 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: + 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 + 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 + + 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 00000000..c991cdde --- /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 00000000..7827347e --- /dev/null +++ b/web_interface/blueprints/api_v3/wifi.py @@ -0,0 +1,390 @@ +"""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, +) + + +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(): + """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 = _parse_bool_ish(data['auto_enable_ap_mode'], default=False) + + 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 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) + + 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