mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety, BDF preview (#655)
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety - Route plugin lookups (installed list, update, recorded version, config form, web UI pages) through the plugin manager's resolver so plugins in ledmatrix-<id> directories work. - Captive-portal detection also sees the nmcli fallback AP (cached). - WiFi monitor daemon re-reads wifi_config.json when its mtime changes. - Drop the AP check in disconnect_from_network that could never fire. - LED status file per WiFiManager; config path falls back to this checkout. - BDF font preview via src.common.bdf_font. - Asset uploads validate every file before saving; metadata and calendar credentials written atomically; no absolute path in the response; asset delete answers 400 for a missing body. - Coerce string booleans in plugin toggle, on-demand start and AP force. - SSE broadcaster clears its thread handle before exiting. - start.py log filter handles every exc_info form. - Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls, raw-config error helper, update-route tidy, redundant imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): request BDF font previews now that the server renders them The Fonts tab skipped the preview request for .bdf files because the server used to refuse them; /fonts/preview now draws BDF with the shared loader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): take the update route's plugin directory from a directory listing CodeQL flagged the path built from the request's plugin_id (the id was already validated with safe_path_component, which CodeQL doesn't model; the same flow on main is alerts 738/739). The directory is now the entry of plugins_dir matched by name, so nothing built from user input reaches the filesystem; an id with nothing installed goes to the store manager, which reports it not found as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): read the blueprint's plugin_manager defensively in _plugin_directory _get_plugin_version now goes through _plugin_directory, which read api_v3.plugin_manager directly; the attribute exists only once the app sets it, so test_path_traversal_guards::test_a_real_manifest_is_read failed when run on its own (order-dependent in the full suite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,18 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Web backend and WiFi fixes:
|
||||||
|
- Plugins installed as `ledmatrix-<id>` (or in a directory not named after their id) work in the installed list, the update button, recorded versions, the plugin config form and plugin web UI pages. Those routes built `plugins_dir/<id>` themselves instead of asking the plugin manager.
|
||||||
|
- The captive-portal checks (`/generate_204` and friends) also detect an access point brought up through NetworkManager, the fallback `enable_ap_mode` uses without hostapd; only hostapd was checked, so phones on that AP were told the internet worked.
|
||||||
|
- The WiFi monitor daemon re-reads `wifi_config.json` when it changes, so the "auto-enable AP mode" toggle takes effect without restarting the daemon.
|
||||||
|
- Disconnecting from WiFi in the web UI no longer runs an AP-mode check that could never enable the AP; it only added seconds of waiting. The daemon still enables the AP after its grace period.
|
||||||
|
- The WiFi status message file follows each WiFi manager's own config directory, and the config path falls back to this checkout rather than `/home/ledpi/LEDMatrix`.
|
||||||
|
- Fonts tab: the preview endpoint renders BDF fonts with the panel's own rasterizer instead of refusing them. (The Fonts page still skips the request for `.bdf`; enabling it there is a separate template change.)
|
||||||
|
- Uploading several plugin images checks every file before saving any, so a rejected file no longer leaves the others saved; the images' `.metadata.json` and the calendar plugin's `credentials.json` are written atomically, and the credentials upload no longer returns the server's absolute path.
|
||||||
|
- `"false"` sent as a string no longer counts as true when toggling a plugin (including Starlark apps) or starting on-demand mode (`pinned`, `start_service`); `force` on the AP-enable route is parsed like every other WiFi boolean (`"yes"` and `1` now force).
|
||||||
|
- The live-preview stream starts a new broadcast thread for a client that connects while the previous one is shutting down; that client got no updates.
|
||||||
|
- The web server's log filter no longer raises when werkzeug logs with `exc_info=True`.
|
||||||
|
- The raw secrets editor's save errors carry `error_code` like the main config's; the asset delete route answers 400 for a missing body instead of 415/500. Dead code removed: an unused manifest scan on each Plugins-tab load, backup routes' duplicate catch-alls, redundant imports.
|
||||||
- Plugin system fixes:
|
- Plugin system fixes:
|
||||||
- Unloading a plugin waits (up to 5s) for an in-flight `update()` before running `cleanup()`/`on_disable()`, and an update that finishes after the unload no longer puts the plugin back to ENABLED.
|
- Unloading a plugin waits (up to 5s) for an in-flight `update()` before running `cleanup()`/`on_disable()`, and an update that finishes after the unload no longer puts the plugin back to ENABLED.
|
||||||
- A plugin whose load fails after its module was imported (constructor, `validate_config()` or `on_enable()` raising) no longer leaves that module cached: fixing the plugin and reloading it runs the new code without a restart. Its font registrations are dropped too.
|
- A plugin whose load fails after its module was imported (constructor, `validate_config()` or `on_enable()` raising) no longer leaves that module cached: fixing the plugin and reloading it runs the new code without a restart. Its font registrations are dropped too.
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class WiFiMonitorDaemon:
|
|||||||
"""
|
"""
|
||||||
self.check_interval = check_interval
|
self.check_interval = check_interval
|
||||||
self.wifi_manager = WiFiManager()
|
self.wifi_manager = WiFiManager()
|
||||||
|
# mtime of wifi_config.json as last loaded; see _reload_config_if_changed.
|
||||||
|
self._config_mtime = self._config_file_mtime()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_state = None
|
self.last_state = None
|
||||||
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||||
@@ -57,7 +59,32 @@ class WiFiMonitorDaemon:
|
|||||||
"""Handle shutdown signals"""
|
"""Handle shutdown signals"""
|
||||||
logger.info(f"Received signal {signum}, shutting down...")
|
logger.info(f"Received signal {signum}, shutting down...")
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
|
def _config_file_mtime(self):
|
||||||
|
try:
|
||||||
|
return self.wifi_manager.config_path.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _reload_config_if_changed(self):
|
||||||
|
"""Re-read wifi_config.json when it has changed on disk.
|
||||||
|
|
||||||
|
The web UI's auto-enable toggle (POST /api/v3/wifi/ap/auto-enable)
|
||||||
|
only writes the file; this process read it once at startup, so the
|
||||||
|
toggle did nothing until the daemon restarted. One stat per check.
|
||||||
|
"""
|
||||||
|
mtime = self._config_file_mtime()
|
||||||
|
if mtime is None or mtime == self._config_mtime:
|
||||||
|
return
|
||||||
|
before = self.wifi_manager.config.get("auto_enable_ap_mode", True)
|
||||||
|
self.wifi_manager._load_config()
|
||||||
|
# _load_config can itself save (it fills in missing keys), so take
|
||||||
|
# the mtime after it, or that save would trigger another reload.
|
||||||
|
self._config_mtime = self._config_file_mtime()
|
||||||
|
after = self.wifi_manager.config.get("auto_enable_ap_mode", True)
|
||||||
|
if after != before:
|
||||||
|
logger.info(f"wifi_config.json changed: auto_enable_ap_mode={after}")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Main daemon loop"""
|
"""Main daemon loop"""
|
||||||
logger.info("WiFi Monitor Daemon started")
|
logger.info("WiFi Monitor Daemon started")
|
||||||
@@ -78,6 +105,8 @@ class WiFiMonitorDaemon:
|
|||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
|
self._reload_config_if_changed()
|
||||||
|
|
||||||
# One combined check that also returns the state it observed —
|
# One combined check that also returns the state it observed —
|
||||||
# the previous flow fetched status before AND after the check
|
# the previous flow fetched status before AND after the check
|
||||||
# on top of the check's own internal fetch, each one several
|
# on top of the check's own internal fetch, each one several
|
||||||
|
|||||||
+36
-37
@@ -44,14 +44,12 @@ def get_wifi_config_path():
|
|||||||
# Try to determine project root
|
# Try to determine project root
|
||||||
project_root = os.environ.get('LEDMATRIX_ROOT')
|
project_root = os.environ.get('LEDMATRIX_ROOT')
|
||||||
if not project_root:
|
if not project_root:
|
||||||
# Try to find project root by looking for config directory
|
# This file is <root>/src/wifi_manager.py. The root is used even when
|
||||||
current = Path(__file__).resolve().parent.parent
|
# config/ does not exist yet (WiFiManager creates it): the old
|
||||||
if (current / 'config').exists():
|
# fallback was a hardcoded /home/ledpi/LEDMatrix, which is some other
|
||||||
project_root = str(current)
|
# user's checkout, or nothing, on any install not made as ledpi.
|
||||||
else:
|
project_root = str(Path(__file__).resolve().parents[1])
|
||||||
# Fallback to common location
|
|
||||||
project_root = "/home/ledpi/LEDMatrix"
|
|
||||||
|
|
||||||
return Path(project_root) / "config" / "wifi_config.json"
|
return Path(project_root) / "config" / "wifi_config.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -90,8 +88,11 @@ AP_PROFILE_NAME = "LEDMatrix-Setup-AP"
|
|||||||
#: Deleted by name only, never by SSID, so a saved home network is never hit.
|
#: Deleted by name only, never by SSID, so a saved home network is never hit.
|
||||||
AP_PROFILE_NAMES = (AP_PROFILE_NAME, "Hotspot", "TickerSetup-AP")
|
AP_PROFILE_NAMES = (AP_PROFILE_NAME, "Hotspot", "TickerSetup-AP")
|
||||||
|
|
||||||
# LED status message file (for display_controller integration)
|
# LED status message file (for display_controller integration). None means
|
||||||
LED_STATUS_FILE = None # Will be set dynamically
|
# each WiFiManager writes next to its own config file, which for the default
|
||||||
|
# config is get_wifi_status_path(), the file the display reads. Set only to
|
||||||
|
# redirect it (tests).
|
||||||
|
LED_STATUS_FILE = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -131,12 +132,12 @@ class WiFiManager:
|
|||||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
# Set LED status file path (for display_controller integration)
|
# LED status file (for display_controller integration): next to this
|
||||||
global LED_STATUS_FILE
|
# manager's config. It used to be a module global set by whichever
|
||||||
if LED_STATUS_FILE is None:
|
# manager was built first, so a later one with a different config
|
||||||
project_root = self.config_path.parent.parent
|
# path wrote its messages to the first one's directory.
|
||||||
LED_STATUS_FILE = project_root / "config" / "wifi_status.json"
|
self._led_status_file = self.config_path.parent / "wifi_status.json"
|
||||||
|
|
||||||
# Check which tools are available
|
# Check which tools are available
|
||||||
self.has_nmcli = self._check_command("nmcli")
|
self.has_nmcli = self._check_command("nmcli")
|
||||||
self.has_iwlist = self._check_command("iwlist")
|
self.has_iwlist = self._check_command("iwlist")
|
||||||
@@ -182,21 +183,22 @@ class WiFiManager:
|
|||||||
duration: How long to show message (seconds)
|
duration: How long to show message (seconds)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if LED_STATUS_FILE is None:
|
status_file = LED_STATUS_FILE or getattr(self, '_led_status_file', None)
|
||||||
|
if status_file is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
'message': message,
|
'message': message,
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'duration': duration
|
'duration': duration
|
||||||
}
|
}
|
||||||
LED_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
status_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Write-then-rename: the display reads this at ~1 Hz and deletes
|
# Write-then-rename: the display reads this at ~1 Hz and deletes
|
||||||
# a file it can't parse, so a half-written one would lose the message.
|
# a file it can't parse, so a half-written one would lose the message.
|
||||||
tmp_path = LED_STATUS_FILE.with_name(LED_STATUS_FILE.name + '.tmp')
|
tmp_path = status_file.with_name(status_file.name + '.tmp')
|
||||||
with open(tmp_path, 'w') as f:
|
with open(tmp_path, 'w') as f:
|
||||||
json.dump(status, f)
|
json.dump(status, f)
|
||||||
os.replace(tmp_path, LED_STATUS_FILE)
|
os.replace(tmp_path, status_file)
|
||||||
logger.info(f"LED message: {message}")
|
logger.info(f"LED message: {message}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not write LED status message: {e}")
|
logger.debug(f"Could not write LED status message: {e}")
|
||||||
@@ -204,8 +206,9 @@ class WiFiManager:
|
|||||||
def _clear_led_message(self):
|
def _clear_led_message(self):
|
||||||
"""Clear any WiFi status message from LED display."""
|
"""Clear any WiFi status message from LED display."""
|
||||||
try:
|
try:
|
||||||
if LED_STATUS_FILE and LED_STATUS_FILE.exists():
|
status_file = LED_STATUS_FILE or getattr(self, '_led_status_file', None)
|
||||||
LED_STATUS_FILE.unlink()
|
if status_file and status_file.exists():
|
||||||
|
status_file.unlink()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not clear LED status message: {e}")
|
logger.debug(f"Could not clear LED status message: {e}")
|
||||||
|
|
||||||
@@ -1668,8 +1671,9 @@ class WiFiManager:
|
|||||||
Disconnect from the current WiFi network
|
Disconnect from the current WiFi network
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skip_ap_check: If True, skip auto-enabling AP mode after disconnect
|
skip_ap_check: Accepted for callers that still pass it; ignored.
|
||||||
(useful when switching networks)
|
Auto-enabling AP mode after a disconnect is the
|
||||||
|
wifi monitor daemon's job.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
@@ -1705,18 +1709,13 @@ class WiFiManager:
|
|||||||
logger.info("Successfully disconnected from WiFi network")
|
logger.info("Successfully disconnected from WiFi network")
|
||||||
# Wait longer for the disconnect to fully complete
|
# Wait longer for the disconnect to fully complete
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Check if AP mode should be auto-enabled
|
# No AP-mode check here. It used to run one, but the web
|
||||||
# Skip if we're switching networks (skip_ap_check=True)
|
# routes build a fresh WiFiManager per request, so its
|
||||||
if not skip_ap_check:
|
# grace-period counter started at 0 and one check could
|
||||||
auto_enable = self.config.get("auto_enable_ap_mode", True)
|
# never reach the 3 needed to enable the AP: it only added
|
||||||
if auto_enable:
|
# seconds of sleeps and nmcli calls. The monitor daemon's
|
||||||
# Give it a moment, then check if we should enable AP mode
|
# long-lived manager is what counts consecutive checks.
|
||||||
time.sleep(1)
|
|
||||||
self.check_and_manage_ap_mode()
|
|
||||||
else:
|
|
||||||
logger.debug("Skipping AP mode check (network switch in progress)")
|
|
||||||
|
|
||||||
return True, "Disconnected from WiFi network"
|
return True, "Disconnected from WiFi network"
|
||||||
else:
|
else:
|
||||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Boolean request fields are coerced, not used raw.
|
||||||
|
|
||||||
|
``bool("false")`` is True. /plugins/toggle stored a JSON ``"enabled": "false"``
|
||||||
|
as-is in config.json (a truthy string the display then treats as enabled) and
|
||||||
|
passed it to the Starlark toggle the same way, and /display/on-demand/start
|
||||||
|
pinned the mode and restarted the service for ``"pinned": "false"`` /
|
||||||
|
``"start_service": "false"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginToggle:
|
||||||
|
@pytest.fixture
|
||||||
|
def saved(self, api_v3_module, monkeypatch):
|
||||||
|
monkeypatch.setattr(api_v3_module, '_discovered_plugin_manifests',
|
||||||
|
lambda *a, **k: {'clock': {}})
|
||||||
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={})
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def save(config_manager, config, create_backup=True):
|
||||||
|
captured.update(config)
|
||||||
|
return True, None
|
||||||
|
monkeypatch.setattr(api_v3_module, '_save_config_atomic', save)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("false", False), ("true", True), (False, False), (True, True), (0, False),
|
||||||
|
])
|
||||||
|
def test_enabled_is_stored_as_a_real_bool(self, api_v3_client, saved, raw, expected):
|
||||||
|
response = api_v3_client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'clock', 'enabled': raw})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert saved['clock']['enabled'] is expected
|
||||||
|
|
||||||
|
def test_a_starlark_app_gets_the_coerced_value(self, api_v3_client, api_v3_module):
|
||||||
|
toggle = MagicMock(return_value=({'status': 'success'}, 200))
|
||||||
|
with patch('web_interface.blueprints.api_v3.plugins._toggle_starlark_app', toggle):
|
||||||
|
api_v3_client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:clock', 'enabled': 'false'})
|
||||||
|
toggle.assert_called_once_with('clock', False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnDemandStart:
|
||||||
|
@pytest.fixture
|
||||||
|
def service(self, api_v3_module):
|
||||||
|
api_v3_module.api_v3.plugin_manager = None
|
||||||
|
api_v3_module.api_v3.config_manager = None
|
||||||
|
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||||
|
return_value={"active": True}), \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
||||||
|
return_value={"active": True}) as ensure:
|
||||||
|
yield stop, ensure
|
||||||
|
|
||||||
|
def test_string_false_neither_pins_nor_restarts(self, api_v3_client, service):
|
||||||
|
stop, ensure = service
|
||||||
|
response = api_v3_client.post('/api/v3/display/on-demand/start', json={
|
||||||
|
'plugin_id': 'weather', 'pinned': 'false', 'start_service': 'false'})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()['data']['pinned'] is False
|
||||||
|
stop.assert_not_called()
|
||||||
|
ensure.assert_not_called()
|
||||||
|
|
||||||
|
def test_string_true_pins(self, api_v3_client, service):
|
||||||
|
response = api_v3_client.post('/api/v3/display/on-demand/start', json={
|
||||||
|
'plugin_id': 'weather', 'pinned': 'true', 'start_service': False})
|
||||||
|
assert response.get_json()['data']['pinned'] is True
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Plugin asset uploads and the calendar credentials upload write safely.
|
||||||
|
|
||||||
|
* An upload of several files checked and saved them one at a time, so a bad
|
||||||
|
third file answered 400 after the first two were already on disk and in
|
||||||
|
.metadata.json -- the user was told it failed and the images appeared anyway.
|
||||||
|
* .metadata.json and credentials.json were written in place (open 'w' /
|
||||||
|
FileStorage.save), so a failure mid-write left a truncated file.
|
||||||
|
* The asset delete route called get_json() without silent=True.
|
||||||
|
* The credentials route returned the server's absolute path; nothing reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
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
|
||||||
|
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project(tmp_path, api_v3_module, monkeypatch):
|
||||||
|
import web_interface.blueprints.api_v3.plugins as plugins_module
|
||||||
|
monkeypatch.setattr(plugins_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
return tmp_path / "assets" / "plugins" / "static-image" / "uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client, files):
|
||||||
|
return client.post(
|
||||||
|
"/api/v3/plugins/assets/upload",
|
||||||
|
data={"plugin_id": "static-image",
|
||||||
|
"files": [(io.BytesIO(body), name) for name, body in files]},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetUpload:
|
||||||
|
def test_a_bad_file_saves_none_of_the_batch(self, api_v3_client, project):
|
||||||
|
response = _upload(api_v3_client, [
|
||||||
|
("a.png", PNG), ("b.png", PNG), ("c.png", b"not an image at all"),
|
||||||
|
])
|
||||||
|
assert response.status_code == 400
|
||||||
|
saved = [p.name for p in project.iterdir()] if project.exists() else []
|
||||||
|
assert saved == [], "files before the bad one were saved anyway"
|
||||||
|
|
||||||
|
def test_a_batch_over_the_total_limit_saves_none(self, api_v3_client, project, monkeypatch):
|
||||||
|
# The total-size check also runs before anything is written.
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / ".metadata.json").write_text(json.dumps(
|
||||||
|
{"old": {"id": "old", "size": 50 * 1024 * 1024 - 30}}), encoding="utf-8")
|
||||||
|
response = _upload(api_v3_client, [("a.png", PNG), ("b.png", PNG)])
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert sorted(p.name for p in project.iterdir()) == [".metadata.json"]
|
||||||
|
|
||||||
|
def test_a_good_batch_is_all_saved_and_recorded(self, api_v3_client, project):
|
||||||
|
response = _upload(api_v3_client, [("a.png", PNG), ("b.png", PNG)])
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
body = response.get_json()
|
||||||
|
assert len(body["uploaded_files"]) == 2
|
||||||
|
metadata = json.loads((project / ".metadata.json").read_text(encoding="utf-8"))
|
||||||
|
assert sorted(metadata) == sorted(f["id"] for f in body["uploaded_files"])
|
||||||
|
for entry in body["uploaded_files"]:
|
||||||
|
assert (project / entry["filename"]).exists()
|
||||||
|
|
||||||
|
def test_metadata_is_replaced_by_rename(self, api_v3_client, project, monkeypatch):
|
||||||
|
import src.config_manager_atomic as atomic
|
||||||
|
replaced = []
|
||||||
|
real = atomic.os.replace
|
||||||
|
monkeypatch.setattr(atomic.os, "replace",
|
||||||
|
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||||
|
_upload(api_v3_client, [("a.png", PNG)])
|
||||||
|
assert ".metadata.json" in replaced
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetDelete:
|
||||||
|
@pytest.mark.parametrize("kwargs", [
|
||||||
|
{}, # no body at all
|
||||||
|
{"data": "plugin_id=x", "content_type": "application/x-www-form-urlencoded"},
|
||||||
|
{"json": ["plugin_id", "image_id"]}, # JSON, not an object
|
||||||
|
])
|
||||||
|
def test_a_missing_or_non_object_body_is_a_400(self, api_v3_client, project, kwargs):
|
||||||
|
response = api_v3_client.post("/api/v3/plugins/assets/delete", **kwargs)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["status"] == "error"
|
||||||
|
|
||||||
|
def test_metadata_is_replaced_by_rename(self, api_v3_client, project, monkeypatch):
|
||||||
|
uploaded = _upload(api_v3_client, [("a.png", PNG)]).get_json()["uploaded_files"][0]
|
||||||
|
import src.config_manager_atomic as atomic
|
||||||
|
replaced = []
|
||||||
|
real = atomic.os.replace
|
||||||
|
monkeypatch.setattr(atomic.os, "replace",
|
||||||
|
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||||
|
response = api_v3_client.post("/api/v3/plugins/assets/delete",
|
||||||
|
json={"plugin_id": "static-image",
|
||||||
|
"image_id": uploaded["id"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert ".metadata.json" in replaced
|
||||||
|
assert json.loads((project / ".metadata.json").read_text(encoding="utf-8")) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalendarCredentials:
|
||||||
|
CREDS = {"installed": {"client_id": "x", "client_secret": "y"}}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_dir(self, tmp_path, api_v3_module):
|
||||||
|
directory = tmp_path / "plugins" / "calendar"
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
def _post(self, client):
|
||||||
|
return client.post(
|
||||||
|
"/api/v3/plugins/calendar/upload-credentials",
|
||||||
|
data={"file": (io.BytesIO(json.dumps(self.CREDS).encode()), "credentials.json")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_absolute_server_path_is_not_returned(self, api_v3_client, plugin_dir):
|
||||||
|
body = self._post(api_v3_client).get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["path"] == "credentials.json"
|
||||||
|
assert str(plugin_dir) not in json.dumps(body)
|
||||||
|
|
||||||
|
def test_the_file_is_replaced_by_rename(self, api_v3_client, plugin_dir, monkeypatch):
|
||||||
|
(plugin_dir / "credentials.json").write_text(json.dumps({"installed": {"old": 1}}))
|
||||||
|
import src.config_manager_atomic as atomic
|
||||||
|
replaced = []
|
||||||
|
real = atomic.os.replace
|
||||||
|
monkeypatch.setattr(atomic.os, "replace",
|
||||||
|
lambda s, d: (replaced.append(Path(d).name), real(s, d)))
|
||||||
|
assert self._post(api_v3_client).status_code == 200
|
||||||
|
assert replaced == ["credentials.json"]
|
||||||
|
assert json.loads((plugin_dir / "credentials.json").read_text()) == self.CREDS
|
||||||
@@ -261,8 +261,13 @@ class TestApMode:
|
|||||||
@pytest.mark.parametrize("raw,expected", [
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
(True, True), (False, False),
|
(True, True), (False, False),
|
||||||
("true", True), ("TRUE", True), ("1", True),
|
("true", True), ("TRUE", True), ("1", True),
|
||||||
("false", False), ("no", False), ("yes", False),
|
("false", False), ("no", False),
|
||||||
(1, False), # only real True or the listed strings count
|
# Parsed by _parse_bool_ish like every other boolean on these routes
|
||||||
|
# (the radio route's force included); this one used to have its own
|
||||||
|
# rules, under which "yes" and 1 meant False.
|
||||||
|
("yes", True), (1, True), (0, False),
|
||||||
|
# Anything unrecognised is not a request to force.
|
||||||
|
("typo", False), (None, False), (2, False),
|
||||||
])
|
])
|
||||||
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
def test_force_coercion(self, api_v3_client, wifi_manager, raw, expected):
|
||||||
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
wifi_manager.enable_ap_mode.return_value = (True, "ok")
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Web routes find a plugin through the plugin manager's resolver.
|
||||||
|
|
||||||
|
Several routes built ``plugins_dir/<id>`` themselves. A plugin whose
|
||||||
|
directory is ``ledmatrix-<id>`` (the store's repo naming), or whose directory
|
||||||
|
name is not its manifest id, is not there, so those routes silently worked
|
||||||
|
on nothing: the installed list never refreshed its manifest or read its git
|
||||||
|
info, the recorded version was '', the update route compared manifests and
|
||||||
|
commits of a directory that does not exist, and the plugin pages 404'd or
|
||||||
|
rendered no schema. ``_plugin_directory()`` / ``get_plugin_directory()``
|
||||||
|
(src/plugin_system/plugin_dirs.py) is the one answer to "where is plugin X".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_dirs import resolve_plugin_dir # noqa: E402
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver(plugins_dir):
|
||||||
|
"""What PluginManager.get_plugin_directory does for an id discovery has
|
||||||
|
not mapped: <id>, then ledmatrix-<id>, in plugins_dir."""
|
||||||
|
def get_plugin_directory(plugin_id):
|
||||||
|
found = resolve_plugin_dir(plugin_id, [plugins_dir], prefix=True,
|
||||||
|
by_manifest=False)
|
||||||
|
return str(found) if found else None
|
||||||
|
return get_plugin_directory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prefixed_plugin(tmp_path, api_v3_module):
|
||||||
|
"""A 'demo' plugin installed as plugins_dir/ledmatrix-demo."""
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
plugin_dir = plugins_dir / "ledmatrix-demo"
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo", "name": "Demo", "version": "2.0.0",
|
||||||
|
"description": "fresh from disk", "last_updated": "2026-09-01",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
api.plugin_manager.plugins_dir = str(plugins_dir)
|
||||||
|
api.plugin_manager.get_plugin_directory = MagicMock(side_effect=_resolver(plugins_dir))
|
||||||
|
api.plugin_store_manager.plugins_dir = str(plugins_dir)
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledList:
|
||||||
|
def test_the_manifest_is_refreshed_from_the_prefixed_directory(
|
||||||
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
|
||||||
|
"description": "stale cached copy", "loaded": False}
|
||||||
|
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
|
||||||
|
api.plugin_manager.get_plugin = MagicMock(return_value=None)
|
||||||
|
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||||
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||||
|
api.config_manager.load_config = MagicMock(return_value={})
|
||||||
|
|
||||||
|
response = api_v3_client.get("/api/v3/plugins/installed")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
[entry] = [p for p in response.get_json()["data"]["plugins"] if p["id"] == "demo"]
|
||||||
|
assert entry["description"] == "fresh from disk"
|
||||||
|
# And git info is read from the same directory.
|
||||||
|
api.plugin_store_manager._get_local_git_info.assert_called_once_with(prefixed_plugin)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordedVersion:
|
||||||
|
def test_the_version_is_read_from_the_prefixed_directory(
|
||||||
|
self, api_v3_module, prefixed_plugin):
|
||||||
|
assert api_v3_module._get_plugin_version("demo") == "2.0.0"
|
||||||
|
|
||||||
|
def test_an_unsafe_id_is_still_refused(self, api_v3_module, prefixed_plugin):
|
||||||
|
assert api_v3_module._get_plugin_version("../ledmatrix-demo") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateRoute:
|
||||||
|
def _update(self, client, api):
|
||||||
|
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
|
||||||
|
api.plugin_store_manager.update_plugin = MagicMock(return_value=True)
|
||||||
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||||
|
api.plugin_manager.plugins = {}
|
||||||
|
api.schema_manager = None
|
||||||
|
api.plugin_state_manager = None
|
||||||
|
api.operation_history = None
|
||||||
|
return client.post("/api/v3/plugins/update", json={"plugin_id": "demo"})
|
||||||
|
|
||||||
|
def test_it_works_on_the_prefixed_directory(
|
||||||
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
response = self._update(api_v3_client, api)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
# The manifest it reports from is the plugin's, not a missing one.
|
||||||
|
assert response.get_json()["data"]["last_updated"] == "2026-09-01"
|
||||||
|
called_with = {c.args[0] for c in api.plugin_store_manager._get_local_git_info.call_args_list}
|
||||||
|
assert called_with == {prefixed_plugin}
|
||||||
|
|
||||||
|
def test_git_info_is_read_once_before_and_once_after(
|
||||||
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
||||||
|
# A third read existed only to feed a debug log line. (A plain
|
||||||
|
# plugins_dir/demo, so the old code's existence check let it run.)
|
||||||
|
(prefixed_plugin.parent / "demo").mkdir()
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
self._update(api_v3_client, api)
|
||||||
|
assert api.plugin_store_manager._get_local_git_info.call_count == 2
|
||||||
|
|
||||||
|
def test_a_plugin_that_is_not_installed_touches_no_path(
|
||||||
|
self, api_v3_client, api_v3_module, prefixed_plugin):
|
||||||
|
# The directory is taken from a listing of plugins_dir, so an id
|
||||||
|
# with nothing by that name installed never becomes a path at all.
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)
|
||||||
|
api.plugin_store_manager.update_plugin = MagicMock(return_value=False)
|
||||||
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||||
|
api.plugin_manager.plugins = {}
|
||||||
|
api.operation_history = None
|
||||||
|
|
||||||
|
response = api_v3_client.post("/api/v3/plugins/update", json={"plugin_id": "ghost"})
|
||||||
|
|
||||||
|
assert response.status_code >= 400
|
||||||
|
assert "not found" in response.get_json()["message"]
|
||||||
|
api.plugin_store_manager._get_local_git_info.assert_not_called()
|
||||||
|
api.plugin_store_manager.update_plugin.assert_called_once_with("ghost")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pages(tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import pages_v3 as module
|
||||||
|
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.plugins_dir = plugins_dir
|
||||||
|
plugin_manager.get_plugin.return_value = None
|
||||||
|
monkeypatch.setattr(module.pages_v3, "plugin_manager", plugin_manager, raising=False)
|
||||||
|
monkeypatch.setattr(module.pages_v3, "config_manager",
|
||||||
|
MagicMock(load_config=lambda: {}), raising=False)
|
||||||
|
monkeypatch.setattr(module.pages_v3, "schema_manager", None, raising=False)
|
||||||
|
monkeypatch.setattr(module.pages_v3, "plugin_store_manager", MagicMock(), raising=False)
|
||||||
|
app = Flask(__name__, template_folder=str(
|
||||||
|
Path(module.__file__).resolve().parents[1] / "templates"))
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(module.pages_v3)
|
||||||
|
return module, plugins_dir, plugin_manager, app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginPages:
|
||||||
|
def test_web_ui_is_served_from_the_directory_discovery_found(self, pages):
|
||||||
|
# A directory name that is neither <id> nor ledmatrix-<id>: only the
|
||||||
|
# plugin manager's discovery map knows it holds 'radar'.
|
||||||
|
_, plugins_dir, plugin_manager, client = pages
|
||||||
|
web_ui = plugins_dir / "Radar-Checkout" / "web_ui"
|
||||||
|
web_ui.mkdir(parents=True)
|
||||||
|
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
|
||||||
|
plugin_manager.get_plugin_directory.side_effect = (
|
||||||
|
lambda pid: str(plugins_dir / "Radar-Checkout") if pid == "radar" else None)
|
||||||
|
|
||||||
|
response = client.get("/plugin-ui/radar/web-ui/panel.html")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "radar panel" in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_the_config_form_reads_the_prefixed_directorys_schema(self, pages):
|
||||||
|
_, plugins_dir, plugin_manager, client = pages
|
||||||
|
plugin_dir = plugins_dir / "ledmatrix-weather"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
(plugin_dir / "config_schema.json").write_text(json.dumps({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"enabled": {"type": "boolean"},
|
||||||
|
"units": {"type": "string", "title": "Turn It On"}},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(plugin_dir / "manifest.json").write_text(
|
||||||
|
json.dumps({"id": "weather", "name": "Weather"}), encoding="utf-8")
|
||||||
|
plugin_manager.get_plugin_info.return_value = {"id": "weather", "name": "Weather"}
|
||||||
|
plugin_manager.get_plugin_directory.side_effect = _resolver(plugins_dir)
|
||||||
|
|
||||||
|
response = client.get("/partials/plugin-config/weather")
|
||||||
|
|
||||||
|
# plugins_dir/weather has no schema, which rendered as a 500
|
||||||
|
# "schema unavailable".
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)[:300]
|
||||||
|
assert "Turn It On" in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialsDoNoUnusedWork:
|
||||||
|
def test_the_plugins_tab_reads_no_plugin_data(self, pages):
|
||||||
|
# plugins.html takes no plugin list; plugins_manager.js fetches it.
|
||||||
|
_, _, plugin_manager, client = pages
|
||||||
|
response = client.get("/partials/plugins")
|
||||||
|
assert response.status_code == 200
|
||||||
|
plugin_manager.get_all_plugin_info.assert_not_called()
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""WiFi / AP-mode fixes around the web UI and the monitor daemon.
|
||||||
|
|
||||||
|
* disconnect_from_network ran an AP-mode check that could never enable the
|
||||||
|
AP: the web routes build a fresh WiFiManager per request, whose grace
|
||||||
|
counter starts at 0 and needs 3 consecutive checks. It only added sleeps.
|
||||||
|
* The monitor daemon read wifi_config.json once at startup, so the web UI's
|
||||||
|
auto-enable toggle (which only writes the file) had no effect until the
|
||||||
|
daemon restarted.
|
||||||
|
* The captive-portal endpoints only checked hostapd, but enable_ap_mode falls
|
||||||
|
back to an nmcli AP; with that AP up, phones got "internet works".
|
||||||
|
* The LED status file path was a module global set by whichever WiFiManager
|
||||||
|
was built first, and the config path fell back to /home/ledpi/LEDMatrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import src.wifi_manager as wm # noqa: E402
|
||||||
|
from src.wifi_manager import WiFiManager, WiFiStatus # noqa: E402
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(stdout=""):
|
||||||
|
return SimpleNamespace(returncode=0, stdout=stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(config_path):
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok("wlan0\n")):
|
||||||
|
manager = WiFiManager(config_path=config_path)
|
||||||
|
manager._wifi_interface = "wlan0"
|
||||||
|
manager.has_nmcli = True
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisconnect:
|
||||||
|
def test_no_ap_check_and_no_extra_sleep(self, tmp_path):
|
||||||
|
manager = _manager(tmp_path / "config" / "wifi_config.json")
|
||||||
|
manager.get_wifi_status = MagicMock(
|
||||||
|
return_value=WiFiStatus(connected=True, ssid="home"))
|
||||||
|
manager._find_profile_for_ssid = MagicMock(return_value=None)
|
||||||
|
manager.check_and_manage_ap_mode = MagicMock()
|
||||||
|
sleeps = []
|
||||||
|
with patch("src.wifi_manager.subprocess.run", return_value=_ok()), \
|
||||||
|
patch("src.wifi_manager.time.sleep", side_effect=sleeps.append):
|
||||||
|
ok, _message = manager.disconnect_from_network()
|
||||||
|
assert ok
|
||||||
|
manager.check_and_manage_ap_mode.assert_not_called()
|
||||||
|
assert sum(sleeps) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestLedStatusFile:
|
||||||
|
def test_each_manager_writes_next_to_its_own_config(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(wm, "LED_STATUS_FILE", None)
|
||||||
|
first = _manager(tmp_path / "a" / "wifi_config.json")
|
||||||
|
second = _manager(tmp_path / "b" / "wifi_config.json")
|
||||||
|
second._show_led_message("hello", duration=3)
|
||||||
|
assert json.loads((tmp_path / "b" / "wifi_status.json").read_text())["message"] == "hello"
|
||||||
|
assert not (tmp_path / "a" / "wifi_status.json").exists()
|
||||||
|
first._show_led_message("from a")
|
||||||
|
assert (tmp_path / "a" / "wifi_status.json").exists()
|
||||||
|
|
||||||
|
def test_the_default_manager_writes_where_the_display_reads(self, tmp_path):
|
||||||
|
manager = _manager(tmp_path / "config" / "wifi_config.json")
|
||||||
|
assert manager._led_status_file == tmp_path / "config" / "wifi_status.json"
|
||||||
|
# And for the default config, that is get_wifi_status_path().
|
||||||
|
assert wm.get_wifi_config_path().parent / "wifi_status.json" == wm.get_wifi_status_path()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigPathFallback:
|
||||||
|
def test_without_a_config_dir_the_project_root_is_used(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("LEDMATRIX_ROOT", raising=False)
|
||||||
|
# A fresh checkout has no config/ yet; the fallback was a hardcoded
|
||||||
|
# /home/ledpi/LEDMatrix, which is nobody's checkout on most installs.
|
||||||
|
monkeypatch.setattr(pathlib.Path, "exists", lambda self: False)
|
||||||
|
assert wm.get_wifi_config_path() == REPO / "config" / "wifi_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_daemon():
|
||||||
|
"""Import scripts/utils/wifi_monitor_daemon.py without its /var/log
|
||||||
|
FileHandler, which cannot be opened off the Pi."""
|
||||||
|
path = REPO / "scripts" / "utils" / "wifi_monitor_daemon.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("wifi_monitor_daemon_under_test", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
with patch.object(logging, "FileHandler", lambda *a, **k: logging.NullHandler()):
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class TestDaemonReloadsConfig:
|
||||||
|
@pytest.fixture
|
||||||
|
def daemon(self, tmp_path):
|
||||||
|
module = _load_daemon()
|
||||||
|
config_path = tmp_path / "wifi_config.json"
|
||||||
|
config_path.write_text(json.dumps({"auto_enable_ap_mode": True}))
|
||||||
|
|
||||||
|
manager = SimpleNamespace(config_path=config_path,
|
||||||
|
config={"auto_enable_ap_mode": True})
|
||||||
|
manager._load_config = MagicMock(side_effect=lambda: manager.config.update(
|
||||||
|
json.loads(config_path.read_text())))
|
||||||
|
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
|
||||||
|
daemon.wifi_manager = manager
|
||||||
|
daemon._config_mtime = daemon._config_file_mtime()
|
||||||
|
return daemon, manager, config_path
|
||||||
|
|
||||||
|
def _rewrite(self, config_path, data):
|
||||||
|
before = config_path.stat().st_mtime_ns
|
||||||
|
config_path.write_text(json.dumps(data))
|
||||||
|
os.utime(config_path, ns=(before + 10**9, before + 10**9))
|
||||||
|
|
||||||
|
def test_a_changed_file_is_reread(self, daemon):
|
||||||
|
daemon, manager, config_path = daemon
|
||||||
|
self._rewrite(config_path, {"auto_enable_ap_mode": False})
|
||||||
|
daemon._reload_config_if_changed()
|
||||||
|
assert manager.config["auto_enable_ap_mode"] is False
|
||||||
|
|
||||||
|
def test_an_unchanged_file_is_not(self, daemon):
|
||||||
|
daemon, manager, _ = daemon
|
||||||
|
daemon._reload_config_if_changed()
|
||||||
|
daemon._reload_config_if_changed()
|
||||||
|
manager._load_config.assert_not_called()
|
||||||
|
|
||||||
|
def test_the_loop_rereads_before_each_check(self, tmp_path):
|
||||||
|
module = _load_daemon()
|
||||||
|
daemon = module.WiFiMonitorDaemon.__new__(module.WiFiMonitorDaemon)
|
||||||
|
daemon.check_interval = 0
|
||||||
|
daemon.running = True
|
||||||
|
daemon.last_state = None
|
||||||
|
daemon._consecutive_internet_failures = 0
|
||||||
|
daemon._nm_restart_threshold = 5
|
||||||
|
order = []
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.config = {}
|
||||||
|
manager.get_wifi_status.return_value = WiFiStatus(connected=False)
|
||||||
|
manager._is_ethernet_connected.return_value = False
|
||||||
|
manager.check_and_manage_ap_mode_with_state.side_effect = lambda: (
|
||||||
|
order.append("check"), (False, WiFiStatus(connected=False), False, False))[1]
|
||||||
|
daemon.wifi_manager = manager
|
||||||
|
daemon._reload_config_if_changed = lambda: order.append("reload")
|
||||||
|
|
||||||
|
def stop(_seconds):
|
||||||
|
daemon.running = False
|
||||||
|
with patch.object(module.time, "sleep", side_effect=stop):
|
||||||
|
daemon.run()
|
||||||
|
assert order == ["reload", "check"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptivePortalSeesTheNmcliAp:
|
||||||
|
@pytest.fixture
|
||||||
|
def web_app(self, monkeypatch):
|
||||||
|
import web_interface.app as web_app
|
||||||
|
from web_interface.cache import TTLCache
|
||||||
|
monkeypatch.setattr(web_app, "_service_status_cache", TTLCache())
|
||||||
|
monkeypatch.setattr(web_app, "_SYSTEMCTL", "/bin/systemctl")
|
||||||
|
monkeypatch.setattr(web_app, "_NMCLI", "/usr/bin/nmcli")
|
||||||
|
return web_app
|
||||||
|
|
||||||
|
def _run(self, active_connections):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(argv, **kwargs):
|
||||||
|
calls.append(argv)
|
||||||
|
if "is-active" in argv:
|
||||||
|
return _ok("inactive\n")
|
||||||
|
return _ok(active_connections)
|
||||||
|
return calls, run
|
||||||
|
|
||||||
|
def test_an_nmcli_ap_counts_as_ap_mode(self, web_app, monkeypatch):
|
||||||
|
calls, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
|
||||||
|
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||||
|
assert web_app.is_ap_mode_active() is True
|
||||||
|
# Cached: these endpoints are hit per request.
|
||||||
|
web_app.is_ap_mode_active()
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
def test_an_ordinary_wifi_connection_does_not(self, web_app, monkeypatch):
|
||||||
|
_, run = self._run("home:802-11-wireless\nWired connection 1:802-3-ethernet\n")
|
||||||
|
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||||
|
assert web_app.is_ap_mode_active() is False
|
||||||
|
|
||||||
|
def test_the_detection_endpoints_redirect(self, web_app, monkeypatch):
|
||||||
|
_, run = self._run("LEDMatrix-Setup-AP:802-11-wireless\n")
|
||||||
|
monkeypatch.setattr(web_app.subprocess, "run", run)
|
||||||
|
web_app.app.config["TESTING"] = True
|
||||||
|
with web_app.app.test_client() as client:
|
||||||
|
response = client.get("/generate_204")
|
||||||
|
assert response.status_code == 302
|
||||||
@@ -178,6 +178,25 @@ class TestSaveRawSecrets:
|
|||||||
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||||
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
assert env.client.post(SECRETS, json={"a": 1}).status_code == 500
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("error,code", [
|
||||||
|
(ConfigError("cannot write", config_path="/etc/s.json"), "CONFIG_SAVE_FAILED"),
|
||||||
|
(RuntimeError("nope"), "UNKNOWN_ERROR"),
|
||||||
|
])
|
||||||
|
def test_errors_answer_in_the_main_routes_shape(self, env, monkeypatch, error, code):
|
||||||
|
# Both raw routes build their 500 with one helper now; this one used
|
||||||
|
# to hand-roll a body without error_code or context. raw_json.html
|
||||||
|
# reads only `message`, which both shapes carry.
|
||||||
|
def boom(kind, data):
|
||||||
|
raise error
|
||||||
|
monkeypatch.setattr(env.config_manager, "save_raw_file_content", boom)
|
||||||
|
secrets = env.client.post(SECRETS, json={"a": 1})
|
||||||
|
main = env.client.post(MAIN, json={"a": 1})
|
||||||
|
assert secrets.status_code == main.status_code == 500
|
||||||
|
body = secrets.get_json()
|
||||||
|
assert body["error_code"] == code
|
||||||
|
assert body["message"] == main.get_json()["message"]
|
||||||
|
assert set(body) == set(main.get_json())
|
||||||
|
|
||||||
|
|
||||||
class TestRawEndpointsBypassSecretSeparation:
|
class TestRawEndpointsBypassSecretSeparation:
|
||||||
"""Pinned behaviour, deliberately not "fixed".
|
"""Pinned behaviour, deliberately not "fixed".
|
||||||
|
|||||||
@@ -122,8 +122,22 @@ def test_credentials_are_redacted_from_the_detail(client):
|
|||||||
assert body["details"].startswith("RuntimeError: forced failure")
|
assert body["details"].startswith("RuntimeError: forced failure")
|
||||||
|
|
||||||
|
|
||||||
def test_a_client_error_keeps_its_own_status(client):
|
def _raise_415():
|
||||||
|
raise UnsupportedMediaType(
|
||||||
|
"Did not attempt to load JSON data because the request Content-Type "
|
||||||
|
"was not 'application/json'.")
|
||||||
|
|
||||||
|
|
||||||
|
# An api_v3 route raising a 415 from inside. No route does that on its own
|
||||||
|
# any more (the asset delete route, which used to, now reads its body with
|
||||||
|
# get_json(silent=True)), so one route's view is swapped for one that does;
|
||||||
|
# the endpoint stays api_v3's, so its error handler is the one that answers.
|
||||||
|
_SWAPPED_ENDPOINT = "api_v3.delete_plugin_asset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_error_keeps_its_own_status(client, monkeypatch):
|
||||||
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
||||||
|
monkeypatch.setitem(client.application.view_functions, _SWAPPED_ENDPOINT, _raise_415)
|
||||||
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
||||||
content_type="text/plain")
|
content_type="text/plain")
|
||||||
assert resp.status_code == 415
|
assert resp.status_code == 415
|
||||||
@@ -151,8 +165,9 @@ class TestInTheRealApp:
|
|||||||
assert resp.get_json() == EXPECTED
|
assert resp.get_json() == EXPECTED
|
||||||
|
|
||||||
def test_client_errors_read_the_same_as_the_global_handler(
|
def test_client_errors_read_the_same_as_the_global_handler(
|
||||||
self, web_app, exploding_managers):
|
self, web_app, exploding_managers, monkeypatch):
|
||||||
"""The blueprint's 4xx shape must not drift from app.py's."""
|
"""The blueprint's 4xx shape must not drift from app.py's."""
|
||||||
|
monkeypatch.setitem(web_app.app.view_functions, _SWAPPED_ENDPOINT, _raise_415)
|
||||||
resp = web_app.app.test_client().post(
|
resp = web_app.app.test_client().post(
|
||||||
"/api/v3/plugins/assets/delete", data="not json",
|
"/api/v3/plugins/assets/delete", data="not json",
|
||||||
content_type="text/plain")
|
content_type="text/plain")
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ class TestUpdateRouteReportsNoOps:
|
|||||||
self._install(tmp_path, 'clock', '1.0.0')
|
self._install(tmp_path, 'clock', '1.0.0')
|
||||||
store._get_local_git_info.side_effect = [
|
store._get_local_git_info.side_effect = [
|
||||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
|
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
|
||||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # is-git check
|
|
||||||
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
|
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
|
||||||
]
|
]
|
||||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Small web-backend fixes: BDF font preview, the SSE broadcaster restart
|
||||||
|
window, start.py's werkzeug log filter, and the backup routes' errors."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBdfFontPreview:
|
||||||
|
"""The route refused every BDF font ("needs complex rendering") although
|
||||||
|
src/common/bdf_font.py draws them for the panel."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fonts_root(self, tmp_path, api_v3_module, monkeypatch):
|
||||||
|
import web_interface.blueprints.api_v3.fonts as fonts_module
|
||||||
|
fonts_dir = tmp_path / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True)
|
||||||
|
shutil.copy(REPO / "assets" / "fonts" / "6x10.bdf", fonts_dir / "6x10.bdf")
|
||||||
|
monkeypatch.setattr(fonts_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
return fonts_dir
|
||||||
|
|
||||||
|
def _preview(self, client, **params):
|
||||||
|
query = {"font": "6x10.bdf", "text": "Hi", "size": 10,
|
||||||
|
"bg": "000000", "fg": "ffffff"}
|
||||||
|
query.update(params)
|
||||||
|
return client.get("/api/v3/fonts/preview", query_string=query)
|
||||||
|
|
||||||
|
def test_a_bdf_font_renders(self, api_v3_client, fonts_root):
|
||||||
|
response = self._preview(api_v3_client)
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
data = response.get_json()["data"]
|
||||||
|
assert data["image"].startswith("data:image/png;base64,")
|
||||||
|
image = Image.open(io.BytesIO(base64.b64decode(data["image"].split(",", 1)[1])))
|
||||||
|
assert image.size == (data["width"], data["height"])
|
||||||
|
colors = {c for _, c in image.convert("RGB").getcolors(10**6)}
|
||||||
|
assert (255, 255, 255) in colors
|
||||||
|
|
||||||
|
def test_the_glyphs_are_the_panels(self, api_v3_client, fonts_root):
|
||||||
|
# The same rasterizer as the panel: exactly the pixels draw_bdf_text
|
||||||
|
# lights for this string, nothing anti-aliased.
|
||||||
|
from PIL import ImageDraw
|
||||||
|
from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||||
|
data = self._preview(api_v3_client, text="A").get_json()["data"]
|
||||||
|
preview = Image.open(io.BytesIO(base64.b64decode(data["image"].split(",", 1)[1]))).convert("RGB")
|
||||||
|
|
||||||
|
face, _ = load_bdf_face(str(fonts_root / "6x10.bdf"), 10)
|
||||||
|
glyph = Image.new("RGB", (20, 20))
|
||||||
|
draw_bdf_text(ImageDraw.Draw(glyph), "A", 0, 0, face, color=(255, 255, 255))
|
||||||
|
lit = sum(1 for p in glyph.getdata() if p == (255, 255, 255))
|
||||||
|
assert lit > 0
|
||||||
|
assert sum(1 for p in preview.getdata() if p == (255, 255, 255)) == lit
|
||||||
|
assert set(preview.getdata()) <= {(0, 0, 0), (255, 255, 255)}
|
||||||
|
|
||||||
|
def test_multi_line_text_is_taller(self, api_v3_client, fonts_root):
|
||||||
|
one = self._preview(api_v3_client, text="Hi", size=10).get_json()["data"]
|
||||||
|
# Tall enough to exceed the 30px minimum.
|
||||||
|
three = self._preview(api_v3_client, text="a\nb\nc", size=10).get_json()["data"]
|
||||||
|
assert three["height"] > one["height"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamBroadcasterRestart:
|
||||||
|
"""Between the broadcast thread's break and its exit, is_alive() was
|
||||||
|
still True, so a client subscribing in that window got no thread."""
|
||||||
|
|
||||||
|
def test_a_subscriber_during_shutdown_gets_a_new_thread(self):
|
||||||
|
import web_interface.app as web_app
|
||||||
|
|
||||||
|
closing = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
produced = threading.Event()
|
||||||
|
started = []
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
started.append(threading.current_thread())
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
produced.set()
|
||||||
|
yield {"n": 1}
|
||||||
|
finally:
|
||||||
|
# Closing the generator after the break: hold the thread
|
||||||
|
# alive here, which is the window in question.
|
||||||
|
closing.set()
|
||||||
|
release.wait(5)
|
||||||
|
return gen()
|
||||||
|
|
||||||
|
broadcaster = web_app._StreamBroadcaster(factory)
|
||||||
|
first = broadcaster.subscribe()
|
||||||
|
assert produced.wait(5)
|
||||||
|
broadcaster.unsubscribe(first)
|
||||||
|
assert closing.wait(5), "the broadcast thread never noticed it had no clients"
|
||||||
|
try:
|
||||||
|
second = broadcaster.subscribe()
|
||||||
|
assert second.get(timeout=5) == {"n": 1}
|
||||||
|
assert len(started) == 2
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
broadcaster.unsubscribe(second)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartLogFilterExcInfo:
|
||||||
|
"""logging takes exc_info as a tuple, an exception, or True; the filter
|
||||||
|
unpacked it as a 3-tuple, so the other two raised TypeError."""
|
||||||
|
|
||||||
|
def test_every_form_yields_the_exception(self):
|
||||||
|
from web_interface.start import _exc_info_value
|
||||||
|
err = BrokenPipeError(32, "Broken pipe")
|
||||||
|
assert _exc_info_value(err) is err
|
||||||
|
assert _exc_info_value((type(err), err, None)) is err
|
||||||
|
try:
|
||||||
|
raise err
|
||||||
|
except BrokenPipeError:
|
||||||
|
assert _exc_info_value(True) is err
|
||||||
|
|
||||||
|
def test_true_outside_an_except_is_none(self):
|
||||||
|
from web_interface.start import _exc_info_value
|
||||||
|
assert _exc_info_value(True) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupErrors:
|
||||||
|
"""The backup routes' own catch-alls are gone; the blueprint handler
|
||||||
|
answers with the fields backup_restore.html reads."""
|
||||||
|
|
||||||
|
def test_a_failed_export_is_a_500_with_a_message(self, api_v3_client):
|
||||||
|
with patch("src.backup_manager.create_backup", side_effect=OSError(28, "No space left")):
|
||||||
|
response = api_v3_client.post("/api/v3/backup/export")
|
||||||
|
assert response.status_code == 500
|
||||||
|
body = response.get_json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["message"]
|
||||||
+58
-18
@@ -44,6 +44,7 @@ from src.plugin_system.operation_history import OperationHistory
|
|||||||
|
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
_SYSTEMCTL = shutil.which('systemctl')
|
_SYSTEMCTL = shutil.which('systemctl')
|
||||||
|
_NMCLI = shutil.which('nmcli')
|
||||||
_VCGENCMD = shutil.which('vcgencmd')
|
_VCGENCMD = shutil.which('vcgencmd')
|
||||||
|
|
||||||
from web_interface import display_preview
|
from web_interface import display_preview
|
||||||
@@ -288,6 +289,7 @@ except ImportError:
|
|||||||
# (AP mode) or per SSE tick (display service). A failed check keeps the last
|
# (AP mode) or per SSE tick (display service). A failed check keeps the last
|
||||||
# known answer for the same TTL rather than retrying on every request.
|
# known answer for the same TTL rather than retrying on every request.
|
||||||
from web_interface.cache import TTLCache
|
from web_interface.cache import TTLCache
|
||||||
|
from src.wifi_manager import AP_PROFILE_NAME
|
||||||
_service_status_cache = TTLCache()
|
_service_status_cache = TTLCache()
|
||||||
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine
|
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine
|
||||||
_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds
|
_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds
|
||||||
@@ -319,12 +321,49 @@ def _unit_is_active(unit, ttl):
|
|||||||
_service_status_cache.set(unit, active, ttl=ttl)
|
_service_status_cache.set(unit, active, ttl=ttl)
|
||||||
return active
|
return active
|
||||||
|
|
||||||
|
def _nmcli_ap_is_active(ttl):
|
||||||
|
"""Whether NetworkManager has our access-point profile up, cached for
|
||||||
|
``ttl`` seconds.
|
||||||
|
|
||||||
|
WiFiManager.enable_ap_mode falls back to an nmcli AP when hostapd is not
|
||||||
|
available, and there is no hostapd unit running then. The match is
|
||||||
|
WiFiManager._get_ap_status_nmcli's: our profile name, or a connection of
|
||||||
|
a hotspot type. False where there is no nmcli; on a failed check, the
|
||||||
|
last known answer.
|
||||||
|
"""
|
||||||
|
active = _service_status_cache.get('nmcli-ap')
|
||||||
|
if active is not None:
|
||||||
|
return active
|
||||||
|
active = _service_status_cache.peek('nmcli-ap', False)
|
||||||
|
if _NMCLI:
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # nosec B603 - fixed argv # nosemgrep
|
||||||
|
[_NMCLI, '-t', '-f', 'NAME,TYPE', 'connection', 'show', '--active'],
|
||||||
|
capture_output=True, text=True, timeout=2)
|
||||||
|
active = False
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split(':')
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
if parts[0].strip() == AP_PROFILE_NAME or 'hotspot' in parts[1].strip().lower():
|
||||||
|
active = True
|
||||||
|
break
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
logging.getLogger('web_interface').warning(
|
||||||
|
"nmcli active-connection check failed: %s", e)
|
||||||
|
_service_status_cache.set('nmcli-ap', active, ttl=ttl)
|
||||||
|
return active
|
||||||
|
|
||||||
def is_ap_mode_active():
|
def is_ap_mode_active():
|
||||||
"""
|
"""
|
||||||
Check if access point mode is currently active (cached, 30s TTL).
|
Check if access point mode is currently active (cached, 30s TTL).
|
||||||
Uses a direct systemctl check instead of instantiating WiFiManager.
|
Uses direct systemctl/nmcli checks instead of instantiating WiFiManager,
|
||||||
|
and the same two WiFiManager._is_ap_mode_active uses: hostapd, else the
|
||||||
|
nmcli AP it falls back to. Checking hostapd alone meant the captive
|
||||||
|
portal never triggered on a Pi whose AP came up through nmcli.
|
||||||
"""
|
"""
|
||||||
return _unit_is_active('hostapd', _AP_MODE_CACHE_TTL)
|
return (_unit_is_active('hostapd', _AP_MODE_CACHE_TTL)
|
||||||
|
or _nmcli_ap_is_active(_AP_MODE_CACHE_TTL))
|
||||||
|
|
||||||
# Captive portal detection endpoints
|
# Captive portal detection endpoints
|
||||||
# When AP mode is active, return responses that TRIGGER the captive portal popup.
|
# When AP mode is active, return responses that TRIGGER the captive portal popup.
|
||||||
@@ -377,7 +416,6 @@ def not_found_error(error):
|
|||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
"""Handle 500 errors."""
|
"""Handle 500 errors."""
|
||||||
import logging
|
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Internal server error", exc_info=True)
|
logger.error("Internal server error", exc_info=True)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -416,7 +454,6 @@ def handle_exception(error):
|
|||||||
'message': error.description,
|
'message': error.description,
|
||||||
}), error.code or 500
|
}), error.code or 500
|
||||||
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Unhandled exception", exc_info=True)
|
logger.error("Unhandled exception", exc_info=True)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -620,6 +657,11 @@ class _StreamBroadcaster:
|
|||||||
if not self._clients:
|
if not self._clients:
|
||||||
# No subscribers — exit so the thread doesn't spin indefinitely.
|
# No subscribers — exit so the thread doesn't spin indefinitely.
|
||||||
# subscribe() will restart it when a new client arrives.
|
# subscribe() will restart it when a new client arrives.
|
||||||
|
# Drop the handle here, under the lock: between this break
|
||||||
|
# and the thread actually ending (closing the generator
|
||||||
|
# can take a while) is_alive() is still True, and a
|
||||||
|
# client subscribing then got no thread at all.
|
||||||
|
self._thread = None
|
||||||
break
|
break
|
||||||
for q in self._clients:
|
for q in self._clients:
|
||||||
try:
|
try:
|
||||||
@@ -870,8 +912,7 @@ def favicon():
|
|||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
_reconciliation_started = False
|
_reconciliation_started = False
|
||||||
import threading as _threading
|
_reconciliation_lock = threading.Lock()
|
||||||
_reconciliation_lock = _threading.Lock()
|
|
||||||
|
|
||||||
def _run_startup_reconciliation() -> None:
|
def _run_startup_reconciliation() -> None:
|
||||||
"""Run state reconciliation in background to auto-repair missing plugins.
|
"""Run state reconciliation in background to auto-repair missing plugins.
|
||||||
@@ -911,7 +952,7 @@ def _run_startup_reconciliation() -> None:
|
|||||||
# Write status file so the web UI can surface unresolved issues as a
|
# Write status file so the web UI can surface unresolved issues as a
|
||||||
# banner without the user having to read journalctl. Mirrors the
|
# banner without the user having to read journalctl. Mirrors the
|
||||||
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
||||||
import json as _json, tempfile as _tempfile, os as _os
|
import tempfile
|
||||||
_recon_status = {
|
_recon_status = {
|
||||||
"done": True,
|
"done": True,
|
||||||
"successful": result.reconciliation_successful,
|
"successful": result.reconciliation_successful,
|
||||||
@@ -925,21 +966,21 @@ def _run_startup_reconciliation() -> None:
|
|||||||
for inc in result.inconsistencies_manual
|
for inc in result.inconsistencies_manual
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
||||||
_tmp = None
|
_tmp = None
|
||||||
try:
|
try:
|
||||||
if not _os.path.islink(_recon_path):
|
if not os.path.islink(_recon_path):
|
||||||
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
|
_fd, _tmp = tempfile.mkstemp(dir=tempfile.gettempdir(), prefix=".led_recon_")
|
||||||
with _os.fdopen(_fd, "w") as _f:
|
with os.fdopen(_fd, "w") as _f:
|
||||||
_json.dump(_recon_status, _f)
|
json.dump(_recon_status, _f)
|
||||||
_os.replace(_tmp, _recon_path)
|
os.replace(_tmp, _recon_path)
|
||||||
_tmp = None # Rename succeeded; nothing to clean up
|
_tmp = None # Rename succeeded; nothing to clean up
|
||||||
except (OSError, ValueError, TypeError) as _e:
|
except (OSError, ValueError, TypeError) as _e:
|
||||||
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
||||||
finally:
|
finally:
|
||||||
if _tmp is not None and _os.path.exists(_tmp):
|
if _tmp is not None and os.path.exists(_tmp):
|
||||||
try:
|
try:
|
||||||
_os.unlink(_tmp)
|
os.unlink(_tmp)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -953,7 +994,7 @@ def start_startup_reconciliation():
|
|||||||
with _reconciliation_lock:
|
with _reconciliation_lock:
|
||||||
if not _reconciliation_started:
|
if not _reconciliation_started:
|
||||||
_reconciliation_started = True
|
_reconciliation_started = True
|
||||||
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
|
||||||
|
|
||||||
_auto_updater = None
|
_auto_updater = None
|
||||||
|
|
||||||
@@ -983,10 +1024,9 @@ def start_auto_update_scheduler():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import os as _os
|
|
||||||
start_auto_update_scheduler()
|
start_auto_update_scheduler()
|
||||||
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
# threaded=True is Flask's default since 1.0 but stated explicitly so that
|
||||||
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
|
||||||
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
|
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
|
||||||
_debug = _os.environ.get('FLASK_DEBUG', '0') == '1'
|
_debug = os.environ.get('FLASK_DEBUG', '0') == '1'
|
||||||
app.run(host='0.0.0.0', port=5000, debug=_debug, threaded=True) # nosec B104 - intentional; local network device
|
app.run(host='0.0.0.0', port=5000, debug=_debug, threaded=True) # nosec B104 - intentional; local network device
|
||||||
|
|||||||
@@ -170,9 +170,16 @@ def _get_plugin_version(plugin_id: str) -> str:
|
|||||||
that arrived in a request body, so the name is validated here rather
|
that arrived in a request body, so the name is validated here rather
|
||||||
than relying on each of them to have done it.
|
than relying on each of them to have done it.
|
||||||
"""
|
"""
|
||||||
manifest_path = resolve_under(
|
# The resolver first: a plugin installed as ledmatrix-<id>, or whose
|
||||||
api_v3.plugin_store_manager.plugins_dir, plugin_id, "manifest.json"
|
# directory is named differently from its manifest id, is not at
|
||||||
)
|
# plugins_dir/<id>, and recording '' as its version hid that it worked.
|
||||||
|
plugin_dir = _plugin_directory(plugin_id)
|
||||||
|
if plugin_dir is not None:
|
||||||
|
manifest_path = plugin_dir / "manifest.json"
|
||||||
|
else:
|
||||||
|
manifest_path = resolve_under(
|
||||||
|
api_v3.plugin_store_manager.plugins_dir, plugin_id, "manifest.json"
|
||||||
|
)
|
||||||
if manifest_path is None:
|
if manifest_path is None:
|
||||||
logger.warning("[PluginVersion] Rejected unsafe plugin id %r", plugin_id)
|
logger.warning("[PluginVersion] Rejected unsafe plugin id %r", plugin_id)
|
||||||
return ''
|
return ''
|
||||||
@@ -1420,9 +1427,11 @@ def _plugin_directory(plugin_id: str) -> Optional[Path]:
|
|||||||
is no fallback to the legacy plugins/ directory: the loader never scans
|
is no fallback to the legacy plugins/ directory: the loader never scans
|
||||||
it, so a plugin found only there is one that never runs.
|
it, so a plugin found only there is one that never runs.
|
||||||
"""
|
"""
|
||||||
if not api_v3.plugin_manager:
|
# getattr: the blueprint only has plugin_manager once the app has set it.
|
||||||
|
manager = getattr(api_v3, 'plugin_manager', None)
|
||||||
|
if not manager:
|
||||||
return None
|
return None
|
||||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
plugin_dir = manager.get_plugin_directory(plugin_id)
|
||||||
if not plugin_dir or not Path(plugin_dir).exists():
|
if not plugin_dir or not Path(plugin_dir).exists():
|
||||||
return None
|
return None
|
||||||
return Path(plugin_dir)
|
return Path(plugin_dir)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
||||||
so their endpoint names are unchanged by living here.
|
so their endpoint names are unchanged by living here.
|
||||||
|
|
||||||
|
No route here catches Exception: the blueprint's handler in `__init__`
|
||||||
|
logs it and answers 500 with the same `status`/`message` fields the
|
||||||
|
backup page reads (backup_restore.html), plus `details`.
|
||||||
"""
|
"""
|
||||||
from web_interface.blueprints.api_v3 import (
|
from web_interface.blueprints.api_v3 import (
|
||||||
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
||||||
@@ -17,78 +21,62 @@ import web_interface.blueprints.api_v3 as _pkg
|
|||||||
@api_v3.route('/backup/preview', methods=['GET'])
|
@api_v3.route('/backup/preview', methods=['GET'])
|
||||||
def backup_preview():
|
def backup_preview():
|
||||||
"""Return a summary of what a new backup would include."""
|
"""Return a summary of what a new backup would include."""
|
||||||
try:
|
from src.backup_manager import preview_backup_contents
|
||||||
from src.backup_manager import preview_backup_contents
|
data = preview_backup_contents(PROJECT_ROOT)
|
||||||
data = preview_backup_contents(PROJECT_ROOT)
|
return jsonify({'status': 'success', 'data': data})
|
||||||
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'])
|
@api_v3.route('/backup/list', methods=['GET'])
|
||||||
def backup_list():
|
def backup_list():
|
||||||
"""List backup ZIPs stored in the export directory."""
|
"""List backup ZIPs stored in the export directory."""
|
||||||
try:
|
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
entries = []
|
||||||
entries = []
|
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
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':
|
||||||
if not p.is_file() or p.suffix != '.zip':
|
continue
|
||||||
continue
|
st = p.stat()
|
||||||
st = p.stat()
|
entries.append({
|
||||||
entries.append({
|
'filename': p.name,
|
||||||
'filename': p.name,
|
'size': st.st_size,
|
||||||
'size': st.st_size,
|
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
})
|
||||||
})
|
return jsonify({'status': 'success', 'data': entries})
|
||||||
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'])
|
@api_v3.route('/backup/export', methods=['POST'])
|
||||||
def backup_export():
|
def backup_export():
|
||||||
"""Create a new backup ZIP and return its filename."""
|
"""Create a new backup ZIP and return its filename."""
|
||||||
try:
|
from src.backup_manager import create_backup
|
||||||
from src.backup_manager import create_backup
|
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
||||||
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
return jsonify({'status': 'success', 'filename': zip_path.name})
|
||||||
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'])
|
@api_v3.route('/backup/validate', methods=['POST'])
|
||||||
def backup_validate():
|
def backup_validate():
|
||||||
"""Validate an uploaded backup ZIP and return its manifest."""
|
"""Validate an uploaded backup ZIP and return its manifest."""
|
||||||
|
from src.backup_manager import validate_backup
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
try:
|
try:
|
||||||
from src.backup_manager import validate_backup
|
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
||||||
if 'backup_file' not in request.files:
|
finally:
|
||||||
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:
|
try:
|
||||||
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
os.unlink(tmp_path)
|
||||||
finally:
|
except OSError:
|
||||||
try:
|
pass
|
||||||
os.unlink(tmp_path)
|
if not ok:
|
||||||
except OSError:
|
logger.warning("Backup validation failed: %s", err_msg)
|
||||||
pass
|
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
||||||
if not ok:
|
safe_manifest = {
|
||||||
logger.warning("Backup validation failed: %s", err_msg)
|
'schema_version': manifest.get('schema_version'),
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
'created_at': manifest.get('created_at'),
|
||||||
safe_manifest = {
|
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
||||||
'schema_version': manifest.get('schema_version'),
|
'hostname': manifest.get('hostname'),
|
||||||
'created_at': manifest.get('created_at'),
|
'contents': manifest.get('contents', []),
|
||||||
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
'detected_contents': manifest.get('detected_contents', []),
|
||||||
'hostname': manifest.get('hostname'),
|
'plugins': manifest.get('plugins', []),
|
||||||
'contents': manifest.get('contents', []),
|
'total_uncompressed': manifest.get('total_uncompressed'),
|
||||||
'detected_contents': manifest.get('detected_contents', []),
|
'file_count': manifest.get('file_count'),
|
||||||
'plugins': manifest.get('plugins', []),
|
}
|
||||||
'total_uncompressed': manifest.get('total_uncompressed'),
|
return jsonify({'status': 'success', 'data': safe_manifest})
|
||||||
'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
|
|
||||||
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
|
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
|
||||||
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
|
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
|
||||||
#: leaving that flag at its True default -- restoring secrets a caller's
|
#: leaving that flag at its True default -- restoring secrets a caller's
|
||||||
@@ -100,105 +88,101 @@ _RESTORE_OPTION_KEYS = frozenset((
|
|||||||
@api_v3.route('/backup/restore', methods=['POST'])
|
@api_v3.route('/backup/restore', methods=['POST'])
|
||||||
def backup_restore():
|
def backup_restore():
|
||||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||||
|
from src.backup_manager import restore_backup, RestoreOptions
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
options_raw = request.form.get('options', '{}')
|
||||||
try:
|
try:
|
||||||
from src.backup_manager import restore_backup, RestoreOptions
|
opts_dict = json.loads(options_raw)
|
||||||
if 'backup_file' not in request.files:
|
except json.JSONDecodeError:
|
||||||
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
opts_dict = None
|
||||||
f = request.files['backup_file']
|
if not isinstance(opts_dict, dict):
|
||||||
options_raw = request.form.get('options', '{}')
|
# Every option defaults to True, so falling back to {} on a
|
||||||
|
# parse failure would silently perform a FULL restore —
|
||||||
|
# secrets and all — for a caller who asked for a narrow one
|
||||||
|
# and mis-serialized it. Refuse instead of guessing.
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid options: expected a JSON object',
|
||||||
|
}), 400
|
||||||
|
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
|
||||||
|
if unknown_keys:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
|
||||||
|
}), 400
|
||||||
|
# _coerce_to_bool (not bare bool()) because a request can send these
|
||||||
|
# as JSON strings: bool("false") is True in Python, so a caller who
|
||||||
|
# explicitly asked to skip secrets would have had them restored
|
||||||
|
# anyway.
|
||||||
|
options = RestoreOptions(
|
||||||
|
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
||||||
|
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
||||||
|
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
||||||
|
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
||||||
|
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
|
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
||||||
|
finally:
|
||||||
try:
|
try:
|
||||||
opts_dict = json.loads(options_raw)
|
os.unlink(tmp_path)
|
||||||
except json.JSONDecodeError:
|
except OSError:
|
||||||
opts_dict = None
|
pass
|
||||||
if not isinstance(opts_dict, dict):
|
|
||||||
# Every option defaults to True, so falling back to {} on a
|
# Reinstall plugins if requested and store manager available
|
||||||
# parse failure would silently perform a FULL restore —
|
if options.reinstall_plugins and result.plugins_to_install:
|
||||||
# secrets and all — for a caller who asked for a narrow one
|
psm = getattr(api_v3, 'plugin_store_manager', None)
|
||||||
# and mis-serialized it. Refuse instead of guessing.
|
for plug in result.plugins_to_install:
|
||||||
return jsonify({
|
pid = plug.get('plugin_id')
|
||||||
'status': 'error',
|
if not pid:
|
||||||
'message': 'Invalid options: expected a JSON object',
|
continue
|
||||||
}), 400
|
|
||||||
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
|
|
||||||
if unknown_keys:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
|
|
||||||
}), 400
|
|
||||||
# _coerce_to_bool (not bare bool()) because a request can send these
|
|
||||||
# as JSON strings: bool("false") is True in Python, so a caller who
|
|
||||||
# explicitly asked to skip secrets would have had them restored
|
|
||||||
# anyway.
|
|
||||||
options = RestoreOptions(
|
|
||||||
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
|
||||||
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
|
||||||
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
|
||||||
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
|
||||||
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
|
||||||
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
|
||||||
)
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
|
||||||
tmp_path = tmp.name
|
|
||||||
f.save(tmp_path)
|
|
||||||
try:
|
|
||||||
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
|
||||||
finally:
|
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp_path)
|
if psm and hasattr(psm, 'install_plugin'):
|
||||||
except OSError:
|
ok = psm.install_plugin(pid)
|
||||||
pass
|
if ok:
|
||||||
|
result.plugins_installed.append(pid)
|
||||||
# Reinstall plugins if requested and store manager available
|
|
||||||
if options.reinstall_plugins and result.plugins_to_install:
|
|
||||||
psm = getattr(api_v3, 'plugin_store_manager', None)
|
|
||||||
for plug in result.plugins_to_install:
|
|
||||||
pid = plug.get('plugin_id')
|
|
||||||
if not pid:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if psm and hasattr(psm, 'install_plugin'):
|
|
||||||
ok = psm.install_plugin(pid)
|
|
||||||
if ok:
|
|
||||||
result.plugins_installed.append(pid)
|
|
||||||
else:
|
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
|
||||||
else:
|
else:
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
||||||
except Exception as pe:
|
else:
|
||||||
logger.error(
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
||||||
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
|
except Exception as pe:
|
||||||
)
|
logger.error(
|
||||||
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
|
"[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
|
# A restore that dropped files can still report success if the only
|
||||||
# failures were plugin reinstalls, since those don't touch result.errors.
|
# failures were plugin reinstalls, since those don't touch result.errors.
|
||||||
if result.plugins_failed:
|
if result.plugins_failed:
|
||||||
result.success = False
|
result.success = False
|
||||||
|
|
||||||
data = result.to_dict()
|
data = result.to_dict()
|
||||||
if not result.success:
|
if not result.success:
|
||||||
# Name what failed, and what nonetheless landed. A restore is
|
# Name what failed, and what nonetheless landed. A restore is
|
||||||
# partial far more often than it is total -- a fresh install can
|
# partial far more often than it is total -- a fresh install can
|
||||||
# leave config_secrets.json unwritable by the web service, so
|
# leave config_secrets.json unwritable by the web service, so
|
||||||
# config restores and secrets do not. "Restore had errors" alone
|
# config restores and secrets do not. "Restore had errors" alone
|
||||||
# left the user unable to tell a wholly failed restore from one
|
# left the user unable to tell a wholly failed restore from one
|
||||||
# that quietly dropped their API keys.
|
# that quietly dropped their API keys.
|
||||||
failed_plugins = [
|
failed_plugins = [
|
||||||
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
||||||
]
|
]
|
||||||
parts = []
|
parts = []
|
||||||
if result.restored:
|
if result.restored:
|
||||||
parts.append(f"restored: {', '.join(result.restored)}")
|
parts.append(f"restored: {', '.join(result.restored)}")
|
||||||
if result.errors:
|
if result.errors:
|
||||||
parts.append(f"failed: {'; '.join(result.errors)}")
|
parts.append(f"failed: {'; '.join(result.errors)}")
|
||||||
if failed_plugins:
|
if failed_plugins:
|
||||||
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
||||||
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
||||||
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
||||||
return jsonify({'status': 'success', 'data': data})
|
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/<path:filename>', methods=['GET'])
|
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||||
def backup_download(filename):
|
def backup_download(filename):
|
||||||
"""Stream a backup ZIP to the browser."""
|
"""Stream a backup ZIP to the browser."""
|
||||||
|
|||||||
@@ -1150,6 +1150,27 @@ def get_secrets_config():
|
|||||||
# alone so a client can still tell "set" from "not set".
|
# alone so a client can still tell "set" from "not set".
|
||||||
return jsonify({'status': 'success',
|
return jsonify({'status': 'success',
|
||||||
'data': mask_all_secret_values(config)})
|
'data': mask_all_secret_values(config)})
|
||||||
|
def _raw_config_save_error(e):
|
||||||
|
"""The 500 both raw-config save routes answer a failed save with.
|
||||||
|
|
||||||
|
A ConfigError that names its file says which one; the rest is the same
|
||||||
|
for every failure. raw_json.html reads only ``message``, which this
|
||||||
|
keeps, alongside ``status`` and ``details`` as before.
|
||||||
|
"""
|
||||||
|
from src.exceptions import ConfigError
|
||||||
|
error_message = 'An error occurred; see logs for details'
|
||||||
|
config_path = getattr(e, 'config_path', None) if isinstance(e, ConfigError) else None
|
||||||
|
if config_path:
|
||||||
|
error_message = f"{error_message} (config_path: {config_path})"
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.CONFIG_SAVE_FAILED if isinstance(e, ConfigError) else ErrorCode.UNKNOWN_ERROR,
|
||||||
|
error_message,
|
||||||
|
details=describe_exception(e),
|
||||||
|
context={'config_path': config_path} if config_path else None,
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/config/raw/main', methods=['POST'])
|
@api_v3.route('/config/raw/main', methods=['POST'])
|
||||||
def save_raw_main_config():
|
def save_raw_main_config():
|
||||||
"""Save raw main configuration JSON"""
|
"""Save raw main configuration JSON"""
|
||||||
@@ -1193,31 +1214,8 @@ def save_raw_main_config():
|
|||||||
logger.warning("Automatic update setup could not be started", exc_info=True)
|
logger.warning("Automatic update setup could not be started", exc_info=True)
|
||||||
return jsonify({'status': 'success', 'message': message})
|
return jsonify({'status': 'success', 'message': message})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from src.exceptions import ConfigError
|
|
||||||
logger.error("Error saving raw main config", exc_info=True)
|
logger.error("Error saving raw main config", exc_info=True)
|
||||||
|
return _raw_config_save_error(e)
|
||||||
# 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'])
|
@api_v3.route('/config/raw/secrets', methods=['POST'])
|
||||||
def save_raw_secrets_config():
|
def save_raw_secrets_config():
|
||||||
"""Save raw secrets configuration JSON"""
|
"""Save raw secrets configuration JSON"""
|
||||||
@@ -1257,17 +1255,5 @@ def save_raw_secrets_config():
|
|||||||
|
|
||||||
return jsonify({'status': 'success', 'message': 'Secrets configuration saved successfully'})
|
return jsonify({'status': 'success', 'message': 'Secrets configuration saved successfully'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from src.exceptions import ConfigError
|
|
||||||
logger.error("Error saving raw secrets config", exc_info=True)
|
logger.error("Error saving raw secrets config", exc_info=True)
|
||||||
|
return _raw_config_save_error(e)
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|||||||
so their endpoint names are unchanged by living here.
|
so their endpoint names are unchanged by living here.
|
||||||
"""
|
"""
|
||||||
from web_interface.blueprints.api_v3 import (
|
from web_interface.blueprints.api_v3 import (
|
||||||
_ensure_display_service_running,
|
_coerce_to_bool, _ensure_display_service_running,
|
||||||
_get_display_service_status, _stop_display_service, api_v3,
|
_get_display_service_status, _stop_display_service, api_v3,
|
||||||
jsonify, logger, request, uuid,
|
jsonify, logger, request, uuid,
|
||||||
)
|
)
|
||||||
@@ -151,8 +151,10 @@ def start_on_demand_display():
|
|||||||
plugin_id = data.get('plugin_id')
|
plugin_id = data.get('plugin_id')
|
||||||
mode = data.get('mode')
|
mode = data.get('mode')
|
||||||
duration = data.get('duration')
|
duration = data.get('duration')
|
||||||
pinned = bool(data.get('pinned', False))
|
# _coerce_to_bool: bool("false") is True, so a string "false" pinned the
|
||||||
start_service = data.get('start_service', True)
|
# mode or (re)started the service it asked to leave alone.
|
||||||
|
pinned = _coerce_to_bool(data.get('pinned', False))
|
||||||
|
start_service = _coerce_to_bool(data.get('start_service', True))
|
||||||
|
|
||||||
if not plugin_id and not mode:
|
if not plugin_id and not mode:
|
||||||
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
||||||
|
|||||||
@@ -309,13 +309,30 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
|||||||
|
|
||||||
# Load font
|
# Load font
|
||||||
font = None
|
font = None
|
||||||
if str(font_path).endswith('.bdf'):
|
bdf_face = None
|
||||||
# BDF fonts require complex per-glyph rendering via freetype
|
lines = text.split('\n')
|
||||||
# Return explicit error rather than showing misleading preview with default font
|
line_height = 0
|
||||||
return jsonify({
|
if font_path.suffix.lower() == '.bdf':
|
||||||
'status': 'error',
|
# PIL cannot draw a BDF strike, so it goes through the same loader
|
||||||
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
# and glyph rasterizer the panel uses (src/common/bdf_font.py). The
|
||||||
}), 400
|
# face comes back at the file's native size when it has no strike at
|
||||||
|
# the one asked for -- which is also what the panel would draw.
|
||||||
|
from src.common.bdf_font import load_bdf_face, draw_bdf_text
|
||||||
|
try:
|
||||||
|
bdf_face, realised_px = load_bdf_face(str(font_path), size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[FontPreview] Failed to load BDF font %s: %s", font_path, e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not load BDF font'}), 400
|
||||||
|
line_height = (bdf_face.size.height >> 6) or realised_px
|
||||||
|
line_widths = []
|
||||||
|
for line in lines:
|
||||||
|
width = 0
|
||||||
|
for char in line:
|
||||||
|
bdf_face.load_char(char)
|
||||||
|
width += bdf_face.glyph.advance.x >> 6
|
||||||
|
line_widths.append(width)
|
||||||
|
text_width = max(line_widths)
|
||||||
|
text_height = line_height * len(lines)
|
||||||
else:
|
else:
|
||||||
# TTF/OTF fonts
|
# TTF/OTF fonts
|
||||||
try:
|
try:
|
||||||
@@ -325,12 +342,12 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
|||||||
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
# Calculate text size
|
# Calculate text size
|
||||||
temp_img = Image.new('RGB', (1, 1))
|
temp_img = Image.new('RGB', (1, 1))
|
||||||
temp_draw = ImageDraw.Draw(temp_img)
|
temp_draw = ImageDraw.Draw(temp_img)
|
||||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||||
text_width = bbox[2] - bbox[0]
|
text_width = bbox[2] - bbox[0]
|
||||||
text_height = bbox[3] - bbox[1]
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
# Create image with padding
|
# Create image with padding
|
||||||
padding = 10
|
padding = 10
|
||||||
@@ -350,7 +367,11 @@ def get_font_preview() -> tuple[Response, int] | Response:
|
|||||||
x = (img_width - text_width) // 2
|
x = (img_width - text_width) // 2
|
||||||
y = (img_height - text_height) // 2
|
y = (img_height - text_height) // 2
|
||||||
|
|
||||||
draw.text((x, y), text, font=font, fill=fg_rgb)
|
if bdf_face is not None:
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
draw_bdf_text(draw, line, x, y + index * line_height, bdf_face, color=fg_rgb)
|
||||||
|
else:
|
||||||
|
draw.text((x, y), text, font=font, fill=fg_rgb)
|
||||||
|
|
||||||
# Convert to base64
|
# Convert to base64
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ from src.common.path_safety import (
|
|||||||
)
|
)
|
||||||
from src.web_interface.config_arrays import coerce_array_shapes
|
from src.web_interface.config_arrays import coerce_array_shapes
|
||||||
from src.web_interface.validators import dedup_unique_arrays
|
from src.web_interface.validators import dedup_unique_arrays
|
||||||
|
from src.config_manager_atomic import atomic_write_text
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
from typing import Optional
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
# Several are also called from helpers that live in __init__, so the
|
# Several are also called from helpers that live in __init__, so the
|
||||||
@@ -70,9 +72,12 @@ def get_installed_plugins():
|
|||||||
plugin_state = state_info.get('state')
|
plugin_state = state_info.get('state')
|
||||||
plugin_error_info = state_info.get('error_info')
|
plugin_error_info = state_info.get('error_info')
|
||||||
|
|
||||||
# Re-read manifest from disk to ensure we have the latest metadata
|
# 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"
|
# Through the resolver, not plugins_dir/<id>: a plugin installed as
|
||||||
if manifest_path.exists():
|
# ledmatrix-<id> otherwise never had its manifest refreshed here.
|
||||||
|
plugin_path = _plugin_directory(plugin_id)
|
||||||
|
manifest_path = plugin_path / "manifest.json" if plugin_path else None
|
||||||
|
if manifest_path is not None and manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
fresh_manifest = json.load(f)
|
fresh_manifest = json.load(f)
|
||||||
@@ -103,8 +108,7 @@ def get_installed_plugins():
|
|||||||
update_available = _is_plugin_update_available(installed_version, latest_version)
|
update_available = _is_plugin_update_available(installed_version, latest_version)
|
||||||
|
|
||||||
# Local git info (single subprocess on cache miss, zero on hit)
|
# 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 else None
|
||||||
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None
|
|
||||||
|
|
||||||
if local_git_info:
|
if local_git_info:
|
||||||
sha = local_git_info.get('sha', '')
|
sha = local_git_info.get('sha', '')
|
||||||
@@ -411,7 +415,10 @@ def toggle_plugin():
|
|||||||
if not data or 'plugin_id' not in data or 'enabled' not in data:
|
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
|
return jsonify({'status': 'error', 'message': 'plugin_id and enabled required'}), 400
|
||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
enabled = data['enabled']
|
# Coerced, not stored raw: "false" is a truthy string, and this
|
||||||
|
# value is written to config.json and handed to the Starlark
|
||||||
|
# toggle, so {"enabled": "false"} used to switch a plugin ON.
|
||||||
|
enabled = _coerce_to_bool(data['enabled'])
|
||||||
else:
|
else:
|
||||||
# Form data or query string (HTMX submission)
|
# Form data or query string (HTMX submission)
|
||||||
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
|
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
|
||||||
@@ -863,6 +870,22 @@ def get_plugin_config():
|
|||||||
return success_response(data=plugin_config)
|
return success_response(data=plugin_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
||||||
|
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
|
||||||
|
"""The entry of ``base`` called ``name``, or None.
|
||||||
|
|
||||||
|
The path returned comes from listing ``base``, not from joining ``name``
|
||||||
|
onto it, so a caller that validated ``name`` doesn't have to rely on that
|
||||||
|
validation alone: nothing reaches the filesystem unless it's already there.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for entry in base.iterdir():
|
||||||
|
if entry.name == name:
|
||||||
|
return entry
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/update', methods=['POST'])
|
@api_v3.route('/plugins/update', methods=['POST'])
|
||||||
def update_plugin():
|
def update_plugin():
|
||||||
"""Update plugin"""
|
"""Update plugin"""
|
||||||
@@ -922,22 +945,31 @@ def update_plugin():
|
|||||||
|
|
||||||
# Always do direct updates (they're fast git pull operations)
|
# Always do direct updates (they're fast git pull operations)
|
||||||
# Operation queue is reserved for longer operations like install/uninstall
|
# Operation queue is reserved for longer operations like install/uninstall
|
||||||
plugins_base = Path(api_v3.plugin_store_manager.plugins_dir)
|
# The resolver finds a plugin installed as ledmatrix-<id>. Either way
|
||||||
plugin_dir = plugins_base / plugin_id
|
# the directory used is taken from a listing of plugins_dir, matched by
|
||||||
manifest_path = resolve_under(plugin_dir, "manifest.json")
|
# name, never built from the request value -- so no path here depends
|
||||||
if manifest_path is None:
|
# on user input. None means nothing by that name is installed; the
|
||||||
return error_response(
|
# store manager still gets the id and reports that itself.
|
||||||
ErrorCode.INVALID_INPUT,
|
resolved = _plugin_directory(plugin_id)
|
||||||
'Invalid plugin_id',
|
plugin_dir = _listed_plugin_dir(
|
||||||
status_code=400
|
Path(api_v3.plugin_store_manager.plugins_dir),
|
||||||
)
|
resolved.name if resolved else plugin_id)
|
||||||
|
manifest_path = None
|
||||||
|
if plugin_dir is not None:
|
||||||
|
manifest_path = resolve_under(plugin_dir, "manifest.json")
|
||||||
|
if manifest_path is None:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.INVALID_INPUT,
|
||||||
|
'Invalid plugin_id',
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
current_last_updated = None
|
current_last_updated = None
|
||||||
current_version = None
|
current_version = None
|
||||||
current_commit = None
|
current_commit = None
|
||||||
current_branch = None
|
current_branch = None
|
||||||
|
|
||||||
if manifest_path.exists():
|
if manifest_path is not None and manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
@@ -958,22 +990,12 @@ def update_plugin():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read local manifest for plugin: %s", 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)
|
||||||
git_info_before = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
|
if plugin_dir is not None else None)
|
||||||
if git_info_before:
|
if git_info_before:
|
||||||
current_commit = git_info_before.get('sha')
|
current_commit = git_info_before.get('sha')
|
||||||
current_branch = git_info_before.get('branch')
|
current_branch = git_info_before.get('branch')
|
||||||
|
logger.debug("Plugin is a git repository, will update via git pull")
|
||||||
# Check if plugin is a git repo first (for better error messages)
|
|
||||||
# plugin_id is validated above; reuse the same directory rather
|
|
||||||
# than rebuilding it from a value that might not match.
|
|
||||||
plugin_path_dir = plugin_dir
|
|
||||||
is_git_repo = False
|
|
||||||
if plugin_path_dir.exists():
|
|
||||||
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
|
|
||||||
is_git_repo = git_info is not None
|
|
||||||
if is_git_repo:
|
|
||||||
logger.debug("Plugin is a git repository, will update via git pull")
|
|
||||||
|
|
||||||
remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)
|
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_commit = remote_info.get('last_commit_sha') if remote_info else None
|
||||||
@@ -986,7 +1008,7 @@ def update_plugin():
|
|||||||
updated_last_updated = current_last_updated
|
updated_last_updated = current_last_updated
|
||||||
updated_version = current_version
|
updated_version = current_version
|
||||||
try:
|
try:
|
||||||
if manifest_path.exists():
|
if manifest_path is not None and manifest_path.exists():
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
updated_last_updated = manifest.get('last_updated', current_last_updated)
|
||||||
@@ -996,11 +1018,11 @@ def update_plugin():
|
|||||||
|
|
||||||
updated_commit = None
|
updated_commit = None
|
||||||
updated_branch = remote_branch or current_branch
|
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)
|
||||||
git_info_after = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
|
if plugin_dir is not None else None)
|
||||||
if git_info_after:
|
if git_info_after:
|
||||||
updated_commit = git_info_after.get('sha')
|
updated_commit = git_info_after.get('sha')
|
||||||
updated_branch = git_info_after.get('branch') or updated_branch
|
updated_branch = git_info_after.get('branch') or updated_branch
|
||||||
|
|
||||||
# update_plugin() answers True for "nothing to do" as well as for
|
# update_plugin() answers True for "nothing to do" as well as for
|
||||||
# a real update (a ZIP-installed monorepo plugin already at the
|
# a real update (a ZIP-installed monorepo plugin already at the
|
||||||
@@ -1069,11 +1091,10 @@ def update_plugin():
|
|||||||
message=message
|
message=message
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
plugin_path_dir = plugin_dir
|
if plugin_dir is None or not plugin_dir.exists():
|
||||||
if not plugin_path_dir.exists():
|
|
||||||
client_msg = 'Plugin update failed: plugin not found'
|
client_msg = 'Plugin update failed: plugin not found'
|
||||||
else:
|
else:
|
||||||
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
|
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
|
||||||
if not git_info:
|
if not git_info:
|
||||||
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
|
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
@@ -2759,6 +2780,16 @@ def _plugin_uploads_dir(plugin_id):
|
|||||||
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
|
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
|
||||||
|
|
||||||
|
|
||||||
|
def _write_upload_metadata(metadata_file, metadata):
|
||||||
|
"""Replace an uploads directory's .metadata.json atomically.
|
||||||
|
|
||||||
|
The plugin config page reads this file to list a plugin's images; one cut
|
||||||
|
off mid-write (a power loss on a Pi) failed to parse, and every upload it
|
||||||
|
recorded dropped out of the list while the files stayed on disk.
|
||||||
|
"""
|
||||||
|
atomic_write_text(metadata_file, json.dumps(metadata, indent=2))
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
||||||
def upload_plugin_asset():
|
def upload_plugin_asset():
|
||||||
"""Upload asset files for a plugin"""
|
"""Upload asset files for a plugin"""
|
||||||
@@ -2804,6 +2835,11 @@ def upload_plugin_asset():
|
|||||||
if 'size' in entry:
|
if 'size' in entry:
|
||||||
total_size += entry.get('size', 0)
|
total_size += entry.get('size', 0)
|
||||||
|
|
||||||
|
# Every file is checked before any is saved. Checking and saving in one
|
||||||
|
# loop meant a bad third file answered 400 after the first two were
|
||||||
|
# already written -- on disk and in the metadata the UI lists, though
|
||||||
|
# the user was told the upload failed.
|
||||||
|
accepted = []
|
||||||
for file in files:
|
for file in files:
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
continue
|
continue
|
||||||
@@ -2853,6 +2889,10 @@ def upload_plugin_asset():
|
|||||||
'message': f'File {file.filename} is not a valid image file'
|
'message': f'File {file.filename} is not a valid image file'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
total_size += file_size
|
||||||
|
accepted.append((file, file_ext, file_size, file_content))
|
||||||
|
|
||||||
|
for file, file_ext, file_size, file_content in accepted:
|
||||||
# Generate unique filename
|
# Generate unique filename
|
||||||
timestamp = int(_pkg.time.time())
|
timestamp = int(_pkg.time.time())
|
||||||
file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8]
|
file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8]
|
||||||
@@ -2894,11 +2934,7 @@ def upload_plugin_asset():
|
|||||||
'uploaded_at': metadata[image_id]['uploaded_at']
|
'uploaded_at': metadata[image_id]['uploaded_at']
|
||||||
})
|
})
|
||||||
|
|
||||||
total_size += file_size
|
_write_upload_metadata(metadata_file, metadata)
|
||||||
|
|
||||||
# Save metadata
|
|
||||||
with open(metadata_file, 'w') as f:
|
|
||||||
json.dump(metadata, f, indent=2)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -3018,16 +3054,20 @@ def upload_calendar_credentials():
|
|||||||
shutil.copy2(credentials_path, backup_path)
|
shutil.copy2(credentials_path, backup_path)
|
||||||
_prune_credential_backups(Path(plugin_dir))
|
_prune_credential_backups(Path(plugin_dir))
|
||||||
|
|
||||||
# Save new file
|
# Save new file: atomically, and created 0o600 (read/write for owner
|
||||||
file.save(str(credentials_path))
|
# only) rather than chmod-ed after. file.save() truncated the old file
|
||||||
|
# first, so a failure mid-write left the plugin with a broken
|
||||||
# Set proper permissions
|
# credentials.json, and the secret sat world-readable until the chmod.
|
||||||
os.chmod(credentials_path, 0o600) # Read/write for owner only
|
atomic_write_text(credentials_path,
|
||||||
|
file_content.decode(json.detect_encoding(file_content)),
|
||||||
|
mode=0o600)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Credentials file uploaded successfully',
|
'message': 'Credentials file uploaded successfully',
|
||||||
'path': str(credentials_path)
|
# Relative to the plugin: nothing reads this field, and the server's
|
||||||
|
# absolute layout is not the client's business.
|
||||||
|
'path': credentials_path.name
|
||||||
})
|
})
|
||||||
|
|
||||||
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
||||||
@@ -3170,7 +3210,12 @@ def list_calendar_calendars():
|
|||||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||||
def delete_plugin_asset():
|
def delete_plugin_asset():
|
||||||
"""Delete an asset file for a plugin"""
|
"""Delete an asset file for a plugin"""
|
||||||
data = request.get_json()
|
# silent=True: without it a missing or non-JSON body raised inside
|
||||||
|
# get_json() and came back as a 415 in the generic error shape, or, for
|
||||||
|
# a JSON array, an AttributeError 500.
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
|
||||||
plugin_id = data.get('plugin_id')
|
plugin_id = data.get('plugin_id')
|
||||||
image_id = data.get('image_id')
|
image_id = data.get('image_id')
|
||||||
|
|
||||||
@@ -3209,9 +3254,7 @@ def delete_plugin_asset():
|
|||||||
# Remove from metadata
|
# Remove from metadata
|
||||||
del metadata[image_id]
|
del metadata[image_id]
|
||||||
|
|
||||||
# Save metadata
|
_write_upload_metadata(metadata_file, metadata)
|
||||||
with open(metadata_file, 'w') as f:
|
|
||||||
json.dump(metadata, f, indent=2)
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
|
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
|
||||||
|
|
||||||
|
|||||||
@@ -305,8 +305,9 @@ def enable_ap_mode():
|
|||||||
from src.wifi_manager import WiFiManager
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
# The same parsing as every other boolean on these routes. Anything it
|
||||||
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
# does not recognise (None) is not a request to force.
|
||||||
|
force = _parse_bool_ish((request.get_json(silent=True) or {}).get('force', False)) is True
|
||||||
success, message = wifi_manager.enable_ap_mode(force=force)
|
success, message = wifi_manager.enable_ap_mode(force=force)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -320,13 +320,31 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_plugin_dir(plugin_id):
|
||||||
|
"""The plugin manager's answer for where ``plugin_id`` lives, or None.
|
||||||
|
|
||||||
|
Its discovery map is authoritative (a plugin whose directory name is not
|
||||||
|
its id is found there), and it refuses anything that is not one plain
|
||||||
|
path segment. See src/plugin_system/plugin_dirs.py.
|
||||||
|
"""
|
||||||
|
found = pages_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||||
|
if isinstance(found, (str, Path)) and Path(found).exists():
|
||||||
|
return Path(found)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _plugin_dir_for(safe_id):
|
def _plugin_dir_for(safe_id):
|
||||||
"""A sanitised plugin id's directory, which may not exist.
|
"""A sanitised plugin id's directory, which may not exist.
|
||||||
|
|
||||||
Contained under the configured plugins directory, with PluginManager's
|
The plugin manager's resolver first; otherwise contained under the
|
||||||
``ledmatrix-`` prefix fallback. Raises ValueError for an id that would
|
configured plugins directory, with PluginManager's ``ledmatrix-`` prefix
|
||||||
leave it; the routes answer that with a 403.
|
fallback. Raises ValueError for an id that would leave it; the routes
|
||||||
|
answer that with a 403.
|
||||||
"""
|
"""
|
||||||
|
resolved = _resolved_plugin_dir(safe_id)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved
|
||||||
|
|
||||||
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
plugin_dir = resolve_under(plugins_base, safe_id)
|
plugin_dir = resolve_under(plugins_base, safe_id)
|
||||||
if plugin_dir is None:
|
if plugin_dir is None:
|
||||||
@@ -556,91 +574,15 @@ def _load_schedule_partial():
|
|||||||
|
|
||||||
def _load_plugins_partial():
|
def _load_plugins_partial():
|
||||||
"""Load plugins management partial"""
|
"""Load plugins management partial"""
|
||||||
# Load plugin data from the plugin system
|
# plugins.html takes no plugin data: plugins_manager.js fetches the list
|
||||||
plugins_data = []
|
# from /api/v3/plugins/installed. Building it here read every manifest on
|
||||||
|
# disk on each Plugins-tab load for nothing.
|
||||||
# Get installed plugins if managers are available
|
return render_template('v3/partials/plugins.html')
|
||||||
if pages_v3.plugin_manager and pages_v3.plugin_store_manager:
|
|
||||||
try:
|
|
||||||
# Get all installed plugin info
|
|
||||||
all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info()
|
|
||||||
|
|
||||||
# Load config once before the loop (not per-plugin)
|
|
||||||
full_config = pages_v3.config_manager.load_config() if pages_v3.config_manager else {}
|
|
||||||
|
|
||||||
# Format for the web interface
|
|
||||||
for plugin_info in all_plugin_info:
|
|
||||||
plugin_id = plugin_info.get('id')
|
|
||||||
|
|
||||||
# Re-read manifest from disk to ensure we have the latest metadata
|
|
||||||
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
|
||||||
if manifest_path.exists():
|
|
||||||
try:
|
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
||||||
fresh_manifest = json.load(f)
|
|
||||||
# Update plugin_info with fresh manifest data
|
|
||||||
plugin_info.update(fresh_manifest)
|
|
||||||
except Exception:
|
|
||||||
# If we can't read the fresh manifest, use the cached one
|
|
||||||
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
|
||||||
|
|
||||||
# Get enabled status from config (source of truth)
|
|
||||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
|
||||||
enabled = None
|
|
||||||
if pages_v3.config_manager:
|
|
||||||
plugin_config = full_config.get(plugin_id, {})
|
|
||||||
# Check if 'enabled' key exists in config (even if False)
|
|
||||||
if 'enabled' in plugin_config:
|
|
||||||
enabled = bool(plugin_config['enabled'])
|
|
||||||
|
|
||||||
# Fallback to plugin instance if config doesn't have enabled key
|
|
||||||
if enabled is None:
|
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
|
||||||
if plugin_instance:
|
|
||||||
enabled = plugin_instance.enabled
|
|
||||||
else:
|
|
||||||
# Default to True if no config key and plugin not loaded (matches BasePlugin default)
|
|
||||||
enabled = True
|
|
||||||
|
|
||||||
# Get verified status from store registry (no GitHub API calls needed)
|
|
||||||
store_info = pages_v3.plugin_store_manager.get_registry_info(plugin_id)
|
|
||||||
verified = store_info.get('verified', False) if store_info else False
|
|
||||||
|
|
||||||
last_updated = plugin_info.get('last_updated')
|
|
||||||
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
|
||||||
branch = plugin_info.get('branch')
|
|
||||||
|
|
||||||
if store_info:
|
|
||||||
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
|
||||||
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
|
||||||
branch = branch or store_info.get('branch') or store_info.get('default_branch')
|
|
||||||
|
|
||||||
plugins_data.append({
|
|
||||||
'id': plugin_id,
|
|
||||||
'name': plugin_info.get('name', plugin_id),
|
|
||||||
'author': plugin_info.get('author', 'Unknown'),
|
|
||||||
'category': plugin_info.get('category', 'General'),
|
|
||||||
'description': plugin_info.get('description', 'No description available'),
|
|
||||||
'tags': plugin_info.get('tags', []),
|
|
||||||
'enabled': enabled,
|
|
||||||
'verified': verified,
|
|
||||||
'loaded': plugin_info.get('loaded', False),
|
|
||||||
'last_updated': last_updated,
|
|
||||||
'last_commit': last_commit,
|
|
||||||
'branch': branch
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
logger.error("Error loading plugin data", exc_info=True)
|
|
||||||
|
|
||||||
return render_template('v3/partials/plugins.html',
|
|
||||||
plugins=plugins_data)
|
|
||||||
|
|
||||||
def _load_fonts_partial():
|
def _load_fonts_partial():
|
||||||
"""Load fonts management partial"""
|
"""Load fonts management partial"""
|
||||||
# This would load font data from the font system
|
# The page fetches its font data from /api/v3/fonts/* itself.
|
||||||
fonts_data = {} # Placeholder for font data
|
return render_template('v3/partials/fonts.html')
|
||||||
return render_template('v3/partials/fonts.html',
|
|
||||||
fonts=fonts_data)
|
|
||||||
|
|
||||||
def _load_logs_partial():
|
def _load_logs_partial():
|
||||||
"""Load logs viewer partial"""
|
"""Load logs viewer partial"""
|
||||||
@@ -749,6 +691,11 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
||||||
|
|
||||||
|
# The containment check above only validates the id. The files are
|
||||||
|
# read from wherever the plugin manager says the plugin lives, which
|
||||||
|
# for one installed as ledmatrix-<id> is not plugins_dir/<id>.
|
||||||
|
_plugin_dir = _resolved_plugin_dir(plugin_id) or _plugin_dir
|
||||||
|
|
||||||
# Get plugin instance (may be None if not loaded)
|
# Get plugin instance (may be None if not loaded)
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -17,6 +17,21 @@ logger = logging.getLogger('web_interface.start')
|
|||||||
_CLIENT_DISCONNECT_ERRNOS = (113, 32, 104)
|
_CLIENT_DISCONNECT_ERRNOS = (113, 32, 104)
|
||||||
|
|
||||||
|
|
||||||
|
def _exc_info_value(exc_info):
|
||||||
|
"""The exception a logging call's ``exc_info`` argument refers to.
|
||||||
|
|
||||||
|
logging accepts a (type, value, traceback) tuple, an exception instance,
|
||||||
|
or True for the exception being handled. The werkzeug filter below used
|
||||||
|
to unpack it as a tuple, so True or an instance raised TypeError from
|
||||||
|
inside the logging call itself.
|
||||||
|
"""
|
||||||
|
if isinstance(exc_info, BaseException):
|
||||||
|
return exc_info
|
||||||
|
if isinstance(exc_info, tuple):
|
||||||
|
return exc_info[1] if len(exc_info) > 1 else None
|
||||||
|
return sys.exc_info()[1]
|
||||||
|
|
||||||
|
|
||||||
def get_local_ips():
|
def get_local_ips():
|
||||||
"""Get list of local IP addresses the service will be accessible on."""
|
"""Get list of local IP addresses the service will be accessible on."""
|
||||||
ips = []
|
ips = []
|
||||||
@@ -94,7 +109,7 @@ def main():
|
|||||||
return
|
return
|
||||||
# For exceptions, check if it's a socket error
|
# For exceptions, check if it's a socket error
|
||||||
if 'exc_info' in kwargs and kwargs['exc_info']:
|
if 'exc_info' in kwargs and kwargs['exc_info']:
|
||||||
exc_type, exc_value, exc_tb = kwargs['exc_info']
|
exc_value = _exc_info_value(kwargs['exc_info'])
|
||||||
if isinstance(exc_value, OSError):
|
if isinstance(exc_value, OSError):
|
||||||
if exc_value.errno in _CLIENT_DISCONNECT_ERRNOS:
|
if exc_value.errno in _CLIENT_DISCONNECT_ERRNOS:
|
||||||
werkzeug_logger.debug(message, *args, **kwargs)
|
werkzeug_logger.debug(message, *args, **kwargs)
|
||||||
|
|||||||
@@ -586,14 +586,6 @@ async function updateFontPreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
|
||||||
if (family.toLowerCase().endsWith('.bdf')) {
|
|
||||||
previewImage.style.display = 'none';
|
|
||||||
loadingText.style.display = 'block';
|
|
||||||
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
loadingText.textContent = 'Loading preview...';
|
loadingText.textContent = 'Loading preview...';
|
||||||
loadingText.style.display = 'block';
|
loadingText.style.display = 'block';
|
||||||
|
|||||||
Reference in New Issue
Block a user