mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-30 20:43:00 +00:00
Compare commits
14 Commits
941291561a
...
fix/wifi-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd981acad | ||
|
|
82a183c623 | ||
|
|
5800d24603 | ||
|
|
ca2a65918e | ||
|
|
5ae800492f | ||
|
|
9a7c008b90 | ||
|
|
d6bd1ee215 | ||
|
|
acaf8a248e | ||
|
|
db9585cea9 | ||
|
|
65e3e8319b | ||
|
|
4ef3f8cad5 | ||
|
|
338bdc44cb | ||
|
|
73c00140df | ||
|
|
68a38c39f7 |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 657 KiB |
@@ -1,17 +1,9 @@
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
},
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"music": {
|
||||
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
|
||||
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
|
||||
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,9 +599,13 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||
{
|
||||
"weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
}
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# Check if service runs as root and set ownership accordingly
|
||||
|
||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
"""Initialize the Web UI Info plugin."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# AP mode cache (must be initialized before _get_local_ip)
|
||||
self._ap_mode_cached = False
|
||||
self._ap_mode_cache_time = 0.0
|
||||
self._ap_mode_cache_ttl = 60.0
|
||||
|
||||
# Get device hostname
|
||||
try:
|
||||
self.device_id = socket.gethostname()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||
self.device_id = "localhost"
|
||||
|
||||
|
||||
# Get device IP address
|
||||
self.device_ip = self._get_local_ip()
|
||||
|
||||
|
||||
# IP refresh tracking
|
||||
self.last_ip_refresh = time.time()
|
||||
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
||||
|
||||
# AP mode cache
|
||||
self._ap_mode_cached = False
|
||||
self._ap_mode_cache_time = 0.0
|
||||
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
||||
self.ip_refresh_interval = 300.0
|
||||
|
||||
# Rotation state
|
||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||
@@ -200,9 +200,7 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
elif current_interface == "wlan0":
|
||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
|
||||
605
src/backup_manager.py
Normal file
605
src/backup_manager.py
Normal file
@@ -0,0 +1,605 @@
|
||||
"""
|
||||
User configuration backup and restore.
|
||||
|
||||
Packages the user's LEDMatrix configuration, secrets, WiFi settings,
|
||||
user-uploaded fonts, plugin image uploads, and installed-plugin manifest
|
||||
into a single ``.zip`` that can be exported from one installation and
|
||||
imported on a fresh install.
|
||||
|
||||
This module is intentionally Flask-free so it can be unit-tested and
|
||||
used from scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Filenames shipped with the LEDMatrix repository under ``assets/fonts/``.
|
||||
# Anything present on disk but NOT in this set is treated as a user upload
|
||||
# and included in backups. Keep this snapshot in sync with the repo — regenerate
|
||||
# with::
|
||||
#
|
||||
# ls assets/fonts/
|
||||
#
|
||||
# Tests assert the set matches the checked-in fonts.
|
||||
BUNDLED_FONTS: frozenset[str] = frozenset({
|
||||
"10x20.bdf",
|
||||
"4x6.bdf",
|
||||
"4x6-font.ttf",
|
||||
"5by7.regular.ttf",
|
||||
"5x7.bdf",
|
||||
"5x8.bdf",
|
||||
"6x9.bdf",
|
||||
"6x10.bdf",
|
||||
"6x12.bdf",
|
||||
"6x13.bdf",
|
||||
"6x13B.bdf",
|
||||
"6x13O.bdf",
|
||||
"7x13.bdf",
|
||||
"7x13B.bdf",
|
||||
"7x13O.bdf",
|
||||
"7x14.bdf",
|
||||
"7x14B.bdf",
|
||||
"8x13.bdf",
|
||||
"8x13B.bdf",
|
||||
"8x13O.bdf",
|
||||
"9x15.bdf",
|
||||
"9x15B.bdf",
|
||||
"9x18.bdf",
|
||||
"9x18B.bdf",
|
||||
"AUTHORS",
|
||||
"bdf_font_guide",
|
||||
"clR6x12.bdf",
|
||||
"helvR12.bdf",
|
||||
"ic8x8u.bdf",
|
||||
"MatrixChunky8.bdf",
|
||||
"MatrixChunky8X.bdf",
|
||||
"MatrixLight6.bdf",
|
||||
"MatrixLight6X.bdf",
|
||||
"MatrixLight8X.bdf",
|
||||
"PressStart2P-Regular.ttf",
|
||||
"README",
|
||||
"README.md",
|
||||
"texgyre-27.bdf",
|
||||
"tom-thumb.bdf",
|
||||
})
|
||||
|
||||
# Relative paths inside the project that the backup knows how to round-trip.
|
||||
_CONFIG_REL = Path("config/config.json")
|
||||
_SECRETS_REL = Path("config/config_secrets.json")
|
||||
_WIFI_REL = Path("config/wifi_config.json")
|
||||
_FONTS_REL = Path("assets/fonts")
|
||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||
_STATE_REL = Path("data/plugin_state.json")
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||
|
||||
# Hard cap on the size of a single file we'll accept inside an uploaded ZIP
|
||||
# to limit zip-bomb risk. 50 MB matches the existing plugin-image upload cap.
|
||||
_MAX_MEMBER_BYTES = 50 * 1024 * 1024
|
||||
# Hard cap on the total uncompressed size of an uploaded ZIP.
|
||||
_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RestoreOptions:
|
||||
"""Which sections of a backup should be restored."""
|
||||
|
||||
restore_config: bool = True
|
||||
restore_secrets: bool = True
|
||||
restore_wifi: bool = True
|
||||
restore_fonts: bool = True
|
||||
restore_plugin_uploads: bool = True
|
||||
reinstall_plugins: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class RestoreResult:
|
||||
"""Outcome of a restore operation."""
|
||||
|
||||
success: bool = False
|
||||
restored: List[str] = field(default_factory=list)
|
||||
skipped: List[str] = field(default_factory=list)
|
||||
plugins_to_install: List[Dict[str, Any]] = field(default_factory=list)
|
||||
plugins_installed: List[str] = field(default_factory=list)
|
||||
plugins_failed: List[Dict[str, str]] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
manifest: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ledmatrix_version(project_root: Path) -> str:
|
||||
"""Best-effort version string for the current install."""
|
||||
version_file = project_root / "VERSION"
|
||||
if version_file.exists():
|
||||
try:
|
||||
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
head_file = project_root / ".git" / "HEAD"
|
||||
if head_file.exists():
|
||||
try:
|
||||
head = head_file.read_text(encoding="utf-8").strip()
|
||||
if head.startswith("ref: "):
|
||||
ref = head[5:]
|
||||
ref_path = project_root / ".git" / ref
|
||||
if ref_path.exists():
|
||||
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
||||
return head[:12] or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"ledmatrix_version": _ledmatrix_version(project_root),
|
||||
"hostname": socket.gethostname(),
|
||||
"contents": contents,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installed-plugin enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return a list of currently-installed plugins suitable for the backup
|
||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||
|
||||
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
||||
directory and reads each ``manifest.json``.
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
state_file = project_root / _STATE_REL
|
||||
if state_file.exists():
|
||||
try:
|
||||
with state_file.open("r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||
if isinstance(raw_plugins, dict):
|
||||
for plugin_id, info in raw_plugins.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": info.get("version") or "",
|
||||
"enabled": bool(info.get("enabled", True)),
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Could not read plugin_state.json: %s", e)
|
||||
|
||||
# Fall back to scanning plugin-repos/ for manifests.
|
||||
plugins_root = project_root / "plugin-repos"
|
||||
if plugins_root.exists():
|
||||
for entry in sorted(plugins_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
manifest = entry / "manifest.json"
|
||||
if not manifest.exists():
|
||||
continue
|
||||
try:
|
||||
with manifest.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
plugin_id = data.get("id") or entry.name
|
||||
if plugin_id not in plugins:
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": data.get("version", ""),
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def iter_user_fonts(project_root: Path) -> List[Path]:
|
||||
"""Return absolute paths to user-uploaded fonts (anything in
|
||||
``assets/fonts/`` not listed in :data:`BUNDLED_FONTS`)."""
|
||||
fonts_dir = project_root / _FONTS_REL
|
||||
if not fonts_dir.exists():
|
||||
return []
|
||||
user_fonts: List[Path] = []
|
||||
for entry in sorted(fonts_dir.iterdir()):
|
||||
if entry.is_file() and entry.name not in BUNDLED_FONTS:
|
||||
user_fonts.append(entry)
|
||||
return user_fonts
|
||||
|
||||
|
||||
def iter_plugin_uploads(project_root: Path) -> List[Path]:
|
||||
"""Return every file under ``assets/plugins/*/uploads/`` (recursive)."""
|
||||
plugin_root = project_root / _PLUGIN_UPLOADS_REL
|
||||
if not plugin_root.exists():
|
||||
return []
|
||||
out: List[Path] = []
|
||||
for plugin_dir in sorted(plugin_root.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
uploads = plugin_dir / "uploads"
|
||||
if not uploads.exists():
|
||||
continue
|
||||
for root, _dirs, files in os.walk(uploads):
|
||||
for name in sorted(files):
|
||||
out.append(Path(root) / name)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_backup(
|
||||
project_root: Path,
|
||||
output_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Build a backup ZIP and write it into ``output_dir`` (defaults to
|
||||
``<project_root>/config/backups/exports/``). Returns the path to the
|
||||
created file.
|
||||
"""
|
||||
project_root = Path(project_root).resolve()
|
||||
if output_dir is None:
|
||||
output_dir = project_root / "config" / "backups" / "exports"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
hostname = socket.gethostname() or "ledmatrix"
|
||||
safe_host = "".join(c for c in hostname if c.isalnum() or c in "-_") or "ledmatrix"
|
||||
zip_name = f"ledmatrix-backup-{safe_host}-{timestamp}.zip"
|
||||
zip_path = output_dir / zip_name
|
||||
|
||||
contents: List[str] = []
|
||||
|
||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Config files.
|
||||
if (project_root / _CONFIG_REL).exists():
|
||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||
contents.append("config")
|
||||
if (project_root / _SECRETS_REL).exists():
|
||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||
contents.append("secrets")
|
||||
if (project_root / _WIFI_REL).exists():
|
||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||
contents.append("wifi")
|
||||
|
||||
# User-uploaded fonts.
|
||||
user_fonts = iter_user_fonts(project_root)
|
||||
if user_fonts:
|
||||
for font in user_fonts:
|
||||
arcname = font.relative_to(project_root).as_posix()
|
||||
zf.write(font, arcname)
|
||||
contents.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
plugin_uploads = iter_plugin_uploads(project_root)
|
||||
if plugin_uploads:
|
||||
for upload in plugin_uploads:
|
||||
arcname = upload.relative_to(project_root).as_posix()
|
||||
zf.write(upload, arcname)
|
||||
contents.append("plugin_uploads")
|
||||
|
||||
# Installed plugins manifest.
|
||||
plugins = list_installed_plugins(project_root)
|
||||
if plugins:
|
||||
zf.writestr(
|
||||
PLUGINS_MANIFEST_NAME,
|
||||
json.dumps(plugins, indent=2),
|
||||
)
|
||||
contents.append("plugins")
|
||||
|
||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||
manifest = _build_manifest(contents, project_root)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||
return zip_path
|
||||
|
||||
|
||||
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||
"""Return a summary of what ``create_backup`` would include."""
|
||||
project_root = Path(project_root).resolve()
|
||||
return {
|
||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||
"plugins": list_installed_plugins(project_root),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_extract_path(base_dir: Path, member_name: str) -> Optional[Path]:
|
||||
"""Resolve a ZIP member name against ``base_dir`` and reject anything
|
||||
that escapes it. Returns the resolved absolute path, or ``None`` if the
|
||||
name is unsafe."""
|
||||
# Reject absolute paths and Windows-style drives outright.
|
||||
if member_name.startswith(("/", "\\")) or (len(member_name) >= 2 and member_name[1] == ":"):
|
||||
return None
|
||||
target = (base_dir / member_name).resolve()
|
||||
try:
|
||||
target.relative_to(base_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
"""
|
||||
Inspect a backup ZIP without extracting to disk.
|
||||
|
||||
Returns ``(ok, error_message, manifest_dict)``. ``manifest_dict`` contains
|
||||
the parsed manifest plus diagnostic fields:
|
||||
- ``detected_contents``: list of section names present in the archive
|
||||
- ``plugins``: parsed plugins.json if present
|
||||
- ``total_uncompressed``: sum of uncompressed sizes
|
||||
"""
|
||||
zip_path = Path(zip_path)
|
||||
if not zip_path.exists():
|
||||
return False, f"Backup file not found: {zip_path}", {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
if MANIFEST_NAME not in names:
|
||||
return False, "Backup is missing manifest.json", {}
|
||||
|
||||
total = 0
|
||||
with tempfile.TemporaryDirectory() as _sandbox:
|
||||
sandbox = Path(_sandbox)
|
||||
for info in zf.infolist():
|
||||
if info.file_size > _MAX_MEMBER_BYTES:
|
||||
return False, f"Member {info.filename} is too large", {}
|
||||
total += info.file_size
|
||||
if total > _MAX_TOTAL_BYTES:
|
||||
return False, "Backup exceeds maximum allowed size", {}
|
||||
# Safety: reject members with unsafe paths up front.
|
||||
if _safe_extract_path(sandbox, info.filename) is None:
|
||||
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
manifest = json.loads(manifest_raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
return False, f"Invalid manifest.json: {e}", {}
|
||||
|
||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||
return False, "Invalid manifest structure", {}
|
||||
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||
return (
|
||||
False,
|
||||
f"Unsupported backup schema version: {manifest.get('schema_version')}",
|
||||
{},
|
||||
)
|
||||
|
||||
detected: List[str] = []
|
||||
if _CONFIG_REL.as_posix() in names:
|
||||
detected.append("config")
|
||||
if _SECRETS_REL.as_posix() in names:
|
||||
detected.append("secrets")
|
||||
if _WIFI_REL.as_posix() in names:
|
||||
detected.append("wifi")
|
||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||
detected.append("fonts")
|
||||
if any(
|
||||
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
||||
for n in names
|
||||
):
|
||||
detected.append("plugin_uploads")
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
if PLUGINS_MANIFEST_NAME in names:
|
||||
try:
|
||||
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
|
||||
if not isinstance(plugins, list):
|
||||
plugins = []
|
||||
else:
|
||||
detected.append("plugins")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
plugins = []
|
||||
|
||||
result_manifest = dict(manifest)
|
||||
result_manifest["detected_contents"] = detected
|
||||
result_manifest["plugins"] = plugins
|
||||
result_manifest["total_uncompressed"] = total
|
||||
result_manifest["file_count"] = len(names)
|
||||
return True, "", result_manifest
|
||||
except zipfile.BadZipFile:
|
||||
return False, "File is not a valid ZIP archive", {}
|
||||
except OSError as e:
|
||||
return False, f"Could not read backup: {e}", {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract ``zip_path`` into ``dest_dir`` rejecting any unsafe members."""
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
target = _safe_extract_path(dest_dir, info.filename)
|
||||
if target is None:
|
||||
raise ValueError(f"Unsafe path in backup: {info.filename}")
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info, "r") as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||
|
||||
|
||||
def _copy_file(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def restore_backup(
|
||||
zip_path: Path,
|
||||
project_root: Path,
|
||||
options: Optional[RestoreOptions] = None,
|
||||
) -> RestoreResult:
|
||||
"""
|
||||
Restore ``zip_path`` into ``project_root`` according to ``options``.
|
||||
|
||||
Plugin reinstalls are NOT performed here — the caller is responsible for
|
||||
walking ``result.plugins_to_install`` and calling the store manager. This
|
||||
keeps this module Flask-free and side-effect free beyond the filesystem.
|
||||
"""
|
||||
if options is None:
|
||||
options = RestoreOptions()
|
||||
project_root = Path(project_root).resolve()
|
||||
result = RestoreResult()
|
||||
|
||||
ok, err, manifest = validate_backup(zip_path)
|
||||
if not ok:
|
||||
result.errors.append(err)
|
||||
return result
|
||||
result.manifest = manifest
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ledmatrix_restore_") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
try:
|
||||
_extract_zip_safe(Path(zip_path), tmp_dir)
|
||||
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
||||
result.errors.append(f"Failed to extract backup: {e}")
|
||||
return result
|
||||
|
||||
# Main config.
|
||||
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||
result.restored.append("config")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore config.json: {e}")
|
||||
elif (tmp_dir / _CONFIG_REL).exists():
|
||||
result.skipped.append("config")
|
||||
|
||||
# Secrets.
|
||||
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||
result.restored.append("secrets")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore config_secrets.json: {e}")
|
||||
elif (tmp_dir / _SECRETS_REL).exists():
|
||||
result.skipped.append("secrets")
|
||||
|
||||
# WiFi.
|
||||
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||
result.restored.append("wifi")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore wifi_config.json: {e}")
|
||||
elif (tmp_dir / _WIFI_REL).exists():
|
||||
result.skipped.append("wifi")
|
||||
|
||||
# User fonts — skip anything that collides with a bundled font.
|
||||
tmp_fonts = tmp_dir / _FONTS_REL
|
||||
if options.restore_fonts and tmp_fonts.exists():
|
||||
restored_count = 0
|
||||
for font in sorted(tmp_fonts.iterdir()):
|
||||
if not font.is_file():
|
||||
continue
|
||||
if font.name in BUNDLED_FONTS:
|
||||
result.skipped.append(f"font:{font.name} (bundled)")
|
||||
continue
|
||||
try:
|
||||
_copy_file(font, project_root / _FONTS_REL / font.name)
|
||||
restored_count += 1
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore font {font.name}: {e}")
|
||||
if restored_count:
|
||||
result.restored.append(f"fonts ({restored_count})")
|
||||
elif tmp_fonts.exists():
|
||||
result.skipped.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
tmp_uploads = tmp_dir / _PLUGIN_UPLOADS_REL
|
||||
if options.restore_plugin_uploads and tmp_uploads.exists():
|
||||
count = 0
|
||||
for root, _dirs, files in os.walk(tmp_uploads):
|
||||
for name in files:
|
||||
src = Path(root) / name
|
||||
rel = src.relative_to(tmp_dir)
|
||||
if "/uploads/" not in rel.as_posix():
|
||||
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
||||
continue
|
||||
try:
|
||||
_copy_file(src, project_root / rel)
|
||||
count += 1
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore {rel}: {e}")
|
||||
if count:
|
||||
result.restored.append(f"plugin_uploads ({count})")
|
||||
elif tmp_uploads.exists():
|
||||
result.skipped.append("plugin_uploads")
|
||||
|
||||
# Plugins list (for caller to reinstall).
|
||||
if options.reinstall_plugins and (tmp_dir / PLUGINS_MANIFEST_NAME).exists():
|
||||
try:
|
||||
with (tmp_dir / PLUGINS_MANIFEST_NAME).open("r", encoding="utf-8") as f:
|
||||
plugins = json.load(f)
|
||||
if isinstance(plugins, list):
|
||||
result.plugins_to_install = [
|
||||
{"plugin_id": p.get("plugin_id"), "version": p.get("version", "")}
|
||||
for p in plugins
|
||||
if isinstance(p, dict) and p.get("plugin_id")
|
||||
]
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
result.errors.append(f"Could not read plugins.json: {e}")
|
||||
|
||||
result.success = not result.errors
|
||||
return result
|
||||
@@ -677,6 +677,44 @@ class PluginManager:
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
plugin_id: str,
|
||||
exc: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Apply the standard failure-recovery path for a plugin update.
|
||||
|
||||
Stamps plugin_last_update with the actual failure time so the full
|
||||
configured interval elapses before the next retry, then transitions
|
||||
the plugin back to ENABLED (not ERROR) with structured error context
|
||||
so automatic recovery happens on the next scheduled cycle.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
exc: The exception that caused the failure, if any. When None a
|
||||
synthetic ExecutionFailure exception is constructed from the
|
||||
timeout/executor-error path.
|
||||
"""
|
||||
failure_time = time.time()
|
||||
if exc is not None:
|
||||
err: Exception = exc
|
||||
error_type = type(exc).__name__
|
||||
else:
|
||||
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||
error_type = 'ExecutionFailure'
|
||||
|
||||
error_info = {
|
||||
'error': str(err),
|
||||
'error_type': error_type,
|
||||
'timestamp': failure_time,
|
||||
'recoverable': True,
|
||||
}
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
|
||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||
"""
|
||||
Trigger plugin updates based on their defined update intervals.
|
||||
@@ -734,16 +772,10 @@ class PluginManager:
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
# Execution failed (timeout or error)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, Exception("Plugin execution failed"))
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
# Record failure
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -769,14 +801,12 @@ class PluginManager:
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
# Execution failed
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
|
||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ Manages plugin state machine (loaded → enabled → running → error)
|
||||
with state transitions and queries.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -34,6 +35,7 @@ class PluginStateManager:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or get_logger(__name__)
|
||||
self._lock = threading.RLock()
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
self._state_history: Dict[str, list] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -48,44 +50,44 @@ class PluginStateManager:
|
||||
) -> None:
|
||||
"""
|
||||
Set plugin state and record transition.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error: Optional error if transitioning to ERROR state
|
||||
"""
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
# Record state transition
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
self._state_history[plugin_id].append(transition)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
self._error_info[plugin_id] = {
|
||||
'error': str(error),
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
|
||||
transition = {
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None
|
||||
}
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value
|
||||
)
|
||||
self._state_history[plugin_id].append(transition)
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
self._error_info[plugin_id] = {
|
||||
'error': str(error),
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value
|
||||
)
|
||||
|
||||
def get_state(self, plugin_id: str) -> PluginState:
|
||||
"""
|
||||
@@ -136,17 +138,82 @@ class PluginStateManager:
|
||||
"""
|
||||
return self._state_history.get(plugin_id, [])
|
||||
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
def set_error_info(self, plugin_id: str, error_info: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Get error information for a plugin in ERROR state.
|
||||
|
||||
Persist structured error context without changing plugin state.
|
||||
|
||||
Used for recoverable failures (e.g. update timeout) where the plugin
|
||||
stays ENABLED but the error details should remain queryable.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Error information dict or None
|
||||
error_info: Arbitrary dict describing the error
|
||||
"""
|
||||
return self._error_info.get(plugin_id)
|
||||
with self._lock:
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
def set_state_with_error(
|
||||
self,
|
||||
plugin_id: str,
|
||||
state: PluginState,
|
||||
error_info: Dict[str, Any],
|
||||
error: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Set plugin state and persist error context atomically.
|
||||
|
||||
Unlike calling set_state() then set_error_info() separately, this
|
||||
method holds ``_lock`` for both writes so no reader can observe the
|
||||
new state without the accompanying error context.
|
||||
|
||||
Intentionally does not clear ``_error_info`` the way set_state() does
|
||||
for non-ERROR transitions — this is the recoverable-failure path where
|
||||
the error dict is the entire point.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
state: New state
|
||||
error_info: Structured error dict to persist alongside the state
|
||||
error: Optional exception recorded in the transition history
|
||||
"""
|
||||
with self._lock:
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
|
||||
if plugin_id not in self._state_history:
|
||||
self._state_history[plugin_id] = []
|
||||
self._state_history[plugin_id].append({
|
||||
'timestamp': datetime.now(),
|
||||
'from': old_state.value,
|
||||
'to': state.value,
|
||||
'error': str(error) if error else None,
|
||||
})
|
||||
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s (recoverable error stored)",
|
||||
plugin_id,
|
||||
old_state.value,
|
||||
state.value,
|
||||
)
|
||||
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get error information for a plugin.
|
||||
|
||||
Returns the stored error dict whether the plugin is in ERROR state or
|
||||
still ENABLED after a recoverable failure. Returns a shallow copy so
|
||||
callers cannot mutate the stored snapshot.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
Copy of the error information dict, or None
|
||||
"""
|
||||
with self._lock:
|
||||
info = self._error_info.get(plugin_id)
|
||||
return dict(info) if info is not None else None
|
||||
|
||||
def record_update(self, plugin_id: str) -> None:
|
||||
"""Record that plugin update() was called."""
|
||||
|
||||
@@ -65,7 +65,6 @@ DNSMASQ_SERVICE = "dnsmasq"
|
||||
|
||||
# Default AP settings
|
||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||
DEFAULT_AP_PASSWORD = "ledmatrix123"
|
||||
DEFAULT_AP_CHANNEL = 7
|
||||
|
||||
# LED status message file (for display_controller integration)
|
||||
@@ -303,7 +302,6 @@ class WiFiManager:
|
||||
else:
|
||||
self.config = {
|
||||
"ap_ssid": DEFAULT_AP_SSID,
|
||||
"ap_password": DEFAULT_AP_PASSWORD,
|
||||
"ap_channel": DEFAULT_AP_CHANNEL,
|
||||
"auto_enable_ap_mode": True, # Default: auto-enable when no network (safe due to grace period)
|
||||
"saved_networks": []
|
||||
@@ -658,7 +656,135 @@ class WiFiManager:
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved")
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
import re as _re
|
||||
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
|
||||
if not ssid or len(ssid) > 32 or not _re.match(r'^[\x20-\x7E]+$', ssid):
|
||||
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
|
||||
ssid = DEFAULT_AP_SSID
|
||||
try:
|
||||
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
|
||||
if channel < 1 or channel > 14:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("AP channel out of range, falling back to default")
|
||||
channel = DEFAULT_AP_CHANNEL
|
||||
return ssid, channel
|
||||
|
||||
def _setup_iptables_redirect(self) -> bool:
|
||||
"""
|
||||
Add iptables rules that redirect port 80 → Flask on 5000 for the captive portal.
|
||||
The INPUT rule must accept port 5000 (post-redirect destination), not port 80.
|
||||
ip_forward state is saved to disk before enabling; call _teardown_iptables_redirect
|
||||
to restore it.
|
||||
Returns True if rules were applied.
|
||||
"""
|
||||
try:
|
||||
if subprocess.run(["which", "iptables"], capture_output=True,
|
||||
timeout=2).returncode != 0:
|
||||
logger.debug("iptables unavailable; captive portal requires direct port-5000 access")
|
||||
return False
|
||||
|
||||
# Save current ip_forward state so we can restore it exactly on teardown
|
||||
fwd = subprocess.run(["sysctl", "-n", "net.ipv4.ip_forward"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
saved = fwd.stdout.strip() if fwd.returncode == 0 else "0"
|
||||
try:
|
||||
self._IP_FORWARD_SAVE_PATH.write_text(saved)
|
||||
except OSError:
|
||||
pass # non-fatal; restore will fall back to "0"
|
||||
|
||||
sysctl_r = subprocess.run(
|
||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if sysctl_r.returncode != 0:
|
||||
logger.error(f"Failed to enable ip_forward: {sysctl_r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
# PREROUTING: redirect HTTP → Flask
|
||||
if subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
add_r = subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if add_r.returncode != 0:
|
||||
logger.error(f"Failed to add PREROUTING rule: {add_r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
# INPUT: accept traffic on port 5000 (the post-redirect destination port)
|
||||
if subprocess.run(
|
||||
["sudo", "iptables", "-C", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||
"-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
add_r = subprocess.run(
|
||||
["sudo", "iptables", "-A", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||
"-j", "ACCEPT"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if add_r.returncode != 0:
|
||||
logger.error(f"Failed to add INPUT rule: {add_r.stderr.strip()}")
|
||||
self._teardown_iptables_redirect()
|
||||
return False
|
||||
|
||||
logger.info("iptables: port 80→5000 redirect and INPUT accept-5000 rules added")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set up iptables redirect: {e}")
|
||||
return False
|
||||
|
||||
def _teardown_iptables_redirect(self) -> None:
|
||||
"""Remove the port 80→5000 iptables rules and restore the saved ip_forward state."""
|
||||
try:
|
||||
if subprocess.run(["which", "iptables"], capture_output=True,
|
||||
timeout=2).returncode != 0:
|
||||
return
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-D", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||
"-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
|
||||
# Restore ip_forward to whatever it was before we touched it
|
||||
try:
|
||||
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
|
||||
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
saved = "0"
|
||||
subprocess.run(["sudo", "sysctl", "-w", f"net.ipv4.ip_forward={saved}"],
|
||||
capture_output=True, timeout=5)
|
||||
logger.info(f"iptables redirect rules removed; ip_forward restored to {saved}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not tear down iptables redirect: {e}")
|
||||
|
||||
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
|
||||
"""
|
||||
Scan for available WiFi networks.
|
||||
@@ -1649,63 +1775,14 @@ class WiFiManager:
|
||||
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
|
||||
return False, f"Failed to start dnsmasq: {result.stderr}"
|
||||
|
||||
# Set up iptables port forwarding: redirect port 80 to 5000
|
||||
# This makes the captive portal work on standard HTTP port
|
||||
try:
|
||||
# Check if iptables is available
|
||||
iptables_check = subprocess.run(
|
||||
["which", "iptables"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if iptables_check.returncode == 0:
|
||||
# Enable IP forwarding (needed for NAT)
|
||||
subprocess.run(
|
||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Add NAT rule to redirect port 80 to 5000 on WiFi interface
|
||||
# First check if rule already exists
|
||||
check_result = subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-C", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if check_result.returncode != 0:
|
||||
# Rule doesn't exist, add it
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Added iptables rule to redirect port 80 to 5000")
|
||||
|
||||
# Also allow incoming connections on port 80
|
||||
check_input = subprocess.run(
|
||||
["sudo", "iptables", "-C", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if check_input.returncode != 0:
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-A", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
else:
|
||||
logger.debug("iptables not available, port forwarding not set up")
|
||||
logger.info("Note: Port 80 forwarding requires iptables. Users will need to access port 5000 directly.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set up iptables port forwarding: {e}")
|
||||
# Continue anyway - port 5000 will still work
|
||||
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
|
||||
self._setup_iptables_redirect()
|
||||
|
||||
logger.info("AP mode enabled successfully")
|
||||
self._show_led_message("Setup Mode Active", duration=5)
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
self._show_led_message(
|
||||
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
||||
)
|
||||
return True, "AP mode enabled"
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting AP services: {e}")
|
||||
@@ -1716,245 +1793,103 @@ class WiFiManager:
|
||||
|
||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable AP mode using nmcli hotspot.
|
||||
Enable AP mode using nmcli as an open (passwordless) access point.
|
||||
|
||||
This method is optimized for both Bookworm and Trixie:
|
||||
- Trixie: Uses Netplan, connections stored in /run/NetworkManager/system-connections
|
||||
- Bookworm: Traditional NetworkManager, connections in /etc/NetworkManager/system-connections
|
||||
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
|
||||
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
|
||||
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
|
||||
strip security after creation.
|
||||
|
||||
On Trixie, we also disable PMF (Protected Management Frames) which can cause
|
||||
connection issues with certain WiFi adapters and clients.
|
||||
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
|
||||
"""
|
||||
try:
|
||||
# Stop any existing connection
|
||||
self.disconnect_from_network()
|
||||
time.sleep(1)
|
||||
|
||||
# Delete any existing hotspot connections (more thorough cleanup)
|
||||
# First, list all connections to find any with the same SSID or hotspot-related ones
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_type = parts[1].strip().lower() if len(parts) > 1 else ""
|
||||
conn_ssid = parts[2].strip() if len(parts) > 2 else ""
|
||||
ap_ssid, ap_channel = self._validate_ap_config()
|
||||
|
||||
# Delete if:
|
||||
# 1. It's a hotspot type
|
||||
# 2. It has the same SSID as our AP
|
||||
# 3. It matches our known connection names
|
||||
should_delete = (
|
||||
'hotspot' in conn_type or
|
||||
conn_ssid == ap_ssid or
|
||||
'hotspot' in conn_name.lower() or
|
||||
conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]
|
||||
)
|
||||
|
||||
if should_delete:
|
||||
logger.info(f"Deleting existing connection: {conn_name} (type: {conn_type}, SSID: {conn_ssid})")
|
||||
# First disconnect it if active
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Then delete it
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Also explicitly delete known connection names (in case they weren't caught above)
|
||||
# Delete only the specific application-managed AP profiles by name.
|
||||
# Never delete by SSID — that would destroy a user's saved home network.
|
||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True, timeout=5)
|
||||
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||
capture_output=True, timeout=10)
|
||||
|
||||
# Wait a moment for deletions to complete
|
||||
time.sleep(1)
|
||||
|
||||
# Get AP settings from config
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||
|
||||
# Use nmcli hotspot command (simpler, works with Broadcom chips)
|
||||
# Open network (no password) for easy setup access
|
||||
logger.info(f"Creating open hotspot with nmcli: {ap_ssid} on {self._wifi_interface} (no password)")
|
||||
|
||||
# Note: Some NetworkManager versions add a default password to hotspots
|
||||
# We'll create it and then immediately remove all security settings
|
||||
# Create an open AP connection profile from scratch.
|
||||
# Using 'connection add' instead of 'device wifi hotspot' because the
|
||||
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
|
||||
# ignores post-creation security modifications.
|
||||
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
|
||||
f"{self._wifi_interface} (no password)")
|
||||
cmd = [
|
||||
"nmcli", "device", "wifi", "hotspot",
|
||||
"ifname", self._wifi_interface,
|
||||
"nmcli", "connection", "add",
|
||||
"type", "wifi",
|
||||
"con-name", "LEDMatrix-Setup-AP",
|
||||
"ifname", self._wifi_interface,
|
||||
"ssid", ap_ssid,
|
||||
"band", "bg", # 2.4GHz for maximum compatibility
|
||||
"channel", str(ap_channel),
|
||||
# Don't pass password parameter - we'll remove security after creation
|
||||
"802-11-wireless.mode", "ap",
|
||||
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||
"802-11-wireless.channel", str(ap_channel),
|
||||
"ipv4.method", "shared",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
# No 802-11-wireless-security section → open network
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
# On Trixie disable PMF which can prevent older clients from connecting
|
||||
if self._is_trixie:
|
||||
cmd += ["802-11-wireless-security.pmf", "disable"]
|
||||
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
||||
|
||||
if result.returncode == 0:
|
||||
# Always explicitly remove all security settings to ensure open network
|
||||
# NetworkManager sometimes adds default security even when not specified
|
||||
logger.info("Ensuring hotspot is open (no password)...")
|
||||
time.sleep(2) # Give it a moment to create
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
# Remove all possible security settings
|
||||
security_settings = [
|
||||
("802-11-wireless-security.key-mgmt", "none"),
|
||||
("802-11-wireless-security.psk", ""),
|
||||
("802-11-wireless-security.wep-key", ""),
|
||||
("802-11-wireless-security.wep-key-type", ""),
|
||||
("802-11-wireless-security.auth-alg", "open"),
|
||||
]
|
||||
|
||||
# On Trixie, also disable PMF (Protected Management Frames)
|
||||
# This can cause connection issues with certain WiFi adapters and clients
|
||||
if self._is_trixie:
|
||||
security_settings.append(("802-11-wireless-security.pmf", "disable"))
|
||||
logger.info("Trixie detected: disabling PMF for better client compatibility")
|
||||
|
||||
for setting, value in security_settings:
|
||||
result_modify = subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP", setting, str(value)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result_modify.returncode != 0:
|
||||
logger.debug(f"Could not set {setting} to {value}: {result_modify.stderr}")
|
||||
|
||||
# On Trixie, set static IP address for the hotspot (default is 10.42.0.1)
|
||||
# We want 192.168.4.1 for consistency
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
"ipv4.method", "shared"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Verify it's open
|
||||
verify_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "802-11-wireless-security.key-mgmt,802-11-wireless-security.psk", "connection", "show", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if verify_result.returncode == 0:
|
||||
output = verify_result.stdout.strip()
|
||||
key_mgmt = ""
|
||||
psk = ""
|
||||
for line in output.split('\n'):
|
||||
if 'key-mgmt:' in line:
|
||||
key_mgmt = line.split(':', 1)[1].strip() if ':' in line else ""
|
||||
elif 'psk:' in line:
|
||||
psk = line.split(':', 1)[1].strip() if ':' in line else ""
|
||||
|
||||
if key_mgmt != "none" or (psk and psk != ""):
|
||||
logger.warning(f"Hotspot still has security (key-mgmt={key_mgmt}, psk={'set' if psk else 'empty'}), deleting and recreating...")
|
||||
# Delete and recreate as last resort
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
time.sleep(1)
|
||||
# Recreate without any password parameters
|
||||
cmd_recreate = [
|
||||
"nmcli", "device", "wifi", "hotspot",
|
||||
"ifname", self._wifi_interface,
|
||||
"con-name", "LEDMatrix-Setup-AP",
|
||||
"ssid", ap_ssid,
|
||||
"band", "bg",
|
||||
"channel", str(ap_channel),
|
||||
]
|
||||
subprocess.run(cmd_recreate, capture_output=True, timeout=30)
|
||||
# Set IP address for consistency
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
"ipv4.method", "shared"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Disable PMF on Trixie
|
||||
if self._is_trixie:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "LEDMatrix-Setup-AP",
|
||||
"802-11-wireless-security.pmf", "disable"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Recreated hotspot as open network")
|
||||
else:
|
||||
logger.info("Hotspot verified as open (no password)")
|
||||
|
||||
# Restart the connection to apply all changes
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
time.sleep(1)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info("Hotspot restarted with open network settings")
|
||||
logger.info(f"AP mode started via nmcli hotspot: {ap_ssid}")
|
||||
time.sleep(2)
|
||||
|
||||
# Verify hotspot is running
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip}")
|
||||
self._show_led_message(f"Setup: {ip}", duration=5)
|
||||
return True, f"AP mode enabled (hotspot mode) - Access at {ip}:5000"
|
||||
else:
|
||||
logger.error("AP mode started but not verified")
|
||||
return False, "AP mode started but verification failed"
|
||||
else:
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||
logger.error(f"Failed to start AP mode via nmcli: {error_msg}")
|
||||
logger.error(f"Failed to create AP connection profile: {error_msg}")
|
||||
self._show_led_message("AP mode failed", duration=5)
|
||||
return False, f"Failed to start AP mode: {error_msg}"
|
||||
|
||||
return False, f"Failed to create AP profile: {error_msg}"
|
||||
|
||||
logger.info("AP connection profile created, bringing it up...")
|
||||
up_result = subprocess.run(
|
||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, text=True, timeout=20
|
||||
)
|
||||
if up_result.returncode != 0:
|
||||
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
self._show_led_message("AP mode failed", duration=5)
|
||||
return False, f"Failed to start AP: {error_msg}"
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# NM's ipv4.method=shared manages ip_forward automatically, so we only
|
||||
# need to add the iptables port-redirect rules for the captive portal.
|
||||
self._setup_iptables_redirect()
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
||||
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
||||
else:
|
||||
logger.error("AP mode started but not verified by status check — rolling back")
|
||||
self._teardown_iptables_redirect()
|
||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
capture_output=True, timeout=10)
|
||||
self._clear_led_message()
|
||||
return False, "AP mode started but verification failed"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting AP mode with nmcli hotspot: {e}")
|
||||
logger.error(f"Error starting AP mode with nmcli: {e}")
|
||||
self._show_led_message("Setup mode error", duration=5)
|
||||
return False, str(e)
|
||||
|
||||
@@ -1976,7 +1911,12 @@ class WiFiManager:
|
||||
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2 and 'hotspot' in parts[1].lower():
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
conn_type = parts[1].strip().lower()
|
||||
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||
ip = '192.168.4.1'
|
||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||
@@ -2072,45 +2012,9 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||
|
||||
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
||||
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
|
||||
if hostapd_active:
|
||||
try:
|
||||
# Check if iptables is available
|
||||
iptables_check = subprocess.run(
|
||||
["which", "iptables"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if iptables_check.returncode == 0:
|
||||
# Remove NAT redirect rule
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", "5000"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Remove INPUT rule
|
||||
subprocess.run(
|
||||
["sudo", "iptables", "-D", "INPUT", "-i", self._wifi_interface, "-p", "tcp", "--dport", "80", "-j", "ACCEPT"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
logger.info("Removed iptables port forwarding rules")
|
||||
else:
|
||||
logger.debug("iptables not available, skipping rule removal")
|
||||
|
||||
# Disable IP forwarding (restore to default client mode)
|
||||
subprocess.run(
|
||||
["sudo", "sysctl", "-w", "net.ipv4.ip_forward=0"],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
logger.info("Disabled IP forwarding")
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Could not remove iptables rules or disable forwarding: {e}")
|
||||
# Continue anyway
|
||||
self._teardown_iptables_redirect()
|
||||
|
||||
# Clean up WiFi interface IP configuration
|
||||
subprocess.run(
|
||||
@@ -2153,13 +2057,14 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
|
||||
else:
|
||||
# nmcli hotspot mode - restart not needed, just ensure WiFi radio is enabled
|
||||
logger.info("Skipping NetworkManager restart (nmcli hotspot mode, restart not needed)")
|
||||
# Still ensure WiFi radio is enabled (may have been disabled by nmcli operations)
|
||||
# Use retries for safety
|
||||
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
|
||||
# so we only need to remove the iptables redirect rules we added.
|
||||
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
|
||||
self._teardown_iptables_redirect()
|
||||
# Ensure WiFi radio is enabled after nmcli operations
|
||||
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
|
||||
if not wifi_enabled:
|
||||
logger.warning("WiFi radio may be disabled after nmcli hotspot cleanup")
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
@@ -2175,9 +2080,11 @@ class WiFiManager:
|
||||
try:
|
||||
config_dir = HOSTAPD_CONFIG_PATH.parent
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ap_ssid = self.config.get("ap_ssid", DEFAULT_AP_SSID)
|
||||
ap_channel = self.config.get("ap_channel", DEFAULT_AP_CHANNEL)
|
||||
|
||||
# Use validated values — strips invalid chars and ensures channel is an int.
|
||||
# Also strip newlines from SSID to prevent config-file injection.
|
||||
ap_ssid, ap_channel = self._validate_ap_config()
|
||||
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
|
||||
|
||||
# Open network configuration (no password) for easy setup access
|
||||
config_content = f"""interface={self._wifi_interface}
|
||||
|
||||
@@ -7,7 +7,7 @@ Wants=network.target
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
|
||||
284
test/test_backup_manager.py
Normal file
284
test/test_backup_manager.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Tests for src.backup_manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import backup_manager
|
||||
from src.backup_manager import (
|
||||
BUNDLED_FONTS,
|
||||
SCHEMA_VERSION,
|
||||
RestoreOptions,
|
||||
create_backup,
|
||||
list_installed_plugins,
|
||||
preview_backup_contents,
|
||||
restore_backup,
|
||||
validate_backup,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_project(root: Path) -> Path:
|
||||
"""Build a minimal fake project tree under ``root``."""
|
||||
(root / "config").mkdir(parents=True)
|
||||
(root / "config" / "config.json").write_text(
|
||||
json.dumps({"web_ui": {"port": 8080}, "my-plugin": {"enabled": True, "favorites": ["A", "B"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "config" / "config_secrets.json").write_text(
|
||||
json.dumps({"ledmatrix-weather": {"api_key": "SECRET"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "config" / "wifi_config.json").write_text(
|
||||
json.dumps({"ap_mode": {"ssid": "LEDMatrix"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
fonts = root / "assets" / "fonts"
|
||||
fonts.mkdir(parents=True)
|
||||
# One bundled font (should be excluded) and one user-uploaded font.
|
||||
(fonts / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||
(fonts / "my-custom-font.ttf").write_bytes(b"\x00\x01USER")
|
||||
|
||||
uploads = root / "assets" / "plugins" / "static-image" / "uploads"
|
||||
uploads.mkdir(parents=True)
|
||||
(uploads / "image_1.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
(uploads / ".metadata.json").write_text(json.dumps({"a": 1}), encoding="utf-8")
|
||||
|
||||
# plugin-repos for installed-plugin enumeration.
|
||||
plugin_dir = root / "plugin-repos" / "my-plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "manifest.json").write_text(
|
||||
json.dumps({"id": "my-plugin", "version": "1.2.3"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# plugin_state.json
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / "plugin_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"states": {
|
||||
"my-plugin": {"version": "1.2.3", "enabled": True},
|
||||
"other-plugin": {"version": "0.1.0", "enabled": False},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
return _make_project(tmp_path / "src_project")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_project(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "dst_project"
|
||||
root.mkdir()
|
||||
# Pre-seed only the bundled font to simulate a fresh install.
|
||||
(root / "assets" / "fonts").mkdir(parents=True)
|
||||
(root / "assets" / "fonts" / "5x7.bdf").write_text("BUNDLED", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUNDLED_FONTS sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bundled_fonts_matches_repo() -> None:
|
||||
"""Every entry in BUNDLED_FONTS must exist on disk in assets/fonts/.
|
||||
|
||||
The reverse direction is intentionally not checked: real installations
|
||||
have user-uploaded fonts in the same directory, and they should be
|
||||
treated as user data (not bundled).
|
||||
"""
|
||||
repo_fonts = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||
if not repo_fonts.exists():
|
||||
pytest.skip("assets/fonts not present in test env")
|
||||
on_disk = {p.name for p in repo_fonts.iterdir() if p.is_file()}
|
||||
missing = set(BUNDLED_FONTS) - on_disk
|
||||
assert not missing, f"BUNDLED_FONTS references files not in assets/fonts/: {missing}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview / enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_installed_plugins(project: Path) -> None:
|
||||
plugins = list_installed_plugins(project)
|
||||
ids = [p["plugin_id"] for p in plugins]
|
||||
assert "my-plugin" in ids
|
||||
assert "other-plugin" in ids
|
||||
my = next(p for p in plugins if p["plugin_id"] == "my-plugin")
|
||||
assert my["version"] == "1.2.3"
|
||||
|
||||
|
||||
def test_preview_backup_contents(project: Path) -> None:
|
||||
preview = preview_backup_contents(project)
|
||||
assert preview["has_config"] is True
|
||||
assert preview["has_secrets"] is True
|
||||
assert preview["has_wifi"] is True
|
||||
assert preview["user_fonts"] == ["my-custom-font.ttf"]
|
||||
assert preview["plugin_uploads"] >= 2
|
||||
assert any(p["plugin_id"] == "my-plugin" for p in preview["plugins"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_backup_contents(project: Path, tmp_path: Path) -> None:
|
||||
out_dir = tmp_path / "exports"
|
||||
zip_path = create_backup(project, output_dir=out_dir)
|
||||
assert zip_path.exists()
|
||||
assert zip_path.parent == out_dir
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "manifest.json" in names
|
||||
assert "config/config.json" in names
|
||||
assert "config/config_secrets.json" in names
|
||||
assert "config/wifi_config.json" in names
|
||||
assert "assets/fonts/my-custom-font.ttf" in names
|
||||
# Bundled font must NOT be included.
|
||||
assert "assets/fonts/5x7.bdf" not in names
|
||||
assert "assets/plugins/static-image/uploads/image_1.png" in names
|
||||
assert "plugins.json" in names
|
||||
|
||||
|
||||
def test_create_backup_manifest(project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
manifest = json.loads(zf.read("manifest.json"))
|
||||
assert manifest["schema_version"] == backup_manager.SCHEMA_VERSION
|
||||
assert "created_at" in manifest
|
||||
assert set(manifest["contents"]) >= {"config", "secrets", "wifi", "fonts", "plugin_uploads", "plugins"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_backup_ok(project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
ok, err, manifest = validate_backup(zip_path)
|
||||
assert ok, err
|
||||
assert err == ""
|
||||
assert "config" in manifest["detected_contents"]
|
||||
assert "secrets" in manifest["detected_contents"]
|
||||
assert any(p["plugin_id"] == "my-plugin" for p in manifest["plugins"])
|
||||
|
||||
|
||||
def test_validate_backup_missing_manifest(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("config/config.json", "{}")
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "manifest" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_bad_schema_version(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": 999}))
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "schema" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_rejects_zip_traversal(tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "malicious.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||
zf.writestr("../../etc/passwd", "x")
|
||||
ok, err, _ = validate_backup(zip_path)
|
||||
assert not ok
|
||||
assert "unsafe" in err.lower()
|
||||
|
||||
|
||||
def test_validate_backup_not_a_zip(tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.zip"
|
||||
p.write_text("hello", encoding="utf-8")
|
||||
ok, _err, _ = validate_backup(p)
|
||||
assert not ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_roundtrip(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
|
||||
assert result.success, result.errors
|
||||
assert "config" in result.restored
|
||||
assert "secrets" in result.restored
|
||||
assert "wifi" in result.restored
|
||||
|
||||
# Files exist with correct contents.
|
||||
restored_config = json.loads((empty_project / "config" / "config.json").read_text())
|
||||
assert restored_config["my-plugin"]["favorites"] == ["A", "B"]
|
||||
|
||||
restored_secrets = json.loads((empty_project / "config" / "config_secrets.json").read_text())
|
||||
assert restored_secrets["ledmatrix-weather"]["api_key"] == "SECRET"
|
||||
|
||||
# User font restored, bundled font untouched.
|
||||
assert (empty_project / "assets" / "fonts" / "my-custom-font.ttf").read_bytes() == b"\x00\x01USER"
|
||||
assert (empty_project / "assets" / "fonts" / "5x7.bdf").read_text() == "BUNDLED"
|
||||
|
||||
# Plugin uploads restored.
|
||||
assert (empty_project / "assets" / "plugins" / "static-image" / "uploads" / "image_1.png").exists()
|
||||
|
||||
# Plugins to install surfaced for the caller.
|
||||
plugin_ids = {p["plugin_id"] for p in result.plugins_to_install}
|
||||
assert "my-plugin" in plugin_ids
|
||||
|
||||
|
||||
def test_restore_honors_options(project: Path, empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
opts = RestoreOptions(
|
||||
restore_config=True,
|
||||
restore_secrets=False,
|
||||
restore_wifi=False,
|
||||
restore_fonts=False,
|
||||
restore_plugin_uploads=False,
|
||||
reinstall_plugins=False,
|
||||
)
|
||||
result = restore_backup(zip_path, empty_project, opts)
|
||||
assert result.success, result.errors
|
||||
assert (empty_project / "config" / "config.json").exists()
|
||||
assert not (empty_project / "config" / "config_secrets.json").exists()
|
||||
assert not (empty_project / "config" / "wifi_config.json").exists()
|
||||
assert not (empty_project / "assets" / "fonts" / "my-custom-font.ttf").exists()
|
||||
assert result.plugins_to_install == []
|
||||
assert "secrets" in result.skipped
|
||||
assert "wifi" in result.skipped
|
||||
|
||||
|
||||
def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> None:
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("manifest.json", json.dumps({"schema_version": SCHEMA_VERSION, "contents": []}))
|
||||
zf.writestr("../escape.txt", "x")
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
# validate_backup catches it before extraction.
|
||||
assert not result.success
|
||||
assert any("unsafe" in e.lower() for e in result.errors)
|
||||
@@ -2,13 +2,15 @@ from flask import Blueprint, request, jsonify, Response, send_from_directory
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import hashlib
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any, Type
|
||||
|
||||
@@ -1105,6 +1107,290 @@ def save_raw_secrets_config():
|
||||
status_code=500
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup & Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKUP_FILENAME_RE = re.compile(r'^ledmatrix-backup-[A-Za-z0-9_-]+-\d{8}_\d{6}\.zip$')
|
||||
|
||||
|
||||
def _backup_exports_dir() -> Path:
|
||||
"""Directory where user-downloadable backup ZIPs are stored."""
|
||||
d = PROJECT_ROOT / 'config' / 'backups' / 'exports'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _is_safe_backup_filename(name: str) -> bool:
|
||||
"""Allow-list filter for backup filenames used in download/delete."""
|
||||
return bool(_BACKUP_FILENAME_RE.match(name))
|
||||
|
||||
|
||||
@api_v3.route('/backup/preview', methods=['GET'])
|
||||
def backup_preview():
|
||||
"""Return a summary of what a new backup would include."""
|
||||
try:
|
||||
from src import backup_manager
|
||||
preview = backup_manager.preview_backup_contents(PROJECT_ROOT)
|
||||
return jsonify({'status': 'success', 'data': preview})
|
||||
except Exception:
|
||||
logger.exception("[Backup] preview failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to compute backup preview'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/backup/export', methods=['POST'])
|
||||
def backup_export():
|
||||
"""Create a new backup ZIP and return its filename."""
|
||||
try:
|
||||
from src import backup_manager
|
||||
zip_path = backup_manager.create_backup(PROJECT_ROOT, output_dir=_backup_exports_dir())
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'filename': zip_path.name,
|
||||
'size': zip_path.stat().st_size,
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("[Backup] export failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to create backup'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/backup/list', methods=['GET'])
|
||||
def backup_list():
|
||||
"""List backup ZIPs currently stored on disk."""
|
||||
try:
|
||||
exports = _backup_exports_dir()
|
||||
entries = []
|
||||
for path in sorted(exports.glob('ledmatrix-backup-*.zip'), reverse=True):
|
||||
if not _is_safe_backup_filename(path.name):
|
||||
continue
|
||||
stat = path.stat()
|
||||
entries.append({
|
||||
'filename': path.name,
|
||||
'size': stat.st_size,
|
||||
'created_at': datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
||||
})
|
||||
return jsonify({'status': 'success', 'data': entries})
|
||||
except Exception:
|
||||
logger.exception("[Backup] list failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to list backups'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||
def backup_download(filename):
|
||||
"""Stream a previously-created backup ZIP to the browser."""
|
||||
try:
|
||||
if not _is_safe_backup_filename(filename):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid backup filename'}), 400
|
||||
exports = _backup_exports_dir()
|
||||
target = exports / filename
|
||||
if not target.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
return send_from_directory(
|
||||
str(exports),
|
||||
filename,
|
||||
as_attachment=True,
|
||||
mimetype='application/zip',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[Backup] download failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to download backup'}), 500
|
||||
|
||||
|
||||
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
||||
def backup_delete(filename):
|
||||
"""Delete a stored backup ZIP."""
|
||||
try:
|
||||
if not _is_safe_backup_filename(filename):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid backup filename'}), 400
|
||||
target = _backup_exports_dir() / filename
|
||||
if not target.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||
target.unlink()
|
||||
return jsonify({'status': 'success', 'message': f'Deleted {filename}'})
|
||||
except Exception:
|
||||
logger.exception("[Backup] delete failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to delete backup'}), 500
|
||||
|
||||
|
||||
def _save_uploaded_backup_to_temp() -> Tuple[Optional[Path], Optional[Tuple[Response, int]]]:
|
||||
"""Shared upload handler for validate/restore endpoints. Returns
|
||||
``(temp_path, None)`` on success or ``(None, error_response)`` on failure.
|
||||
The caller is responsible for deleting the returned temp file."""
|
||||
import tempfile as _tempfile
|
||||
if 'backup_file' not in request.files:
|
||||
return None, (jsonify({'status': 'error', 'message': 'No backup file provided'}), 400)
|
||||
upload = request.files['backup_file']
|
||||
if not upload.filename:
|
||||
return None, (jsonify({'status': 'error', 'message': 'No file selected'}), 400)
|
||||
is_valid, err = validate_file_upload(
|
||||
upload.filename,
|
||||
max_size_mb=200,
|
||||
allowed_extensions=['.zip'],
|
||||
)
|
||||
if not is_valid:
|
||||
return None, (jsonify({'status': 'error', 'message': err}), 400)
|
||||
fd, tmp_name = _tempfile.mkstemp(prefix='ledmatrix_upload_', suffix='.zip')
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
max_bytes = 200 * 1024 * 1024
|
||||
try:
|
||||
written = 0
|
||||
with open(tmp_path, 'wb') as fh:
|
||||
while True:
|
||||
chunk = upload.stream.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > max_bytes:
|
||||
fh.close()
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return None, (jsonify({'status': 'error', 'message': 'Backup file exceeds 200 MB limit'}), 413)
|
||||
fh.write(chunk)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.exception("[Backup] Failed to save uploaded backup")
|
||||
return None, (jsonify({'status': 'error', 'message': 'Failed to read uploaded file'}), 500)
|
||||
return tmp_path, None
|
||||
|
||||
|
||||
@api_v3.route('/backup/validate', methods=['POST'])
|
||||
def backup_validate():
|
||||
"""Inspect an uploaded backup without applying it."""
|
||||
tmp_path, err = _save_uploaded_backup_to_temp()
|
||||
if err is not None:
|
||||
return err
|
||||
try:
|
||||
from src import backup_manager
|
||||
ok, error, manifest = backup_manager.validate_backup(tmp_path)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': error}), 400
|
||||
return jsonify({'status': 'success', 'data': manifest})
|
||||
except Exception:
|
||||
logger.exception("[Backup] validate failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to validate backup'}), 500
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@api_v3.route('/backup/restore', methods=['POST'])
|
||||
def backup_restore():
|
||||
"""
|
||||
Restore an uploaded backup into the running installation.
|
||||
|
||||
The request is multipart/form-data with:
|
||||
- ``backup_file``: the ZIP upload
|
||||
- ``options``: JSON string with RestoreOptions fields (all boolean)
|
||||
"""
|
||||
tmp_path, err = _save_uploaded_backup_to_temp()
|
||||
if err is not None:
|
||||
return err
|
||||
try:
|
||||
from src import backup_manager
|
||||
|
||||
# Parse options (all optional; default is "restore everything").
|
||||
raw_opts = request.form.get('options') or '{}'
|
||||
try:
|
||||
opts_dict = json.loads(raw_opts)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400
|
||||
if not isinstance(opts_dict, dict):
|
||||
return jsonify({'status': 'error', 'message': 'options must be an object'}), 400
|
||||
|
||||
opts = backup_manager.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)),
|
||||
)
|
||||
|
||||
# Snapshot current config through the atomic manager so the pre-restore
|
||||
# state is recoverable via the existing rollback_config() path.
|
||||
if api_v3.config_manager and opts.restore_config:
|
||||
try:
|
||||
current = api_v3.config_manager.load_config()
|
||||
snapshot_ok, snapshot_err = _save_config_atomic(api_v3.config_manager, current, create_backup=True)
|
||||
if not snapshot_ok:
|
||||
logger.warning("[Backup] Pre-restore snapshot failed: %s (continuing)", snapshot_err)
|
||||
except Exception:
|
||||
logger.warning("[Backup] Pre-restore snapshot failed (continuing)", exc_info=True)
|
||||
|
||||
result = backup_manager.restore_backup(tmp_path, PROJECT_ROOT, opts)
|
||||
|
||||
# Reinstall plugins via the store manager, one at a time.
|
||||
if opts.reinstall_plugins and api_v3.plugin_store_manager and result.plugins_to_install:
|
||||
installed_names = set()
|
||||
if api_v3.plugin_manager:
|
||||
try:
|
||||
existing = api_v3.plugin_manager.get_all_plugin_info() or []
|
||||
installed_names = {p.get('id') for p in existing if p.get('id')}
|
||||
except Exception:
|
||||
installed_names = set()
|
||||
for entry in result.plugins_to_install:
|
||||
plugin_id = entry.get('plugin_id')
|
||||
if not plugin_id:
|
||||
continue
|
||||
if plugin_id in installed_names:
|
||||
result.plugins_installed.append(plugin_id)
|
||||
continue
|
||||
try:
|
||||
ok = api_v3.plugin_store_manager.install_plugin(plugin_id)
|
||||
if ok:
|
||||
if api_v3.schema_manager:
|
||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||
if api_v3.plugin_manager:
|
||||
api_v3.plugin_manager.discover_plugins()
|
||||
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||
if api_v3.plugin_state_manager:
|
||||
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
|
||||
result.plugins_installed.append(plugin_id)
|
||||
else:
|
||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': 'install returned False'})
|
||||
except Exception as install_err:
|
||||
logger.exception("[Backup] plugin reinstall failed for %s", plugin_id)
|
||||
result.plugins_failed.append({'plugin_id': plugin_id, 'error': str(install_err)})
|
||||
|
||||
# Clear font catalog cache so restored fonts show up.
|
||||
if any(r.startswith("fonts") for r in result.restored):
|
||||
try:
|
||||
from web_interface.cache import delete_cached
|
||||
delete_cached('fonts_catalog')
|
||||
except Exception:
|
||||
logger.warning("[Backup] Failed to clear font cache", exc_info=True)
|
||||
|
||||
# Reload config_manager state so the UI picks up the new values
|
||||
# without a full service restart.
|
||||
if api_v3.config_manager and opts.restore_config:
|
||||
try:
|
||||
api_v3.config_manager.load_config(force_reload=True)
|
||||
except TypeError:
|
||||
try:
|
||||
api_v3.config_manager.load_config()
|
||||
except Exception:
|
||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
||||
except Exception:
|
||||
logger.warning("[Backup] Could not reload config after restore", exc_info=True)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success' if result.success else 'partial',
|
||||
'data': result.to_dict(),
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("[Backup] restore failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to restore backup'}), 500
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@api_v3.route('/system/status', methods=['GET'])
|
||||
def get_system_status():
|
||||
"""Get system status"""
|
||||
@@ -1341,6 +1627,71 @@ def get_system_version():
|
||||
logger.exception("[System] get_system_version failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to get system version'}), 500
|
||||
|
||||
_update_check_cache: Dict = {}
|
||||
_UPDATE_CHECK_TTL = 300 # 5 minutes
|
||||
_update_check_lock = threading.Lock()
|
||||
|
||||
@api_v3.route('/system/check-update', methods=['GET'])
|
||||
def check_for_update():
|
||||
"""Check if a newer version is available on the remote."""
|
||||
now = time.time()
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
with _update_check_lock:
|
||||
if _update_check_cache.get('ts', 0) + _UPDATE_CHECK_TTL > now:
|
||||
return jsonify(_update_check_cache['data'])
|
||||
|
||||
try:
|
||||
fetch_result = subprocess.run(
|
||||
['git', 'fetch', 'origin', 'main'],
|
||||
capture_output=True, text=True, timeout=15, cwd=project_dir
|
||||
)
|
||||
if fetch_result.returncode != 0:
|
||||
raise RuntimeError(f"git fetch failed: {fetch_result.stderr.strip()}")
|
||||
|
||||
local_result = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if local_result.returncode != 0:
|
||||
raise RuntimeError(f"git rev-parse HEAD failed: {local_result.stderr.strip()}")
|
||||
local_sha = local_result.stdout.strip()
|
||||
|
||||
remote_result = subprocess.run(
|
||||
['git', 'rev-parse', 'origin/main'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if remote_result.returncode != 0:
|
||||
raise RuntimeError(f"git rev-parse origin/main failed: {remote_result.stderr.strip()}")
|
||||
remote_sha = remote_result.stdout.strip()
|
||||
|
||||
if local_sha == remote_sha:
|
||||
data = {'status': 'success', 'update_available': False,
|
||||
'local_sha': local_sha[:8], 'remote_sha': remote_sha[:8]}
|
||||
else:
|
||||
log_result = subprocess.run(
|
||||
['git', 'log', 'HEAD..origin/main', '--oneline'],
|
||||
capture_output=True, text=True, timeout=5, cwd=project_dir
|
||||
)
|
||||
if log_result.returncode != 0:
|
||||
raise RuntimeError(f"git log failed: {log_result.stderr.strip()}")
|
||||
lines = [commit_line for commit_line in log_result.stdout.strip().split('\n') if commit_line]
|
||||
commits_behind = len(lines)
|
||||
data = {
|
||||
'status': 'success',
|
||||
'update_available': commits_behind > 0,
|
||||
'local_sha': local_sha[:8],
|
||||
'remote_sha': remote_sha[:8],
|
||||
'commits_behind': commits_behind,
|
||||
'latest_message': lines[0].split(' ', 1)[1] if lines else '',
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("[System] check-update failed: %s", e)
|
||||
data = {'status': 'error', 'update_available': False, 'message': str(e)}
|
||||
|
||||
_update_check_cache['ts'] = now
|
||||
_update_check_cache['data'] = data
|
||||
return jsonify(data)
|
||||
|
||||
@api_v3.route('/system/action', methods=['POST'])
|
||||
def execute_system_action():
|
||||
"""Execute system actions (start/stop/reboot/etc)"""
|
||||
@@ -1450,6 +1801,10 @@ def execute_system_action():
|
||||
cwd=project_dir
|
||||
)
|
||||
|
||||
# Invalidate update-check cache so the banner hides immediately
|
||||
with _update_check_lock:
|
||||
_update_check_cache.clear()
|
||||
|
||||
# Return custom response for git_pull
|
||||
if result.returncode == 0:
|
||||
pull_message = "Code updated successfully."
|
||||
@@ -6365,18 +6720,146 @@ def upload_calendar_credentials():
|
||||
logger.exception("[PluginConfig] upload_calendar_credentials failed")
|
||||
return jsonify({'status': 'error', 'message': 'Failed to upload calendar credentials'}), 500
|
||||
|
||||
def _load_calendar_plugin_dir():
|
||||
"""Resolve the calendar plugin's on-disk directory without requiring a running instance.
|
||||
|
||||
The web service and display service are separate processes — the web
|
||||
process discovers plugins but does not instantiate them, so
|
||||
plugin_manager.get_plugin('calendar') is typically None here.
|
||||
"""
|
||||
plugin_id = 'calendar'
|
||||
if api_v3.plugin_manager:
|
||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||
if plugin_dir and Path(plugin_dir).exists():
|
||||
return Path(plugin_dir)
|
||||
fallback = PROJECT_ROOT / 'plugins' / plugin_id
|
||||
return fallback if fallback.exists() else None
|
||||
|
||||
|
||||
_GOOGLE_API_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
def _load_calendar_credentials(token_path):
|
||||
"""Load OAuth credentials from the plugin's token file.
|
||||
|
||||
The calendar plugin historically persists credentials with pickle
|
||||
(``token.pickle``). pickle.load is only applied to this specific file,
|
||||
which is owned by the same user as the web service, chmod 0600, and
|
||||
located inside the plugin install directory — it is not user-supplied
|
||||
input. We still constrain the unpickle to a reasonable size to reduce
|
||||
blast radius. New installs may use a JSON token (``token.json``)
|
||||
written via google-auth's safe serializer; prefer that when present.
|
||||
"""
|
||||
json_path = token_path.with_suffix('.json')
|
||||
if json_path.exists():
|
||||
from google.oauth2.credentials import Credentials
|
||||
return Credentials.from_authorized_user_file(str(json_path))
|
||||
|
||||
# Fall back to the pickle token the plugin writes today.
|
||||
# nosemgrep: python.lang.security.audit.avoid-pickle.avoid-pickle
|
||||
import pickle # noqa: S403
|
||||
try:
|
||||
size = token_path.stat().st_size
|
||||
except OSError as e:
|
||||
raise RuntimeError(f'Cannot stat token file: {e}') from e
|
||||
if size > 64 * 1024:
|
||||
raise RuntimeError('Token file is unexpectedly large; refusing to load.')
|
||||
with open(token_path, 'rb') as f:
|
||||
return pickle.load(f) # noqa: S301 # trusted file, owner-only perms
|
||||
|
||||
|
||||
def _list_google_calendars_from_disk():
|
||||
"""List calendars using the plugin's stored OAuth token.
|
||||
|
||||
Returns (calendars, error_message). calendars is a list of raw Google
|
||||
calendarList items on success; on failure calendars is None and
|
||||
error_message describes the problem.
|
||||
|
||||
Refreshed credentials are intentionally not persisted back to disk
|
||||
from this request path — the display service owns token.pickle and
|
||||
concurrent writes across processes could corrupt it. If refresh is
|
||||
needed, it happens only in memory for the duration of this request.
|
||||
"""
|
||||
try:
|
||||
import google_auth_httplib2
|
||||
import httplib2
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError:
|
||||
return None, 'Google API libraries not installed on this host.'
|
||||
|
||||
plugin_dir = _load_calendar_plugin_dir()
|
||||
if plugin_dir is None:
|
||||
return None, 'Calendar plugin directory not found.'
|
||||
|
||||
token_path = plugin_dir / 'token.pickle'
|
||||
if not token_path.exists() and not (plugin_dir / 'token.json').exists():
|
||||
return None, 'Not authenticated yet — complete the Google authentication step first.'
|
||||
|
||||
try:
|
||||
creds = _load_calendar_credentials(token_path)
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: failed to load stored credentials')
|
||||
return None, f'Failed to load stored authentication: {e}'
|
||||
|
||||
if not creds or not getattr(creds, 'valid', False):
|
||||
if creds and getattr(creds, 'expired', False) and getattr(creds, 'refresh_token', None):
|
||||
try:
|
||||
# In-memory refresh only; do not write back to shared token file.
|
||||
creds.refresh(Request(timeout=_GOOGLE_API_TIMEOUT_SECONDS))
|
||||
except (socket.timeout, TimeoutError) as e:
|
||||
logger.warning('list_calendar_calendars: token refresh timed out: %s', e)
|
||||
return None, 'Token refresh timed out. Please try again.'
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: token refresh failed')
|
||||
return None, f'Stored authentication expired and refresh failed: {e}. Re-run the Google authentication step.'
|
||||
else:
|
||||
return None, 'Stored authentication is invalid. Re-run the Google authentication step.'
|
||||
|
||||
try:
|
||||
# Build an Http with an explicit socket timeout so API calls cannot
|
||||
# hang the Flask worker on flaky connectivity.
|
||||
authed_http = google_auth_httplib2.AuthorizedHttp(
|
||||
creds, http=httplib2.Http(timeout=_GOOGLE_API_TIMEOUT_SECONDS)
|
||||
)
|
||||
service = build('calendar', 'v3', http=authed_http, cache_discovery=False)
|
||||
items = []
|
||||
page_token = None
|
||||
while True:
|
||||
response = service.calendarList().list(pageToken=page_token).execute(
|
||||
num_retries=1
|
||||
)
|
||||
items.extend(response.get('items', []))
|
||||
page_token = response.get('nextPageToken')
|
||||
if not page_token:
|
||||
break
|
||||
return items, None
|
||||
except (socket.timeout, TimeoutError) as e:
|
||||
logger.warning('list_calendar_calendars: Google API call timed out: %s', e)
|
||||
return None, 'Google Calendar request timed out. Please try again.'
|
||||
except Exception as e:
|
||||
logger.exception('list_calendar_calendars: Google API call failed')
|
||||
return None, f'Google Calendar API call failed: {e}'
|
||||
|
||||
|
||||
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||
def list_calendar_calendars():
|
||||
"""Return Google Calendars accessible with the currently authenticated credentials."""
|
||||
if not api_v3.plugin_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Plugin manager not available'}), 500
|
||||
plugin = api_v3.plugin_manager.get_plugin('calendar')
|
||||
if not plugin:
|
||||
return jsonify({'status': 'error', 'message': 'Calendar plugin is not running. Enable it and save config first.'}), 404
|
||||
if not hasattr(plugin, 'get_calendars'):
|
||||
return jsonify({'status': 'error', 'message': 'Installed plugin version does not support calendar listing — update the plugin.'}), 400
|
||||
"""Return Google Calendars accessible with the currently authenticated credentials.
|
||||
|
||||
Reads credentials from the plugin directory directly so this works from the
|
||||
web process (which does not instantiate plugins).
|
||||
"""
|
||||
# Prefer a live plugin instance if one happens to exist (e.g. local dev where
|
||||
# web and display share a process); otherwise fall back to on-disk credentials.
|
||||
plugin = api_v3.plugin_manager.get_plugin('calendar') if api_v3.plugin_manager else None
|
||||
|
||||
try:
|
||||
raw = plugin.get_calendars()
|
||||
if plugin is not None and hasattr(plugin, 'get_calendars'):
|
||||
raw = plugin.get_calendars()
|
||||
else:
|
||||
raw, err = _list_google_calendars_from_disk()
|
||||
if raw is None:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
import collections.abc
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
logger.error('list_calendar_calendars: get_calendars() returned non-sequence type %r', type(raw))
|
||||
|
||||
@@ -76,6 +76,8 @@ def load_partial(partial_name):
|
||||
return _load_logs_partial()
|
||||
elif partial_name == 'raw-json':
|
||||
return _load_raw_json_partial()
|
||||
elif partial_name == 'backup-restore':
|
||||
return _load_backup_restore_partial()
|
||||
elif partial_name == 'wifi':
|
||||
return _load_wifi_partial()
|
||||
elif partial_name == 'cache':
|
||||
@@ -296,6 +298,13 @@ def _load_raw_json_partial():
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
|
||||
def _load_backup_restore_partial():
|
||||
"""Load backup & restore partial."""
|
||||
try:
|
||||
return render_template('v3/partials/backup_restore.html')
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}", 500
|
||||
|
||||
@pages_v3.route('/setup')
|
||||
def captive_setup():
|
||||
"""Lightweight captive portal setup page — self-contained, no frameworks."""
|
||||
|
||||
@@ -1004,3 +1004,39 @@ button.bg-white {
|
||||
[data-theme="dark"] .theme-toggle-btn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Update available banner */
|
||||
.update-banner {
|
||||
background-color: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.update-banner-action {
|
||||
background-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.update-banner-action:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
.update-banner-dismiss {
|
||||
color: #1e40af;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.update-banner-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .update-banner {
|
||||
background-color: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #93c5fd;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-action {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-action:hover {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
[data-theme="dark"] .update-banner-dismiss {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -898,6 +898,10 @@ window.currentPluginConfig = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
let storeFilteredList = [];
|
||||
|
||||
function storeCacheExpired() {
|
||||
return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION);
|
||||
}
|
||||
|
||||
// ── Plugin Store Filter State ───────────────────────────────────────────
|
||||
const storeFilterState = {
|
||||
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
||||
@@ -1165,9 +1169,11 @@ function initializePluginPageWhenReady() {
|
||||
if (target.id === 'plugins-content' ||
|
||||
target.querySelector('#installed-plugins-grid')) {
|
||||
console.log('HTMX swap detected for plugins, initializing...');
|
||||
// Reset initialization flag to allow re-initialization after HTMX swap
|
||||
// Reset all initialization flags so the fresh empty DOM gets populated
|
||||
window.pluginManager.initialized = false;
|
||||
window.pluginManager.initializing = false;
|
||||
window.pluginManager._reswap = true; // signal: use cached store, don't re-fetch GitHub
|
||||
pluginsInitialized = false;
|
||||
initTimer = setTimeout(attemptInit, 100);
|
||||
}
|
||||
}, { once: false }); // Allow multiple swaps
|
||||
@@ -1211,9 +1217,17 @@ function initializePlugins() {
|
||||
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
||||
}
|
||||
|
||||
// Load both installed plugins and plugin store
|
||||
loadInstalledPlugins();
|
||||
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub
|
||||
// Load both installed plugins and plugin store.
|
||||
// On HTMX re-swaps with a still-warm cache, skip GitHub metadata to avoid
|
||||
// re-hitting the API on every tab switch. If the cache TTL has expired even
|
||||
// during a re-swap, fetch fresh data including GitHub commit/version info.
|
||||
const isReswapWarm = !!window.pluginManager._reswap && !storeCacheExpired();
|
||||
window.pluginManager._reswap = false;
|
||||
// Await the installed-plugins fetch so window.installedPlugins is populated before
|
||||
// searchPluginStore renders Installed/Reinstall badges against it.
|
||||
loadInstalledPlugins().then(() => {
|
||||
searchPluginStore(!isReswapWarm);
|
||||
});
|
||||
|
||||
// Setup search functionality (with guard against duplicate listeners)
|
||||
const searchInput = document.getElementById('plugin-search');
|
||||
@@ -5127,10 +5141,13 @@ function refreshPlugins() {
|
||||
pluginStoreCache = null;
|
||||
cacheTimestamp = null;
|
||||
|
||||
loadInstalledPlugins();
|
||||
// Fetch latest metadata from GitHub when refreshing
|
||||
searchPluginStore(true);
|
||||
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
||||
// refreshInstalledPlugins() is async (returns a Promise via loadInstalledPlugins).
|
||||
// Only search the store and notify after window.installedPlugins is updated so
|
||||
// that Installed/Reinstall badges reflect the freshly fetched state.
|
||||
refreshInstalledPlugins().then(() => {
|
||||
searchPluginStore(true);
|
||||
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function restartDisplay() {
|
||||
|
||||
@@ -900,6 +900,34 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Update available banner -->
|
||||
<div id="update-banner" style="display:none"
|
||||
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-arrow-circle-up text-lg"></i>
|
||||
<span class="text-sm font-medium" id="update-banner-text"
|
||||
aria-live="polite" aria-atomic="true">
|
||||
A new LEDMatrix update is available
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="applyUpdate()" id="update-banner-btn"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||
update-banner-action transition-colors duration-150">
|
||||
<i class="fas fa-download mr-1"></i> Update Now
|
||||
</button>
|
||||
<button type="button" onclick="dismissUpdateBanner()"
|
||||
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||
title="Dismiss" aria-label="Dismiss update">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||||
<!-- Navigation tabs -->
|
||||
@@ -937,6 +965,11 @@
|
||||
class="nav-tab">
|
||||
<i class="fas fa-file-code"></i>Config Editor
|
||||
</button>
|
||||
<button @click="activeTab = 'backup-restore'"
|
||||
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
<i class="fas fa-save"></i>Backup & Restore
|
||||
</button>
|
||||
<button @click="activeTab = 'fonts'"
|
||||
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
@@ -1139,6 +1172,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore tab -->
|
||||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config Editor tab -->
|
||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
||||
@@ -4857,6 +4902,77 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Update banner logic -->
|
||||
<script>
|
||||
(function() {
|
||||
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
function getDismissedSha() {
|
||||
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function checkForUpdate() {
|
||||
fetch('/api/v3/system/check-update')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||||
var n = data.commits_behind || 0;
|
||||
var msg = 'A new LEDMatrix update is available';
|
||||
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||||
document.getElementById('update-banner-text').textContent = msg;
|
||||
document.getElementById('update-banner').style.display = '';
|
||||
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||||
} else {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
window.dismissUpdateBanner = function() {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
try {
|
||||
var sha = sessionStorage.getItem('update-sha');
|
||||
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
window.applyUpdate = function() {
|
||||
var btn = document.getElementById('update-banner-btn');
|
||||
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now';
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...';
|
||||
btn.disabled = true;
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'git_pull' })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
if (data.status === 'success') {
|
||||
document.getElementById('update-banner').style.display = 'none';
|
||||
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
|
||||
}
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(data.message || 'Update complete', data.status || 'success');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Update failed — check your connection', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initial check shortly after page load, then periodic
|
||||
setTimeout(checkForUpdate, 2000);
|
||||
setInterval(checkForUpdate, CHECK_INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
358
web_interface/templates/v3/partials/backup_restore.html
Normal file
358
web_interface/templates/v3/partials/backup_restore.html
Normal file
@@ -0,0 +1,358 @@
|
||||
<div class="space-y-6" id="backup-restore-root">
|
||||
|
||||
<!-- Security warning -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Backup files contain secrets in plaintext</h3>
|
||||
<div class="mt-1 text-sm text-red-700">
|
||||
Your API keys (weather, Spotify, YouTube, GitHub, etc.) and any saved WiFi passwords
|
||||
are stored inside the backup ZIP as plain text. Treat the file like a password —
|
||||
store it somewhere private and delete it when you no longer need it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export card -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Export backup</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Download a single ZIP with all of your settings so you can restore it later.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="exportBackup()" id="export-backup-btn"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="export-preview" class="text-sm text-gray-600">
|
||||
<div class="animate-pulse">Loading summary…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore card -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Restore from backup</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Upload a backup ZIP exported from this or another LEDMatrix install.
|
||||
You'll see a summary before anything is written to disk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
|
||||
<input type="file" id="restore-file-input" accept=".zip"
|
||||
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onclick="validateRestoreFile()" id="validate-restore-btn"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Inspect file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="restore-preview" class="hidden bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-2">Backup contents</h3>
|
||||
<dl id="restore-preview-body" class="text-sm text-gray-700 space-y-1"></dl>
|
||||
|
||||
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button onclick="runRestore()" id="run-restore-btn"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Restore now
|
||||
</button>
|
||||
<button onclick="clearRestore()"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="restore-result" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History card -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Backup history</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Previously exported backups stored on this device.</p>
|
||||
</div>
|
||||
<button onclick="loadBackupList()"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="backup-history" class="text-sm text-gray-600">Loading…</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let inspectedFile = null;
|
||||
|
||||
function notify(message, kind) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(message, kind || 'info');
|
||||
} else {
|
||||
console.log('[backup]', kind || 'info', message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0, size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const el = document.getElementById('export-preview');
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/preview');
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'Preview failed');
|
||||
const d = payload.data || {};
|
||||
el.innerHTML = `
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Main config: <strong>${d.has_config ? 'yes' : 'no'}</strong></li>
|
||||
<li>Secrets: <strong>${d.has_secrets ? 'yes' : 'no'}</strong></li>
|
||||
<li>WiFi config: <strong>${d.has_wifi ? 'yes' : 'no'}</strong></li>
|
||||
<li>User fonts: <strong>${(d.user_fonts || []).length}</strong> ${d.user_fonts && d.user_fonts.length ? '(' + d.user_fonts.map(escapeHtml).join(', ') + ')' : ''}</li>
|
||||
<li>Plugin image uploads: <strong>${d.plugin_uploads || 0}</strong> file(s)</li>
|
||||
<li>Installed plugins: <strong>${(d.plugins || []).length}</strong></li>
|
||||
</ul>`;
|
||||
} catch (err) {
|
||||
el.textContent = 'Could not load preview: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackupList() {
|
||||
const el = document.getElementById('backup-history');
|
||||
el.textContent = 'Loading…';
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/list');
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'List failed');
|
||||
const entries = payload.data || [];
|
||||
if (!entries.length) {
|
||||
el.innerHTML = '<p>No backups have been created yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left py-2">Filename</th>
|
||||
<th class="text-left py-2">Size</th>
|
||||
<th class="text-left py-2">Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
${entries.map(e => `
|
||||
<tr>
|
||||
<td class="py-2 font-mono text-xs">${escapeHtml(e.filename)}</td>
|
||||
<td class="py-2">${formatSize(e.size)}</td>
|
||||
<td class="py-2">${escapeHtml(e.created_at)}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<a href="/api/v3/backup/download/${encodeURIComponent(e.filename)}"
|
||||
class="text-blue-600 hover:underline">Download</a>
|
||||
<button data-filename="${escapeHtml(e.filename)}"
|
||||
class="text-red-600 hover:underline backup-delete-btn">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
el.querySelectorAll('.backup-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteBackup(btn.dataset.filename));
|
||||
});
|
||||
} catch (err) {
|
||||
el.textContent = 'Could not load backups: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBackup() {
|
||||
const btn = document.getElementById('export-backup-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Creating…';
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/export', { method: 'POST' });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'Export failed');
|
||||
notify('Backup created: ' + payload.filename, 'success');
|
||||
// Trigger browser download immediately.
|
||||
window.location.href = '/api/v3/backup/download/' + encodeURIComponent(payload.filename);
|
||||
await loadBackupList();
|
||||
} catch (err) {
|
||||
notify('Export failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-download mr-2"></i>Download backup';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(filename) {
|
||||
if (!confirm('Delete ' + filename + '?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'Delete failed');
|
||||
notify('Backup deleted', 'success');
|
||||
await loadBackupList();
|
||||
} catch (err) {
|
||||
notify('Delete failed: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRestoreFile() {
|
||||
const input = document.getElementById('restore-file-input');
|
||||
if (!input.files || !input.files[0]) {
|
||||
notify('Choose a backup file first', 'error');
|
||||
return;
|
||||
}
|
||||
const file = input.files[0];
|
||||
const fd = new FormData();
|
||||
fd.append('backup_file', file);
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/validate', { method: 'POST', body: fd });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') throw new Error(payload.message || 'Validation failed');
|
||||
inspectedFile = file;
|
||||
renderRestorePreview(payload.data);
|
||||
} catch (err) {
|
||||
notify('Invalid backup: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderRestorePreview(manifest) {
|
||||
const wrap = document.getElementById('restore-preview');
|
||||
const body = document.getElementById('restore-preview-body');
|
||||
const detected = manifest.detected_contents || [];
|
||||
const plugins = manifest.plugins || [];
|
||||
body.innerHTML = `
|
||||
<div><strong>Created:</strong> ${escapeHtml(manifest.created_at || 'unknown')}</div>
|
||||
<div><strong>Source host:</strong> ${escapeHtml(manifest.hostname || 'unknown')}</div>
|
||||
<div><strong>LEDMatrix version:</strong> ${escapeHtml(manifest.ledmatrix_version || 'unknown')}</div>
|
||||
<div><strong>Includes:</strong> ${detected.length ? detected.map(escapeHtml).join(', ') : '(nothing detected)'}</div>
|
||||
<div><strong>Plugins referenced:</strong> ${plugins.length ? plugins.map(p => escapeHtml(p.plugin_id)).join(', ') : 'none'}</div>
|
||||
`;
|
||||
wrap.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearRestore() {
|
||||
inspectedFile = null;
|
||||
document.getElementById('restore-preview').classList.add('hidden');
|
||||
document.getElementById('restore-result').classList.add('hidden');
|
||||
document.getElementById('restore-file-input').value = '';
|
||||
}
|
||||
|
||||
async function runRestore() {
|
||||
if (!inspectedFile) {
|
||||
notify('Inspect the file before restoring', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Restore from this backup? Current configuration will be overwritten.')) return;
|
||||
|
||||
const options = {
|
||||
restore_config: document.getElementById('opt-config').checked,
|
||||
restore_secrets: document.getElementById('opt-secrets').checked,
|
||||
restore_wifi: document.getElementById('opt-wifi').checked,
|
||||
restore_fonts: document.getElementById('opt-fonts').checked,
|
||||
restore_plugin_uploads: document.getElementById('opt-plugin-uploads').checked,
|
||||
reinstall_plugins: document.getElementById('opt-reinstall').checked,
|
||||
};
|
||||
const fd = new FormData();
|
||||
fd.append('backup_file', inspectedFile);
|
||||
fd.append('options', JSON.stringify(options));
|
||||
|
||||
const btn = document.getElementById('run-restore-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Restoring…';
|
||||
try {
|
||||
const res = await fetch('/api/v3/backup/restore', { method: 'POST', body: fd });
|
||||
const payload = await res.json();
|
||||
if (payload.status !== 'success') {
|
||||
const msgs = (payload.data?.errors || []).join('; ');
|
||||
throw new Error(payload.message || msgs || 'Restore had errors');
|
||||
}
|
||||
const data = payload.data || {};
|
||||
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
|
||||
const result = document.getElementById('restore-result');
|
||||
result.className = (hasPartial
|
||||
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
||||
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
|
||||
result.classList.remove('hidden');
|
||||
result.innerHTML = `
|
||||
<h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
|
||||
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
|
||||
<div><strong>Plugins failed:</strong> ${(data.plugins_failed || []).map(p => escapeHtml(p.plugin_id + ' (' + p.error + ')')).join(', ') || 'none'}</div>
|
||||
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
|
||||
${((data.restored || []).length || (data.plugins_installed || []).length) ? '<p class="mt-2">Restart the display service to apply all changes.</p>' : ''}
|
||||
`;
|
||||
notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
|
||||
} catch (err) {
|
||||
notify('Restore failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-upload mr-2"></i>Restore now';
|
||||
}
|
||||
}
|
||||
|
||||
// Expose handlers to inline onclick attributes.
|
||||
window.exportBackup = exportBackup;
|
||||
window.loadBackupList = loadBackupList;
|
||||
window.validateRestoreFile = validateRestoreFile;
|
||||
window.clearRestore = clearRestore;
|
||||
window.runRestore = runRestore;
|
||||
|
||||
// Clear inspection state whenever the user picks a new file.
|
||||
document.getElementById('restore-file-input').addEventListener('change', function () {
|
||||
inspectedFile = null;
|
||||
document.getElementById('restore-preview').classList.add('hidden');
|
||||
document.getElementById('restore-result').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Initial load.
|
||||
loadPreview();
|
||||
loadBackupList();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user