mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +00:00
merge: bring the element-style work onto main's api_v3 package split
main moved 16 commits while this branch was open, and one of them split api_v3.py into a package. A rebase would have conflicted on that file in each of the 15 commits that touch it; merging resolves it once. Reapplied into the package, unchanged in substance: - _schema_type_is / _schema_allows_null and the nullable-field handling (api_v3/__init__.py, where the form parser now lives) - the indexed-array recombiner's union-type fix and its all-blank-means-null case (api_v3/plugins.py) - scalable / native_size on the font catalog, and the removal of the three font-override stubs (api_v3/fonts.py) Two places took main's version rather than mine, deliberately: - pages_v3 now reads the schema through SchemaManager *and* keeps resolve_under for the raw-read fallback, so the containment guard main added in the CodeQL triage survives. - serve_plugin_widget moves off os.path.basename onto safe_path_component and resolve_under. main dropped `import os` from pages_v3 in favour of those helpers, and they reject a bad component rather than mangling it into a valid-looking one. The url-map snapshot loses exactly the three font-override routes. Edited rather than regenerated: that test exists to catch a route lost by accident during a move, and regenerating it wholesale is how such a loss would be papered over. Verified against a fresh run of current main: same failure set, 4692 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+33
@@ -53,3 +53,36 @@ skin_renders/
|
|||||||
# JS test deps (test/js)
|
# JS test deps (test/js)
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Team logos fetched at runtime.
|
||||||
|
#
|
||||||
|
# src/logo_downloader.py and LogoHelper write into assets/sports/<league>_logos/
|
||||||
|
# whenever a plugin meets a team whose logo is not on disk. Those directories are
|
||||||
|
# also tracked -- 209 NCAA logos and 153 soccer ones ship with the repo -- so
|
||||||
|
# every rig accumulated untracked files it was never meant to commit and
|
||||||
|
# `git status` was permanently dirty. That noise is not harmless: it trains
|
||||||
|
# everyone to ignore the one signal that says a checkout is not what you think
|
||||||
|
# it is, which is how a stale tree sat unnoticed on a rig until a restart
|
||||||
|
# surfaced four dead sports plugins.
|
||||||
|
#
|
||||||
|
# Ignoring a directory does not untrack what is already in it, so the logos that
|
||||||
|
# ship keep shipping. Only new downloads are hidden.
|
||||||
|
#
|
||||||
|
# Adding a logo on purpose is rare and deliberate -- the last time was #415, four
|
||||||
|
# named NCAA logos a plugin needed, and there has been no other in a year. Do it
|
||||||
|
# with an explicit override:
|
||||||
|
# git add -f assets/sports/ncaa_logos/DUKE.png
|
||||||
|
assets/sports/*_logos/
|
||||||
|
assets/stocks/ticker_icons/
|
||||||
|
assets/stocks/crypto_icons/
|
||||||
|
|
||||||
|
# Plugin operation state written at runtime.
|
||||||
|
#
|
||||||
|
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
||||||
|
# and data/operation_history.json as the web interface runs, into a directory that
|
||||||
|
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
||||||
|
# opened the web UI -- and every test run that constructs the app -- left three
|
||||||
|
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||||
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# App storage
|
# App storage
|
||||||
self.apps_dir = self._get_apps_directory()
|
self.apps_dir = self._get_apps_directory()
|
||||||
self.manifest_file = self.apps_dir / "manifest.json"
|
self.manifest_file = self.apps_dir / "manifest.json"
|
||||||
|
# A dedicated, never-replaced file to flock -- see _update_manifest_safe.
|
||||||
|
self.manifest_lock_file = self.apps_dir / "manifest.json.lock"
|
||||||
self.apps: Dict[str, StarlarkApp] = {}
|
self.apps: Dict[str, StarlarkApp] = {}
|
||||||
|
|
||||||
# Display state
|
# Display state
|
||||||
@@ -564,7 +566,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Save apps manifest to file with file locking to prevent race conditions.
|
Save apps manifest to file with file locking to prevent race conditions.
|
||||||
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
|
Acquires exclusive lock on the manifest lock sidecar before writing to
|
||||||
|
prevent concurrent modifications.
|
||||||
"""
|
"""
|
||||||
temp_file = None
|
temp_file = None
|
||||||
lock_fd = None
|
lock_fd = None
|
||||||
@@ -572,9 +575,14 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# Create parent directory if needed
|
# Create parent directory if needed
|
||||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
# Lock the sidecar file, not manifest_file itself: manifest_file is
|
||||||
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
|
# replaced by an atomic rename below, which swaps in a fresh inode
|
||||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
# a second locker's fresh os.open() would pick up unguarded. The
|
||||||
|
# sidecar is never written to or renamed over, so it always
|
||||||
|
# resolves to the same inode for every locker (see
|
||||||
|
# _update_manifest_safe and web_interface's _starlark_manifest_lock,
|
||||||
|
# which must lock this same file for the guarantee to hold).
|
||||||
|
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
|
||||||
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
||||||
# This serializes all writers and prevents concurrent races
|
# This serializes all writers and prevents concurrent races
|
||||||
@@ -630,8 +638,9 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# Create parent directory if needed
|
# Create parent directory if needed
|
||||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
# Lock the sidecar file, not manifest_file itself -- see the
|
||||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
# comment in _save_manifest for why.
|
||||||
|
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
|
||||||
# Acquire exclusive lock for entire read-modify-write cycle
|
# Acquire exclusive lock for entire read-modify-write cycle
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
@@ -796,15 +805,25 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
magnify = self._get_effective_magnify()
|
magnify = self._get_effective_magnify()
|
||||||
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
||||||
|
|
||||||
# Filter out LEDMatrix-internal timing keys before passing to pixlet
|
# Optional native render size for an app whose own declared canvas
|
||||||
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
# differs from Pixlet's 64x32 default -- without this an app
|
||||||
|
# declaring a wider native canvas got half its own content
|
||||||
|
# clipped at render time, before magnify ever got a chance to
|
||||||
|
# scale anything.
|
||||||
|
render_width = app.config.get("render_width")
|
||||||
|
render_height = app.config.get("render_height")
|
||||||
|
|
||||||
|
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||||
|
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
||||||
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||||
|
|
||||||
success, error = self.pixlet.render(
|
success, error = self.pixlet.render(
|
||||||
star_file=str(app.star_file),
|
star_file=str(app.star_file),
|
||||||
output_path=str(app.cache_file),
|
output_path=str(app.cache_file),
|
||||||
config=pixlet_config,
|
config=pixlet_config,
|
||||||
magnify=magnify
|
magnify=magnify,
|
||||||
|
width=render_width,
|
||||||
|
height=render_height
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ class PixletRenderer:
|
|||||||
star_file: str,
|
star_file: str,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
config: Optional[Dict[str, Any]] = None,
|
config: Optional[Dict[str, Any]] = None,
|
||||||
magnify: int = 1
|
magnify: int = 1,
|
||||||
|
width: Optional[int] = None,
|
||||||
|
height: Optional[int] = None
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Render a .star file to WebP output.
|
Render a .star file to WebP output.
|
||||||
@@ -228,6 +230,21 @@ class PixletRenderer:
|
|||||||
output_path: Where to save WebP output
|
output_path: Where to save WebP output
|
||||||
config: Configuration dictionary to pass to app
|
config: Configuration dictionary to pass to app
|
||||||
magnify: Magnification factor (default 1)
|
magnify: Magnification factor (default 1)
|
||||||
|
width: Optional native render width in pixels. Previously
|
||||||
|
there was no way to tell Pixlet to render at anything
|
||||||
|
other than its own default (64), relying entirely on
|
||||||
|
magnify to scale up afterward -- fine for apps designed
|
||||||
|
at that native size, but wrong for an app whose own
|
||||||
|
declared canvas size is genuinely different (confirmed
|
||||||
|
on real hardware, 2026-09-06, with an imported app
|
||||||
|
declaring width=128: rendering at the default 64 and
|
||||||
|
then magnifying silently clipped half the app's own
|
||||||
|
content before scaling ever happened, rather than
|
||||||
|
producing a correctly-sized image). Passed through as
|
||||||
|
Pixlet's own -w flag when provided; omitted (Pixlet's
|
||||||
|
default) otherwise, preserving existing behavior for
|
||||||
|
every other app.
|
||||||
|
height: Same as width, for Pixlet's -t flag.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success: bool, error_message: Optional[str])
|
Tuple of (success: bool, error_message: Optional[str])
|
||||||
@@ -287,6 +304,10 @@ class PixletRenderer:
|
|||||||
"-o", output_path,
|
"-o", output_path,
|
||||||
"-m", str(magnify)
|
"-m", str(magnify)
|
||||||
])
|
])
|
||||||
|
if width is not None:
|
||||||
|
cmd.extend(["-w", str(width)])
|
||||||
|
if height is not None:
|
||||||
|
cmd.extend(["-t", str(height)])
|
||||||
|
|
||||||
# Build sanitized command for logging (redact sensitive values)
|
# Build sanitized command for logging (redact sensitive values)
|
||||||
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
||||||
@@ -294,6 +315,10 @@ class PixletRenderer:
|
|||||||
config_keys = list(config.keys())
|
config_keys = list(config.keys())
|
||||||
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
||||||
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
||||||
|
if width is not None:
|
||||||
|
sanitized_cmd.extend(["-w", str(width)])
|
||||||
|
if height is not None:
|
||||||
|
sanitized_cmd.extend(["-t", str(height)])
|
||||||
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
||||||
|
|
||||||
# Execute rendering
|
# Execute rendering
|
||||||
|
|||||||
Regular → Executable
@@ -32,6 +32,8 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
|
||||||
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -118,7 +120,8 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
|||||||
one of the plugin search dirs, so a crafted id can never name a path
|
one of the plugin search dirs, so a crafted id can never name a path
|
||||||
outside them.
|
outside them.
|
||||||
"""
|
"""
|
||||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
plugin_id = safe_path_component(plugin_id)
|
||||||
|
if not plugin_id or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
return None
|
return None
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
loader = PluginLoader()
|
loader = PluginLoader()
|
||||||
@@ -140,8 +143,8 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
|||||||
|
|
||||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||||
"""Extract default values from config_schema.json."""
|
"""Extract default values from config_schema.json."""
|
||||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||||
if not schema_path.exists():
|
if schema_path is None or not schema_path.exists():
|
||||||
return {}
|
return {}
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
@@ -175,8 +178,8 @@ def api_plugin_schema(plugin_id):
|
|||||||
if not plugin_dir:
|
if not plugin_dir:
|
||||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||||
|
|
||||||
schema_path = plugin_dir / 'config_schema.json'
|
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||||
if not schema_path.exists():
|
if schema_path is None or not schema_path.exists():
|
||||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||||
|
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -16,20 +16,39 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Create a temporary service file for the main display with the correct paths
|
# Render the main display unit from its template. The display service runs as
|
||||||
# Assuming ledmatrix.service template exists and uses /home/ledpi as a placeholder for user home
|
# root (it needs GPIO), so __USER__ is always root here -- unlike the web unit
|
||||||
|
# below, which runs as whoever installed it.
|
||||||
|
#
|
||||||
|
# A missing template or a failed render is fatal: falling through would leave
|
||||||
|
# whatever unit already sits at /etc/systemd/system/ledmatrix.service (from a
|
||||||
|
# previous install) untouched, and the enable/start step below would then
|
||||||
|
# silently reuse that stale unit instead of the one this run was asked to
|
||||||
|
# install.
|
||||||
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
||||||
sed "s|/home/ledpi|$USER_HOME|g; s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > /tmp/ledmatrix.service.tmp
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
MAIN_UNIT_TMP=$(mktemp)
|
||||||
|
trap 'rm -f "$MAIN_UNIT_TMP"' EXIT
|
||||||
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > "$MAIN_UNIT_TMP"; then
|
||||||
|
echo "ERROR: failed to render ledmatrix.service from its template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Copy the service file to the systemd directory
|
# Copy the service file to the systemd directory
|
||||||
sudo cp /tmp/ledmatrix.service.tmp /etc/systemd/system/ledmatrix.service
|
sudo cp "$MAIN_UNIT_TMP" /etc/systemd/system/ledmatrix.service
|
||||||
# Clean up
|
# Clean up
|
||||||
rm /tmp/ledmatrix.service.tmp
|
rm -f "$MAIN_UNIT_TMP"
|
||||||
|
trap - EXIT
|
||||||
else
|
else
|
||||||
echo "WARNING: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service. Main display service not configured."
|
echo "ERROR: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -48,42 +67,49 @@ fi
|
|||||||
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
||||||
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
||||||
|
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# Rendered from systemd/ledmatrix-web.service, the same template
|
||||||
[Unit]
|
# install_web_service.sh uses. This was an inline heredoc until it drifted from
|
||||||
Description=LED Matrix Web Interface (Conditional Start)
|
# the template: it had lost Wants=network-online.target, RestartSec,
|
||||||
After=network.target
|
# SyslogIdentifier, CacheDirectory and Environment=USE_THREADING. Because
|
||||||
# Wants=ledmatrix.service
|
# src/startup_validator.py compares the installed unit against the template,
|
||||||
# After=network.target ledmatrix.service
|
# every boot warned "re-run install_service.sh" -- and doing so reinstalled the
|
||||||
|
# same stale copy, so the warning could never clear.
|
||||||
[Service]
|
#
|
||||||
Type=simple
|
# As with the main unit above, a missing template or a failed render is
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
# fatal -- otherwise the enable/start check below would fall back to
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
# whatever unit (possibly stale) already exists at the destination path.
|
||||||
StandardOutput=journal
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||||
StandardError=journal
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
User=${ACTUAL_USER}
|
WEB_UNIT_TMP=$(mktemp)
|
||||||
Restart=on-failure
|
trap 'rm -f "$WEB_UNIT_TMP"' EXIT
|
||||||
# Environment="PYTHONUNBUFFERED=1"
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" > "$WEB_UNIT_TMP"; then
|
||||||
[Install]
|
echo "ERROR: failed to render ledmatrix-web.service from its template." >&2
|
||||||
WantedBy=multi-user.target
|
exit 1
|
||||||
EOF
|
fi
|
||||||
)
|
sudo cp "$WEB_UNIT_TMP" /etc/systemd/system/ledmatrix-web.service
|
||||||
|
rm -f "$WEB_UNIT_TMP"
|
||||||
# Write the new service file
|
trap - EXIT
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-web.service > /dev/null
|
else
|
||||||
|
echo "ERROR: ledmatrix-web.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix-web.service." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Reloading systemd daemon for web service..."
|
echo "Reloading systemd daemon for web service..."
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||||
sudo systemctl enable ledmatrix-web.service
|
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||||
|
sudo systemctl enable ledmatrix-web.service
|
||||||
|
|
||||||
echo "Starting ledmatrix-web.service..."
|
echo "Starting ledmatrix-web.service..."
|
||||||
sudo systemctl start ledmatrix-web.service
|
sudo systemctl start ledmatrix-web.service
|
||||||
|
|
||||||
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
||||||
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
||||||
|
else
|
||||||
|
echo "Skipping enable/start for ledmatrix-web.service as it was not configured."
|
||||||
|
fi
|
||||||
# === End of LEDMatrix Web Interface service ===
|
# === End of LEDMatrix Web Interface service ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ fi
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing for user: $ACTUAL_USER"
|
echo "Installing for user: $ACTUAL_USER"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
@@ -26,37 +29,22 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate the service file dynamically with the correct paths
|
# Render the unit from systemd/ledmatrix-web.service. That template is the
|
||||||
echo "Generating service file with dynamic paths..."
|
# only description of the unit; this script used to carry its own heredoc copy,
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# and install_service.sh a third, which is how the installed unit on real rigs
|
||||||
[Unit]
|
# ended up missing RestartSec, SyslogIdentifier and CacheDirectory while
|
||||||
Description=LED Matrix Web Interface Service
|
# src/startup_validator.py warned about drift on every boot.
|
||||||
After=network-online.target
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"
|
||||||
Wants=network-online.target
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${ACTUAL_USER}
|
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
|
||||||
Environment=USE_THREADING=1
|
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-web
|
|
||||||
# Automatically create and manage cache directory
|
|
||||||
CacheDirectory=ledmatrix
|
|
||||||
CacheDirectoryMode=0775
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the service file to systemd directory
|
|
||||||
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" > /etc/systemd/system/ledmatrix-web.service
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
|
||||||
|
|
||||||
# Ensure cache directory exists with proper permissions
|
# Ensure cache directory exists with proper permissions
|
||||||
# This is a fallback for older systemd versions that don't support CacheDirectory
|
# This is a fallback for older systemd versions that don't support CacheDirectory
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
@@ -64,30 +67,19 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
|||||||
echo "✓ Package installation completed"
|
echo "✓ Package installation completed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create service file with correct paths
|
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||||
|
# inlining a second copy here. The copy this replaced had already drifted --
|
||||||
|
# it wrote StandardOutput/StandardError=syslog where the template says journal.
|
||||||
echo ""
|
echo ""
|
||||||
echo "Creating systemd service file..."
|
echo "Creating systemd service file..."
|
||||||
SERVICE_FILE_CONTENT=$(cat <<EOF
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-wifi-monitor.service"
|
||||||
[Unit]
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
Description=LED Matrix WiFi Monitor Daemon
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
After=network.target
|
exit 1
|
||||||
Wants=network.target
|
fi
|
||||||
|
|
||||||
[Service]
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
Type=simple
|
SERVICE_FILE_CONTENT=$(sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$TEMPLATE")
|
||||||
User=root
|
|
||||||
WorkingDirectory=$PROJECT_ROOT_DIR
|
|
||||||
ExecStart=/usr/bin/python3 $PROJECT_ROOT_DIR/scripts/utils/wifi_monitor_daemon.py --interval 30
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Shared helper for rendering systemd unit templates via sed.
|
||||||
|
#
|
||||||
|
# Sourced by install_service.sh, install_web_service.sh and
|
||||||
|
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
||||||
|
# way instead of carrying three copies of the same fix.
|
||||||
|
|
||||||
|
# sed_escape_replacement VALUE
|
||||||
|
#
|
||||||
|
# Print VALUE escaped for safe use as the replacement side of `sed
|
||||||
|
# s|pattern|replacement|`. Every one of these scripts builds its sed
|
||||||
|
# expression by interpolating a shell variable (a path, a username, ...)
|
||||||
|
# straight into the replacement text. sed gives three characters special
|
||||||
|
# meaning there: backslash (escape character), & (whole match) and the
|
||||||
|
# delimiter itself (here `|`). A value containing any of them -- e.g. a
|
||||||
|
# username or path with an `&`, a literal backslash, or a `|` -- would
|
||||||
|
# otherwise corrupt the rendered unit file instead of being substituted
|
||||||
|
# literally. Escape the backslash first so the later escapes aren't
|
||||||
|
# double-escaped.
|
||||||
|
sed_escape_replacement() {
|
||||||
|
local value="$1"
|
||||||
|
value="${value//\\/\\\\}"
|
||||||
|
value="${value//&/\\&}"
|
||||||
|
value="${value//|/\\|}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+92
-12
@@ -18,20 +18,33 @@
|
|||||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||||
#
|
#
|
||||||
# Binds loopback only, and there is deliberately no flag to change that:
|
# Binds the LAN by default, matching the web interface, which already serves
|
||||||
# `pixlet serve` has no authentication, and anything that can reach it can
|
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
||||||
# rewrite the app's config. To edit from another machine, forward the port --
|
# already reconfigure the display there. `pixlet serve` has no authentication
|
||||||
# which authenticates as SSH and leaves nothing listening on the LAN:
|
# either, so treat both the same way: fine on a home network, not on an open
|
||||||
|
# one. Override the bind and the session length with:
|
||||||
|
#
|
||||||
|
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
#
|
||||||
|
# For loopback-only editing from another machine, forward the port instead:
|
||||||
#
|
#
|
||||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||||
|
#
|
||||||
|
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
||||||
|
# (default 30 minutes). The display is stopped while editing, so a session
|
||||||
|
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
||||||
|
# is what makes it safe to start one from the web interface.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||||
# Loopback only. See the header: pixlet serve is unauthenticated.
|
# LAN by default; see the header for why, and how to force loopback.
|
||||||
BIND_HOST="127.0.0.1"
|
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
||||||
|
# Hard stop, so the display cannot be left off by a forgotten session.
|
||||||
|
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
||||||
|
|
||||||
APP_ID="${1:-}"
|
APP_ID="${1:-}"
|
||||||
|
|
||||||
@@ -91,6 +104,29 @@ PIXLET_BIN=$(find_pixlet) || {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# find_pixlet supports Darwin, so this script has to as well. macOS ships no
|
||||||
|
# timeout(1); GNU coreutils installs it as gtimeout. Resolve whichever exists
|
||||||
|
# and fail here with instructions rather than at the invocation far below,
|
||||||
|
# where the failure would land after the display has already been stopped.
|
||||||
|
find_timeout() {
|
||||||
|
local candidate
|
||||||
|
for candidate in timeout gtimeout; do
|
||||||
|
if command -v "$candidate" >/dev/null 2>&1; then
|
||||||
|
command -v "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TIMEOUT_BIN=$(find_timeout) || {
|
||||||
|
echo "Neither 'timeout' nor 'gtimeout' was found on PATH."
|
||||||
|
echo "This script needs one to bound the editing session."
|
||||||
|
echo "On macOS, install GNU coreutils:"
|
||||||
|
echo " brew install coreutils"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG_FILE="$APP_DIR/config.json"
|
CONFIG_FILE="$APP_DIR/config.json"
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
||||||
@@ -109,6 +145,19 @@ fi
|
|||||||
# failure worth guarding against.
|
# failure worth guarding against.
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo ""
|
echo ""
|
||||||
|
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
||||||
|
# it shares this script's process group (without that it makes its own, and
|
||||||
|
# a group signal aimed at this script would orphan pixlet with the port
|
||||||
|
# still bound). Belt and braces: signal the recorded pid too, because a
|
||||||
|
# group signal only reaches it while the group is shared.
|
||||||
|
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$SERVE_PID" 2>/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||||
echo "Restarting the display service..."
|
echo "Restarting the display service..."
|
||||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||||
@@ -122,22 +171,53 @@ if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
|||||||
sudo systemctl stop ledmatrix
|
sudo systemctl stop ledmatrix
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Wildcard, loopback and an explicit interface address are three different
|
||||||
|
# cases. Collapsing the last two into "localhost" printed a URL pointing at the
|
||||||
|
# user's own machine whenever PIXLET_EDITOR_HOST named a LAN address.
|
||||||
|
case "$BIND_HOST" in
|
||||||
|
0.0.0.0|::|"") REACH_HOST="$(hostname).local" ;;
|
||||||
|
127.0.0.1|::1|localhost) REACH_HOST="localhost" ;;
|
||||||
|
*) REACH_HOST="$BIND_HOST" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Editing: $APP_ID"
|
echo "Editing: $APP_ID"
|
||||||
echo "App file: $STAR_FILE"
|
echo "App file: $STAR_FILE"
|
||||||
echo "URL: http://localhost:$PORT/"
|
echo "URL: http://$REACH_HOST:$PORT/"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Listening on localhost only -- pixlet serve has no authentication."
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
echo "From another machine, forward the port:"
|
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
||||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
echo "same footing as the web interface on port 5000. Set"
|
||||||
|
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
||||||
|
else
|
||||||
|
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
||||||
|
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Changes save straight to the real config as you make them."
|
echo "Changes save straight to the real config as you make them."
|
||||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||||
|
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
"$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
||||||
|
# the display however this exits, so a session that outlives the person who
|
||||||
|
# started it still gives the panel back. Exit 124 is timeout's own code for
|
||||||
|
# "expired", which is a normal end here, not a failure.
|
||||||
|
# --foreground: stay in this script's process group so one signal reaches the
|
||||||
|
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
||||||
|
# is still alive; a foreground child would leave bash waiting on it instead.
|
||||||
|
"$TIMEOUT_BIN" --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||||
--host "$BIND_HOST" \
|
--host "$BIND_HOST" \
|
||||||
--port "$PORT" \
|
--port "$PORT" \
|
||||||
--no-browser \
|
--no-browser \
|
||||||
--saveconfig "$CONFIG_FILE"
|
--saveconfig "$CONFIG_FILE" &
|
||||||
|
SERVE_PID=$!
|
||||||
|
|
||||||
|
status=0
|
||||||
|
wait "$SERVE_PID" || status=$?
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
||||||
|
status=0
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
|||||||
@@ -74,23 +74,41 @@ def install_dependencies():
|
|||||||
print(f"Failed to install dependencies: {e}")
|
print(f"Failed to install dependencies: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
#: String spellings that turn autostart OFF. Anything else -- including the key
|
||||||
|
#: being absent entirely -- leaves it on.
|
||||||
|
DISABLED_STRINGS = ("off", "false", "no", "0")
|
||||||
|
|
||||||
|
|
||||||
|
def autostart_enabled(config_data):
|
||||||
|
"""Whether to bring the web interface up. Defaults to True.
|
||||||
|
|
||||||
|
config.template.json and first_time_install.sh both ship
|
||||||
|
``web_display_autostart`` as true, so a config that lacks the key is an
|
||||||
|
older or hand-edited one rather than a request to stay down. Defaulting to
|
||||||
|
False meant any such config silently got no web interface -- and because
|
||||||
|
the "not starting" path exits 0, systemd reported the unit as successfully
|
||||||
|
started while nothing was listening. Only an explicit false/off disables it.
|
||||||
|
"""
|
||||||
|
value = config_data.get("web_display_autostart", True)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() not in DISABLED_STRINGS
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
config_data = json.load(f)
|
config_data = json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(f"Config file {CONFIG_FILE} not found. Web interface will not start.")
|
# The web interface is how a config gets created and repaired, so a
|
||||||
sys.exit(0) # Exit gracefully, don't start
|
# missing one is the case where the user needs it most.
|
||||||
except Exception as e:
|
print(f"Config file {CONFIG_FILE} not found. Starting the web interface so it can be configured.")
|
||||||
print(f"Error reading config file {CONFIG_FILE}: {e}. Web interface will not start.")
|
config_data = {}
|
||||||
sys.exit(1) # Exit with error, service might restart depending on config
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
print(f"Error reading config file {CONFIG_FILE}: {e}. Starting the web interface anyway so the config can be repaired.")
|
||||||
|
config_data = {}
|
||||||
|
|
||||||
autostart_enabled = config_data.get("web_display_autostart", False)
|
if autostart_enabled(config_data):
|
||||||
|
|
||||||
# Handle both boolean True and string "on"/"true" values
|
|
||||||
is_enabled = (autostart_enabled is True) or (isinstance(autostart_enabled, str) and autostart_enabled.lower() in ("on", "true", "yes", "1"))
|
|
||||||
|
|
||||||
if is_enabled:
|
|
||||||
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
||||||
|
|
||||||
# Only install dependencies if not already done during first-time setup
|
# Only install dependencies if not already done during first-time setup
|
||||||
@@ -116,7 +134,7 @@ def main():
|
|||||||
print(f"Failed to exec web interface: {e}")
|
print(f"Failed to exec web interface: {e}")
|
||||||
sys.exit(1) # Failed to start
|
sys.exit(1) # Failed to start
|
||||||
else:
|
else:
|
||||||
print("Configuration 'web_display_autostart' is false or not set. Web interface will not be started.")
|
print("Configuration 'web_display_autostart' is explicitly disabled. Web interface will not be started.")
|
||||||
sys.exit(0) # Exit gracefully, service considered successful
|
sys.exit(0) # Exit gracefully, service considered successful
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -37,51 +37,6 @@ class Baseball(SportsCore):
|
|||||||
self.data_source = ESPNDataSource(logger)
|
self.data_source = ESPNDataSource(logger)
|
||||||
self.sport = "baseball"
|
self.sport = "baseball"
|
||||||
|
|
||||||
def _get_baseball_display_text(self, game: Dict) -> str:
|
|
||||||
"""Get baseball-specific display text."""
|
|
||||||
try:
|
|
||||||
display_parts = []
|
|
||||||
|
|
||||||
# Inning information
|
|
||||||
if self.show_innings:
|
|
||||||
inning = game.get("inning", "")
|
|
||||||
if inning:
|
|
||||||
display_parts.append(f"Inning: {inning}")
|
|
||||||
|
|
||||||
# Outs information
|
|
||||||
if self.show_outs:
|
|
||||||
outs = game.get("outs", 0)
|
|
||||||
if outs is not None:
|
|
||||||
display_parts.append(f"Outs: {outs}")
|
|
||||||
|
|
||||||
# Bases information
|
|
||||||
if self.show_bases:
|
|
||||||
bases = game.get("bases", "")
|
|
||||||
if bases:
|
|
||||||
display_parts.append(f"Bases: {bases}")
|
|
||||||
|
|
||||||
# Count information
|
|
||||||
if self.show_count:
|
|
||||||
strikes = game.get("strikes", 0)
|
|
||||||
balls = game.get("balls", 0)
|
|
||||||
if strikes is not None and balls is not None:
|
|
||||||
display_parts.append(f"Count: {balls}-{strikes}")
|
|
||||||
|
|
||||||
# Pitcher/Batter information
|
|
||||||
if self.show_pitcher_batter:
|
|
||||||
pitcher = game.get("pitcher", "")
|
|
||||||
batter = game.get("batter", "")
|
|
||||||
if pitcher:
|
|
||||||
display_parts.append(f"Pitcher: {pitcher}")
|
|
||||||
if batter:
|
|
||||||
display_parts.append(f"Batter: {batter}")
|
|
||||||
|
|
||||||
return " | ".join(display_parts) if display_parts else ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error getting baseball display text: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _is_baseball_game_live(self, game: Dict) -> bool:
|
def _is_baseball_game_live(self, game: Dict) -> bool:
|
||||||
"""Check if a baseball game is currently live."""
|
"""Check if a baseball game is currently live."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -167,7 +168,14 @@ class SportsCore(ABC):
|
|||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
|
|
||||||
self._logo_cache = {}
|
# LRU-bounded: entries are decoded RGBA thumbnails, not file bytes.
|
||||||
|
# Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on
|
||||||
|
# a 256x64 panel, more for wide wordmarks. The key is a team
|
||||||
|
# abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so
|
||||||
|
# an unbounded dict here held the whole league: ~11-18MB per manager
|
||||||
|
# instance, and a league runs three (live/recent/upcoming) each with
|
||||||
|
# its own cache. That is real money on a 1GB Pi.
|
||||||
|
self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict()
|
||||||
|
|
||||||
# Font caches for _load_custom_font_from_element_config: per-frame
|
# Font caches for _load_custom_font_from_element_config: per-frame
|
||||||
# callers (font-ladder walks) resolve the same (name, size) over and
|
# callers (font-ladder walks) resolve the same (name, size) over and
|
||||||
@@ -560,11 +568,17 @@ class SportsCore(ABC):
|
|||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
#: Decoded logos to keep. A scroll of "other games" shows on the order of
|
||||||
|
#: 20 games (40 teams), so this holds a full cycle without thrashing while
|
||||||
|
#: capping the cache well below a 307-team league.
|
||||||
|
_LOGO_CACHE_MAX = 64
|
||||||
|
|
||||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
||||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
"""Load and resize a team logo, with caching and automatic download if missing."""
|
||||||
self.logger.debug(f"Logo path: {logo_path}")
|
self.logger.debug(f"Logo path: {logo_path}")
|
||||||
if team_abbrev in self._logo_cache:
|
if team_abbrev in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
||||||
|
self._logo_cache.move_to_end(team_abbrev)
|
||||||
return self._logo_cache[team_abbrev]
|
return self._logo_cache[team_abbrev]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -604,6 +618,8 @@ class SportsCore(ABC):
|
|||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * 1.5)
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
self._logo_cache[team_abbrev] = logo
|
self._logo_cache[team_abbrev] = logo
|
||||||
|
while len(self._logo_cache) > self._LOGO_CACHE_MAX:
|
||||||
|
self._logo_cache.popitem(last=False)
|
||||||
return logo
|
return logo
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Vendored
+18
-2
@@ -15,6 +15,8 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.common.path_safety import safe_path_component
|
||||||
|
|
||||||
try: # optional: large speedup on the cache write path, see _dumps below
|
try: # optional: large speedup on the cache write path, see _dumps below
|
||||||
import orjson
|
import orjson
|
||||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||||
@@ -161,15 +163,29 @@ class DiskCache:
|
|||||||
"""
|
"""
|
||||||
Get the path for a cache file.
|
Get the path for a cache file.
|
||||||
|
|
||||||
|
The key becomes a filename, so it has to be one. Keys reach this
|
||||||
|
method from the web API -- POST /api/v3/cache/delete passes the
|
||||||
|
request body's ``key`` straight through CacheManager.clear_cache to
|
||||||
|
os.remove -- and a key of ``../../../../etc/whatever`` named a file
|
||||||
|
well outside the cache directory. Every real key is the stem of a
|
||||||
|
file already sitting flat in cache_dir (that is how list_cache_files
|
||||||
|
derives them), so rejecting anything with a path component turns
|
||||||
|
away only inputs that could never have been written here.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to cache file or None if cache is disabled
|
Path to cache file, or None if cache is disabled or the key is
|
||||||
|
not a usable filename
|
||||||
"""
|
"""
|
||||||
if not self.cache_dir:
|
if not self.cache_dir:
|
||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{key}.json")
|
safe_key = safe_path_component(key)
|
||||||
|
if safe_key is None:
|
||||||
|
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||||
|
return None
|
||||||
|
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
@@ -20,6 +21,21 @@ from src.common.permission_utils import (
|
|||||||
get_assets_file_mode
|
get_assets_file_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# How long a missing logo stays remembered as missing.
|
||||||
|
#
|
||||||
|
# This was 600s, and measured on a live rig that turned out to suppress nothing:
|
||||||
|
# the display rotation is ~618s, so every recheck landed just as the plugin came
|
||||||
|
# round again and the warning rate was unchanged at ~6/hour. A TTL has to be long
|
||||||
|
# relative to the loop that does the asking, not merely "a while".
|
||||||
|
#
|
||||||
|
# An hour is safe because the TTL is not the main way an entry clears. A download
|
||||||
|
# through load_logo_with_download() drops it immediately, and clear_cache() drops
|
||||||
|
# all of them; the TTL only covers a file that appeared some other way -- someone
|
||||||
|
# copying one in by hand. Waiting up to an hour for that, or restarting, is a fair
|
||||||
|
# trade for not re-warning about a file nobody is going to add.
|
||||||
|
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Well above any real team logo; bounds what a remote URL can write to disk.
|
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||||
@@ -57,6 +73,14 @@ class LogoHelper:
|
|||||||
self._logo_cache: Dict[str, Image.Image] = {}
|
self._logo_cache: Dict[str, Image.Image] = {}
|
||||||
self._cache_order: List[str] = [] # For LRU cache management
|
self._cache_order: List[str] = [] # For LRU cache management
|
||||||
|
|
||||||
|
# Misses, so an absent file is stat'd and warned about once rather than
|
||||||
|
# on every call. Without this a permanently missing logo produced a
|
||||||
|
# warning per rotation forever -- measured at 114 lines in 24 hours for
|
||||||
|
# a single missing ticker icon, for a file nobody was going to add.
|
||||||
|
# Time-bounded rather than permanent so a logo that appears later (the
|
||||||
|
# downloader writes them at runtime) is still picked up.
|
||||||
|
self._missing_logos: Dict[str, float] = {}
|
||||||
|
|
||||||
# Session for HTTP requests
|
# Session for HTTP requests
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
@@ -100,9 +124,19 @@ class LogoHelper:
|
|||||||
self._cache_order.append(cache_key)
|
self._cache_order.append(cache_key)
|
||||||
return self._logo_cache[cache_key]
|
return self._logo_cache[cache_key]
|
||||||
|
|
||||||
|
# A known-missing file: skip the stat and stay quiet until the entry
|
||||||
|
# ages out. Checked after the positive cache so a logo that has since
|
||||||
|
# been loaded always wins.
|
||||||
|
missed_at = self._missing_logos.get(cache_key)
|
||||||
|
if missed_at is not None:
|
||||||
|
if time.time() - missed_at < MISSING_LOGO_RECHECK_SECONDS:
|
||||||
|
return None
|
||||||
|
del self._missing_logos[cache_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
if not logo_path.exists():
|
if not logo_path.exists():
|
||||||
|
self._missing_logos[cache_key] = time.time()
|
||||||
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -179,6 +213,11 @@ class LogoHelper:
|
|||||||
self._logo_cache.pop(key, None)
|
self._logo_cache.pop(key, None)
|
||||||
if key in self._cache_order:
|
if key in self._cache_order:
|
||||||
self._cache_order.remove(key)
|
self._cache_order.remove(key)
|
||||||
|
# The file exists now, so any record of it being missing is wrong --
|
||||||
|
# and load_logo() consults that record before it stats the disk, so
|
||||||
|
# leaving it would hide a logo we just downloaded.
|
||||||
|
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
|
||||||
|
del self._missing_logos[key]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
@@ -270,6 +309,7 @@ class LogoHelper:
|
|||||||
"""Clear the logo cache."""
|
"""Clear the logo cache."""
|
||||||
self._logo_cache.clear()
|
self._logo_cache.clear()
|
||||||
self._cache_order.clear()
|
self._cache_order.clear()
|
||||||
|
self._missing_logos.clear()
|
||||||
self.logger.debug("Logo cache cleared")
|
self.logger.debug("Logo cache cleared")
|
||||||
|
|
||||||
def get_cache_stats(self) -> Dict[str, int]:
|
def get_cache_stats(self) -> Dict[str, int]:
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""One place to turn a request-supplied name into a path you can open.
|
||||||
|
|
||||||
|
Every web handler that opens a file under a fixed directory had grown its own
|
||||||
|
version of this: a regex here, an ``os.path.basename`` there, a
|
||||||
|
``str(x).startswith(str(base))`` somewhere else. They were not equivalent.
|
||||||
|
``startswith`` says ``plugin-repos/foo-evil`` is inside ``plugin-repos/foo``;
|
||||||
|
validating a name in one place and rebuilding the path from the *raw* value in
|
||||||
|
another leaves the guard checking something the filesystem never sees.
|
||||||
|
|
||||||
|
Two functions, used the same way everywhere:
|
||||||
|
|
||||||
|
``safe_path_component(value)``
|
||||||
|
``value`` if it is one harmless path segment, otherwise ``None``.
|
||||||
|
|
||||||
|
``resolve_under(base, *parts)``
|
||||||
|
the resolved path, or ``None`` if any part is unsafe or the result would
|
||||||
|
land outside ``base``.
|
||||||
|
|
||||||
|
Both *return the sanitised value* rather than a boolean, so a caller cannot
|
||||||
|
validate one string and then open another -- and so a scanner can follow what
|
||||||
|
actually reaches ``open()``. ``os.path.basename`` does the stripping because it
|
||||||
|
is the sanitiser CodeQL's path-injection query recognises; the equality check
|
||||||
|
after it means an input with a directory part is rejected outright instead of
|
||||||
|
being silently truncated to something the caller did not ask for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'safe_path_component',
|
||||||
|
'safe_relative_parts',
|
||||||
|
'resolve_under',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Names that are a path component syntactically but never name a real entry a
|
||||||
|
# caller means to reach.
|
||||||
|
_RESERVED_COMPONENTS = frozenset({'', '.', '..'})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path_component(value: Any) -> Optional[str]:
|
||||||
|
"""Return ``value`` when it is a single, harmless path segment.
|
||||||
|
|
||||||
|
Returns ``None`` for anything else: a non-string, an empty string, ``.`` or
|
||||||
|
``..``, a value carrying a directory separator (either platform's), a drive
|
||||||
|
letter, or an embedded NUL.
|
||||||
|
|
||||||
|
The return value is what callers must join -- not the argument.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if '\x00' in value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# basename strips any directory component, so what a caller joins cannot
|
||||||
|
# carry one. Comparing the result against the input rejects rather than
|
||||||
|
# truncates: "../etc/passwd" is an error, not a request for "passwd".
|
||||||
|
name = os.path.basename(value)
|
||||||
|
if name != value or name in _RESERVED_COMPONENTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# basename only knows the host platform's separator. On POSIX a backslash
|
||||||
|
# is an ordinary character, and "C:" is a plausible-looking name that
|
||||||
|
# os.path.join would treat as a drive on Windows. Rule both out everywhere
|
||||||
|
# so behaviour does not depend on where the service happens to run.
|
||||||
|
if '/' in name or '\\' in name or os.sep in name or (os.altsep and os.altsep in name):
|
||||||
|
return None
|
||||||
|
if ':' in name and len(name) >= 2 and name[1] == ':':
|
||||||
|
return None
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative_parts(value: Any) -> Optional[List[str]]:
|
||||||
|
"""Split a multi-segment relative path into safe components.
|
||||||
|
|
||||||
|
For Flask's ``<path:...>`` converter, where ``a/b/c.json`` is legitimate but
|
||||||
|
``../../config/config_secrets.json`` is not. Returns the component list, or
|
||||||
|
``None`` if any component fails :func:`safe_path_component`.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith('/') or value.startswith('\\'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts: List[str] = []
|
||||||
|
for raw in value.replace('\\', '/').split('/'):
|
||||||
|
if raw == '':
|
||||||
|
# A trailing or doubled slash names nothing; skip it rather than
|
||||||
|
# rejecting a path a browser may well send.
|
||||||
|
continue
|
||||||
|
part = safe_path_component(raw)
|
||||||
|
if part is None:
|
||||||
|
return None
|
||||||
|
parts.append(part)
|
||||||
|
|
||||||
|
return parts or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_under(base: Union[str, Path], *parts: Any) -> Optional[Path]:
|
||||||
|
"""Resolve ``base/parts...``, or ``None`` if that would escape ``base``.
|
||||||
|
|
||||||
|
Each part is validated with :func:`safe_path_component` first, so the value
|
||||||
|
that reaches the filesystem is the sanitised one. The containment check is
|
||||||
|
kept as well: it is what catches a symlink inside ``base`` pointing out of
|
||||||
|
it, which no amount of name validation can see.
|
||||||
|
"""
|
||||||
|
safe_parts: List[str] = []
|
||||||
|
for part in parts:
|
||||||
|
component = safe_path_component(part)
|
||||||
|
if component is None:
|
||||||
|
return None
|
||||||
|
safe_parts.append(component)
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_resolved = Path(base).resolve()
|
||||||
|
candidate = base_resolved.joinpath(*safe_parts).resolve()
|
||||||
|
candidate.relative_to(base_resolved)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return candidate
|
||||||
@@ -2169,7 +2169,14 @@ class DisplayController:
|
|||||||
types.SimpleNamespace(display=_display_target),
|
types.SimpleNamespace(display=_display_target),
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force_clear=self.force_change,
|
force_clear=self.force_change,
|
||||||
display_mode=active_mode if _accepts_display_mode else None
|
display_mode=active_mode if _accepts_display_mode else None,
|
||||||
|
# Already resolved and cached above.
|
||||||
|
# Without this the executor re-derives
|
||||||
|
# it with inspect.signature() against
|
||||||
|
# the SimpleNamespace built two lines
|
||||||
|
# up -- a fresh callable every call, so
|
||||||
|
# nothing there can ever cache.
|
||||||
|
accepts_display_mode=_accepts_display_mode
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover - defensive;
|
except Exception: # pragma: no cover - defensive;
|
||||||
# execute_display catches everything
|
# execute_display catches everything
|
||||||
@@ -2492,6 +2499,15 @@ class DisplayController:
|
|||||||
1.0 / display_interval
|
1.0 / display_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deliberate: frames after the first call
|
||||||
|
# display() directly rather than through
|
||||||
|
# PluginExecutor. The executor spawns a thread per
|
||||||
|
# call, which at this loop's frame rate would cost
|
||||||
|
# more than the advisory timeout it buys -- and
|
||||||
|
# that timeout cannot cancel a hung plugin anyway
|
||||||
|
# (see execute_with_timeout). The first dispatch
|
||||||
|
# above still goes through it, so load-time
|
||||||
|
# failures are still caught and recorded.
|
||||||
while True:
|
while True:
|
||||||
_frame_start = time.perf_counter()
|
_frame_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
@@ -2564,6 +2580,15 @@ class DisplayController:
|
|||||||
display_interval
|
display_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deliberate: frames after the first call
|
||||||
|
# display() directly rather than through
|
||||||
|
# PluginExecutor. The executor spawns a thread per
|
||||||
|
# call, which at this loop's frame rate would cost
|
||||||
|
# more than the advisory timeout it buys -- and
|
||||||
|
# that timeout cannot cancel a hung plugin anyway
|
||||||
|
# (see execute_with_timeout). The first dispatch
|
||||||
|
# above still goes through it, so load-time
|
||||||
|
# failures are still caught and recorded.
|
||||||
while True:
|
while True:
|
||||||
time.sleep(display_interval)
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
|
|||||||
@@ -684,6 +684,38 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def get_update_interval(self) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
How often this plugin wants update() called, right now, in seconds.
|
||||||
|
|
||||||
|
The manifest's ``update_interval`` is a single static number, which
|
||||||
|
cannot say "poll me every 15 seconds while a game is in progress and
|
||||||
|
every 15 minutes when nothing is on". Only the plugin knows which is
|
||||||
|
true at any moment, so override this to say so.
|
||||||
|
|
||||||
|
Return None (the default) to accept the manifest/config value.
|
||||||
|
|
||||||
|
Two constraints, both because the scheduler calls this on every tick of
|
||||||
|
the render loop:
|
||||||
|
|
||||||
|
- It must be cheap. Attribute reads only -- no config lookups, no I/O,
|
||||||
|
no locks that a fetch might be holding.
|
||||||
|
- It must not raise. A raising hook is ignored and the static interval
|
||||||
|
used, but a hook that raises every tick also logs every tick.
|
||||||
|
|
||||||
|
Values below PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL are clamped up:
|
||||||
|
a plugin asking for 0 would otherwise busy-wait against its own API.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
def get_update_interval(self):
|
||||||
|
# Fast while something is actually live, manifest default otherwise.
|
||||||
|
if any(m.live_games for m in self._live_managers):
|
||||||
|
return self.config.get("live_update_interval", 15)
|
||||||
|
return None
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def has_live_priority(self) -> bool:
|
def has_live_priority(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if this plugin has live priority enabled.
|
Check if this plugin has live priority enabled.
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ class PluginExecutor:
|
|||||||
thread.start()
|
thread.start()
|
||||||
thread.join(timeout=timeout)
|
thread.join(timeout=timeout)
|
||||||
|
|
||||||
|
# NB: this timeout is advisory. Nothing cancels the thread -- Python
|
||||||
|
# has no way to -- so on expiry the operation keeps running to
|
||||||
|
# completion in the background and only this caller gives up waiting.
|
||||||
|
# A plugin that hangs permanently leaks one daemon thread per attempt.
|
||||||
|
# Callers that hold a resource across the call must release it from
|
||||||
|
# inside the wrapped callable rather than after this returns; see the
|
||||||
|
# _release_display_lock guard inside DisplayController.run().
|
||||||
if not result_container['completed']:
|
if not result_container['completed']:
|
||||||
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
@@ -148,7 +155,8 @@ class PluginExecutor:
|
|||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
force_clear: bool = False,
|
force_clear: bool = False,
|
||||||
display_mode: Optional[str] = None,
|
display_mode: Optional[str] = None,
|
||||||
timeout: Optional[float] = None
|
timeout: Optional[float] = None,
|
||||||
|
accepts_display_mode: Optional[bool] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Execute plugin display() method with error handling.
|
Execute plugin display() method with error handling.
|
||||||
@@ -159,6 +167,9 @@ class PluginExecutor:
|
|||||||
force_clear: Whether to force clear display
|
force_clear: Whether to force clear display
|
||||||
display_mode: Optional display mode parameter
|
display_mode: Optional display mode parameter
|
||||||
timeout: Timeout in seconds (None = use default)
|
timeout: Timeout in seconds (None = use default)
|
||||||
|
accepts_display_mode: Whether plugin.display() takes a
|
||||||
|
display_mode keyword. Pass it when the caller already knows;
|
||||||
|
None falls back to inspecting the callable.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if display succeeded, False otherwise
|
True if display succeeded, False otherwise
|
||||||
@@ -166,10 +177,20 @@ class PluginExecutor:
|
|||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Check if plugin accepts display_mode parameter
|
# Does display() take a display_mode keyword? The caller usually
|
||||||
import inspect
|
# knows and caches the answer, so prefer what it passed.
|
||||||
sig = inspect.signature(plugin.display)
|
#
|
||||||
has_display_mode = 'display_mode' in sig.parameters
|
# Inspecting here was not merely redundant, it could never be
|
||||||
|
# cached: display_controller wraps the real plugin in a fresh
|
||||||
|
# SimpleNamespace per call, so inspect.signature() saw a new
|
||||||
|
# callable every time and paid ~55us on a Pi 4 to re-derive a
|
||||||
|
# value the caller had computed one line earlier and stored in
|
||||||
|
# self._plugin_accepts_display_mode.
|
||||||
|
if accepts_display_mode is None:
|
||||||
|
import inspect
|
||||||
|
accepts_display_mode = (
|
||||||
|
'display_mode' in inspect.signature(plugin.display).parameters)
|
||||||
|
has_display_mode = accepts_display_mode
|
||||||
|
|
||||||
# Capture the return value from the plugin's display() method
|
# Capture the return value from the plugin's display() method
|
||||||
if has_display_mode and display_mode:
|
if has_display_mode and display_mode:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ API Version: 1.0.0
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -779,15 +780,68 @@ class PluginManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _dynamic_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||||
|
"""The interval a plugin asks for right now, or None if it has no view."""
|
||||||
|
hook = getattr(plugin_instance, 'get_update_interval', None)
|
||||||
|
if not callable(hook):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
requested = hook()
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self.logger.debug(
|
||||||
|
"get_update_interval() failed for %s, using the static interval: %s",
|
||||||
|
plugin_id, exc)
|
||||||
|
return None
|
||||||
|
if requested is None:
|
||||||
|
return None
|
||||||
|
if isinstance(requested, bool):
|
||||||
|
self.logger.debug(
|
||||||
|
"get_update_interval() returned a bool for %s, which is not a number",
|
||||||
|
plugin_id)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
requested = float(requested)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.debug(
|
||||||
|
"get_update_interval() returned %r for %s, which is not a number",
|
||||||
|
requested, plugin_id)
|
||||||
|
return None
|
||||||
|
if not math.isfinite(requested): # NaN / +inf / -inf
|
||||||
|
return None
|
||||||
|
return max(requested, self.MIN_DYNAMIC_UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
#: Floor for a plugin-requested interval. A plugin asking for 0 (or a
|
||||||
|
#: negative) would otherwise be re-entered on every tick of the render
|
||||||
|
#: loop, which is a busy-wait against whatever API it fetches.
|
||||||
|
MIN_DYNAMIC_UPDATE_INTERVAL = 5.0
|
||||||
|
|
||||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Get the data-fetch interval for a plugin (seconds between update() calls).
|
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||||
|
|
||||||
Result is cached per plugin_id after the first lookup to avoid calling
|
A plugin may implement ``get_update_interval()`` to vary its own cadence
|
||||||
config_manager.get_config() — which returns a full dict copy — on every
|
at runtime, which the static manifest value cannot express. The case
|
||||||
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
this exists for: a sports scoreboard needs to poll every 15s while a
|
||||||
is loaded or unloaded.
|
game is in progress and every 15 minutes when nothing is on, and only
|
||||||
|
the plugin knows which is true right now. Returning None from the hook
|
||||||
|
means "no opinion", and the static resolution below applies.
|
||||||
|
|
||||||
|
The hook is called on every scheduling tick, so implementations must be
|
||||||
|
cheap — attribute reads, no config lookups and no I/O. A raising or
|
||||||
|
non-numeric hook is ignored rather than allowed to stop the plugin
|
||||||
|
updating, since a scheduler that propagates a plugin bug stops every
|
||||||
|
other plugin too.
|
||||||
|
|
||||||
|
The static result is cached per plugin_id after the first lookup to
|
||||||
|
avoid calling config_manager.get_config() — which returns a full dict
|
||||||
|
copy — on every tick of the 30-fps display loop. The cache is
|
||||||
|
invalidated when a plugin is loaded or unloaded. The dynamic hook is
|
||||||
|
deliberately *not* cached: caching it would defeat its only purpose.
|
||||||
"""
|
"""
|
||||||
|
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
||||||
|
if dynamic is not None:
|
||||||
|
return dynamic
|
||||||
|
|
||||||
if plugin_id in self._update_interval_cache:
|
if plugin_id in self._update_interval_cache:
|
||||||
return self._update_interval_cache[plugin_id]
|
return self._update_interval_cache[plugin_id]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Detects and fixes inconsistencies between:
|
|||||||
- State manager state
|
- State manager state
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Dict, Any, List, Set
|
from typing import Dict, Any, List, Set
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -55,6 +56,100 @@ class ReconciliationResult:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def secrets_top_level_keys(config_manager) -> Set[str]:
|
||||||
|
"""Top-level keys load_config() merges in from the secrets file.
|
||||||
|
|
||||||
|
Deliberately fail-safe: an unreadable, absent, malformed or non-path
|
||||||
|
secrets location narrows this set rather than raising, because a failure
|
||||||
|
here must never break reconciliation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = config_manager.get_secrets_path()
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
secrets = json.load(f)
|
||||||
|
except (AttributeError, OSError, TypeError, ValueError):
|
||||||
|
return set()
|
||||||
|
return set(secrets) if isinstance(secrets, dict) else set()
|
||||||
|
|
||||||
|
|
||||||
|
def ignored_config_keys(config_manager) -> Set[str]:
|
||||||
|
"""Config keys that are not plugin ids: system keys plus secrets keys."""
|
||||||
|
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
|
||||||
|
"""Plugin ids a loaded config declares.
|
||||||
|
|
||||||
|
Shared with the web interface so a stored verdict is re-checked against the
|
||||||
|
same definition that produced it. ``set(config)`` is NOT equivalent: it also
|
||||||
|
contains system keys, the secrets-file keys load_config() merges in, and
|
||||||
|
non-dict values. Counting any of those as a plugin is exactly what turned a
|
||||||
|
'data' key in the secrets file into a phantom plugin, and using the loose
|
||||||
|
set to re-check findings would clear ones that are still true.
|
||||||
|
"""
|
||||||
|
return {k for k, v in (config or {}).items()
|
||||||
|
if isinstance(v, dict) and k not in ignored_keys}
|
||||||
|
|
||||||
|
|
||||||
|
def disk_plugin_ids(plugins_dir) -> Set[str]:
|
||||||
|
"""Plugin ids actually installed on disk.
|
||||||
|
|
||||||
|
A directory counts only when it is not a standalone backup and its
|
||||||
|
manifest.json parses. A corrupt manifest must not read as installed, or a
|
||||||
|
live "in config but not on disk" finding gets cleared on the strength of an
|
||||||
|
unreadable file.
|
||||||
|
"""
|
||||||
|
ids: Set[str] = set()
|
||||||
|
root = Path(plugins_dir)
|
||||||
|
try:
|
||||||
|
if not root.exists():
|
||||||
|
return ids
|
||||||
|
for entry in root.iterdir():
|
||||||
|
if not entry.is_dir() or '.standalone-backup-' in entry.name:
|
||||||
|
continue
|
||||||
|
manifest = entry / "manifest.json"
|
||||||
|
if not manifest.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(manifest, 'r') as f:
|
||||||
|
json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
ids.add(entry.name)
|
||||||
|
except OSError:
|
||||||
|
return ids
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def still_unresolved(entries: List[Dict[str, Any]],
|
||||||
|
config_keys: Set[str],
|
||||||
|
installed_ids: Set[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Drop stored reconciliation findings that are no longer true.
|
||||||
|
|
||||||
|
The verdict is written once to a status file and served to the web UI from
|
||||||
|
there, and a run that fails to apply a fix also declares it "will not retry
|
||||||
|
automatically". Together those froze a single moment forever: a device whose
|
||||||
|
plugins were all present in config kept being told, for hours, that four of
|
||||||
|
them were missing and should be removed from config.json.
|
||||||
|
|
||||||
|
Entry kinds this cannot re-check are kept, so filtering only ever removes
|
||||||
|
findings that are provably stale.
|
||||||
|
"""
|
||||||
|
live: List[Dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
kind = entry.get('type')
|
||||||
|
plugin_id = entry.get('plugin_id')
|
||||||
|
if kind == InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value:
|
||||||
|
if plugin_id not in config_keys:
|
||||||
|
live.append(entry)
|
||||||
|
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
|
||||||
|
if plugin_id not in installed_ids:
|
||||||
|
live.append(entry)
|
||||||
|
else:
|
||||||
|
live.append(entry)
|
||||||
|
return live
|
||||||
|
|
||||||
|
|
||||||
class StateReconciliation:
|
class StateReconciliation:
|
||||||
"""
|
"""
|
||||||
State reconciliation system.
|
State reconciliation system.
|
||||||
@@ -200,16 +295,26 @@ class StateReconciliation:
|
|||||||
'github', 'youtube',
|
'github', 'youtube',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _secrets_top_level_keys(self) -> Set[str]:
|
||||||
|
"""Top-level keys that load_config() merges in from the secrets file.
|
||||||
|
|
||||||
|
load_config() merges config_secrets.json into the config it returns, so
|
||||||
|
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
|
||||||
|
individually ('github', 'youtube'), which broke the moment anything else
|
||||||
|
was written there: a 'data' key became a phantom plugin, permanently
|
||||||
|
reported as "in config but not on disk". Reading the file keeps this
|
||||||
|
correct no matter what it holds.
|
||||||
|
"""
|
||||||
|
return secrets_top_level_keys(self.config_manager)
|
||||||
|
|
||||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Get plugin state from config file."""
|
"""Get plugin state from config file."""
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
for plugin_id, plugin_config in config.items():
|
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
|
||||||
if not isinstance(plugin_config, dict):
|
for plugin_id in config_plugin_ids(config, ignored):
|
||||||
continue
|
plugin_config = config[plugin_id]
|
||||||
if plugin_id in self._SYSTEM_CONFIG_KEYS:
|
|
||||||
continue
|
|
||||||
state[plugin_id] = {
|
state[plugin_id] = {
|
||||||
'enabled': plugin_config.get('enabled', True),
|
'enabled': plugin_config.get('enabled', True),
|
||||||
'version': plugin_config.get('version'),
|
'version': plugin_config.get('version'),
|
||||||
@@ -223,25 +328,21 @@ class StateReconciliation:
|
|||||||
"""Get plugin state from disk (installed plugins)."""
|
"""Get plugin state from disk (installed plugins)."""
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
if self.plugins_dir.exists():
|
# Membership comes from the shared extractor so the web interface
|
||||||
for plugin_dir in self.plugins_dir.iterdir():
|
# re-checks stored findings against this same definition; the
|
||||||
if plugin_dir.is_dir():
|
# manifest is then re-read here only for version/name.
|
||||||
plugin_id = plugin_dir.name
|
for plugin_id in disk_plugin_ids(self.plugins_dir):
|
||||||
if '.standalone-backup-' in plugin_id:
|
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
||||||
continue
|
try:
|
||||||
manifest_path = plugin_dir / "manifest.json"
|
with open(manifest_path, 'r') as f:
|
||||||
if manifest_path.exists():
|
manifest = json.load(f)
|
||||||
import json
|
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip
|
||||||
try:
|
continue
|
||||||
with open(manifest_path, 'r') as f:
|
state[plugin_id] = {
|
||||||
manifest = json.load(f)
|
'exists_on_disk': True,
|
||||||
state[plugin_id] = {
|
'version': manifest.get('version'),
|
||||||
'exists_on_disk': True,
|
'name': manifest.get('name')
|
||||||
'version': manifest.get('version'),
|
}
|
||||||
'name': manifest.get('name')
|
|
||||||
}
|
|
||||||
except Exception: # nosec B110 - corrupt/unreadable manifest; skip this plugin, outer except logs
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error reading disk state: {e}")
|
self.logger.warning(f"Error reading disk state: {e}")
|
||||||
return state
|
return state
|
||||||
@@ -367,8 +468,18 @@ class StateReconciliation:
|
|||||||
"""Attempt to fix an inconsistency."""
|
"""Attempt to fix an inconsistency."""
|
||||||
try:
|
try:
|
||||||
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
||||||
# Add plugin to config with default disabled state
|
|
||||||
config = self.config_manager.load_config()
|
config = self.config_manager.load_config()
|
||||||
|
if inconsistency.plugin_id in config:
|
||||||
|
# Detection said "not in config" but it is there -- the
|
||||||
|
# config changed under us, or the id came from a key merged
|
||||||
|
# in from elsewhere. Assigning the stub below would replace
|
||||||
|
# the real entry: one reported case would have traded 4.9KB
|
||||||
|
# of league settings for {'enabled': False}. Nothing to fix.
|
||||||
|
self.logger.info(
|
||||||
|
"Skipped: %s is already in config; not overwriting it",
|
||||||
|
inconsistency.plugin_id)
|
||||||
|
return True
|
||||||
|
# Add plugin to config with default disabled state
|
||||||
config[inconsistency.plugin_id] = {
|
config[inconsistency.plugin_id] = {
|
||||||
'enabled': False
|
'enabled': False
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,17 +111,25 @@ class StartupValidator:
|
|||||||
if not template.is_file() or not installed.is_file():
|
if not template.is_file() or not installed.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# The template carries placeholders the installer substitutes,
|
|
||||||
# so compare the substituted form rather than the raw file.
|
|
||||||
expected = template.read_text(encoding="utf-8")
|
|
||||||
expected = expected.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
|
||||||
expected = expected.replace("__USER__", "root")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actual = installed.read_text(encoding="utf-8")
|
actual = installed.read_text(encoding="utf-8")
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# The template carries placeholders the installer substitutes,
|
||||||
|
# so compare the substituted form rather than the raw file.
|
||||||
|
expected = template.read_text(encoding="utf-8")
|
||||||
|
expected = expected.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
# User= is an install-time decision, not something the template
|
||||||
|
# dictates: the installers write whoever ran them, which on a
|
||||||
|
# non-root install is never "root". Substituting a fixed "root"
|
||||||
|
# here reported drift on every such install, permanently -- and
|
||||||
|
# re-running the installer, which is what the warning tells you
|
||||||
|
# to do, could not clear it. Taking the installed unit's own
|
||||||
|
# value keeps the comparison on the directives the template
|
||||||
|
# actually controls.
|
||||||
|
expected = expected.replace("__USER__", self._installed_user(actual))
|
||||||
|
|
||||||
if self._unit_body(expected) != self._unit_body(actual):
|
if self._unit_body(expected) != self._unit_body(actual):
|
||||||
self.warnings.append(
|
self.warnings.append(
|
||||||
f"{installed.name} differs from {template_rel}; the "
|
f"{installed.name} differs from {template_rel}; the "
|
||||||
@@ -132,6 +140,19 @@ class StartupValidator:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.logger.debug("Could not compare systemd units: %s", e)
|
self.logger.debug("Could not compare systemd units: %s", e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _installed_user(unit_text: str) -> str:
|
||||||
|
"""The installed unit's ``User=``, or "root" when it does not set one.
|
||||||
|
|
||||||
|
systemd itself defaults to root for a system unit with no User=, so that
|
||||||
|
is the right fallback rather than an empty string.
|
||||||
|
"""
|
||||||
|
for line in unit_text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("User="):
|
||||||
|
return stripped.split("=", 1)[1].strip()
|
||||||
|
return "root"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unit_body(text: str) -> str:
|
def _unit_body(text: str) -> str:
|
||||||
"""A unit's meaningful lines, in order: no comments, no blanks.
|
"""A unit's meaningful lines, in order: no comments, no blanks.
|
||||||
|
|||||||
@@ -105,27 +105,3 @@ def validate_request_json(required_fields: list, data: Optional[Dict] = None) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
return data, None
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
def validate_request_params(required_params: list) -> Tuple[Optional[Dict], Optional[Any]]:
|
|
||||||
"""
|
|
||||||
Validate request has required query parameters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
required_params: List of required parameter names
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (params_dict, error_response) or (params_dict, None) if valid
|
|
||||||
"""
|
|
||||||
missing_params = [param for param in required_params if param not in request.args]
|
|
||||||
if missing_params:
|
|
||||||
return None, error_response(
|
|
||||||
ErrorCode.INVALID_INPUT,
|
|
||||||
f"Missing required parameters: {', '.join(missing_params)}",
|
|
||||||
context={'missing_params': missing_params},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
params = {param: request.args.get(param) for param in required_params}
|
|
||||||
return params, None
|
|
||||||
|
|
||||||
|
|||||||
+75
-1
@@ -36,7 +36,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1256,6 +1256,20 @@ class WiFiManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
|
# Both values arrive verbatim from POST /api/v3/wifi/connect and end up
|
||||||
|
# as nmcli argv entries. There is no shell here, so no metacharacter
|
||||||
|
# can start a second command -- but nmcli reads a leading "-" as an
|
||||||
|
# option, so an SSID of "--ask" or "-t" is a request to run nmcli
|
||||||
|
# differently rather than to join a network. See _validate_ssid.
|
||||||
|
ssid, error = self._validate_ssid(ssid)
|
||||||
|
if error:
|
||||||
|
logger.warning("Rejected WiFi connect request: %s", error)
|
||||||
|
return False, error
|
||||||
|
password, error = self._validate_wifi_password(password)
|
||||||
|
if error:
|
||||||
|
logger.warning("Rejected WiFi connect request: %s", error)
|
||||||
|
return False, error
|
||||||
|
|
||||||
# Save current connection info for failsafe restoration
|
# Save current connection info for failsafe restoration
|
||||||
original_connection = None
|
original_connection = None
|
||||||
original_ssid = None
|
original_ssid = None
|
||||||
@@ -1635,6 +1649,66 @@ class WiFiManager:
|
|||||||
self._show_led_message("Connection error", duration=5)
|
self._show_led_message("Connection error", duration=5)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
|
||||||
|
# real one, and a leading "-" would be read by nmcli as an option rather
|
||||||
|
# than a network name.
|
||||||
|
_SSID_MAX_OCTETS = 32
|
||||||
|
# WPA-PSK passphrases are 8-63 printable ASCII characters, or a 64-char hex
|
||||||
|
# key. Anything outside that cannot authenticate, so refusing it early
|
||||||
|
# costs nothing and keeps argv clean.
|
||||||
|
_PSK_MIN_LEN = 8
|
||||||
|
_PSK_MAX_LEN = 63
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_ssid(cls, ssid: Any) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Return (ssid, None) for a usable SSID, or ('', reason) to refuse it.
|
||||||
|
|
||||||
|
Returns the value rather than a boolean so callers pass on what was
|
||||||
|
checked instead of re-reading the original.
|
||||||
|
"""
|
||||||
|
if not isinstance(ssid, str):
|
||||||
|
return '', "SSID must be text"
|
||||||
|
ssid = ssid.strip()
|
||||||
|
if not ssid:
|
||||||
|
return '', "SSID cannot be empty"
|
||||||
|
if len(ssid.encode('utf-8')) > cls._SSID_MAX_OCTETS:
|
||||||
|
return '', f"SSID is longer than {cls._SSID_MAX_OCTETS} bytes"
|
||||||
|
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in ssid):
|
||||||
|
return '', "SSID contains control characters"
|
||||||
|
if ssid.startswith('-'):
|
||||||
|
# nmcli would take this for an option, not a network name.
|
||||||
|
return '', "SSID cannot start with '-'"
|
||||||
|
return ssid, None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_wifi_password(cls, password: Any) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Return (password, None) for a usable passphrase, or ('', reason).
|
||||||
|
|
||||||
|
An empty password means an open network and is allowed through.
|
||||||
|
"""
|
||||||
|
if password is None:
|
||||||
|
return '', None
|
||||||
|
if not isinstance(password, str):
|
||||||
|
return '', "Password must be text"
|
||||||
|
if password == '':
|
||||||
|
return '', None
|
||||||
|
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in password):
|
||||||
|
return '', "Password contains control characters"
|
||||||
|
if not password.isascii():
|
||||||
|
# WPA-PSK passphrases are printable ASCII only; NetworkManager
|
||||||
|
# rejects anything else.
|
||||||
|
return '', "Password must be ASCII"
|
||||||
|
if password.startswith('-'):
|
||||||
|
# Same reason as the SSID: nmcli would read it as an option.
|
||||||
|
return '', "Password cannot start with '-'"
|
||||||
|
is_hex_key = len(password) == 64 and all(c in '0123456789abcdefABCDEF' for c in password)
|
||||||
|
if not is_hex_key and not (cls._PSK_MIN_LEN <= len(password) <= cls._PSK_MAX_LEN):
|
||||||
|
return '', (
|
||||||
|
f"Password must be {cls._PSK_MIN_LEN}-{cls._PSK_MAX_LEN} characters "
|
||||||
|
f"(or a 64-character hex key)"
|
||||||
|
)
|
||||||
|
return password, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_wrong_password_error(error_msg: str) -> bool:
|
def _is_wrong_password_error(error_msg: str) -> bool:
|
||||||
"""Return True when nmcli's error output indicates an authentication failure."""
|
"""Return True when nmcli's error output indicates an authentication failure."""
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
# This is a template file. The actual service file is generated dynamically
|
# This is a template. Do not install it directly -- run
|
||||||
# by install_web_service.sh with the correct paths based on where the project
|
# scripts/install/install_web_service.sh (or install_service.sh, which
|
||||||
# is installed. Do not use this file directly - use install_web_service.sh instead.
|
# renders this same file). Both substitute:
|
||||||
|
# __PROJECT_ROOT_DIR__ -> the directory the project is installed in
|
||||||
|
# __USER__ -> the user the web interface runs as
|
||||||
#
|
#
|
||||||
# Template - paths will be replaced dynamically:
|
# This file is the only description of the unit. It used to be duplicated as
|
||||||
# __PROJECT_ROOT_DIR__ will be replaced with the actual project directory
|
# an inline heredoc in both installers, which drifted apart; src/startup_validator.py
|
||||||
|
# compares the installed unit against this template, so a second copy means a
|
||||||
|
# permanent, unclearable drift warning on every boot.
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LED Matrix Web Interface Service
|
Description=LED Matrix Web Interface Service
|
||||||
@@ -12,7 +16,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=__USER__
|
||||||
WorkingDirectory=__PROJECT_ROOT_DIR__
|
WorkingDirectory=__PROJECT_ROOT_DIR__
|
||||||
Environment=USE_THREADING=1
|
Environment=USE_THREADING=1
|
||||||
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/start_web_conditionally.py
|
ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/scripts/utils/start_web_conditionally.py
|
||||||
|
|||||||
@@ -16,6 +16,60 @@ if str(project_root) not in sys.path:
|
|||||||
sys.path.insert(0, str(project_root))
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Point the emulator at a per-process config that binds no socket.
|
||||||
|
|
||||||
|
Six test modules set EMULATOR=true and build a real DisplayManager. The
|
||||||
|
repo's emulator_config.json selects the "browser" adapter, which binds a
|
||||||
|
fixed TCP port (8888) to serve the dev preview. That port is a machine-wide
|
||||||
|
singleton, so a second pytest process -- a CI shard, another worktree, an
|
||||||
|
agent running the suite alongside -- loses the bind. RGBMatrix construction
|
||||||
|
then raises, DisplayManager falls back to ``self.matrix = None``, and every
|
||||||
|
test that touches the matrix dies with a misleading
|
||||||
|
``AttributeError: 'NoneType' object has no attribute 'SwapOnVSync'``.
|
||||||
|
|
||||||
|
The "raw" adapter renders in memory and binds nothing, so concurrent runs
|
||||||
|
stop fighting over the port. The tests wrap SwapOnVSync on the matrix object
|
||||||
|
itself, so they are indifferent to which adapter sits underneath. Only the
|
||||||
|
adapter is overridden -- every other key is inherited from the repo config,
|
||||||
|
which stays on "browser" for ``run.py -e``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from RGBMatrixEmulator.internal.emulator_config import RGBMatrixEmulatorConfig
|
||||||
|
except ImportError:
|
||||||
|
return # No emulator installed; the EMULATOR=true modules can't run anyway.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
settings = {}
|
||||||
|
try:
|
||||||
|
settings = json.loads((project_root / "emulator_config.json").read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass # Fall back to the emulator's own defaults; only the adapter matters.
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
settings = {}
|
||||||
|
settings["display_adapter"] = "raw"
|
||||||
|
# Falling back would land us back on the browser adapter and its fixed port.
|
||||||
|
settings["allow_adapter_fallback"] = False
|
||||||
|
|
||||||
|
tmp_dir = Path(tempfile.mkdtemp(prefix="ledmatrix-emulator-"))
|
||||||
|
tmp_config = tmp_dir / "emulator_config.json"
|
||||||
|
tmp_config.write_text(json.dumps(settings))
|
||||||
|
# CONFIG_PATH is a bare relative filename resolved against the CWD; an
|
||||||
|
# absolute path makes it independent of where pytest was invoked from.
|
||||||
|
RGBMatrixEmulatorConfig.CONFIG_PATH = str(tmp_config)
|
||||||
|
config._ledmatrix_emulator_tmp = tmp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_unconfigure(config):
|
||||||
|
"""Remove the throwaway emulator config written by pytest_configure."""
|
||||||
|
tmp_dir = getattr(config, "_ledmatrix_emulator_tmp", None)
|
||||||
|
if tmp_dir is not None:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_display_manager():
|
def mock_display_manager():
|
||||||
"""Create a mock DisplayManager for testing."""
|
"""Create a mock DisplayManager for testing."""
|
||||||
|
|||||||
Vendored
+973
@@ -0,0 +1,973 @@
|
|||||||
|
[
|
||||||
|
[
|
||||||
|
"/api/v3/backup/<path:filename>",
|
||||||
|
"api_v3.backup_delete",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/download/<path:filename>",
|
||||||
|
"api_v3.backup_download",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/export",
|
||||||
|
"api_v3.backup_export",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/list",
|
||||||
|
"api_v3.backup_list",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/preview",
|
||||||
|
"api_v3.backup_preview",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/restore",
|
||||||
|
"api_v3.backup_restore",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/backup/validate",
|
||||||
|
"api_v3.backup_validate",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/cache/delete",
|
||||||
|
"api_v3.delete_cache_file",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/cache/list",
|
||||||
|
"api_v3.list_cache_files",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
"api_v3.get_dim_schedule_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
"api_v3.save_dim_schedule_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/main",
|
||||||
|
"api_v3.get_main_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/main",
|
||||||
|
"api_v3.save_main_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/raw/main",
|
||||||
|
"api_v3.save_raw_main_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/raw/secrets",
|
||||||
|
"api_v3.save_raw_secrets_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"api_v3.get_schedule_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"api_v3.save_schedule_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/config/secrets",
|
||||||
|
"api_v3.get_secrets_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/current",
|
||||||
|
"api_v3.get_display_current",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/current-status",
|
||||||
|
"api_v3.get_current_display_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/modes",
|
||||||
|
"api_v3.get_display_modes",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/start",
|
||||||
|
"api_v3.start_on_demand_display",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/status",
|
||||||
|
"api_v3.get_on_demand_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/display/on-demand/stop",
|
||||||
|
"api_v3.stop_on_demand_display",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/clear",
|
||||||
|
"api_v3.clear_old_errors",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/plugin/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_errors",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/errors/summary",
|
||||||
|
"api_v3.get_error_summary",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/<font_family>",
|
||||||
|
"api_v3.delete_font",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/catalog",
|
||||||
|
"api_v3.get_fonts_catalog",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/preview",
|
||||||
|
"api_v3.get_font_preview",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/tokens",
|
||||||
|
"api_v3.get_font_tokens",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/fonts/upload",
|
||||||
|
"api_v3.upload_font",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/hardware/status",
|
||||||
|
"api_v3.get_hardware_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/health",
|
||||||
|
"api_v3.get_health",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/integrations/mqtt-bridge",
|
||||||
|
"api_v3.get_mqtt_bridge",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/integrations/mqtt-bridge/config",
|
||||||
|
"api_v3.update_mqtt_bridge_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"PUT"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/logs",
|
||||||
|
"api_v3.get_logs",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
|
||||||
|
"api_v3.serve_plugin_static",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/action",
|
||||||
|
"api_v3.execute_plugin_action",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/delete",
|
||||||
|
"api_v3.delete_plugin_asset",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/list",
|
||||||
|
"api_v3.list_plugin_assets",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/assets/upload",
|
||||||
|
"api_v3.upload_plugin_asset",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/authenticate/spotify",
|
||||||
|
"api_v3.authenticate_spotify",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/authenticate/ytm",
|
||||||
|
"api_v3.authenticate_ytm",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/authenticate",
|
||||||
|
"api_v3.authenticate_calendar",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/list-calendars",
|
||||||
|
"api_v3.list_calendar_calendars",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/calendar/upload-credentials",
|
||||||
|
"api_v3.upload_calendar_credentials",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config",
|
||||||
|
"api_v3.get_plugin_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config",
|
||||||
|
"api_v3.save_plugin_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/config/reset",
|
||||||
|
"api_v3.reset_plugin_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health",
|
||||||
|
"api_v3.get_plugin_health",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_health_single",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/health/<plugin_id>/reset",
|
||||||
|
"api_v3.reset_plugin_health",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/install",
|
||||||
|
"api_v3.install_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/install-from-url",
|
||||||
|
"api_v3.install_plugin_from_url",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/installed",
|
||||||
|
"api_v3.get_installed_plugins",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/limits/<plugin_id>",
|
||||||
|
"api_v3.manage_plugin_limits",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics",
|
||||||
|
"api_v3.get_plugin_metrics",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics/<plugin_id>",
|
||||||
|
"api_v3.get_plugin_metrics_single",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/metrics/<plugin_id>/reset",
|
||||||
|
"api_v3.reset_plugin_metrics",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/of-the-day/json/delete",
|
||||||
|
"api_v3.delete_of_the_day_json",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/of-the-day/json/upload",
|
||||||
|
"api_v3.upload_of_the_day_json",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/<operation_id>",
|
||||||
|
"api_v3.get_operation_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/history",
|
||||||
|
"api_v3.clear_operation_history",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/operation/history",
|
||||||
|
"api_v3.get_operation_history",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/reconciliation-status",
|
||||||
|
"api_v3.get_reconciliation_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/registry-from-url",
|
||||||
|
"api_v3.get_registry_from_url",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.add_saved_repository",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.get_saved_repositories",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/saved-repositories",
|
||||||
|
"api_v3.remove_saved_repository",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/schema",
|
||||||
|
"api_v3.get_plugin_schema",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/state",
|
||||||
|
"api_v3.get_plugin_state",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/state/reconcile",
|
||||||
|
"api_v3.reconcile_plugin_state",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/github-status",
|
||||||
|
"api_v3.get_github_auth_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/list",
|
||||||
|
"api_v3.list_plugin_store",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/store/refresh",
|
||||||
|
"api_v3.refresh_plugin_store",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/toggle",
|
||||||
|
"api_v3.toggle_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/uninstall",
|
||||||
|
"api_v3.uninstall_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/plugins/update",
|
||||||
|
"api_v3.update_plugin",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/skins",
|
||||||
|
"api_v3.list_skins",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps",
|
||||||
|
"api_v3.get_starlark_apps",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>",
|
||||||
|
"api_v3.get_starlark_app",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>",
|
||||||
|
"api_v3.uninstall_starlark_app",
|
||||||
|
[
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/config",
|
||||||
|
"api_v3.get_starlark_app_config",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/config",
|
||||||
|
"api_v3.update_starlark_app_config",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"PUT"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/render",
|
||||||
|
"api_v3.render_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/apps/<app_id>/toggle",
|
||||||
|
"api_v3.toggle_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/apps",
|
||||||
|
"api_v3.list_pixlet_editor_apps",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/start",
|
||||||
|
"api_v3.start_pixlet_editor",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/status",
|
||||||
|
"api_v3.get_pixlet_editor_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/editor/stop",
|
||||||
|
"api_v3.stop_pixlet_editor",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/install-pixlet",
|
||||||
|
"api_v3.install_pixlet",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/browse",
|
||||||
|
"api_v3.browse_tronbyte_repository",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/categories",
|
||||||
|
"api_v3.get_tronbyte_categories",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/repository/install",
|
||||||
|
"api_v3.install_from_tronbyte_repository",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/status",
|
||||||
|
"api_v3.get_starlark_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/starlark/upload",
|
||||||
|
"api_v3.upload_starlark_app",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/sync/status",
|
||||||
|
"api_v3.get_sync_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/action",
|
||||||
|
"api_v3.execute_system_action",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/check-update",
|
||||||
|
"api_v3.check_for_update",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/git-branches",
|
||||||
|
"api_v3.get_git_branches",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/git-info",
|
||||||
|
"api_v3.get_git_info",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/status",
|
||||||
|
"api_v3.get_system_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/system/version",
|
||||||
|
"api_v3.get_system_version",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/auto-enable",
|
||||||
|
"api_v3.get_auto_enable_ap_mode",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/auto-enable",
|
||||||
|
"api_v3.set_auto_enable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/disable",
|
||||||
|
"api_v3.disable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/ap/enable",
|
||||||
|
"api_v3.enable_ap_mode",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/connect",
|
||||||
|
"api_v3.connect_wifi",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/disconnect",
|
||||||
|
"api_v3.disconnect_wifi",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/radio",
|
||||||
|
"api_v3.get_wifi_radio",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/radio",
|
||||||
|
"api_v3.set_wifi_radio",
|
||||||
|
[
|
||||||
|
"OPTIONS",
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/scan",
|
||||||
|
"api_v3.scan_wifi_networks",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/api/v3/wifi/status",
|
||||||
|
"api_v3.get_wifi_status",
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||||
|
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||||
|
|
||||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
||||||
with two plugins installed will pass while exercising very little.
|
with two plugins installed will pass while exercising very little.
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
||||||
|
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
||||||
|
// renamed field or a changed shape fails this rather than passing quietly.
|
||||||
|
const http = require('http');
|
||||||
|
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
|
const get = p => new Promise((res, rej) =>
|
||||||
|
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const partial = await get('/partials/tools');
|
||||||
|
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
||||||
|
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
||||||
|
|
||||||
|
const errs = [];
|
||||||
|
const vc = new VirtualConsole();
|
||||||
|
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||||
|
|
||||||
|
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
||||||
|
let editorStatus = { status: 'success', data: { running: false } };
|
||||||
|
let bridgePayload = bridge;
|
||||||
|
let onPut = null;
|
||||||
|
const stubFetch = (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
||||||
|
let body = { status: 'success', data: {} };
|
||||||
|
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
||||||
|
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
||||||
|
else if (u.includes('/starlark/editor/apps')) body = apps;
|
||||||
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
||||||
|
// browser runs it -- function declarations land on window. Evaluating the
|
||||||
|
// source by hand instead leaves helpers like escHtml off the global object
|
||||||
|
// and the page fails in ways it never would in a browser.
|
||||||
|
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
||||||
|
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
||||||
|
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
||||||
|
const { window } = dom;
|
||||||
|
|
||||||
|
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
await tick(300);
|
||||||
|
|
||||||
|
const $ = id => window.document.getElementById(id);
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||||
|
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
||||||
|
|
||||||
|
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
||||||
|
|
||||||
|
// ── MQTT bridge ────────────────────────────────────────────────────────
|
||||||
|
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
||||||
|
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
||||||
|
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
||||||
|
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
||||||
|
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
||||||
|
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
||||||
|
ok('password field is EMPTY', $('mqtt-password').value === '');
|
||||||
|
ok('password field is type=password', $('mqtt-password').type === 'password');
|
||||||
|
ok('no password value anywhere in the DOM',
|
||||||
|
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
||||||
|
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
||||||
|
$('mqtt-bridge-state').textContent);
|
||||||
|
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
||||||
|
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
||||||
|
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
||||||
|
|
||||||
|
// The save body must omit the password when the field is blank.
|
||||||
|
let sent = null;
|
||||||
|
onPut = body => { sent = body; };
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
||||||
|
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
||||||
|
|
||||||
|
$('mqtt-password').value = 'typed-secret';
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||||
|
onPut = null;
|
||||||
|
|
||||||
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||||
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||||
|
ok('editor lists the apps on disk',
|
||||||
|
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
||||||
|
ok('no session banner while idle', !$('pixlet-countdown'));
|
||||||
|
// escHtml does not encode single quotes, so an app id interpolated into an
|
||||||
|
// inline onclick="startPixletEditor('...')" could break out of the JS string
|
||||||
|
// and run script. The id must reach the handler through dataset instead.
|
||||||
|
const editBtns = [...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')];
|
||||||
|
ok('edit buttons exist', editBtns.length > 0, editBtns.length);
|
||||||
|
ok('edit buttons carry no inline onclick',
|
||||||
|
editBtns.every(b => !b.getAttribute('onclick')),
|
||||||
|
editBtns.map(b => b.getAttribute('onclick')));
|
||||||
|
ok('edit buttons pass the app id via dataset',
|
||||||
|
editBtns.every(b => appIds.includes(b.dataset.appId)),
|
||||||
|
editBtns.map(b => b.dataset.appId));
|
||||||
|
ok('warns that the display stops',
|
||||||
|
window.document.body.textContent.includes('display stops while a session is open'));
|
||||||
|
|
||||||
|
// ── Pixlet editor, running ─────────────────────────────────────────────
|
||||||
|
editorStatus = { status: 'success', data: {
|
||||||
|
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
||||||
|
timeout: 1800, host_bound: '0.0.0.0' } };
|
||||||
|
window.loadPixletEditor();
|
||||||
|
await tick(200);
|
||||||
|
|
||||||
|
ok('running session shows the banner', !!$('pixlet-countdown'));
|
||||||
|
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
||||||
|
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
||||||
|
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
||||||
|
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
||||||
|
ok('editor link present', !!link);
|
||||||
|
ok('link uses this host, not localhost — no tunnel needed',
|
||||||
|
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
||||||
|
link && link.href);
|
||||||
|
ok('Edit buttons disabled while a session runs',
|
||||||
|
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
||||||
|
ok('banner names the app being edited',
|
||||||
|
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
||||||
|
|
||||||
|
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|
||||||
+4
-2
@@ -14,8 +14,10 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
|
'unit/test_html_escaping.js'];
|
||||||
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
function reachable(url) {
|
function reachable(url) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// Every HTML escaper in the web interface must escape quotes, not just
|
||||||
|
// & < >.
|
||||||
|
//
|
||||||
|
// The escapers are all written as `div.textContent = x; return div.innerHTML`.
|
||||||
|
// That round-trip escapes &, < and > because those are the only characters the
|
||||||
|
// HTML serializer has to escape in a *text node* -- quotes are left alone. But
|
||||||
|
// the widgets interpolate the result into quoted attribute values
|
||||||
|
// (`value="${escapeHtml(v)}"`, `title="${escapeHtml(v)}"`, ...), and there a
|
||||||
|
// bare `"` closes the attribute and lets the value add attributes of its own:
|
||||||
|
//
|
||||||
|
// name" onfocus="alert(1)
|
||||||
|
//
|
||||||
|
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
||||||
|
// exactly this. This suite pins the fix at the source: it reads each real
|
||||||
|
// implementation out of the shipped file and runs it, so an escaper that loses
|
||||||
|
// its quote handling again fails here rather than in a scanner run weeks later.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '../../../web_interface');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 300) : '')));
|
||||||
|
|
||||||
|
// ── DOM shim ───────────────────────────────────────────────────────────────
|
||||||
|
// Mirrors what a browser does reading innerHTML back off textContent: & < >
|
||||||
|
// are escaped, quotes are not. Anything the escaper adds on top of that is
|
||||||
|
// the escaper's own doing, which is what we are testing.
|
||||||
|
class FakeEl {
|
||||||
|
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||||
|
get textContent() { return this._t || ''; }
|
||||||
|
get innerHTML() {
|
||||||
|
return (this._t || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
global.document = { createElement: () => new FakeEl() };
|
||||||
|
global.window = global;
|
||||||
|
|
||||||
|
// ── source extraction ──────────────────────────────────────────────────────
|
||||||
|
// Pull a function out of a real source file by its opening line and balanced
|
||||||
|
// braces, so the test runs the shipped code rather than a copy of it.
|
||||||
|
function extract(file, opener) {
|
||||||
|
const src = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||||
|
const start = src.indexOf(opener);
|
||||||
|
if (start < 0) {
|
||||||
|
console.error(`FAIL: cannot find ${JSON.stringify(opener)} in ${file}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let i = src.indexOf('{', start), depth = 0;
|
||||||
|
for (let j = i; j < src.length; j++) {
|
||||||
|
if (src[j] === '{') depth++;
|
||||||
|
else if (src[j] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return src.slice(start, j + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`FAIL: unbalanced braces after ${JSON.stringify(opener)} in ${file}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate an extracted escaper and return it as a callable.
|
||||||
|
function loadFn(file, opener, name, { method = false } = {}) {
|
||||||
|
const body = extract(file, opener);
|
||||||
|
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
|
||||||
|
// their own -- wrap them in an object literal so they can be evaluated.
|
||||||
|
const code = method
|
||||||
|
? `(function(){ const o = { ${body} }; return o.${name}.bind(o); })()`
|
||||||
|
: `(function(){ ${body}; return ${name}; })()`;
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
return eval(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the escapers, as shipped ───────────────────────────────────────────────
|
||||||
|
const ESCAPERS = [
|
||||||
|
['base-widget.js (BaseWidget.escapeHtml)',
|
||||||
|
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
||||||
|
['plugins_manager.js (top-level escapeHtml)',
|
||||||
|
'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['plugins_manager.js (starlark escapeHtml)',
|
||||||
|
'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||||
|
['error_handler.js (escapeHtml)',
|
||||||
|
'static/v3/js/utils/error_handler.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['json-file-manager.js (_esc)',
|
||||||
|
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
|
||||||
|
['plugin-file-manager.js (escHtml)',
|
||||||
|
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
|
||||||
|
['app-shell.js (escapeHtml)',
|
||||||
|
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
||||||
|
['logs.html (escapeHtml)',
|
||||||
|
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['cache.html (escapeHtml)',
|
||||||
|
'templates/v3/partials/cache.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
];
|
||||||
|
|
||||||
|
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||||
|
// handler. If `"` survives escaping, this is live script in the rendered page.
|
||||||
|
const BREAKOUT = 'x" onmouseover="alert(1)';
|
||||||
|
|
||||||
|
console.log('\n1. every escaper neutralises a double-quote attribute breakout');
|
||||||
|
for (const [label, file, opener, name, method] of ESCAPERS) {
|
||||||
|
const fn = loadFn(file, opener, name, { method });
|
||||||
|
const out = String(fn(BREAKOUT));
|
||||||
|
ok(`${label}: no raw "`, !out.includes('"'), out);
|
||||||
|
ok(`${label}: emits "`, out.includes('"'), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n2. every escaper also escapes single quotes');
|
||||||
|
for (const [label, file, opener, name, method] of ESCAPERS) {
|
||||||
|
const fn = loadFn(file, opener, name, { method });
|
||||||
|
const out = String(fn("x' onmouseover='alert(1)"));
|
||||||
|
ok(`${label}: no raw '`, !out.includes("'"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n3. the & < > behaviour they already had is unchanged');
|
||||||
|
for (const [label, file, opener, name, method] of ESCAPERS) {
|
||||||
|
const fn = loadFn(file, opener, name, { method });
|
||||||
|
const out = String(fn('<img src=x onerror=alert(1)> & done'));
|
||||||
|
ok(`${label}: no raw <`, !out.includes('<'), out);
|
||||||
|
ok(`${label}: no raw >`, !out.includes('>'), out);
|
||||||
|
ok(`${label}: & becomes &`, /&/.test(out), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n4. ampersands are escaped before quotes, so " cannot be forged');
|
||||||
|
// If `&` were escaped last, the input `"` would come out as a literal
|
||||||
|
// `"` after the browser decodes the attribute. Order matters; pin it.
|
||||||
|
for (const [label, file, opener, name, method] of ESCAPERS) {
|
||||||
|
const fn = loadFn(file, opener, name, { method });
|
||||||
|
const out = String(fn('"'));
|
||||||
|
ok(`${label}: " input stays inert`, out === '&quot;', out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── url-input scheme handling (js/xss-through-dom) ─────────────────────────
|
||||||
|
console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
|
||||||
|
{
|
||||||
|
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/url-input.js'), 'utf8');
|
||||||
|
const a = src.indexOf('const RFC_SCHEME_PATTERN');
|
||||||
|
const b = src.indexOf("window.LEDMatrixWidgets.register('url-input'");
|
||||||
|
if (a < 0 || b < 0) { console.error('FAIL: cannot locate url-input helpers'); process.exit(1); }
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
const helpers = eval(`(function(){ ${src.slice(a, b)}; return { normalizeProtocols, isValidUrl, safeHref }; })()`);
|
||||||
|
const { normalizeProtocols, isValidUrl, safeHref } = helpers;
|
||||||
|
|
||||||
|
ok('javascript: rejected under the default protocols',
|
||||||
|
!isValidUrl('javascript:alert(1)', ['http', 'https']));
|
||||||
|
ok('javascript: still rejected when a schema asks for it',
|
||||||
|
!isValidUrl('javascript:alert(1)', normalizeProtocols(['javascript'])));
|
||||||
|
ok('a schema asking only for javascript falls back to http/https',
|
||||||
|
JSON.stringify(normalizeProtocols(['javascript'])) === JSON.stringify(['http', 'https']),
|
||||||
|
normalizeProtocols(['javascript']));
|
||||||
|
ok('data: rejected', !isValidUrl('data:text/html,<script>alert(1)</script>', ['http', 'https', 'data']));
|
||||||
|
ok('vbscript: rejected', !isValidUrl('vbscript:msgbox(1)', ['http', 'https', 'vbscript']));
|
||||||
|
ok('normalizeProtocols drops scriptable schemes but keeps the rest',
|
||||||
|
JSON.stringify(normalizeProtocols('https,javascript,ftp')) === JSON.stringify(['https', 'ftp']),
|
||||||
|
normalizeProtocols('https,javascript,ftp'));
|
||||||
|
|
||||||
|
ok('ordinary https URL still valid', isValidUrl('https://example.com/x?y=1', ['http', 'https']));
|
||||||
|
ok('ordinary http URL still valid', isValidUrl('http://example.com', ['http', 'https']));
|
||||||
|
ok('a scheme outside the allow-list is still rejected',
|
||||||
|
!isValidUrl('ftp://example.com', ['http', 'https']));
|
||||||
|
|
||||||
|
ok('safeHref passes a good URL through', safeHref('https://example.com', ['http', 'https']) === 'https://example.com');
|
||||||
|
ok('safeHref blanks a javascript: URL', safeHref('javascript:alert(1)', ['http', 'https']) === '');
|
||||||
|
ok('safeHref blanks an unparseable value', safeHref('not a url', ['http', 'https']) === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── url-input onInput: the sink itself, not just the pulled-out helpers ────
|
||||||
|
// The scheme check now lives inline in onInput, right where the value reaches
|
||||||
|
// `previewLink.href`, instead of behind safeHref/isValidUrl -- see the comment
|
||||||
|
// at that assignment in url-input.js for why. Run the handler as shipped so a
|
||||||
|
// regression that reintroduces an unguarded `previewLink.href = value` fails
|
||||||
|
// here, not just in a scanner run weeks later.
|
||||||
|
console.log('\n6. url-input onInput: previewLink.href is guarded at the sink');
|
||||||
|
{
|
||||||
|
class FakeClassList {
|
||||||
|
constructor() { this.classes = new Set(['hidden']); }
|
||||||
|
add(c) { this.classes.add(c); }
|
||||||
|
remove(c) { this.classes.delete(c); }
|
||||||
|
contains(c) { return this.classes.has(c); }
|
||||||
|
}
|
||||||
|
const mockDoc = (value, protocolsAttr) => {
|
||||||
|
const elements = {
|
||||||
|
_input: { value, checkValidity: () => true, validationMessage: '', classList: new FakeClassList() },
|
||||||
|
_preview: { classList: new FakeClassList() },
|
||||||
|
_preview_link: {
|
||||||
|
classList: new FakeClassList(),
|
||||||
|
_href: undefined,
|
||||||
|
set href(v) { this._href = v; },
|
||||||
|
get href() { return this._href; },
|
||||||
|
removeAttribute(name) { if (name === 'href') this._href = undefined; },
|
||||||
|
},
|
||||||
|
_widget: { dataset: { protocols: protocolsAttr } },
|
||||||
|
_error: { classList: new FakeClassList(), textContent: '' },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
getElementById: (id) => elements[Object.keys(elements).find(k => id === `field${k}`)] || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function runOnInput(value, protocolsAttr) {
|
||||||
|
const { elements, getElementById } = mockDoc(value, protocolsAttr);
|
||||||
|
const savedDocument = global.document;
|
||||||
|
const savedWindow = global.window;
|
||||||
|
let registered = null;
|
||||||
|
global.document = { createElement: () => new FakeEl(), getElementById };
|
||||||
|
global.window = {
|
||||||
|
LEDMatrixWidgets: {
|
||||||
|
register: (name, obj) => { registered = obj; },
|
||||||
|
get: () => registered,
|
||||||
|
getHandlers: () => registered.handlers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/url-input.js'), 'utf8');
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
eval(src);
|
||||||
|
registered.handlers.onInput('field');
|
||||||
|
} finally {
|
||||||
|
global.document = savedDocument;
|
||||||
|
global.window = savedWindow;
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
let els = runOnInput('javascript:alert(1)', 'http,https');
|
||||||
|
ok('javascript: never reaches previewLink.href', els._preview_link.href === undefined, els._preview_link.href);
|
||||||
|
ok('javascript: leaves the preview hidden', els._preview.classList.contains('hidden'));
|
||||||
|
|
||||||
|
els = runOnInput('https://example.com', 'http,https');
|
||||||
|
ok('an ordinary https URL reaches previewLink.href', els._preview_link.href === 'https://example.com', els._preview_link.href);
|
||||||
|
ok('an ordinary https URL unhides the preview', !els._preview.classList.contains('hidden'));
|
||||||
|
|
||||||
|
els = runOnInput('data:text/html,<script>alert(1)</script>', 'http,https,data');
|
||||||
|
ok('data: never reaches previewLink.href even when the schema allows it',
|
||||||
|
els._preview_link.href === undefined, els._preview_link.href);
|
||||||
|
|
||||||
|
els = runOnInput('not a url', 'http,https');
|
||||||
|
ok('an unparseable value never reaches previewLink.href', els._preview_link.href === undefined, els._preview_link.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── plugin-file-manager: cell edits travel via data-*, not inline handlers ──
|
||||||
|
// A JSON key/day from an uploaded file used to be spliced, HTML-escaped,
|
||||||
|
// into an oninput="...('${escHtml(col)}'...)" attribute. escHtml neutralises
|
||||||
|
// a quote for an ordinary attribute, but here the value also has to survive
|
||||||
|
// as a *JS string literal* -- the browser HTML-decodes the attribute before
|
||||||
|
// running it as script, which turns the escaped quote back into a real one
|
||||||
|
// and lets a column named `x');alert(1);//` break out of the string and run
|
||||||
|
// arbitrary JS. Cell edits now reach _pfmCellEdit only via data-day/data-col
|
||||||
|
// read by one delegated listener, so this pins that no inline handler string
|
||||||
|
// is built from the value at all.
|
||||||
|
console.log("\n7. plugin-file-manager: cell edits never go through an inline handler string");
|
||||||
|
{
|
||||||
|
const src = fs.readFileSync(path.join(ROOT, 'static/v3/js/widgets/plugin-file-manager.js'), 'utf8');
|
||||||
|
|
||||||
|
function extractFn(opener) {
|
||||||
|
const start = src.indexOf(opener);
|
||||||
|
if (start < 0) { console.error(`FAIL: cannot find ${JSON.stringify(opener)}`); process.exit(1); }
|
||||||
|
let i = src.indexOf('{', start), depth = 0;
|
||||||
|
for (let j = i; j < src.length; j++) {
|
||||||
|
if (src[j] === '{') depth++;
|
||||||
|
else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(start, j + 1); }
|
||||||
|
}
|
||||||
|
console.error(`FAIL: unbalanced braces after ${JSON.stringify(opener)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escHtmlFn = loadFn('static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false);
|
||||||
|
const renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {');
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
const fakeWindow = { _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
|
||||||
|
|
||||||
|
class FakeContainer {
|
||||||
|
constructor() { this._html = ''; this._listeners = {}; }
|
||||||
|
set innerHTML(v) { this._html = v; }
|
||||||
|
get innerHTML() { return this._html; }
|
||||||
|
set textContent(v) { this._html = v; }
|
||||||
|
addEventListener(type, fn) { this._listeners[type] = fn; }
|
||||||
|
dispatch(type, target) { this._listeners[type]({ target }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeCell(day, col, value) {
|
||||||
|
return {
|
||||||
|
closest: (sel) => (sel.includes('data-day') ? { dataset: { day: String(day), col: String(col) }, value } : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){
|
||||||
|
${renderEntryTableSrc}
|
||||||
|
return renderEntryTable;
|
||||||
|
})`)(
|
||||||
|
() => ({ entriesPerPage: 20, _tablePage: 1 }),
|
||||||
|
escHtmlFn,
|
||||||
|
(target, html) => { target.innerHTML = html; },
|
||||||
|
fakeWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
const maliciousCol = "x');alert(1);//";
|
||||||
|
const container = new FakeContainer();
|
||||||
|
renderEntryTable('field1', container, { '1': { [maliciousCol]: 'hello' } });
|
||||||
|
|
||||||
|
ok('no inline oninput handler is emitted for a cell', !/oninput=/.test(container.innerHTML), container.innerHTML);
|
||||||
|
ok('the malicious column name never appears unescaped in the markup',
|
||||||
|
!container.innerHTML.includes(maliciousCol), container.innerHTML);
|
||||||
|
|
||||||
|
container.dispatch('input', fakeCell('1', maliciousCol, 'typed value'));
|
||||||
|
ok('the delegated listener still reaches _pfmCellEdit with the real day/col',
|
||||||
|
calls.length === 1 && calls[0].day === '1' && calls[0].col === maliciousCol && calls[0].value === 'typed value',
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
|
||||||
|
|
||||||
|
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
|
||||||
|
like it checks the upload's size, but it only ever validated the filename
|
||||||
|
(traversal characters, extension) -- max_size_mb was accepted and silently
|
||||||
|
ignored. Nothing else in the handler checked the actual upload size either,
|
||||||
|
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
|
||||||
|
size, unlike the sibling .star and plugin-asset upload routes, which check
|
||||||
|
`file.tell()` against a stated limit before saving.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/fonts/upload"
|
||||||
|
TEN_MB = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fonts_root(tmp_path):
|
||||||
|
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
|
||||||
|
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
|
||||||
|
yield tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def upload(client, content, filename="myfont.ttf", family="myfont"):
|
||||||
|
data = {
|
||||||
|
"font_file": (io.BytesIO(content), filename),
|
||||||
|
"font_family": family,
|
||||||
|
}
|
||||||
|
return client.post(URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontUploadSizeLimit:
|
||||||
|
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "too large" in response.get_json()["message"].lower()
|
||||||
|
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
|
||||||
|
"an oversized font was saved to disk before being rejected")
|
||||||
|
|
||||||
|
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"x" * TEN_MB,
|
||||||
|
filename="atlimit.ttf", family="atlimit")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
|
||||||
|
|
||||||
|
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
|
||||||
|
response = upload(api_v3_client, b"fake font bytes",
|
||||||
|
filename="small.ttf", family="small")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Regression test: POST /display/on-demand/start restarting a running
|
||||||
|
service must not import a name that does not exist.
|
||||||
|
|
||||||
|
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
|
||||||
|
mutable, test-patched attributes back through it (`_pkg.time.time()`,
|
||||||
|
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
|
||||||
|
the package's own docstring. One spot went further and wrote a genuine
|
||||||
|
`import` *statement* against that alias --
|
||||||
|
|
||||||
|
import _pkg.time as time_module
|
||||||
|
|
||||||
|
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
|
||||||
|
not a real top-level package, so `import _pkg.time` is not something Python
|
||||||
|
can resolve; it raises ModuleNotFoundError. That line only runs when the
|
||||||
|
display service is already running and the caller also asked to (re)start
|
||||||
|
it, so this endpoint failed on exactly the restart path -- the one where a
|
||||||
|
cache write recording the new on-demand request had already happened.
|
||||||
|
|
||||||
|
The route wraps its body in `except Exception`, so the failure reached the
|
||||||
|
caller as a handled 500 with a generic message, not an unhandled crash --
|
||||||
|
but a 500 all the same on a request that should have restarted the service
|
||||||
|
and reported success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
URL = "/api/v3/display/on-demand/start"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def restart_path(api_v3_module):
|
||||||
|
"""Force the `service_was_running and start_service` branch.
|
||||||
|
|
||||||
|
plugin_manager and config_manager are set to None so the route takes
|
||||||
|
the simplest path to that branch rather than tripping over unrelated
|
||||||
|
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
|
||||||
|
_stop_display_service and _ensure_display_service_running are bound by
|
||||||
|
value in display.py (see its own docstring), so they are patched on
|
||||||
|
that submodule rather than on the package.
|
||||||
|
"""
|
||||||
|
api_v3_module.api_v3.plugin_manager = None
|
||||||
|
api_v3_module.api_v3.config_manager = None
|
||||||
|
|
||||||
|
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
|
||||||
|
ensure_cache.return_value = MagicMock()
|
||||||
|
# Active before the request: service_was_running becomes True.
|
||||||
|
get_status.return_value = {"active": True}
|
||||||
|
ensure_running.return_value = {"active": True}
|
||||||
|
yield {
|
||||||
|
"ensure_cache": ensure_cache,
|
||||||
|
"get_status": get_status,
|
||||||
|
"stop_service": stop_service,
|
||||||
|
"ensure_running": ensure_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRestartingARunningService:
|
||||||
|
def test_it_does_not_500(self, api_v3_client, restart_path):
|
||||||
|
response = api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
body = response.get_json()
|
||||||
|
assert response.status_code == 200, body
|
||||||
|
assert body["status"] == "success", body
|
||||||
|
|
||||||
|
def test_the_service_is_actually_stopped_and_restarted(
|
||||||
|
self, api_v3_client, restart_path):
|
||||||
|
api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
restart_path["stop_service"].assert_called_once()
|
||||||
|
restart_path["ensure_running"].assert_called_once()
|
||||||
|
|
||||||
|
def test_a_service_that_was_not_running_is_not_stopped_first(
|
||||||
|
self, api_v3_client, restart_path):
|
||||||
|
# The buggy import sits inside `if service_was_running and
|
||||||
|
# start_service`, so it only ever fired on the restart path --
|
||||||
|
# this is the other side of that branch, unaffected either way,
|
||||||
|
# kept here so the branch condition itself stays covered.
|
||||||
|
restart_path["get_status"].return_value = {"active": False}
|
||||||
|
response = api_v3_client.post(
|
||||||
|
URL, json={"plugin_id": "weather", "start_service": True})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
restart_path["stop_service"].assert_not_called()
|
||||||
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
|
|||||||
|
|
||||||
|
|
||||||
class TestNoBodyReadContradictsItsOwnGuard:
|
class TestNoBodyReadContradictsItsOwnGuard:
|
||||||
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _source(self) -> str:
|
||||||
|
"""api_v3 is a package; read every module of it."""
|
||||||
|
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
|
||||||
|
|
||||||
def test_no_or_default_read_is_unguarded(self):
|
def test_no_or_default_read_is_unguarded(self):
|
||||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||||
@@ -115,7 +120,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
means the call raises before the default can apply.
|
means the call raises before the default can apply.
|
||||||
"""
|
"""
|
||||||
offenders = [
|
offenders = [
|
||||||
line.strip() for line in self.SOURCE.read_text().splitlines()
|
line.strip() for line in self._source.splitlines()
|
||||||
if "request.get_json()" in line and " or " in line
|
if "request.get_json()" in line and " or " in line
|
||||||
]
|
]
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
|||||||
|
|
||||||
def test_no_not_data_guard_is_unreachable(self):
|
def test_no_not_data_guard_is_unreachable(self):
|
||||||
"""A `if not data:` guard needs a read that can actually return None."""
|
"""A `if not data:` guard needs a read that can actually return None."""
|
||||||
lines = self.SOURCE.read_text().splitlines()
|
lines = self._source.splitlines()
|
||||||
offenders = []
|
offenders = []
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Regression test: per-day schedule validation errors must say "time", not
|
||||||
|
"_pkg.time".
|
||||||
|
|
||||||
|
The split into a package rewrote every bare `time` reference that needed to
|
||||||
|
read through the shared module as `_pkg.time` (see the package's own
|
||||||
|
docstring on why -- tests patch it, so it has to be read back live rather
|
||||||
|
than bound by value). That rewrite was mechanical and matched the substring
|
||||||
|
"time" inside string literals and comments too, so the user-facing message
|
||||||
|
|
||||||
|
"Invalid start time for {day}: ..."
|
||||||
|
|
||||||
|
came out as
|
||||||
|
|
||||||
|
"Invalid start _pkg.time for {day}: ..."
|
||||||
|
|
||||||
|
in both POST /config/schedule and POST /config/dim-schedule, for both the
|
||||||
|
start and end time of a per-day entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"/api/v3/config/schedule",
|
||||||
|
"/api/v3/config/dim-schedule",
|
||||||
|
])
|
||||||
|
class TestPerDayTimeErrorsAreNotCorrupted:
|
||||||
|
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
|
||||||
|
response = api_v3_client.post(url, json={
|
||||||
|
"mode": "per_day",
|
||||||
|
"monday_start": "not-a-time",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
message = response.get_json()["message"]
|
||||||
|
assert "_pkg" not in message, message
|
||||||
|
assert message.startswith("Invalid start time for monday:"), message
|
||||||
|
|
||||||
|
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
|
||||||
|
response = api_v3_client.post(url, json={
|
||||||
|
"mode": "per_day",
|
||||||
|
"monday_start": "07:00",
|
||||||
|
"monday_end": "not-a-time",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
message = response.get_json()["message"]
|
||||||
|
assert "_pkg" not in message, message
|
||||||
|
assert message.startswith("Invalid end time for monday:"), message
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""The /api/v3 URL map is a contract, and the split must not have moved it.
|
||||||
|
|
||||||
|
api_v3.py was one 10,469-line module and is now a package. Every route module
|
||||||
|
in it decorates the *same* Blueprint object, so this refactor was supposed to
|
||||||
|
be invisible from outside: same URLs, same endpoint names, same methods.
|
||||||
|
|
||||||
|
"Supposed to be" is the problem. A route function silently dropped during a
|
||||||
|
move -- a module that never gets imported, a decorator left behind -- costs
|
||||||
|
nothing at import time and fails only when someone hits the URL. So the map is
|
||||||
|
pinned here.
|
||||||
|
|
||||||
|
The snapshot is intentionally the *whole* map rather than a count. A count
|
||||||
|
passes when one route is deleted and another added, which is exactly the shape
|
||||||
|
a careless move produces.
|
||||||
|
|
||||||
|
If you are adding a route, this test is meant to fail: add the entry to
|
||||||
|
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
|
||||||
|
-- endpoint names are `api_v3.<function>` regardless of which module the
|
||||||
|
function lives in, and that is the property that makes the package safe.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"fixtures", "api_v3_url_map.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_map():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
return sorted(
|
||||||
|
[r.rule, r.endpoint, sorted(r.methods)]
|
||||||
|
for r in app.url_map.iter_rules()
|
||||||
|
if r.endpoint != "static"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_url_map_matches_the_snapshot():
|
||||||
|
current = _current_map()
|
||||||
|
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||||
|
expected = json.load(fh)
|
||||||
|
|
||||||
|
cur = {(r, e) for r, e, _ in current}
|
||||||
|
exp = {(r, e) for r, e, _ in expected}
|
||||||
|
|
||||||
|
lost = sorted(exp - cur)
|
||||||
|
added = sorted(cur - exp)
|
||||||
|
assert not lost, (
|
||||||
|
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
|
||||||
|
"A route lost in a module move costs nothing at import time and fails "
|
||||||
|
"only when someone hits the URL.")
|
||||||
|
assert not added, (
|
||||||
|
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
|
||||||
|
f"regenerate {os.path.relpath(SNAPSHOT)}.")
|
||||||
|
|
||||||
|
# Methods too: a route that quietly loses POST is still a broken route.
|
||||||
|
cur_methods = {(r, e): m for r, e, m in current}
|
||||||
|
for rule, endpoint, methods in expected:
|
||||||
|
assert cur_methods[(rule, endpoint)] == methods, (
|
||||||
|
f"{rule} ({endpoint}) methods changed: "
|
||||||
|
f"{methods} -> {cur_methods[(rule, endpoint)]}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_endpoint_is_on_the_one_blueprint():
|
||||||
|
"""The package must not fragment into several blueprints.
|
||||||
|
|
||||||
|
Splitting into per-domain *blueprints* would rename every endpoint from
|
||||||
|
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
|
||||||
|
one. Keeping a single Blueprint object across the modules is what makes
|
||||||
|
the split a pure code move, so assert it rather than trusting it.
|
||||||
|
"""
|
||||||
|
for rule, endpoint, _ in _current_map():
|
||||||
|
assert endpoint.startswith("api_v3."), (
|
||||||
|
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_snapshot_is_not_empty():
|
||||||
|
"""Guards the failure mode this file exists to prevent.
|
||||||
|
|
||||||
|
An empty or truncated snapshot would make every assertion above pass
|
||||||
|
vacuously -- the same trap as a route map that imports no modules.
|
||||||
|
"""
|
||||||
|
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||||
|
expected = json.load(fh)
|
||||||
|
assert len(expected) > 100, (
|
||||||
|
f"snapshot has only {len(expected)} routes; it should have the whole "
|
||||||
|
"/api/v3 surface")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", [
|
||||||
|
"backup", "config", "display", "fonts", "misc",
|
||||||
|
"plugins", "starlark", "system", "wifi",
|
||||||
|
])
|
||||||
|
def test_every_route_module_contributes(module):
|
||||||
|
"""Each module must actually register something.
|
||||||
|
|
||||||
|
A module that fails to import, or that is left out of __init__, takes its
|
||||||
|
routes with it silently -- the package still imports and the app still
|
||||||
|
starts.
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
|
||||||
|
routes = [n for n in dir(mod)
|
||||||
|
if callable(getattr(mod, n, None))
|
||||||
|
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
|
||||||
|
assert routes, f"{module}.py defines no view functions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_root_points_at_the_project():
|
||||||
|
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
|
||||||
|
|
||||||
|
The split moved this code from web_interface/blueprints/api_v3.py to
|
||||||
|
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
|
||||||
|
`Path(__file__).parent.parent.parent` quietly began resolving to
|
||||||
|
web_interface/ instead of the project root. Nothing failed at import. It
|
||||||
|
surfaced as routes returning 404 and "installation script not found",
|
||||||
|
because every path built from it pointed one level too shallow.
|
||||||
|
|
||||||
|
A URL-map check cannot catch that: the routes were all registered, they
|
||||||
|
just could not find anything.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
||||||
|
|
||||||
|
# The project root is the directory holding run.py and web_interface/.
|
||||||
|
assert (PROJECT_ROOT / "run.py").is_file(), (
|
||||||
|
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
|
||||||
|
"project root")
|
||||||
|
assert (PROJECT_ROOT / "web_interface").is_dir()
|
||||||
|
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
|
||||||
@@ -195,7 +195,7 @@ class TestRadio:
|
|||||||
|
|
||||||
@pytest.mark.parametrize("raw,expected", [
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
(True, True), ("true", True), ("1", True), ("yes", True),
|
(True, True), ("true", True), ("1", True), ("yes", True),
|
||||||
(False, False), ("false", False), ("off", False), (0, False),
|
(False, False), ("false", False), ("no", False), (0, False),
|
||||||
])
|
])
|
||||||
def test_enabled_coercion_is_string_aware(
|
def test_enabled_coercion_is_string_aware(
|
||||||
self, api_v3_client, wifi_manager, raw, expected):
|
self, api_v3_client, wifi_manager, raw, expected):
|
||||||
@@ -228,6 +228,84 @@ class TestRadio:
|
|||||||
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
||||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||||
|
|
||||||
|
def test_unrecognized_enabled_value_is_rejected_not_treated_as_false(
|
||||||
|
self, api_v3_client, wifi_manager):
|
||||||
|
# An invalid `enabled` used to silently fall back to False, which
|
||||||
|
# can disconnect Wi-Fi (or, with force=true, drop the caller's own
|
||||||
|
# connection to this interface) even though nothing asked for that.
|
||||||
|
response = api_v3_client.post(self.URL, json={"enabled": "typo"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
wifi_manager.set_wifi_radio.assert_not_called()
|
||||||
|
|
||||||
|
def test_unrecognized_force_value_is_rejected_not_treated_as_false(
|
||||||
|
self, api_v3_client, wifi_manager):
|
||||||
|
response = api_v3_client.post(
|
||||||
|
self.URL, json={"enabled": False, "force": "typo"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
wifi_manager.set_wifi_radio.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoEnableApMode:
|
||||||
|
URL = "/api/v3/wifi/ap/auto-enable"
|
||||||
|
|
||||||
|
def test_requires_the_field(self, api_v3_client, wifi_manager):
|
||||||
|
response = api_v3_client.post(self.URL, json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(True, True), (False, False),
|
||||||
|
("true", True), ("True", True), ("1", True), ("yes", True),
|
||||||
|
("false", False), ("False", False), ("0", False), ("no", False),
|
||||||
|
(1, True), (0, False),
|
||||||
|
])
|
||||||
|
def test_value_is_coerced_not_just_truthy(
|
||||||
|
self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
|
||||||
|
# sent the JSON string "false" got it stored as True — bool("false")
|
||||||
|
# is True, since any non-empty string is truthy.
|
||||||
|
wifi_manager.config = {}
|
||||||
|
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
|
||||||
|
assert wifi_manager.config["auto_enable_ap_mode"] is expected
|
||||||
|
|
||||||
|
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
|
||||||
|
# The exact shape of the bug.
|
||||||
|
wifi_manager.config = {}
|
||||||
|
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
|
||||||
|
assert wifi_manager.config["auto_enable_ap_mode"] is False
|
||||||
|
|
||||||
|
def test_unrecognized_value_is_rejected_not_treated_as_false(
|
||||||
|
self, api_v3_client, wifi_manager):
|
||||||
|
wifi_manager.config = {}
|
||||||
|
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "typo"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "auto_enable_ap_mode" not in wifi_manager.config
|
||||||
|
|
||||||
|
|
||||||
|
class TestRadioEnabledAndForceAcceptIntegers:
|
||||||
|
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||||
|
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
|
||||||
|
`1 is True` is False in Python -- an int is never the `True` singleton
|
||||||
|
even though it equals it -- so a plain integer fell through to False
|
||||||
|
regardless of its value.
|
||||||
|
"""
|
||||||
|
URL = "/api/v3/wifi/radio"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||||
|
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||||
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||||
|
api_v3_client.post(self.URL, json={"enabled": raw})
|
||||||
|
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||||
|
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||||
|
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||||
|
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||||
|
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
|
||||||
|
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
|
||||||
|
|
||||||
|
|
||||||
class TestNoRealNetworking:
|
class TestNoRealNetworking:
|
||||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||||
|
|||||||
@@ -103,6 +103,54 @@ def test_non_dict_input_passes_through():
|
|||||||
assert _redact_credentials(None) is None
|
assert _redact_credentials(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_list_of_bare_credentials_is_redacted():
|
||||||
|
"""A credential-named key whose value is a list of scalars, not a list
|
||||||
|
of named-field dicts, used to pass through untouched: the dict branch
|
||||||
|
recursed into `v` on its own value only, so a bare string list item had
|
||||||
|
no field name to test and fell through the base case unredacted.
|
||||||
|
"""
|
||||||
|
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["tokens"] == ["", ""]
|
||||||
|
assert out["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
|
||||||
|
"""The list fix applies at any depth under a credential-named key, not
|
||||||
|
only when the list is the key's direct value.
|
||||||
|
"""
|
||||||
|
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["auth"]["backup_tokens"] == ["", ""]
|
||||||
|
assert out["auth"]["note"] == "keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_inside_a_credential_named_list_are_blanked_regardless_of_field_names():
|
||||||
|
"""A list item has no field name of its own to test against
|
||||||
|
`_CREDENTIAL_NAME_PARTS`, so an object reached through a credential-owned
|
||||||
|
list must be blanked outright rather than walked by field name -- unlike
|
||||||
|
the dict-directly-under-a-credential-key case in the test above.
|
||||||
|
"""
|
||||||
|
config = {"tokens": [{"value": "secret", "kind": "bearer"}]}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["tokens"] == [{"value": "", "kind": ""}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
|
||||||
|
"""Unchanged behaviour for the case the container test above already
|
||||||
|
covers: a dict whose sub-keys are semantically named fields is walked
|
||||||
|
normally, not blanket-blanked, so an ordinary field next to a
|
||||||
|
credential-named one survives.
|
||||||
|
"""
|
||||||
|
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
|
||||||
|
out = _redact_credentials(config)
|
||||||
|
assert out["secrets"]["api_key"] == ""
|
||||||
|
assert out["secrets"]["note"] == "keep"
|
||||||
|
# "list" isn't itself credential-named, so it is walked, not blanked --
|
||||||
|
# but it holds no credential-named field either, so it survives whole.
|
||||||
|
assert out["secrets"]["list"] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_endpoint_itself_redacts():
|
def test_the_endpoint_itself_redacts():
|
||||||
"""Through the view function, not the helper.
|
"""Through the view function, not the helper.
|
||||||
|
|
||||||
@@ -130,7 +178,8 @@ def test_the_endpoint_itself_redacts():
|
|||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
try:
|
try:
|
||||||
with app.test_request_context("/config/main"):
|
with app.test_request_context("/config/main"):
|
||||||
response = mod.get_main_config()
|
# get_main_config is a route; it lives in the config module now.
|
||||||
|
response = mod.config.get_main_config()
|
||||||
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
||||||
finally:
|
finally:
|
||||||
mod.api_v3.config_manager = previous
|
mod.api_v3.config_manager = previous
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def dm():
|
def dm(tmp_path_factory):
|
||||||
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
@@ -36,6 +36,14 @@ def dm():
|
|||||||
"runtime": {"gpio_slowdown": 0},
|
"runtime": {"gpio_slowdown": 0},
|
||||||
},
|
},
|
||||||
}, suppress_test_pattern=True)
|
}, suppress_test_pattern=True)
|
||||||
|
# DisplayManager defaults _snapshot_path to the fixed /tmp/led_matrix_preview.png
|
||||||
|
# that the web UI reads. That path is shared by every pytest process on the
|
||||||
|
# machine, so two concurrent runs -- CI shards, a second worktree, a agent
|
||||||
|
# running the suite alongside -- write over each other's snapshot and the
|
||||||
|
# mtime assertions below stop meaning anything. Point it somewhere unique to
|
||||||
|
# this session; the individual tests that care still override it further.
|
||||||
|
manager._snapshot_path = str(
|
||||||
|
tmp_path_factory.mktemp("dirty_tracking") / "led_matrix_preview.png")
|
||||||
yield manager
|
yield manager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
DisplayManager._initialized = False
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for the metrics the live status stream reports.
|
||||||
|
|
||||||
|
Two gaps made a struggling 1GB Pi look healthy on the dashboard:
|
||||||
|
|
||||||
|
* ``disk_used_percent`` was the literal ``0`` in the SSE generator, so every
|
||||||
|
consumer of the live stream displayed 0% disk no matter how full the card was.
|
||||||
|
/api/v3/system/status computed it properly, but the live view -- the one people
|
||||||
|
actually watch -- did not.
|
||||||
|
|
||||||
|
* ``memory_available_mb`` was absent. /api/v3/system/status carries it with a
|
||||||
|
comment explaining that MemAvailable, not used%, is what separates a board
|
||||||
|
that is fine from one about to fail fork(). The live stream omitted the one
|
||||||
|
number that predicts the failure.
|
||||||
|
|
||||||
|
A wrong number is worse than a missing one, so an unreadable disk now reports
|
||||||
|
None (the UI renders '--') rather than a confident 0.
|
||||||
|
|
||||||
|
These import web_interface.system_metrics rather than web_interface.app on
|
||||||
|
purpose: importing the app constructs a Flask application and a CacheManager,
|
||||||
|
and the latter claims the cache directory with a cleanup thread -- which broke
|
||||||
|
test_cache_cleanup_thread_ownership and the starlark route tests when this file
|
||||||
|
first reached for the generator directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from web_interface.system_metrics import collect_system_metrics # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metrics(monkeypatch):
|
||||||
|
"""collect_system_metrics() with psutil's readings under our control."""
|
||||||
|
def _collect(disk_percent=12.0, available_bytes=512 * 1024 * 1024,
|
||||||
|
used_percent=67.7, raise_disk=False):
|
||||||
|
import psutil
|
||||||
|
if raise_disk:
|
||||||
|
monkeypatch.setattr(psutil, "disk_usage",
|
||||||
|
lambda _p: (_ for _ in ()).throw(OSError("no such mount")))
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(psutil, "disk_usage",
|
||||||
|
lambda _p: SimpleNamespace(percent=disk_percent))
|
||||||
|
monkeypatch.setattr(psutil, "virtual_memory",
|
||||||
|
lambda: SimpleNamespace(percent=used_percent,
|
||||||
|
available=available_bytes))
|
||||||
|
return collect_system_metrics()
|
||||||
|
return _collect
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiskUsageIsReal:
|
||||||
|
def test_reported_value_comes_from_the_filesystem(self, metrics):
|
||||||
|
# The regression: this field was the literal 0.
|
||||||
|
assert metrics(disk_percent=91.4)["disk_used_percent"] == 91.4
|
||||||
|
|
||||||
|
def test_a_full_disk_is_not_reported_as_zero(self, metrics):
|
||||||
|
assert metrics(disk_percent=99.9)["disk_used_percent"] != 0
|
||||||
|
|
||||||
|
def test_unreadable_disk_is_null_not_a_confident_zero(self, metrics):
|
||||||
|
# null renders as '--'; 0 would read as "plenty of room".
|
||||||
|
assert metrics(raise_disk=True)["disk_used_percent"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMemAvailableIsReported:
|
||||||
|
def test_mem_available_is_present_and_in_mb(self, metrics):
|
||||||
|
got = metrics(available_bytes=292 * 1024 * 1024)["memory_available_mb"]
|
||||||
|
assert got == pytest.approx(292.0, abs=0.1)
|
||||||
|
|
||||||
|
def test_the_1gb_danger_case_is_visible(self, metrics):
|
||||||
|
"""67% used can be fine or nearly out of memory; only MemAvailable says."""
|
||||||
|
m = metrics(used_percent=67.7, available_bytes=40 * 1024 * 1024)
|
||||||
|
assert m["memory_used_percent"] == 67.7
|
||||||
|
assert m["memory_available_mb"] == pytest.approx(40.0, abs=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEveryPredictiveFieldIsPresent:
|
||||||
|
def test_nothing_the_dashboard_needs_is_missing(self, metrics):
|
||||||
|
m = metrics()
|
||||||
|
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
|
||||||
|
"memory_available_mb", "disk_used_percent"):
|
||||||
|
assert field in m, f"{field} missing from collected metrics"
|
||||||
|
|
||||||
|
def test_absent_psutil_still_yields_every_key(self, monkeypatch):
|
||||||
|
"""The no-psutil fallback must not drop fields the UI reads."""
|
||||||
|
import builtins
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def _no_psutil(name, *args, **kwargs):
|
||||||
|
if name == "psutil":
|
||||||
|
raise ImportError("psutil missing")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", _no_psutil)
|
||||||
|
m = collect_system_metrics()
|
||||||
|
for field in ("cpu_percent", "cpu_temp", "memory_used_percent",
|
||||||
|
"memory_available_mb", "disk_used_percent"):
|
||||||
|
assert field in m
|
||||||
|
assert m["disk_used_percent"] is None
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""The scheduler actually polls faster while a plugin reports live content.
|
||||||
|
|
||||||
|
test_plugin_dynamic_update_interval.py proves _get_plugin_update_interval()
|
||||||
|
returns the plugin's requested number. That is not the same claim as "the
|
||||||
|
plugin gets updated more often", and the gap between those two is exactly where
|
||||||
|
the original bug lived: the plugin knew it wanted 15s, said so in
|
||||||
|
live_update_interval, and nothing downstream acted on it.
|
||||||
|
|
||||||
|
So this drives the real run_scheduled_updates() over a simulated hour of ticks
|
||||||
|
and counts dispatches. It is the test that would have caught the reported bug:
|
||||||
|
|
||||||
|
"the football plugin with live games only updates the live game in
|
||||||
|
progress if I restart the display"
|
||||||
|
|
||||||
|
Measured on a rig before the fix, during an NFL fourth quarter, ESPN was polled
|
||||||
|
at 23:21:49 / 23:22:50 / 23:23:50 / 23:24:50 -- exactly the manifest's 60s,
|
||||||
|
never the configured 15.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
from src.plugin_system.plugin_state import PluginState
|
||||||
|
|
||||||
|
|
||||||
|
class _StateManager:
|
||||||
|
"""Enough of the real state machine for reservation to behave."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.states = {}
|
||||||
|
|
||||||
|
def can_execute(self, plugin_id):
|
||||||
|
return self.states.get(plugin_id, PluginState.ENABLED) != PluginState.RUNNING
|
||||||
|
|
||||||
|
def set_state(self, plugin_id, state):
|
||||||
|
self.states[plugin_id] = state
|
||||||
|
|
||||||
|
|
||||||
|
class _Plugin:
|
||||||
|
"""A sports plugin: fast while something is live, quiet otherwise."""
|
||||||
|
|
||||||
|
enabled = True
|
||||||
|
|
||||||
|
def __init__(self, live_interval=15):
|
||||||
|
self.live = False
|
||||||
|
self._live_interval = live_interval
|
||||||
|
|
||||||
|
def get_update_interval(self):
|
||||||
|
return self._live_interval if self.live else None
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scheduler():
|
||||||
|
pm = PluginManager.__new__(PluginManager)
|
||||||
|
import threading
|
||||||
|
pm._update_interval_cache = {}
|
||||||
|
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||||
|
pm.config_manager = None
|
||||||
|
pm.logger = MagicMock()
|
||||||
|
pm.state_manager = _StateManager()
|
||||||
|
pm.health_tracker = None
|
||||||
|
pm._synchronous_updates = False
|
||||||
|
pm._reservation_lock = threading.Lock()
|
||||||
|
pm._plugin_last_update_lock = threading.Lock()
|
||||||
|
pm.plugin_last_update = {}
|
||||||
|
pm.dispatched = []
|
||||||
|
|
||||||
|
# Stand in for the background worker: record the dispatch and complete it,
|
||||||
|
# so the next tick can reserve the plugin again.
|
||||||
|
def _enqueue(plugin_id, scheduled_time):
|
||||||
|
pm.dispatched.append(scheduled_time)
|
||||||
|
pm.plugin_last_update[plugin_id] = scheduled_time
|
||||||
|
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||||
|
|
||||||
|
pm._enqueue_update = _enqueue
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
def _run_for(pm, plugin, seconds, start=1_000_000.0, step=1.0):
|
||||||
|
"""Tick the real scheduler once a second for `seconds`."""
|
||||||
|
pm.plugins = {"sports": plugin}
|
||||||
|
t = start
|
||||||
|
end = start + seconds
|
||||||
|
while t < end:
|
||||||
|
pm.run_scheduled_updates(current_time=t)
|
||||||
|
t += step
|
||||||
|
return len(pm.dispatched)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheReportedBug:
|
||||||
|
def test_a_live_game_is_polled_at_the_live_interval(self, scheduler):
|
||||||
|
"""15s while live, not the manifest's 60s. This is the fix."""
|
||||||
|
plugin = _Plugin(live_interval=15)
|
||||||
|
plugin.live = True
|
||||||
|
count = _run_for(scheduler, plugin, seconds=600) # ten minutes
|
||||||
|
# 600s / 15s = 40, allowing one for the first tick's free run.
|
||||||
|
assert 38 <= count <= 41, (
|
||||||
|
f"{count} updates in 10 minutes of a live game; expected ~40 at 15s. "
|
||||||
|
f"At the manifest's 60s it would be ~10 -- the reported bug.")
|
||||||
|
|
||||||
|
def test_the_same_window_without_the_hook_is_the_old_behaviour(self, scheduler):
|
||||||
|
"""Pin what the bug actually looked like, so the contrast is asserted."""
|
||||||
|
plugin = _Plugin()
|
||||||
|
plugin.live = True
|
||||||
|
plugin.get_update_interval = lambda: None # pre-fix: no opinion
|
||||||
|
count = _run_for(scheduler, plugin, seconds=600)
|
||||||
|
assert 9 <= count <= 11, (
|
||||||
|
f"{count} updates in 10 minutes; expected ~10 at the manifest's 60s")
|
||||||
|
|
||||||
|
def test_idle_polling_is_unchanged(self, scheduler):
|
||||||
|
"""The regression that would be worse than the bug.
|
||||||
|
|
||||||
|
Asking for 15s year-round would poll ESPN four times a minute all
|
||||||
|
summer. Idle must stay exactly on the manifest.
|
||||||
|
"""
|
||||||
|
plugin = _Plugin()
|
||||||
|
plugin.live = False
|
||||||
|
count = _run_for(scheduler, plugin, seconds=3600) # a full hour idle
|
||||||
|
assert 59 <= count <= 61, (
|
||||||
|
f"{count} updates in an idle hour; expected ~60 at the manifest's 60s")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheCadenceTracksTheGame:
|
||||||
|
def test_it_speeds_up_when_a_game_starts_and_slows_when_it_ends(self, scheduler):
|
||||||
|
"""No reload, no restart -- the whole point of a per-tick hook."""
|
||||||
|
plugin = _Plugin(live_interval=15)
|
||||||
|
pm = scheduler
|
||||||
|
|
||||||
|
_run_for(pm, plugin, seconds=300, start=1_000_000.0)
|
||||||
|
idle_before = len(pm.dispatched)
|
||||||
|
|
||||||
|
plugin.live = True
|
||||||
|
_run_for(pm, plugin, seconds=300, start=1_000_300.0)
|
||||||
|
during = len(pm.dispatched) - idle_before
|
||||||
|
|
||||||
|
plugin.live = False
|
||||||
|
_run_for(pm, plugin, seconds=300, start=1_000_600.0)
|
||||||
|
idle_after = len(pm.dispatched) - idle_before - during
|
||||||
|
|
||||||
|
assert during > idle_before * 3, (
|
||||||
|
f"live window got {during} updates vs {idle_before} idle; "
|
||||||
|
"the hook did not speed anything up")
|
||||||
|
assert idle_after <= idle_before + 1, (
|
||||||
|
f"{idle_after} updates after the game ended vs {idle_before} before; "
|
||||||
|
"the fast cadence leaked past the live window")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_hook_does_not_stop_the_plugin_updating(scheduler):
|
||||||
|
"""A scheduler that propagates one plugin's bug stops every other plugin."""
|
||||||
|
plugin = _Plugin()
|
||||||
|
plugin.get_update_interval = MagicMock(side_effect=RuntimeError("boom"))
|
||||||
|
count = _run_for(scheduler, plugin, seconds=600)
|
||||||
|
assert 9 <= count <= 11, (
|
||||||
|
f"{count} updates; a raising hook should fall back to the manifest's 60s")
|
||||||
@@ -24,7 +24,8 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from src.common.logo_helper import MAX_LOGO_BYTES, LogoHelper
|
from src.common.logo_helper import (MAX_LOGO_BYTES, MISSING_LOGO_RECHECK_SECONDS,
|
||||||
|
LogoHelper)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -494,3 +495,70 @@ class TestStalePlaceholderHandling:
|
|||||||
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||||
|
|
||||||
assert should_attempt_download(path) is False
|
assert should_attempt_download(path) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingLogosAreRememberedNotRewarned:
|
||||||
|
"""A file that is not there does not become there by being asked again.
|
||||||
|
|
||||||
|
load_logo() stat'd the path and logged a WARNING on every call, so a
|
||||||
|
permanently absent logo produced one warning per rotation for as long as
|
||||||
|
the process ran -- measured at 114 lines in 24 hours for a single missing
|
||||||
|
ticker icon. The positive cache never covered this because a miss returns
|
||||||
|
None and caches nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_missing_logo_warns_once(self, helper, tmp_path, caplog):
|
||||||
|
absent = tmp_path / "VOO.png"
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
for _ in range(50):
|
||||||
|
assert helper.load_logo("VOO", absent) is None
|
||||||
|
assert caplog.text.count("Logo not found for VOO") == 1, (
|
||||||
|
f"warned {caplog.text.count('Logo not found for VOO')} times in 50 calls")
|
||||||
|
|
||||||
|
def test_the_miss_is_not_remembered_forever(self, helper, tmp_path, monkeypatch):
|
||||||
|
"""A logo written later -- logo_downloader does this at runtime -- must
|
||||||
|
still be picked up without a restart."""
|
||||||
|
path = tmp_path / "LATE.png"
|
||||||
|
assert helper.load_logo("LATE", path) is None
|
||||||
|
|
||||||
|
write_logo(path)
|
||||||
|
# Still inside the recheck window: the remembered miss stands.
|
||||||
|
assert helper.load_logo("LATE", path) is None
|
||||||
|
|
||||||
|
# Once it ages out, the next call stats the disk again and finds it.
|
||||||
|
for key in helper._missing_logos:
|
||||||
|
helper._missing_logos[key] -= MISSING_LOGO_RECHECK_SECONDS + 1
|
||||||
|
assert helper.load_logo("LATE", path) is not None
|
||||||
|
|
||||||
|
def test_a_download_clears_the_miss_immediately(self, helper, tmp_path):
|
||||||
|
"""load_logo_with_download() must not be defeated by its own miss record.
|
||||||
|
|
||||||
|
load_logo() consults _missing_logos before it stats the disk, so a
|
||||||
|
logo we just downloaded would stay invisible for the whole recheck
|
||||||
|
window without the explicit invalidation.
|
||||||
|
"""
|
||||||
|
path = tmp_path / "NEW.png"
|
||||||
|
assert helper.load_logo("NEW", path) is None
|
||||||
|
assert any(k.startswith("NEW_") for k in helper._missing_logos)
|
||||||
|
|
||||||
|
def _fake_download(url, file_path):
|
||||||
|
write_logo(Path(file_path))
|
||||||
|
|
||||||
|
with patch.object(helper, "_download_logo", side_effect=_fake_download):
|
||||||
|
got = helper.load_logo_with_download("NEW", path, logo_url="http://x/NEW.png")
|
||||||
|
assert got is not None, "a freshly downloaded logo was hidden by the miss record"
|
||||||
|
assert not any(k.startswith("NEW_") for k in helper._missing_logos)
|
||||||
|
|
||||||
|
def test_clear_cache_forgets_misses_too(self, helper, tmp_path):
|
||||||
|
helper.load_logo("GONE", tmp_path / "GONE.png")
|
||||||
|
assert helper._missing_logos
|
||||||
|
helper.clear_cache()
|
||||||
|
assert helper._missing_logos == {}
|
||||||
|
|
||||||
|
def test_a_present_logo_is_unaffected(self, helper, tmp_path, caplog):
|
||||||
|
path = write_logo(tmp_path / "OK.png")
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
for _ in range(10):
|
||||||
|
assert helper.load_logo("OK", path) is not None
|
||||||
|
assert "Logo not found" not in caplog.text
|
||||||
|
assert helper._missing_logos == {}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""The MQTT bridge settings endpoint must reject bodies it cannot apply.
|
||||||
|
|
||||||
|
Two defects CodeRabbit raised on #554, both of which reported success while
|
||||||
|
doing something other than what the caller asked:
|
||||||
|
|
||||||
|
* `request.get_json(silent=True) or {}` turned a missing or unparseable body --
|
||||||
|
and the JSON literals `null`, `[]` and `false` -- into an empty dict, which
|
||||||
|
then satisfied the `isinstance(data, dict)` guard directly below it. Malformed
|
||||||
|
JSON therefore returned 200 having applied nothing.
|
||||||
|
|
||||||
|
* `if data.get('clear_password'):` accepted any truthy value. The string
|
||||||
|
"false" is truthy in Python, so a client echoing the field back as a string
|
||||||
|
wiped a stored password it meant to keep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
import web_interface.blueprints.api_v3 as pkg # noqa: E402
|
||||||
|
import web_interface.blueprints.api_v3.misc as misc # noqa: E402
|
||||||
|
|
||||||
|
URL = "/api/v3/integrations/mqtt-bridge/config"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "bridge_config.json"
|
||||||
|
# Both modules: misc.py writes through its own imported copies of these
|
||||||
|
# names, while _read_mqtt_bridge_config() lives in the package __init__ and
|
||||||
|
# reads the one bound there. Patching only one leaves the read and the
|
||||||
|
# write pointing at different files.
|
||||||
|
for mod in (misc, pkg):
|
||||||
|
monkeypatch.setattr(mod, "_MQTT_BRIDGE_CONFIG", cfg, raising=False)
|
||||||
|
monkeypatch.setattr(mod, "_MQTT_BRIDGE_DIR", tmp_path, raising=False)
|
||||||
|
monkeypatch.setattr(api_v3, "config_manager", MagicMock(), raising=False)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
app.cfg_path = cfg
|
||||||
|
return app.test_client(), cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestABodyItCannotApplyIsRejected:
|
||||||
|
@pytest.mark.parametrize("raw", ["{ not json", "null", "[]", "false", '"a string"'])
|
||||||
|
def test_unusable_bodies_are_rejected(self, client, raw):
|
||||||
|
c, _ = client
|
||||||
|
r = c.put(URL, data=raw, content_type="application/json")
|
||||||
|
# The regression: every one of these returned 200 having applied nothing.
|
||||||
|
assert r.status_code == 400, f"{raw!r} was accepted"
|
||||||
|
assert "JSON object" in r.get_json()["message"]
|
||||||
|
|
||||||
|
def test_a_missing_body_is_rejected(self, client):
|
||||||
|
c, _ = client
|
||||||
|
assert c.put(URL).status_code == 400
|
||||||
|
|
||||||
|
def test_a_real_object_is_still_accepted(self, client):
|
||||||
|
c, _ = client
|
||||||
|
r = c.put(URL, json={"mqtt_host": "192.168.1.20"})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearPasswordNeedsARealBoolean:
|
||||||
|
def _seed(self, cfg, password="hunter2"):
|
||||||
|
# TLS on so these cases exercise clear_password alone: a stored password
|
||||||
|
# with TLS off is refused by the CWE-319 guard, which is a separate test.
|
||||||
|
cfg.write_text(json.dumps({"mqtt_password": password, "mqtt_tls": True}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
def _stored(self, cfg):
|
||||||
|
return json.loads(cfg.read_text(encoding="utf-8")).get("mqtt_password")
|
||||||
|
|
||||||
|
def test_the_string_false_does_not_clear_it(self, client):
|
||||||
|
c, cfg = client
|
||||||
|
self._seed(cfg)
|
||||||
|
assert c.put(URL, json={"clear_password": "false"}).status_code == 200
|
||||||
|
# The regression: "false" is truthy, so this wiped the password.
|
||||||
|
assert self._stored(cfg) == "hunter2"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("falsy", [False, "no", "0", "", None])
|
||||||
|
def test_other_falsy_spellings_do_not_clear_it(self, client, falsy):
|
||||||
|
c, cfg = client
|
||||||
|
self._seed(cfg)
|
||||||
|
assert c.put(URL, json={"clear_password": falsy}).status_code == 200
|
||||||
|
assert self._stored(cfg) == "hunter2"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("truthy", [True, "true", "1", "yes", "on"])
|
||||||
|
def test_real_truthy_values_still_clear_it(self, client, truthy):
|
||||||
|
c, cfg = client
|
||||||
|
self._seed(cfg)
|
||||||
|
assert c.put(URL, json={"clear_password": truthy}).status_code == 200
|
||||||
|
assert self._stored(cfg) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleartextCredentialsNeedAnExplicitOptIn:
|
||||||
|
"""CWE-319. A password with TLS off crosses the network in the clear.
|
||||||
|
|
||||||
|
Refused rather than forbidden: unencrypted MQTT on a trusted LAN is a normal
|
||||||
|
deliberate setup, so allow_insecure_mqtt is the explicit acknowledgement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_password_without_tls_is_refused(self, client):
|
||||||
|
c, _ = client
|
||||||
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "allow_insecure_mqtt" in r.get_json()["message"]
|
||||||
|
|
||||||
|
def test_the_opt_in_allows_it(self, client):
|
||||||
|
c, cfg = client
|
||||||
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
||||||
|
"allow_insecure_mqtt": True})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
assert json.loads(cfg.read_text(encoding="utf-8"))["mqtt_password"] == "hunter2"
|
||||||
|
|
||||||
|
def test_tls_on_needs_no_opt_in(self, client):
|
||||||
|
c, _ = client
|
||||||
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": True})
|
||||||
|
assert r.status_code == 200, r.get_json()
|
||||||
|
|
||||||
|
def test_no_password_is_unaffected(self, client):
|
||||||
|
c, _ = client
|
||||||
|
assert c.put(URL, json={"mqtt_tls": False}).status_code == 200
|
||||||
|
|
||||||
|
def test_the_opt_in_is_a_real_boolean(self, client):
|
||||||
|
""""false" must not switch the guard off, same as clear_password."""
|
||||||
|
c, _ = client
|
||||||
|
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
||||||
|
"allow_insecure_mqtt": "false"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""pages_v3 must refuse an id that is not a plain name, not truncate it.
|
||||||
|
|
||||||
|
Both partial loaders used to run the id through ``os.path.basename`` and carry
|
||||||
|
on with what came out, so "../weather" rendered the config form for "weather".
|
||||||
|
Nothing escaped the plugins directory -- the containment guards held -- but the
|
||||||
|
handler answered a request nobody made, and validating one string while the
|
||||||
|
filesystem sees another is the shape both live traversals in this branch had.
|
||||||
|
|
||||||
|
These tests are about that: a rejected id gets a 400, and a real one still
|
||||||
|
renders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
BACKSLASH = chr(92)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pages(tmp_path):
|
||||||
|
"""pages_v3 with a plugins directory holding one real plugin."""
|
||||||
|
from web_interface.blueprints import pages_v3 as module
|
||||||
|
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
(plugins_dir / "weather" / "web_ui").mkdir(parents=True)
|
||||||
|
(plugins_dir / "weather" / "config_schema.json").write_text(
|
||||||
|
'{"type": "object", "properties": {"enabled": {"type": "boolean"}}}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(plugins_dir / "weather" / "manifest.json").write_text(
|
||||||
|
'{"name": "Weather", "version": "1.0.0"}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
(plugins_dir / "weather" / "web_ui" / "panel.html").write_text(
|
||||||
|
"<p>panel</p>", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
original_pm = getattr(module.pages_v3, "plugin_manager", None)
|
||||||
|
original_cm = getattr(module.pages_v3, "config_manager", None)
|
||||||
|
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.plugins_dir = plugins_dir
|
||||||
|
plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}
|
||||||
|
plugin_manager.get_plugin.return_value = None
|
||||||
|
module.pages_v3.plugin_manager = plugin_manager
|
||||||
|
module.pages_v3.config_manager = MagicMock(load_config=lambda: {})
|
||||||
|
|
||||||
|
yield module, plugins_dir
|
||||||
|
|
||||||
|
module.pages_v3.plugin_manager = original_pm
|
||||||
|
module.pages_v3.config_manager = original_cm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plugin_id", [
|
||||||
|
"../weather", "..", ".", "", None, "a/b", "x" + BACKSLASH + "y",
|
||||||
|
])
|
||||||
|
def test_a_plugin_id_that_is_not_a_plain_name_is_a_400(pages, plugin_id):
|
||||||
|
module, _ = pages
|
||||||
|
body, status = module._load_plugin_config_partial(plugin_id)
|
||||||
|
assert status == 400
|
||||||
|
assert "Invalid plugin ID" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_traversing_id_no_longer_renders_the_truncated_one(pages):
|
||||||
|
""""../weather" used to render "weather"'s form. It must not."""
|
||||||
|
module, _ = pages
|
||||||
|
body, status = module._load_plugin_config_partial("../weather")
|
||||||
|
assert status == 400
|
||||||
|
assert "Weather" not in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("app_id", ["../demo", "..", "a/b", "", None])
|
||||||
|
def test_a_starlark_app_id_that_is_not_a_plain_name_is_a_400(pages, app_id):
|
||||||
|
module, _ = pages
|
||||||
|
body, status = module._load_starlark_config_partial(app_id)
|
||||||
|
assert status == 400
|
||||||
|
assert "Invalid app ID" in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestServePluginWebUi:
|
||||||
|
"""GET /plugin-ui/<plugin_id>/web-ui/<path:filename>"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, pages):
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
module, _ = pages
|
||||||
|
base = Path(module.__file__).resolve().parent.parent
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"),
|
||||||
|
)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(module.pages_v3, url_prefix="")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_a_real_fragment_is_still_wrapped_and_served(self, client):
|
||||||
|
response = client.get("/plugin-ui/weather/web-ui/panel.html")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_data(as_text=True)
|
||||||
|
assert "<p>panel</p>" in body
|
||||||
|
assert 'window.PLUGIN_ID = "weather"' in body
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"/plugin-ui/../web-ui/panel.html",
|
||||||
|
"/plugin-ui/%2e%2e/web-ui/panel.html",
|
||||||
|
"/plugin-ui/weather/web-ui/../../manifest.json",
|
||||||
|
"/plugin-ui/weather/web-ui/..%2f..%2fmanifest.json",
|
||||||
|
])
|
||||||
|
def test_traversal_attempts_are_refused(self, client, url):
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code in (400, 403, 404)
|
||||||
|
assert "version" not in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_a_non_html_filename_is_still_refused(self, client):
|
||||||
|
# The allowlist predates this change and must survive it.
|
||||||
|
response = client.get("/plugin-ui/weather/web-ui/manifest.json")
|
||||||
|
assert response.status_code == 400
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""Request-supplied names must not be able to name a file outside their base.
|
||||||
|
|
||||||
|
Three of these were live. Each test that stands in for one says so, and
|
||||||
|
asserts on the *filesystem* -- that the file outside the base is still there,
|
||||||
|
or was never read -- rather than only on the status code, because a handler
|
||||||
|
that returns 403 and deletes the file anyway would pass the weaker check.
|
||||||
|
|
||||||
|
The rest pin the shared helper in src/common/path_safety.py that the handlers
|
||||||
|
now go through, so a future handler gets the same behaviour by using it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.common.path_safety import ( # noqa: E402
|
||||||
|
resolve_under,
|
||||||
|
safe_path_component,
|
||||||
|
safe_relative_parts,
|
||||||
|
)
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
BACKSLASH = chr(92)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafePathComponent:
|
||||||
|
"""One segment, or nothing."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [
|
||||||
|
"plugin", "ledmatrix-of-the-day", "weather_data", "manifest.json",
|
||||||
|
"a.b.c", "UPPER", "with-dash", "with_underscore", "9lives",
|
||||||
|
])
|
||||||
|
def test_ordinary_names_pass_through_unchanged(self, value):
|
||||||
|
assert safe_path_component(value) == value
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [
|
||||||
|
"..", ".", "", "../etc", "a/b", "/abs", "etc/passwd",
|
||||||
|
"a" + BACKSLASH + "b", "C:" + BACKSLASH + "x", "C:",
|
||||||
|
"a\x00b", None, 123, b"bytes", ["list"],
|
||||||
|
])
|
||||||
|
def test_anything_that_could_name_another_directory_is_refused(self, value):
|
||||||
|
assert safe_path_component(value) is None
|
||||||
|
|
||||||
|
def test_it_returns_the_value_rather_than_a_verdict(self):
|
||||||
|
# A boolean lets a caller validate one string and then open a
|
||||||
|
# different one. Returning the checked value is what stops that.
|
||||||
|
assert safe_path_component("ok") == "ok"
|
||||||
|
assert safe_path_component("../ok") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeRelativeParts:
|
||||||
|
def test_a_multi_segment_path_splits(self):
|
||||||
|
assert safe_relative_parts("web_ui/index.html") == ["web_ui", "index.html"]
|
||||||
|
|
||||||
|
def test_empty_segments_are_dropped_not_rejected(self):
|
||||||
|
assert safe_relative_parts("a//b") == ["a", "b"]
|
||||||
|
assert safe_relative_parts("x/") == ["x"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [
|
||||||
|
"../x", "a/../../etc", "/etc/passwd", BACKSLASH + "etc",
|
||||||
|
"a" + BACKSLASH + ".." + BACKSLASH + "b", "", None,
|
||||||
|
])
|
||||||
|
def test_traversal_and_absolute_paths_are_refused(self, value):
|
||||||
|
assert safe_relative_parts(value) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveUnder:
|
||||||
|
def test_a_path_inside_the_base_resolves(self, tmp_path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "f.txt").write_text("x", encoding="utf-8")
|
||||||
|
resolved = resolve_under(tmp_path, "sub", "f.txt")
|
||||||
|
assert resolved == (tmp_path / "sub" / "f.txt").resolve()
|
||||||
|
|
||||||
|
def test_a_path_that_would_escape_is_refused(self, tmp_path):
|
||||||
|
assert resolve_under(tmp_path, "..") is None
|
||||||
|
assert resolve_under(tmp_path, "..", "etc") is None
|
||||||
|
assert resolve_under(tmp_path, "/etc/passwd") is None
|
||||||
|
|
||||||
|
def test_a_sibling_sharing_a_prefix_is_refused(self, tmp_path):
|
||||||
|
# The bug the old `str(x).startswith(str(base))` checks had:
|
||||||
|
# "<root>/foo-evil" starts with "<root>/foo" as a string, but is not
|
||||||
|
# inside it as a directory.
|
||||||
|
(tmp_path / "foo").mkdir()
|
||||||
|
(tmp_path / "foo-evil").mkdir()
|
||||||
|
(tmp_path / "foo-evil" / "secret").write_text("s", encoding="utf-8")
|
||||||
|
assert resolve_under(tmp_path / "foo", "..", "foo-evil", "secret") is None
|
||||||
|
# And the string check it replaces would have said yes:
|
||||||
|
naive = (tmp_path / "foo" / ".." / "foo-evil" / "secret").resolve()
|
||||||
|
assert str(naive).startswith(str((tmp_path / "foo").resolve()))
|
||||||
|
|
||||||
|
def test_it_returns_none_rather_than_raising_on_junk(self, tmp_path):
|
||||||
|
assert resolve_under(tmp_path, None) is None
|
||||||
|
assert resolve_under(tmp_path, 42) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugins_tree(tmp_path):
|
||||||
|
"""A plugins directory with one plugin, plus a secret outside it."""
|
||||||
|
plugins = tmp_path / "plugin-repos"
|
||||||
|
(plugins / "demo" / "web_ui").mkdir(parents=True)
|
||||||
|
(plugins / "demo" / "web_ui" / "index.html").write_text("<p>hi</p>", encoding="utf-8")
|
||||||
|
(plugins / "demo-evil").mkdir()
|
||||||
|
(plugins / "demo-evil" / "stolen.json").write_text('{"a":1}', encoding="utf-8")
|
||||||
|
secrets = tmp_path / "config"
|
||||||
|
secrets.mkdir()
|
||||||
|
(secrets / "config_secrets.json").write_text('{"api_key":"hunter2"}', encoding="utf-8")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestServePluginStatic:
|
||||||
|
"""GET /api/v3/plugins/<plugin_id>/static/<path:file_path>
|
||||||
|
|
||||||
|
This handler read any file whose resolved path *string-prefixed* the
|
||||||
|
plugin directory. Two ways through:
|
||||||
|
|
||||||
|
* plugin_id of "..", because Flask's default converter forbids a slash
|
||||||
|
but not dots, and get_plugin_directory returned the parent of the
|
||||||
|
plugins directory since it exists. Every file under the project then
|
||||||
|
prefixed that directory, config/config_secrets.json included.
|
||||||
|
* a sibling directory sharing a prefix, per the helper test above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _wire(self, api_v3_module, plugins_tree):
|
||||||
|
plugins = plugins_tree / "plugin-repos"
|
||||||
|
|
||||||
|
def get_plugin_directory(plugin_id):
|
||||||
|
candidate = plugins / plugin_id
|
||||||
|
return str(candidate) if candidate.exists() else None
|
||||||
|
|
||||||
|
api_v3_module.api_v3.plugin_manager = MagicMock()
|
||||||
|
api_v3_module.api_v3.plugin_manager.get_plugin_directory = get_plugin_directory
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def test_a_real_plugin_file_is_still_served(
|
||||||
|
self, api_v3_client, api_v3_module, plugins_tree
|
||||||
|
):
|
||||||
|
self._wire(api_v3_module, plugins_tree)
|
||||||
|
response = api_v3_client.get("/api/v3/plugins/demo/static/web_ui/index.html")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"<p>hi</p>" in response.data
|
||||||
|
|
||||||
|
def test_a_dotdot_plugin_id_cannot_reach_the_secrets_file(
|
||||||
|
self, api_v3_client, api_v3_module, plugins_tree
|
||||||
|
):
|
||||||
|
self._wire(api_v3_module, plugins_tree)
|
||||||
|
response = api_v3_client.get(
|
||||||
|
"/api/v3/plugins/../static/config/config_secrets.json"
|
||||||
|
)
|
||||||
|
assert response.status_code in (400, 403, 404)
|
||||||
|
assert b"hunter2" not in response.data
|
||||||
|
|
||||||
|
def test_a_percent_encoded_dotdot_plugin_id_is_refused_too(
|
||||||
|
self, api_v3_client, api_v3_module, plugins_tree
|
||||||
|
):
|
||||||
|
self._wire(api_v3_module, plugins_tree)
|
||||||
|
response = api_v3_client.get(
|
||||||
|
"/api/v3/plugins/%2e%2e/static/config/config_secrets.json"
|
||||||
|
)
|
||||||
|
assert response.status_code in (400, 403, 404)
|
||||||
|
assert b"hunter2" not in response.data
|
||||||
|
|
||||||
|
def test_a_sibling_directory_sharing_a_prefix_is_refused(
|
||||||
|
self, api_v3_client, api_v3_module, plugins_tree
|
||||||
|
):
|
||||||
|
self._wire(api_v3_module, plugins_tree)
|
||||||
|
response = api_v3_client.get(
|
||||||
|
"/api/v3/plugins/demo/static/../demo-evil/stolen.json"
|
||||||
|
)
|
||||||
|
assert response.status_code in (400, 403, 404)
|
||||||
|
assert b'"a"' not in response.data
|
||||||
|
|
||||||
|
def test_an_unknown_plugin_is_still_a_404(
|
||||||
|
self, api_v3_client, api_v3_module, plugins_tree
|
||||||
|
):
|
||||||
|
self._wire(api_v3_module, plugins_tree)
|
||||||
|
response = api_v3_client.get("/api/v3/plugins/nope/static/x.json")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOfTheDayJson:
|
||||||
|
"""POST /api/v3/plugins/of-the-day/json/delete
|
||||||
|
|
||||||
|
file_id came from the request body and was interpolated into
|
||||||
|
``f"{file_id}.json"`` and then unlinked, with no validation at all. A
|
||||||
|
file_id of "../../../../etc/something" deleted that file. This is the one
|
||||||
|
finding in the batch that destroyed data rather than exposing it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_tree(self, tmp_path, api_v3_module):
|
||||||
|
plugin_dir = tmp_path / "plugin-repos" / "ledmatrix-of-the-day"
|
||||||
|
(plugin_dir / "of_the_day").mkdir(parents=True)
|
||||||
|
(plugin_dir / "of_the_day" / "quotes.json").write_text("{}", encoding="utf-8")
|
||||||
|
outside = tmp_path / "victim.json"
|
||||||
|
outside.write_text("important", encoding="utf-8")
|
||||||
|
|
||||||
|
api_v3_module.api_v3.plugin_manager = MagicMock()
|
||||||
|
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
|
return plugin_dir, outside
|
||||||
|
|
||||||
|
URL = "/api/v3/plugins/of-the-day/json/delete"
|
||||||
|
|
||||||
|
def test_a_real_file_in_the_plugin_is_still_deleted(
|
||||||
|
self, api_v3_client, plugin_tree
|
||||||
|
):
|
||||||
|
plugin_dir, _ = plugin_tree
|
||||||
|
target = plugin_dir / "of_the_day" / "quotes.json"
|
||||||
|
response = api_v3_client.post(self.URL, json={"file_id": "quotes"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
def test_a_traversing_file_id_deletes_nothing(self, api_v3_client, plugin_tree):
|
||||||
|
_, outside = plugin_tree
|
||||||
|
response = api_v3_client.post(
|
||||||
|
self.URL, json={"file_id": "../../../victim"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert outside.exists(), "file outside the plugin directory was deleted"
|
||||||
|
assert outside.read_text(encoding="utf-8") == "important"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("file_id", ["..", "a/b", "/etc/x", "x" + BACKSLASH + "y"])
|
||||||
|
def test_other_shapes_of_traversal_are_refused(
|
||||||
|
self, api_v3_client, plugin_tree, file_id
|
||||||
|
):
|
||||||
|
_, outside = plugin_tree
|
||||||
|
response = api_v3_client.post(self.URL, json={"file_id": file_id})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert outside.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiskCacheKeys:
|
||||||
|
"""The cache key becomes a filename, and POST /api/v3/cache/delete passes
|
||||||
|
the request body's key straight through CacheManager.clear_cache to
|
||||||
|
os.remove. A key of "../../../../etc/whatever" named a file well outside
|
||||||
|
the cache directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache(self, tmp_path):
|
||||||
|
from src.cache.disk_cache import DiskCache
|
||||||
|
|
||||||
|
cache_dir = tmp_path / "cache"
|
||||||
|
cache_dir.mkdir()
|
||||||
|
return DiskCache(str(cache_dir)), cache_dir
|
||||||
|
|
||||||
|
def test_an_ordinary_key_still_round_trips(self, cache):
|
||||||
|
disk_cache, cache_dir = cache
|
||||||
|
disk_cache.set("espn_nfl_2024", {"ok": True})
|
||||||
|
assert (cache_dir / "espn_nfl_2024.json").exists()
|
||||||
|
assert disk_cache.get("espn_nfl_2024", max_age=None)["ok"] is True
|
||||||
|
|
||||||
|
def test_a_traversing_key_has_no_path(self, cache):
|
||||||
|
disk_cache, _ = cache
|
||||||
|
assert disk_cache.get_cache_path("../../victim") is None
|
||||||
|
assert disk_cache.get_cache_path("..") is None
|
||||||
|
assert disk_cache.get_cache_path("a/b") is None
|
||||||
|
|
||||||
|
def test_clearing_a_traversing_key_deletes_nothing(self, cache, tmp_path):
|
||||||
|
disk_cache, _ = cache
|
||||||
|
victim = tmp_path / "victim.json"
|
||||||
|
victim.write_text("important", encoding="utf-8")
|
||||||
|
disk_cache.clear("../victim")
|
||||||
|
assert victim.exists(), "file outside the cache directory was deleted"
|
||||||
|
|
||||||
|
def test_writing_a_traversing_key_creates_nothing(self, cache, tmp_path):
|
||||||
|
disk_cache, _ = cache
|
||||||
|
disk_cache.set("../escaped", {"x": 1})
|
||||||
|
assert not (tmp_path / "escaped.json").exists()
|
||||||
|
|
||||||
|
def test_the_delete_endpoint_says_no_rather_than_claiming_success(
|
||||||
|
self, api_v3_client, api_v3_module
|
||||||
|
):
|
||||||
|
api_v3_module.api_v3.cache_manager = MagicMock()
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/cache/delete", json={"key": "../../etc/passwd"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
api_v3_module.api_v3.cache_manager.clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
def test_the_delete_endpoint_still_deletes_an_ordinary_key(
|
||||||
|
self, api_v3_client, api_v3_module
|
||||||
|
):
|
||||||
|
api_v3_module.api_v3.cache_manager = MagicMock()
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/cache/delete", json={"key": "espn_nfl_2024"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
api_v3_module.api_v3.cache_manager.clear_cache.assert_called_once_with(
|
||||||
|
"espn_nfl_2024"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginVersionLookup:
|
||||||
|
"""_get_plugin_version joins a request-supplied id onto the plugins
|
||||||
|
directory and opens manifest.json under it."""
|
||||||
|
|
||||||
|
def test_a_real_manifest_is_read(self, tmp_path):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
plugins = tmp_path / "plugin-repos"
|
||||||
|
(plugins / "demo").mkdir(parents=True)
|
||||||
|
(plugins / "demo" / "manifest.json").write_text(
|
||||||
|
json.dumps({"version": "1.2.3"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
original = getattr(module.api_v3, "plugin_store_manager", None)
|
||||||
|
module.api_v3.plugin_store_manager = MagicMock(plugins_dir=str(plugins))
|
||||||
|
try:
|
||||||
|
assert module._get_plugin_version("demo") == "1.2.3"
|
||||||
|
assert module._get_plugin_version("../demo") == ""
|
||||||
|
assert module._get_plugin_version("..") == ""
|
||||||
|
finally:
|
||||||
|
module.api_v3.plugin_store_manager = original
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""A plugin can ask to be polled faster while it has something live.
|
||||||
|
|
||||||
|
The manifest carries one static `update_interval`, and until now that was the
|
||||||
|
only thing the scheduler would look at. A sports scoreboard needs 15 seconds
|
||||||
|
while a game is in progress and 15 minutes when nothing is on, and no single
|
||||||
|
number expresses that.
|
||||||
|
|
||||||
|
Measured consequence, on a live rig during an NFL game in its fourth quarter:
|
||||||
|
football-scoreboard's manifest pins update_interval to 60, so ESPN was polled
|
||||||
|
exactly once a minute --
|
||||||
|
|
||||||
|
23:21:49 23:22:50 23:23:50 23:24:50 23:25:50
|
||||||
|
|
||||||
|
-- while the plugin's own live_update_interval said 15. The clock and score on
|
||||||
|
the panel therefore lagged by up to a minute during a two-minute drill, which
|
||||||
|
reads to a viewer as a frozen display.
|
||||||
|
|
||||||
|
get_update_interval() lets the plugin say what it needs per tick. These tests
|
||||||
|
pin the parts that are easy to get wrong: that the hook wins, that "no opinion"
|
||||||
|
falls back cleanly, that a broken hook cannot stop a plugin updating, and that
|
||||||
|
the hook is not accidentally cached (which would defeat the whole point).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager():
|
||||||
|
pm = PluginManager.__new__(PluginManager)
|
||||||
|
pm._update_interval_cache = {}
|
||||||
|
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||||
|
pm.config_manager = None
|
||||||
|
pm.logger = MagicMock()
|
||||||
|
return pm
|
||||||
|
|
||||||
|
|
||||||
|
class _Plugin:
|
||||||
|
def __init__(self, wants=None, raises=False):
|
||||||
|
self._wants = wants
|
||||||
|
self._raises = raises
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def get_update_interval(self):
|
||||||
|
self.calls += 1
|
||||||
|
if self._raises:
|
||||||
|
raise RuntimeError("plugin is broken")
|
||||||
|
return self._wants
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hook_overrides_the_manifest(manager):
|
||||||
|
"""The whole point: 15s while live, not the manifest's 60."""
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_means_no_opinion_and_the_manifest_applies(manager):
|
||||||
|
"""A plugin with nothing live should not have to restate the default."""
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=None)) == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plugin_without_the_hook_is_unaffected(manager):
|
||||||
|
"""Every existing plugin predates this and must keep its manifest value."""
|
||||||
|
assert manager._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_hook_is_consulted_every_time(manager):
|
||||||
|
"""Caching the answer would make it exactly as static as the manifest.
|
||||||
|
|
||||||
|
The static path *is* cached -- reading config is expensive -- so it would be
|
||||||
|
an easy mistake to let the dynamic answer ride along in that cache.
|
||||||
|
"""
|
||||||
|
plugin = _Plugin(wants=15)
|
||||||
|
for _ in range(5):
|
||||||
|
manager._get_plugin_update_interval("sports", plugin)
|
||||||
|
assert plugin.calls == 5, f"hook called {plugin.calls} times in 5 lookups"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_cadence_can_change_between_ticks(manager):
|
||||||
|
"""A game going final must slow the polling back down without a reload."""
|
||||||
|
plugin = _Plugin(wants=15)
|
||||||
|
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
|
||||||
|
plugin._wants = None # game ended
|
||||||
|
assert manager._get_plugin_update_interval("sports", plugin) == 60.0
|
||||||
|
plugin._wants = 15 # another game started
|
||||||
|
assert manager._get_plugin_update_interval("sports", plugin) == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestABrokenHookCannotStopUpdates:
|
||||||
|
"""A scheduler that propagates a plugin's bug stops every other plugin too."""
|
||||||
|
|
||||||
|
def test_a_raising_hook_falls_back_to_the_manifest(self, manager):
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(raises=True)) == 60.0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("junk", ["soon", object(), [], {}])
|
||||||
|
def test_a_non_numeric_hook_falls_back(self, manager, junk):
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("junk", [float("nan"), float("inf"), float("-inf")])
|
||||||
|
def test_nan_and_infinity_fall_back(self, manager, junk):
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("junk", [True, False])
|
||||||
|
def test_a_bool_hook_falls_back(self, manager, junk):
|
||||||
|
"""bool is a subclass of int, so it must be rejected before float()
|
||||||
|
turns it into 0.0/1.0 and the floor silently swallows the mistake."""
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=junk)) == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheFloor:
|
||||||
|
"""A plugin asking for 0 would be re-entered on every tick of the render
|
||||||
|
loop, which is a busy-wait against whatever API it fetches."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("wants", [0, 0.5, -10])
|
||||||
|
def test_small_and_negative_requests_are_clamped(self, manager, wants):
|
||||||
|
got = manager._get_plugin_update_interval("sports", _Plugin(wants=wants))
|
||||||
|
assert got == PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL
|
||||||
|
|
||||||
|
def test_a_reasonable_request_is_not_clamped(self, manager):
|
||||||
|
assert manager._get_plugin_update_interval("sports", _Plugin(wants=15)) == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_static_path_still_prefers_the_manifest_over_config():
|
||||||
|
"""Deliberately unchanged, and not an oversight.
|
||||||
|
|
||||||
|
Config values that have sat inert behind the manifest are stale by
|
||||||
|
definition -- nothing has been honouring them. On one rig, football and
|
||||||
|
baseball both carry update_interval 3600 in config against a manifest 60;
|
||||||
|
making config win would slow those plugins by 60x. The dynamic hook is the
|
||||||
|
supported way for a plugin to vary its own cadence, so flipping this is
|
||||||
|
both risky and no longer necessary.
|
||||||
|
"""
|
||||||
|
pm = PluginManager.__new__(PluginManager)
|
||||||
|
pm._update_interval_cache = {}
|
||||||
|
pm.plugin_manifests = {"sports": {"update_interval": 60}}
|
||||||
|
cfg = MagicMock()
|
||||||
|
cfg.get_config.return_value = {"sports": {"update_interval": 3600}}
|
||||||
|
pm.config_manager = cfg
|
||||||
|
pm.logger = MagicMock()
|
||||||
|
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_is_still_used_when_the_manifest_is_silent():
|
||||||
|
pm = PluginManager.__new__(PluginManager)
|
||||||
|
pm._update_interval_cache = {}
|
||||||
|
pm.plugin_manifests = {"sports": {}}
|
||||||
|
cfg = MagicMock()
|
||||||
|
cfg.get_config.return_value = {"sports": {"update_interval": 120}}
|
||||||
|
pm.config_manager = cfg
|
||||||
|
pm.logger = MagicMock()
|
||||||
|
assert pm._get_plugin_update_interval("sports", MagicMock(spec=[])) == 120.0
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Tests for plugin-state reconciliation reporting phantom inconsistencies.
|
||||||
|
|
||||||
|
Observed on a device running four installed, configured, working plugins: the
|
||||||
|
web UI showed "Stale plugin config entries found: football-scoreboard,
|
||||||
|
odds-ticker, data, ledmatrix-weather, starlark-apps. Remove them from
|
||||||
|
config.json" for hours. Three separate defects combined to produce that:
|
||||||
|
|
||||||
|
1. load_config() merges config_secrets.json into the config it returns, and the
|
||||||
|
ignore list named only 'github' and 'youtube'. A 'data' key in the secrets
|
||||||
|
file therefore became a phantom plugin, reported as in-config-not-on-disk.
|
||||||
|
|
||||||
|
2. The auto-fix for "on disk but not in config" assigned
|
||||||
|
``config[plugin_id] = {'enabled': False}`` unconditionally. When detection was
|
||||||
|
wrong, that traded a plugin's real configuration (4.9KB of league settings in
|
||||||
|
the reported case) for a stub. It only avoided firing there because the write
|
||||||
|
failed with EACCES.
|
||||||
|
|
||||||
|
3. The verdict is a snapshot written once per run and served to the UI from a
|
||||||
|
status file, and a run that fails to apply a fix also declines to retry. So a
|
||||||
|
condition that had since resolved was reported indefinitely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.state_reconciliation import (
|
||||||
|
InconsistencyType,
|
||||||
|
StateReconciliation,
|
||||||
|
still_unresolved,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfigManager:
|
||||||
|
"""Minimal config manager with the secrets path the real one exposes."""
|
||||||
|
|
||||||
|
def __init__(self, config, secrets_path=None):
|
||||||
|
self._config = config
|
||||||
|
self._secrets_path = secrets_path
|
||||||
|
self.saved = []
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return dict(self._config)
|
||||||
|
|
||||||
|
def save_config(self, config):
|
||||||
|
self.saved.append(config)
|
||||||
|
self._config = dict(config)
|
||||||
|
|
||||||
|
def get_secrets_path(self):
|
||||||
|
return self._secrets_path
|
||||||
|
|
||||||
|
|
||||||
|
def _reconciler(config, plugins_dir, secrets_path=None):
|
||||||
|
return StateReconciliation(
|
||||||
|
state_manager=Mock(),
|
||||||
|
config_manager=_ConfigManager(config, secrets_path),
|
||||||
|
plugin_manager=Mock(),
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecretsKeysAreNotPlugins:
|
||||||
|
def test_secrets_key_is_not_treated_as_a_plugin(self, tmp_path):
|
||||||
|
secrets = tmp_path / "config_secrets.json"
|
||||||
|
# The shape actually found on the device: display state written here.
|
||||||
|
secrets.write_text(json.dumps({"data": {"mode": "nfl_recent"},
|
||||||
|
"timestamp": 1.0}), encoding="utf-8")
|
||||||
|
config = {
|
||||||
|
"data": {"mode": "nfl_recent"},
|
||||||
|
"football-scoreboard": {"enabled": True, "nfl": {"favorite_teams": ["TB"]}},
|
||||||
|
}
|
||||||
|
state = _reconciler(config, tmp_path, secrets).\
|
||||||
|
_get_config_state()
|
||||||
|
|
||||||
|
assert "data" not in state, "secrets key leaked in as a phantom plugin"
|
||||||
|
assert "football-scoreboard" in state
|
||||||
|
|
||||||
|
def test_missing_secrets_file_is_survivable(self, tmp_path):
|
||||||
|
state = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
||||||
|
tmp_path / "does-not-exist.json")._get_config_state()
|
||||||
|
assert "odds-ticker" in state
|
||||||
|
|
||||||
|
def test_non_path_secrets_location_is_survivable(self, tmp_path):
|
||||||
|
"""Regression: a non-path return raised TypeError out of the helper,
|
||||||
|
which the broad handler in _get_config_state() swallowed as "Error
|
||||||
|
reading config state" -- emptying the config state and making every
|
||||||
|
downstream detection wrong."""
|
||||||
|
r = _reconciler({"odds-ticker": {"enabled": True}}, tmp_path,
|
||||||
|
secrets_path=Mock())
|
||||||
|
assert "odds-ticker" in r._get_config_state()
|
||||||
|
|
||||||
|
def test_config_manager_without_secrets_path_is_survivable(self, tmp_path):
|
||||||
|
r = StateReconciliation(state_manager=Mock(),
|
||||||
|
config_manager=Mock(spec=["load_config", "save_config"]),
|
||||||
|
plugin_manager=Mock(), plugins_dir=tmp_path)
|
||||||
|
r.config_manager.load_config.return_value = {"odds-ticker": {"enabled": True}}
|
||||||
|
assert "odds-ticker" in r._get_config_state()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFixNeverClobbersRealConfig:
|
||||||
|
def _missing_in_config(self, plugin_id):
|
||||||
|
inc = Mock()
|
||||||
|
inc.inconsistency_type = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
|
||||||
|
inc.plugin_id = plugin_id
|
||||||
|
return inc
|
||||||
|
|
||||||
|
def test_existing_entry_is_not_overwritten(self, tmp_path):
|
||||||
|
rich = {"enabled": True, "nfl": {"favorite_teams": ["TB"], "show_odds": True}}
|
||||||
|
r = _reconciler({"football-scoreboard": dict(rich)}, tmp_path)
|
||||||
|
|
||||||
|
assert r._fix_inconsistency(self._missing_in_config("football-scoreboard")) is True
|
||||||
|
# The regression: this wrote {'enabled': False} over the real settings.
|
||||||
|
assert r.config_manager.saved == []
|
||||||
|
assert r.config_manager.load_config()["football-scoreboard"] == rich
|
||||||
|
|
||||||
|
def test_genuinely_absent_plugin_is_still_added(self, tmp_path):
|
||||||
|
r = _reconciler({"other-plugin": {"enabled": True}}, tmp_path)
|
||||||
|
|
||||||
|
assert r._fix_inconsistency(self._missing_in_config("new-plugin")) is True
|
||||||
|
assert len(r.config_manager.saved) == 1
|
||||||
|
assert r.config_manager.load_config()["new-plugin"] == {"enabled": False}
|
||||||
|
# and it must not disturb what was already there
|
||||||
|
assert r.config_manager.load_config()["other-plugin"] == {"enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleFindingsAreDropped:
|
||||||
|
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value
|
||||||
|
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK.value
|
||||||
|
|
||||||
|
def test_resolved_missing_in_config_is_dropped(self):
|
||||||
|
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
||||||
|
assert still_unresolved(entries, {"football-scoreboard"}, set()) == []
|
||||||
|
|
||||||
|
def test_genuinely_missing_in_config_is_kept(self):
|
||||||
|
entries = [{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG}]
|
||||||
|
assert still_unresolved(entries, set(), set()) == entries
|
||||||
|
|
||||||
|
def test_resolved_missing_on_disk_is_dropped(self):
|
||||||
|
entries = [{"plugin_id": "odds-ticker", "type": self.ON_DISK}]
|
||||||
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
||||||
|
|
||||||
|
def test_genuinely_missing_on_disk_is_kept(self):
|
||||||
|
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
|
||||||
|
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
|
||||||
|
|
||||||
|
def test_unrecheckable_kinds_are_kept(self):
|
||||||
|
# Filtering must only ever remove what it can prove stale.
|
||||||
|
entries = [{"plugin_id": "x", "type": "plugin_version_mismatch"}]
|
||||||
|
assert still_unresolved(entries, set(), set()) == entries
|
||||||
|
|
||||||
|
def test_the_reported_device_state_clears_every_false_finding(self):
|
||||||
|
"""The exact verdict the device served, against its real state."""
|
||||||
|
entries = [
|
||||||
|
{"plugin_id": "football-scoreboard", "type": self.IN_CONFIG},
|
||||||
|
{"plugin_id": "odds-ticker", "type": self.IN_CONFIG},
|
||||||
|
{"plugin_id": "data", "type": self.ON_DISK},
|
||||||
|
{"plugin_id": "ledmatrix-weather", "type": self.IN_CONFIG},
|
||||||
|
{"plugin_id": "starlark-apps", "type": self.IN_CONFIG},
|
||||||
|
]
|
||||||
|
installed = {"football-scoreboard", "ledmatrix-weather", "odds-ticker",
|
||||||
|
"starlark-apps", "web-ui-info"}
|
||||||
|
live = still_unresolved(entries, installed, installed)
|
||||||
|
|
||||||
|
# All four "installed but not in config" findings were false and clear.
|
||||||
|
# 'data' legitimately is not installed, so this filter keeps it; it stops
|
||||||
|
# being reported because _get_config_state() no longer invents it from the
|
||||||
|
# secrets file, which takes effect on the next reconciliation run.
|
||||||
|
assert [e["plugin_id"] for e in live] == ["data"]
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""The reconciliation-status endpoint must re-check its stored verdict.
|
||||||
|
|
||||||
|
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
|
||||||
|
being reported: the verdict is a snapshot written once per run, and a run that
|
||||||
|
fails to apply a fix also declines to retry, so a device whose plugins were all
|
||||||
|
present in config kept being told for hours that four of them were missing.
|
||||||
|
|
||||||
|
That wiring had no test. Only the pure still_unresolved() helper in
|
||||||
|
src/plugin_system/ was covered, which lives outside web_interface and therefore
|
||||||
|
survived the api_v3 blueprint split untouched -- so when #553 moved this
|
||||||
|
endpoint into api_v3/plugins.py, losing the filter would have been completely
|
||||||
|
silent. This test exists so that cannot happen again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
|
||||||
|
IN_CONFIG = "plugin_missing_in_config"
|
||||||
|
ON_DISK = "plugin_missing_on_disk"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
"""App whose status file, config and plugins dir are all under our control."""
|
||||||
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
||||||
|
|
||||||
|
plugins_dir = tmp_path / "plugin-repos"
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
|
||||||
|
def _install(plugin_id):
|
||||||
|
d = plugins_dir / plugin_id
|
||||||
|
d.mkdir()
|
||||||
|
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
|
||||||
|
|
||||||
|
def _setup(verdict, config=None, installed=(), secrets=None, corrupt=()):
|
||||||
|
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
||||||
|
json.dumps(verdict), encoding="utf-8")
|
||||||
|
for pid in installed:
|
||||||
|
_install(pid)
|
||||||
|
for pid in corrupt:
|
||||||
|
d = plugins_dir / pid
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
(d / "manifest.json").write_text("{ this is not json", encoding="utf-8")
|
||||||
|
|
||||||
|
secrets_path = tmp_path / "config_secrets.json"
|
||||||
|
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
||||||
|
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.load_config.return_value = dict(config or {})
|
||||||
|
cm.get_secrets_path.return_value = str(secrets_path)
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.plugins_dir = str(plugins_dir)
|
||||||
|
# setattr via monkeypatch: these live on a module-level blueprint
|
||||||
|
# singleton, so assigning them directly leaks mocks -- pointing at a
|
||||||
|
# deleted tmp_path -- into every later test that imports api_v3.
|
||||||
|
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
|
||||||
|
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
return _setup
|
||||||
|
|
||||||
|
|
||||||
|
def _get(c):
|
||||||
|
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleFindingsAreDroppedByTheEndpoint:
|
||||||
|
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
|
||||||
|
config={"football-scoreboard": {"enabled": True}})
|
||||||
|
# The regression: this kept being reported for hours after it resolved.
|
||||||
|
assert _get(c)["unresolved"] == []
|
||||||
|
|
||||||
|
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
|
||||||
|
installed=["odds-ticker"])
|
||||||
|
assert _get(c)["unresolved"] == []
|
||||||
|
|
||||||
|
def test_a_finding_that_still_holds_is_kept(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
|
||||||
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
||||||
|
|
||||||
|
def test_the_reported_device_verdict_clears(self, client):
|
||||||
|
"""The five findings the live device served, against its real state."""
|
||||||
|
installed = ["football-scoreboard", "ledmatrix-weather",
|
||||||
|
"odds-ticker", "starlark-apps"]
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
|
||||||
|
config={p: {"enabled": True} for p in installed},
|
||||||
|
installed=installed)
|
||||||
|
assert _get(c)["unresolved"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheEndpointStaysRobust:
|
||||||
|
def test_an_empty_verdict_is_passed_through(self, client):
|
||||||
|
c = client({"done": True, "unresolved": []})
|
||||||
|
assert _get(c) == {"done": True, "unresolved": []}
|
||||||
|
|
||||||
|
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
|
||||||
|
"""Best-effort: a stale warning beats a failed endpoint."""
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
|
||||||
|
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
|
||||||
|
body = _get(c)
|
||||||
|
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
|
||||||
|
|
||||||
|
def test_a_run_still_in_progress_is_reported_as_such(self, client):
|
||||||
|
c = client({"done": False, "unresolved": []})
|
||||||
|
assert _get(c)["done"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheFilterUsesTheReconcilersOwnRules:
|
||||||
|
"""A looser definition of "in config" or "on disk" clears findings that are
|
||||||
|
still true. Both cases raised by CodeRabbit on #553."""
|
||||||
|
|
||||||
|
def test_a_secrets_key_does_not_clear_an_in_config_finding(self, client):
|
||||||
|
# load_config() merges the secrets file in, so 'data' appears in the
|
||||||
|
# config dict -- but it is not a plugin. A plain set(config) would treat
|
||||||
|
# it as one and clear this finding.
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "data", "type": IN_CONFIG}]},
|
||||||
|
config={"data": {"mode": "nfl_recent"}},
|
||||||
|
secrets={"data": {"mode": "nfl_recent"}})
|
||||||
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["data"]
|
||||||
|
|
||||||
|
def test_a_system_key_does_not_clear_an_in_config_finding(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "display", "type": IN_CONFIG}]},
|
||||||
|
config={"display": {"hardware": {}}})
|
||||||
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["display"]
|
||||||
|
|
||||||
|
def test_a_non_dict_value_does_not_clear_an_in_config_finding(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "timezone", "type": IN_CONFIG}]},
|
||||||
|
config={"timezone": "America/Chicago"})
|
||||||
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["timezone"]
|
||||||
|
|
||||||
|
def test_an_unparseable_manifest_does_not_count_as_installed(self, client):
|
||||||
|
# Otherwise a corrupt file clears a live "in config but not on disk"
|
||||||
|
# finding on the strength of something nothing can read.
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
||||||
|
corrupt=["broken-plugin"])
|
||||||
|
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
|
||||||
|
|
||||||
|
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
|
||||||
|
c = client({"done": True, "unresolved": [
|
||||||
|
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
||||||
|
installed=["weather.standalone-backup-20260101"])
|
||||||
|
assert len(_get(c)["unresolved"]) == 1
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
|
||||||
|
|
||||||
|
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
|
||||||
|
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
|
||||||
|
36KB on a 256x64 panel, more for wide wordmarks -- and
|
||||||
|
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
|
||||||
|
therefore held the whole league in memory: roughly 11-18MB per manager
|
||||||
|
instance, and a league runs three of them (live/recent/upcoming) each with its
|
||||||
|
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
|
||||||
|
|
||||||
|
The bound has to be LRU rather than "clear when full": the logos on screen
|
||||||
|
right now are exactly the ones that must not be thrown away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# src.base_classes.sports transitively imports the hardware matrix driver.
|
||||||
|
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||||
|
|
||||||
|
from src.base_classes.sports import SportsCore # noqa: E402
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
|
||||||
|
|
||||||
|
|
||||||
|
class _StubSports(SportsCore):
|
||||||
|
def _fetch_data(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_game_details(self, game_event):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def host(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.base_classes.sports.core.get_background_service",
|
||||||
|
lambda *args, **kwargs: MagicMock())
|
||||||
|
# Never reach for the network: every logo these tests ask for exists.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.base_classes.sports.core.download_missing_logo",
|
||||||
|
lambda *a, **k: None)
|
||||||
|
|
||||||
|
display_manager = MagicMock()
|
||||||
|
display_manager.matrix.width = 128
|
||||||
|
display_manager.matrix.height = 32
|
||||||
|
display_manager.width = 128
|
||||||
|
display_manager.height = 32
|
||||||
|
display_manager.image = Image.new("RGB", (128, 32))
|
||||||
|
cache_manager = MagicMock()
|
||||||
|
cache_manager.cache_dir = str(tmp_path)
|
||||||
|
instance = _StubSports({"timezone": "UTC"}, display_manager,
|
||||||
|
cache_manager, LOGGER, "nhl")
|
||||||
|
instance._logo_dir = tmp_path
|
||||||
|
return instance, tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load(host, abbrev):
|
||||||
|
"""Create a real logo file for `abbrev` and load it through the cache."""
|
||||||
|
instance, tmp_path = host
|
||||||
|
path = tmp_path / f"{abbrev}.png"
|
||||||
|
if not path.exists():
|
||||||
|
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
|
||||||
|
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheCacheIsBounded:
|
||||||
|
def test_it_never_exceeds_the_limit(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit + 40):
|
||||||
|
assert _load(host, f"T{i}") is not None
|
||||||
|
# The regression: this grew to limit + 40, and for NCAA to 307.
|
||||||
|
assert len(instance._logo_cache) == limit
|
||||||
|
|
||||||
|
def test_the_limit_is_smaller_than_a_real_league(self, host):
|
||||||
|
"""ncaa_logos ships 307 files; the cap has to be well under that."""
|
||||||
|
instance, _ = host
|
||||||
|
assert instance._LOGO_CACHE_MAX < 307
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvictionIsLRUNotArbitrary:
|
||||||
|
def test_the_least_recently_used_goes_first(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit):
|
||||||
|
_load(host, f"T{i}")
|
||||||
|
_load(host, "NEW")
|
||||||
|
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
|
||||||
|
assert "NEW" in instance._logo_cache
|
||||||
|
|
||||||
|
def test_touching_an_entry_saves_it(self, host):
|
||||||
|
instance, _ = host
|
||||||
|
limit = instance._LOGO_CACHE_MAX
|
||||||
|
for i in range(limit):
|
||||||
|
_load(host, f"T{i}")
|
||||||
|
_load(host, "T0") # a cache hit -- T0 is on screen again
|
||||||
|
_load(host, "NEW") # forces one eviction
|
||||||
|
|
||||||
|
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
|
||||||
|
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHitsStillAvoidDiskWork:
|
||||||
|
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
|
||||||
|
instance, _ = host
|
||||||
|
first = _load(host, "TB")
|
||||||
|
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise AssertionError("cache hit re-opened the file from disk")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Image, "open", _boom)
|
||||||
|
assert _load(host, "TB") is first
|
||||||
@@ -6,6 +6,7 @@ frame for the whole display_duration instead of rotating on -- the same class
|
|||||||
of defect as a display() that returns None.
|
of defect as a display() that returns None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -197,6 +198,50 @@ class TestInstalledAppsTakeTurns:
|
|||||||
assert p.display(force_clear=True) is False
|
assert p.display(force_clear=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestLockSidecarIsStable:
|
||||||
|
"""_save_manifest and _update_manifest_safe must lock a sidecar file that
|
||||||
|
a manifest write never replaces. manifest.json itself is swapped for a
|
||||||
|
fresh inode by every atomic write (temp file + rename); a lock taken on
|
||||||
|
manifest.json directly would not exclude a second locker whose fresh
|
||||||
|
os.open() lands on that new inode right after a write, so two writers
|
||||||
|
could still race each other despite each believing it held the lock.
|
||||||
|
web_interface._starlark_manifest_lock (web_interface/blueprints/api_v3)
|
||||||
|
must lock this same sidecar name for that guarantee to hold across both
|
||||||
|
the plugin-loaded and standalone paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _plugin_with_manifest_dir(self, manager_module, tmp_path):
|
||||||
|
p = _plugin(manager_module)
|
||||||
|
p.manifest_file = tmp_path / "manifest.json"
|
||||||
|
p.manifest_lock_file = tmp_path / "manifest.json.lock"
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_the_lock_sidecar_is_not_the_manifest_file(self, manager_module, tmp_path):
|
||||||
|
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
|
||||||
|
assert p.manifest_lock_file != p.manifest_file
|
||||||
|
assert p.manifest_lock_file.name == "manifest.json.lock"
|
||||||
|
|
||||||
|
def test_the_locked_files_inode_survives_repeated_writes(self, manager_module, tmp_path):
|
||||||
|
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
|
||||||
|
|
||||||
|
assert p._save_manifest({"apps": {}})
|
||||||
|
lock_ino_before = p.manifest_lock_file.stat().st_ino
|
||||||
|
|
||||||
|
for app_id in ("one", "two", "three"):
|
||||||
|
def _update(manifest, app_id=app_id):
|
||||||
|
manifest.setdefault("apps", {})[app_id] = {"enabled": True}
|
||||||
|
assert p._update_manifest_safe(_update)
|
||||||
|
|
||||||
|
lock_ino_after = p.manifest_lock_file.stat().st_ino
|
||||||
|
assert lock_ino_after == lock_ino_before, (
|
||||||
|
"the locked file's inode changed across writes -- a locker that "
|
||||||
|
"opened it before this write and one that opens it after would "
|
||||||
|
"no longer contend for the same lock")
|
||||||
|
|
||||||
|
manifest = json.loads(p.manifest_file.read_text())
|
||||||
|
assert set(manifest["apps"]) == {"one", "two", "three"}
|
||||||
|
|
||||||
|
|
||||||
class TestAnimationsRunAtFrameRate:
|
class TestAnimationsRunAtFrameRate:
|
||||||
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
|
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
|
||||||
"""The controller reads this attribute; a multi-frame app is otherwise
|
"""The controller reads this attribute; a multi-frame app is otherwise
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""A privileged system action that cannot run must say why.
|
||||||
|
|
||||||
|
The web interface runs unprivileged. Its systemctl/reboot/journalctl calls only
|
||||||
|
work once scripts/install/configure_web_sudo.sh has granted NOPASSWD, and
|
||||||
|
first_time_install.sh never invokes that script -- so on a fresh device every
|
||||||
|
one of those actions fails. What the user got back was:
|
||||||
|
|
||||||
|
{"message": "Action failed; see logs for details", "status": "error"}
|
||||||
|
|
||||||
|
...and the log viewer was broken for exactly the same reason, so "see logs" led
|
||||||
|
nowhere. This is the closed loop that src/web_interface/error_handler.py's
|
||||||
|
describe_exception() was written to break; /system/action's exception handler
|
||||||
|
was simply still discarding the cause.
|
||||||
|
|
||||||
|
Observed live: POSTing reboot_system to a Pi returned that bare sentence, and
|
||||||
|
pinning down "no passwordless sudo" took reading the installer instead of
|
||||||
|
reading the response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _act(client, action='stop_display'):
|
||||||
|
return client.post('/api/v3/system/action', json={'action': action})
|
||||||
|
|
||||||
|
|
||||||
|
def _result(args, rc=0, stderr=''):
|
||||||
|
return subprocess.CompletedProcess(args, rc, stdout='', stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSudoRefusalIsNamed:
|
||||||
|
@pytest.mark.parametrize("stderr", [
|
||||||
|
"sudo: a password is required",
|
||||||
|
"sudo: no tty present and no askpass program specified",
|
||||||
|
"sudo: a terminal is required to read the password",
|
||||||
|
])
|
||||||
|
def test_every_sudo_refusal_wording_is_recognised(self, client, stderr):
|
||||||
|
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 1, stderr)):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
# The point: the response tells the user what to do about it.
|
||||||
|
assert 'configure_web_sudo.sh' in body['message']
|
||||||
|
|
||||||
|
def test_the_raw_stderr_is_still_returned(self, client):
|
||||||
|
with patch('subprocess.run',
|
||||||
|
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
assert 'a password is required' in body['stderr']
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnrelatedFailuresAreNotMisattributed:
|
||||||
|
def test_a_real_systemctl_error_keeps_the_generic_message(self, client):
|
||||||
|
with patch('subprocess.run',
|
||||||
|
side_effect=lambda a, **k: _result(a, 5, "Unit ledmatrix.service not found.")):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
|
||||||
|
assert 'configure_web_sudo.sh' not in body['message'], \
|
||||||
|
"a missing unit was blamed on sudo"
|
||||||
|
assert 'Unit ledmatrix.service not found.' in body['stderr']
|
||||||
|
assert body['returncode'] == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheExceptionPathNamesTheCause:
|
||||||
|
def test_the_exception_type_and_message_are_returned(self, client):
|
||||||
|
with patch('subprocess.run', side_effect=FileNotFoundError(2, "sudo")):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
|
||||||
|
# The regression: 'details' was absent and the cause was discarded.
|
||||||
|
assert 'FileNotFoundError' in body['details']
|
||||||
|
|
||||||
|
def test_a_sudo_shaped_exception_also_gets_the_hint(self, client):
|
||||||
|
with patch('subprocess.run',
|
||||||
|
side_effect=PermissionError("sudo: a password is required")):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
assert 'configure_web_sudo.sh' in body['message']
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheEarlyReturnPathsAlsoGetTheHint:
|
||||||
|
"""start_display with a mode builds its own response and returns before the
|
||||||
|
shared nonzero-result path, so it needed the hint separately. Found by
|
||||||
|
CodeRabbit on #560; verified against the code before fixing."""
|
||||||
|
|
||||||
|
def test_on_demand_start_reports_the_sudo_cause(self, client):
|
||||||
|
with patch('subprocess.run',
|
||||||
|
side_effect=lambda a, **k: _result(a, 1, "sudo: a password is required")):
|
||||||
|
body = client.post('/api/v3/system/action',
|
||||||
|
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||||
|
|
||||||
|
assert 'configure_web_sudo.sh' in body['message'], "the on-demand branch still reported only 'Failed to start display'"
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
|
||||||
|
def test_on_demand_start_keeps_its_success_message(self, client):
|
||||||
|
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
|
||||||
|
body = client.post('/api/v3/system/action',
|
||||||
|
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert body['message'] == 'Display started'
|
||||||
|
|
||||||
|
def test_an_unrelated_on_demand_failure_is_not_blamed_on_sudo(self, client):
|
||||||
|
with patch('subprocess.run',
|
||||||
|
side_effect=lambda a, **k: _result(a, 5, "Unit not found.")):
|
||||||
|
body = client.post('/api/v3/system/action',
|
||||||
|
json={'action': 'start_display', 'mode': 'nfl_live'}).get_json()
|
||||||
|
assert body['message'] == 'Failed to start display'
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuccessIsUnchanged:
|
||||||
|
def test_a_working_action_still_reports_success(self, client):
|
||||||
|
with patch('subprocess.run', side_effect=lambda a, **k: _result(a, 0)):
|
||||||
|
body = _act(client).get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert 'configure_web_sudo.sh' not in body.get('message', '')
|
||||||
@@ -17,6 +17,8 @@ editing files under /etc and restarting services is the installer's job, not
|
|||||||
something a display process should do to a machine while it boots.
|
something a display process should do to a machine while it boots.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -150,3 +152,122 @@ def test_a_missing_installed_unit_is_silent(validator, tmp_path):
|
|||||||
validator._validate_systemd_units()
|
validator._validate_systemd_units()
|
||||||
assert not validator.warnings
|
assert not validator.warnings
|
||||||
assert not validator.errors
|
assert not validator.errors
|
||||||
|
|
||||||
|
|
||||||
|
# --- the web unit: one template, three copies, and a warning that never cleared ---
|
||||||
|
#
|
||||||
|
# install_service.sh and install_web_service.sh each carried their own inline
|
||||||
|
# heredoc of ledmatrix-web.service. install_service.sh's had drifted -- no
|
||||||
|
# Wants=network-online.target, RestartSec, SyslogIdentifier or CacheDirectory --
|
||||||
|
# and that is what was installed on real rigs. The validator correctly reported
|
||||||
|
# the drift and told the user to re-run install_service.sh, which reinstalled the
|
||||||
|
# same stale copy, so the warning could never clear. Separately, the template
|
||||||
|
# hardcoded User=root while the installers write whoever ran them, so even the
|
||||||
|
# *correct* installer produced a permanent warning on any non-root install.
|
||||||
|
|
||||||
|
|
||||||
|
def _render(template_text, project_root, user):
|
||||||
|
"""Exactly what the installers' sed does."""
|
||||||
|
return (template_text
|
||||||
|
.replace("__PROJECT_ROOT_DIR__", str(project_root))
|
||||||
|
.replace("__USER__", user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_unit_installed_as_a_non_root_user_is_not_drift(validator, tmp_path):
|
||||||
|
"""The web interface runs as whoever installed it, not as root.
|
||||||
|
|
||||||
|
This is the case that warned forever: nothing the user could do would make
|
||||||
|
an installed `User=pi` match a template that said `User=root`.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix-web.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
installed = tmp_path / "ledmatrix-web.service"
|
||||||
|
installed.write_text(
|
||||||
|
_render(template.read_text(encoding="utf-8"), project_root, "hdpi"),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert not validator.warnings, (
|
||||||
|
f"a correctly installed non-root web unit warned: {validator.warnings}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_unit_still_reports_a_real_changed_directive(validator, tmp_path):
|
||||||
|
"""Ignoring User= must not make the check blind to everything else."""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
template_rel = "systemd/ledmatrix-web.service"
|
||||||
|
template = project_root / template_rel
|
||||||
|
if not template.is_file():
|
||||||
|
pytest.skip("repo unit template not present")
|
||||||
|
|
||||||
|
rendered = _render(template.read_text(encoding="utf-8"), project_root, "hdpi")
|
||||||
|
# Drop RestartSec -- one of the directives the stale heredoc was missing.
|
||||||
|
stale = "\n".join(line for line in rendered.splitlines() if not line.startswith("RestartSec="))
|
||||||
|
installed = tmp_path / "ledmatrix-web.service"
|
||||||
|
installed.write_text(stale + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
validator._UNITS = ((template_rel, str(installed)),)
|
||||||
|
validator._validate_systemd_units()
|
||||||
|
assert validator.warnings, "a web unit missing RestartSec= produced no warning"
|
||||||
|
assert not validator.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_user_falls_back_to_root():
|
||||||
|
"""systemd defaults a system unit with no User= to root, so we must too."""
|
||||||
|
assert StartupValidator._installed_user("[Service]\nExecStart=/x\n") == "root"
|
||||||
|
assert StartupValidator._installed_user("[Service]\nUser=pi\n") == "pi"
|
||||||
|
assert StartupValidator._installed_user("[Service]\n User=hdpi \n") == "hdpi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sed_escape_replacement_preserves_special_characters():
|
||||||
|
"""A project path or username containing sed-special characters must render literally.
|
||||||
|
|
||||||
|
The installers build their sed expression by interpolating a shell
|
||||||
|
variable into the replacement side of `sed s|pattern|replacement|`.
|
||||||
|
Unescaped, sed treats `&` as "insert the whole match" and `\\` as an
|
||||||
|
escape character, so a path like `/opt/led&matrix` would corrupt the
|
||||||
|
rendered unit instead of being substituted as-is. lib_systemd_render.sh's
|
||||||
|
sed_escape_replacement exists to prevent exactly that.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
helper = project_root / "scripts" / "install" / "lib_systemd_render.sh"
|
||||||
|
if not helper.is_file():
|
||||||
|
pytest.skip("install helper not present")
|
||||||
|
|
||||||
|
value = "/opt/led&matrix\\pi|two"
|
||||||
|
escape_cmd = f'source {shlex.quote(str(helper))}; sed_escape_replacement {shlex.quote(value)}'
|
||||||
|
escaped = subprocess.run(
|
||||||
|
["bash", "-c", escape_cmd], capture_output=True, text=True, check=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
rendered = subprocess.run(
|
||||||
|
["sed", f"s|__X__|{escaped}|g"],
|
||||||
|
input="path=__X__\n", capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
assert rendered == f"path={value}\n", (
|
||||||
|
"a sed-special character in the replacement was not preserved literally")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_installer_carries_its_own_copy_of_a_unit():
|
||||||
|
"""The regression guard.
|
||||||
|
|
||||||
|
Both installers used to inline the unit as a heredoc, and the two copies
|
||||||
|
drifted from the template and from each other. A unit body in a shell script
|
||||||
|
is the bug, so assert there isn't one rather than asserting the current
|
||||||
|
contents match -- matching contents is exactly what silently stops being
|
||||||
|
true.
|
||||||
|
"""
|
||||||
|
project_root = Path("src/startup_validator.py").resolve().parent.parent
|
||||||
|
offenders = []
|
||||||
|
for script in sorted((project_root / "scripts" / "install").glob("*.sh")):
|
||||||
|
text = script.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if "[Unit]" in text and "Description=" in text:
|
||||||
|
offenders.append(script.name)
|
||||||
|
assert not offenders, (
|
||||||
|
f"{offenders} contain an inline systemd unit; render "
|
||||||
|
f"systemd/*.service instead so there is one source of truth")
|
||||||
|
|||||||
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
|
|||||||
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
|
# api_v3 is a package; the routes are spread across its modules.
|
||||||
|
src = '\n'.join(
|
||||||
|
p.read_text() for p in
|
||||||
|
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if not isinstance(node, ast.Assign):
|
if not isinstance(node, ast.Assign):
|
||||||
|
|||||||
+29
-4
@@ -127,6 +127,31 @@ class TestConfigAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
mock_config_manager.save_config_atomic.assert_called_once()
|
mock_config_manager.save_config_atomic.assert_called_once()
|
||||||
|
|
||||||
|
def test_save_main_config_fails_closed_when_schema_path_is_unresolvable(
|
||||||
|
self, client, mock_config_manager, mock_plugin_manager
|
||||||
|
):
|
||||||
|
"""A plugin id whose schema path fails safe-resolution must not have
|
||||||
|
its config saved with secret_fields left empty.
|
||||||
|
|
||||||
|
Regression test for the CodeQL/CodeRabbit finding on
|
||||||
|
web_interface/blueprints/api_v3/config.py: previously, when
|
||||||
|
resolve_under() returned None (e.g. a plugin directory reached via a
|
||||||
|
symlink), the code fell through to `secret_fields = set()` and saved
|
||||||
|
the plugin's submitted config -- credentials included -- as
|
||||||
|
ordinary, unencrypted configuration instead of refusing the request.
|
||||||
|
"""
|
||||||
|
mock_plugin_manager.plugin_manifests = {'evil': {}}
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3.config.resolve_under', return_value=None):
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data=json.dumps({'evil': {'api_key': 'super-secret'}}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
mock_config_manager.save_config_atomic.assert_not_called()
|
||||||
|
|
||||||
def test_save_main_config_validation_error(self, client, mock_config_manager):
|
def test_save_main_config_validation_error(self, client, mock_config_manager):
|
||||||
"""Test saving config with validation error."""
|
"""Test saving config with validation error."""
|
||||||
invalid_config = {'invalid': 'data'}
|
invalid_config = {'invalid': 'data'}
|
||||||
@@ -390,7 +415,7 @@ class TestConfigAPI:
|
|||||||
class TestSystemAPI:
|
class TestSystemAPI:
|
||||||
"""Test system API endpoints."""
|
"""Test system API endpoints."""
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_get_system_status(self, mock_subprocess, client):
|
def test_get_system_status(self, mock_subprocess, client):
|
||||||
"""Test getting system status."""
|
"""Test getting system status."""
|
||||||
# The endpoint returns 503 without psutil, which is an optional
|
# The endpoint returns 503 without psutil, which is an optional
|
||||||
@@ -407,7 +432,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'service' in data or 'status' in data or 'active' in data
|
assert 'service' in data or 'status' in data or 'active' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_get_system_version(self, mock_subprocess, client):
|
def test_get_system_version(self, mock_subprocess, client):
|
||||||
"""Test getting system version."""
|
"""Test getting system version."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -421,7 +446,7 @@ class TestSystemAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'version' in data.get('data', {}) or 'version' in data
|
assert 'version' in data.get('data', {}) or 'version' in data
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||||
def test_execute_system_action(self, mock_subprocess, client):
|
def test_execute_system_action(self, mock_subprocess, client):
|
||||||
"""Test executing system action."""
|
"""Test executing system action."""
|
||||||
mock_result = MagicMock()
|
mock_result = MagicMock()
|
||||||
@@ -502,7 +527,7 @@ class TestDisplayAPI:
|
|||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
assert api_v3.cache_manager.set.called
|
assert api_v3.cache_manager.set.called
|
||||||
|
|
||||||
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
|
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
|
||||||
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
||||||
"""Test stopping on-demand display."""
|
"""Test stopping on-demand display."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for the web interface's autostart default.
|
||||||
|
|
||||||
|
Regression under test: ``start_web_conditionally.py`` read the flag with
|
||||||
|
``config_data.get("web_display_autostart", False)``, so a config that simply
|
||||||
|
lacked the key got no web interface. Both config.template.json and
|
||||||
|
first_time_install.sh ship the key as ``true``, so absence means an older or
|
||||||
|
hand-edited config -- not a request to stay down.
|
||||||
|
|
||||||
|
The failure was silent in the worst way: the "not starting" path exits 0, so
|
||||||
|
``systemctl status ledmatrix-web`` reported the unit as successfully started
|
||||||
|
while nothing was listening on the port, and the journal's only trace was one
|
||||||
|
line saying the flag was "false or not set".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = (Path(__file__).resolve().parents[1]
|
||||||
|
/ "scripts" / "utils" / "start_web_conditionally.py")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mod():
|
||||||
|
spec = importlib.util.spec_from_file_location("start_web_conditionally", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutostartDefault:
|
||||||
|
def test_absent_key_still_starts(self, mod):
|
||||||
|
# The regression: this returned False and the UI silently stayed down.
|
||||||
|
assert mod.autostart_enabled({}) is True
|
||||||
|
|
||||||
|
def test_unreadable_config_still_starts(self, mod):
|
||||||
|
# main() falls back to {} when the config is missing or corrupt, because
|
||||||
|
# the web interface is how a broken config gets repaired.
|
||||||
|
assert mod.autostart_enabled({}) is True
|
||||||
|
|
||||||
|
def test_explicit_true_starts(self, mod):
|
||||||
|
assert mod.autostart_enabled({"web_display_autostart": True}) is True
|
||||||
|
|
||||||
|
def test_explicit_false_does_not_start(self, mod):
|
||||||
|
assert mod.autostart_enabled({"web_display_autostart": False}) is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["off", "false", "no", "0", "OFF", " False "])
|
||||||
|
def test_disabling_strings_do_not_start(self, mod, value):
|
||||||
|
assert mod.autostart_enabled({"web_display_autostart": value}) is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["on", "true", "yes", "1", "TRUE"])
|
||||||
|
def test_enabling_strings_start(self, mod, value):
|
||||||
|
assert mod.autostart_enabled({"web_display_autostart": value}) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestShippedDefaultsAgree:
|
||||||
|
def test_template_ships_autostart_true(self):
|
||||||
|
"""The code default must match what the installer actually writes."""
|
||||||
|
import json
|
||||||
|
template = json.loads(
|
||||||
|
(SCRIPT.parents[2] / "config" / "config.template.json").read_text(encoding="utf-8"))
|
||||||
|
assert template["web_display_autostart"] is True
|
||||||
@@ -114,7 +114,11 @@ class TestHandlersCarryDetail:
|
|||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
src = open("web_interface/blueprints/api_v3.py").read()
|
# api_v3 is a package; the routes are spread across its modules.
|
||||||
|
import pathlib
|
||||||
|
src = "\n".join(
|
||||||
|
p.read_text() for p in
|
||||||
|
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
|
|
||||||
# This used to match one exact message string, so a handler that wrote
|
# This used to match one exact message string, so a handler that wrote
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""The SSID and password reaching nmcli's argv come from an HTTP request body.
|
||||||
|
|
||||||
|
POST /api/v3/wifi/connect takes both verbatim and WiFiManager.connect_to_network
|
||||||
|
hands them to::
|
||||||
|
|
||||||
|
subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password])
|
||||||
|
|
||||||
|
There is no shell in that, so no metacharacter can start a second command --
|
||||||
|
CodeQL's py/command-line-injection alert overstates it on that point. What is
|
||||||
|
real is argument injection: nmcli reads a leading "-" as an option, so an SSID
|
||||||
|
of "--ask" or "-t" asks nmcli to *run differently* rather than to join a
|
||||||
|
network. Neither value was checked for shape at all before reaching argv.
|
||||||
|
|
||||||
|
These tests pin the validation without touching real networking: the
|
||||||
|
validators are classmethods, so nothing here constructs a WiFiManager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.wifi_manager import WiFiManager # noqa: E402
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestSsidValidation:
|
||||||
|
@pytest.mark.parametrize("ssid", [
|
||||||
|
"HomeNet", "my wifi 5G", "Cafe-Guest", "café", "x" * 32, "-not-leading".lstrip("-"),
|
||||||
|
])
|
||||||
|
def test_ordinary_ssids_pass_through(self, ssid):
|
||||||
|
value, error = WiFiManager._validate_ssid(ssid)
|
||||||
|
assert error is None
|
||||||
|
assert value == ssid
|
||||||
|
|
||||||
|
def test_surrounding_whitespace_is_trimmed_not_rejected(self):
|
||||||
|
value, error = WiFiManager._validate_ssid(" HomeNet ")
|
||||||
|
assert error is None
|
||||||
|
assert value == "HomeNet"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ssid", ["--ask", "-t", "-"])
|
||||||
|
def test_an_ssid_nmcli_would_read_as_an_option_is_refused(self, ssid):
|
||||||
|
value, error = WiFiManager._validate_ssid(ssid)
|
||||||
|
assert error is not None
|
||||||
|
assert value == ""
|
||||||
|
|
||||||
|
def test_an_ssid_over_32_octets_is_refused(self):
|
||||||
|
# 802.11 caps the SSID element at 32 octets, so a longer one could
|
||||||
|
# never name a real network.
|
||||||
|
_, error = WiFiManager._validate_ssid("x" * 33)
|
||||||
|
assert error is not None
|
||||||
|
# Multi-byte characters count as octets, not characters.
|
||||||
|
_, error = WiFiManager._validate_ssid("é" * 17)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ssid", ["a\nb", "a\rb", "a\x00b", "a\x7fb", "a\tb"])
|
||||||
|
def test_control_characters_are_refused(self, ssid):
|
||||||
|
_, error = WiFiManager._validate_ssid(ssid)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ssid", ["", " ", None, 42, ["HomeNet"]])
|
||||||
|
def test_empty_and_non_text_values_are_refused(self, ssid):
|
||||||
|
_, error = WiFiManager._validate_ssid(ssid)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordValidation:
|
||||||
|
def test_an_empty_password_means_an_open_network(self):
|
||||||
|
value, error = WiFiManager._validate_wifi_password("")
|
||||||
|
assert error is None
|
||||||
|
assert value == ""
|
||||||
|
value, error = WiFiManager._validate_wifi_password(None)
|
||||||
|
assert error is None
|
||||||
|
assert value == ""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("password", ["hunter22", "a" * 63, "0" * 64, "AbCdEf0123" * 6 + "abcd"])
|
||||||
|
def test_valid_psk_lengths_pass_through(self, password):
|
||||||
|
value, error = WiFiManager._validate_wifi_password(password)
|
||||||
|
assert error is None, f"{password!r} rejected: {error}"
|
||||||
|
assert value == password
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("password", ["short", "z" * 64, "a" * 200])
|
||||||
|
def test_lengths_that_could_never_authenticate_are_refused(self, password):
|
||||||
|
# 8-63 chars for a passphrase, or exactly 64 hex chars for a raw key.
|
||||||
|
# "z" * 64 is the right length for a key but is not hex, so it is
|
||||||
|
# neither -- note "a" * 64 *is* valid hex and must stay accepted.
|
||||||
|
_, error = WiFiManager._validate_wifi_password(password)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("password", ["-password", "--ask"])
|
||||||
|
def test_a_password_nmcli_would_read_as_an_option_is_refused(self, password):
|
||||||
|
_, error = WiFiManager._validate_wifi_password(password)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
def test_control_characters_are_refused(self):
|
||||||
|
_, error = WiFiManager._validate_wifi_password("pass\nword")
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("password", ["pässword", "你好12345678"])
|
||||||
|
def test_non_ascii_passphrases_are_refused(self, password):
|
||||||
|
# NetworkManager accepts only printable ASCII WPA-PSK passphrases (or
|
||||||
|
# a 64-char hex key); a non-ASCII value would otherwise reach
|
||||||
|
# _connect_nmcli and be saved/attempted before nmcli itself rejects it.
|
||||||
|
_, error = WiFiManager._validate_wifi_password(password)
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectRefusesBeforeRunningNmcli:
|
||||||
|
"""connect_to_network must not reach subprocess with a rejected value."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ssid,password", [
|
||||||
|
("--ask", "hunter22"),
|
||||||
|
("x" * 40, "hunter22"),
|
||||||
|
("Home\nNet", "hunter22"),
|
||||||
|
("HomeNet", "-secret1"),
|
||||||
|
("HomeNet", "short"),
|
||||||
|
])
|
||||||
|
def test_no_subprocess_runs_for_a_rejected_request(self, ssid, password):
|
||||||
|
manager = WiFiManager.__new__(WiFiManager) # no __init__: no real host access
|
||||||
|
with patch("src.wifi_manager.subprocess.run") as run:
|
||||||
|
ok, message = manager.connect_to_network(ssid, password)
|
||||||
|
assert ok is False
|
||||||
|
assert message
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectEndpointSurfacesTheRefusal:
|
||||||
|
URL = "/api/v3/wifi/connect"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wifi_manager(self):
|
||||||
|
with patch("src.wifi_manager.WiFiManager") as cls:
|
||||||
|
instance = MagicMock()
|
||||||
|
cls.return_value = instance
|
||||||
|
yield instance
|
||||||
|
|
||||||
|
def test_a_rejected_ssid_comes_back_as_a_client_error(
|
||||||
|
self, api_v3_client, wifi_manager
|
||||||
|
):
|
||||||
|
# The route delegates the shape check to the manager, so mirror what
|
||||||
|
# the real one now returns rather than asserting on a mock's default.
|
||||||
|
wifi_manager.connect_to_network.return_value = (False, "SSID cannot start with '-'")
|
||||||
|
response = api_v3_client.post(self.URL, json={"ssid": "--ask", "password": "hunter22"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "-" in response.get_json()["message"]
|
||||||
|
|
||||||
|
def test_an_ordinary_request_is_unaffected(self, api_v3_client, wifi_manager):
|
||||||
|
wifi_manager.connect_to_network.return_value = (True, "Connected to HomeNet")
|
||||||
|
response = api_v3_client.post(self.URL, json={"ssid": "HomeNet", "password": "hunter22"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
wifi_manager.connect_to_network.assert_called_once_with("HomeNet", "hunter22")
|
||||||
@@ -139,6 +139,58 @@ class TestRequestValidation:
|
|||||||
assert post(client, options="{}").status_code == 200
|
assert post(client, options="{}").status_code == 200
|
||||||
assert restore.call_args[0][2].restore_config is True
|
assert restore.call_args[0][2].restore_config is True
|
||||||
|
|
||||||
|
def test_unknown_option_key_is_refused(self, client, restore):
|
||||||
|
# Regression: opts_dict.get('restore_secrets', True) silently
|
||||||
|
# ignores a typo'd/renamed key like "restoreSecrets" and keeps the
|
||||||
|
# True default, restoring secrets a caller's request clearly meant
|
||||||
|
# to exclude -- with no indication anything was wrong.
|
||||||
|
response = post(client, options=json.dumps({"restoreSecrets": False}))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Unknown restore option" in response.get_json()["message"]
|
||||||
|
assert "restoreSecrets" in response.get_json()["message"]
|
||||||
|
restore.assert_not_called()
|
||||||
|
|
||||||
|
def test_known_and_unknown_keys_together_are_refused(self, client, restore):
|
||||||
|
response = post(client, options=json.dumps({
|
||||||
|
"restore_secrets": False, "restore_everything": True}))
|
||||||
|
assert response.status_code == 400
|
||||||
|
restore.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionsAreBooleanAware:
|
||||||
|
"""Regression: bool("false") is True in Python.
|
||||||
|
|
||||||
|
Every restore flag used bare bool() coercion, so a caller that sends its
|
||||||
|
options as JSON strings rather than real booleans -- a form field, a
|
||||||
|
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
|
||||||
|
anyway, the opposite of what was asked. Fixed with the same
|
||||||
|
string-aware `_coerce_to_bool` already used for checkbox-style config
|
||||||
|
fields elsewhere in this package (config.py, plugins.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("false", False), ("False", False), ("FALSE", False),
|
||||||
|
("0", False),
|
||||||
|
("true", True), ("True", True), ("1", True),
|
||||||
|
])
|
||||||
|
def test_string_valued_flags_are_parsed_not_just_truthy(
|
||||||
|
self, client, restore, raw, expected):
|
||||||
|
post(client, options=json.dumps({"restore_secrets": raw}))
|
||||||
|
assert restore.call_args[0][2].restore_secrets is expected
|
||||||
|
|
||||||
|
def test_a_string_false_does_not_restore_secrets(self, client, restore):
|
||||||
|
# The exact shape of the bug: a truthy non-empty string coerced by
|
||||||
|
# bare bool() to True regardless of its contents.
|
||||||
|
post(client, options=json.dumps({"restore_secrets": "false"}))
|
||||||
|
assert restore.call_args[0][2].restore_secrets is False
|
||||||
|
|
||||||
|
def test_real_json_booleans_still_work(self, client, restore):
|
||||||
|
post(client, options=json.dumps({"restore_secrets": False,
|
||||||
|
"restore_config": True}))
|
||||||
|
options = restore.call_args[0][2]
|
||||||
|
assert options.restore_secrets is False
|
||||||
|
assert options.restore_config is True
|
||||||
|
|
||||||
|
|
||||||
class TestSuccess:
|
class TestSuccess:
|
||||||
def test_success_returns_the_result(self, client, restore):
|
def test_success_returns_the_result(self, client, restore):
|
||||||
@@ -230,7 +282,7 @@ class TestPluginReinstall:
|
|||||||
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
||||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||||
api_v3.plugin_store_manager = None
|
api_v3.plugin_store_manager = None
|
||||||
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
|
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
|
||||||
body = post(client).get_json()
|
body = post(client).get_json()
|
||||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ class TestTheRoutesExistAtAll:
|
|||||||
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||||
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||||
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
# api_v3 is a package now, so the route strings are spread across its
|
||||||
|
# modules; read the whole directory rather than one file.
|
||||||
|
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
|
||||||
|
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
|
||||||
assert "'/plugins/calendar/list-calendars'" in source
|
assert "'/plugins/calendar/list-calendars'" in source
|
||||||
assert "'/plugins/calendar/authenticate'" in source
|
assert "'/plugins/calendar/authenticate'" in source
|
||||||
|
|
||||||
@@ -360,6 +363,22 @@ class TestDiagnosticsAreRedacted:
|
|||||||
assert 'hunter2' not in error, error
|
assert 'hunter2' not in error, error
|
||||||
assert '<redacted>' in error, error
|
assert '<redacted>' in error, error
|
||||||
|
|
||||||
|
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
|
||||||
|
# Regression: the return value went through redact_text (asserted
|
||||||
|
# above), but the logger.error call right next to it logged `raw`
|
||||||
|
# verbatim -- a script that handles OAuth client secrets and can
|
||||||
|
# quote them in its stderr, landing unredacted in the log (CWE-532).
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys\n'
|
||||||
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
with caplog.at_level('ERROR', logger=mod.logger.name):
|
||||||
|
mod._run_calendar_registration(tmp_path, '')
|
||||||
|
logged = '\n'.join(r.getMessage() for r in caplog.records)
|
||||||
|
assert 'hunter2' not in logged, logged
|
||||||
|
assert '<redacted>' in logged, logged
|
||||||
|
|
||||||
def test_a_failing_script_payload_is_redacted(self, client):
|
def test_a_failing_script_payload_is_redacted(self, client):
|
||||||
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||||
(client.plugin_dir / 'calendar_registration.py').write_text(
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
SOURCE = (Path(__file__).resolve().parents[2]
|
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
/ "web_interface" / "blueprints" / "api_v3")
|
||||||
|
|
||||||
#: Objects that still hold submitted secret values at the point these log
|
#: Objects that still hold submitted secret values at the point these log
|
||||||
#: calls run. Interpolating one whole into a log message leaks credentials.
|
#: calls run. Interpolating one whole into a log message leaks credentials.
|
||||||
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
|
|||||||
|
|
||||||
|
|
||||||
def _logging_lines():
|
def _logging_lines():
|
||||||
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
|
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ from pathlib import Path
|
|||||||
|
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
|
|
||||||
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
/ "web_interface" / "blueprints" / "api_v3")
|
||||||
|
|
||||||
|
|
||||||
|
def _api_v3_source() -> str:
|
||||||
|
"""Every module of the api_v3 package as one string.
|
||||||
|
|
||||||
|
It used to be a single file; the inline copies this guards against could
|
||||||
|
now reappear in any module of the package.
|
||||||
|
"""
|
||||||
|
return "\n".join(p.read_text(encoding="utf-8")
|
||||||
|
for p in sorted(API_V3_PKG.glob("*.py")))
|
||||||
|
|
||||||
# The migration is complete: any inline reimplementation is a regression.
|
# The migration is complete: any inline reimplementation is a regression.
|
||||||
EXPECTED_INLINE_COPIES = 0
|
EXPECTED_INLINE_COPIES = 0
|
||||||
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
|
|||||||
|
|
||||||
class TestNoInlineCopies:
|
class TestNoInlineCopies:
|
||||||
def _count(self, name: str) -> int:
|
def _count(self, name: str) -> int:
|
||||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
source = _api_v3_source()
|
||||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||||
|
|
||||||
def test_no_inline_find_secret_fields(self):
|
def test_no_inline_find_secret_fields(self):
|
||||||
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
|
|||||||
def test_canonical_import_present(self):
|
def test_canonical_import_present(self):
|
||||||
# Tripwire: the endpoints still need the helpers, so removing the
|
# Tripwire: the endpoints still need the helpers, so removing the
|
||||||
# import means either dead secret handling or a new local copy.
|
# import means either dead secret handling or a new local copy.
|
||||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
source = _api_v3_source()
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ class TestRoutesAreRegistered:
|
|||||||
|
|
||||||
class TestInstallPixlet:
|
class TestInstallPixlet:
|
||||||
def test_it_does_not_404(self, client):
|
def test_it_does_not_404(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
assert resp.status_code != 404, "the route is still missing"
|
assert resp.status_code != 404, "the route is still missing"
|
||||||
assert resp.get_json().get('message') != 'Resource not found'
|
assert resp.get_json().get('message') != 'Resource not found'
|
||||||
|
|
||||||
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
body = resp.get_json()
|
body = resp.get_json()
|
||||||
@@ -78,7 +78,7 @@ class TestInstallPixlet:
|
|||||||
assert 'message' in body, "the JS shows data.message on success"
|
assert 'message' in body, "the JS shows data.message on success"
|
||||||
|
|
||||||
def test_a_failed_download_says_why(self, client):
|
def test_a_failed_download_says_why(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||||
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
body = resp.get_json()
|
body = resp.get_json()
|
||||||
@@ -88,7 +88,7 @@ class TestInstallPixlet:
|
|||||||
|
|
||||||
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
with patch('web_interface.blueprints.api_v3.subprocess.run',
|
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
|
||||||
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
||||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||||
assert resp.get_json()['status'] == 'error'
|
assert resp.get_json()['status'] == 'error'
|
||||||
@@ -395,6 +395,164 @@ class TestTheManifestStaysRelocatable:
|
|||||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestLockPreventsLostUpdates:
|
||||||
|
"""The standalone manifest fallback (no plugin instance loaded) reads,
|
||||||
|
mutates and writes manifest.json with no coordination across requests.
|
||||||
|
Each write is atomic on its own (temp file + rename), but two concurrent
|
||||||
|
read-modify-write cycles can still race: both read the same starting
|
||||||
|
manifest, and the second write silently discards whatever the first one
|
||||||
|
added. _starlark_manifest_lock closes that window -- mirrors
|
||||||
|
StarlarkAppsPlugin._update_manifest_safe, which already does this when
|
||||||
|
the plugin instance is loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def starlark_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||||
|
module._write_starlark_manifest({'apps': {}})
|
||||||
|
return apps_dir
|
||||||
|
|
||||||
|
def test_the_locked_file_survives_a_manifest_write(self, starlark_dir):
|
||||||
|
"""_write_starlark_manifest replaces manifest.json with a fresh inode
|
||||||
|
on every write (temp file + rename). If the lock were taken on that
|
||||||
|
same file, a second locker's fresh os.open() right after the rename
|
||||||
|
would land on the new inode -- unguarded, because only the old,
|
||||||
|
now-orphaned inode was ever locked -- and two writers could race
|
||||||
|
despite each believing it "held the lock" (see the docstring on
|
||||||
|
_starlark_manifest_lock). Locking a sidecar path that no write ever
|
||||||
|
touches or renames over closes that: the inode identity of what gets
|
||||||
|
locked must not change across writes.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
lock_ino_before = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
|
||||||
|
|
||||||
|
for app_id in ('one', 'two', 'three'):
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||||
|
assert module._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
lock_ino_after = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
|
||||||
|
assert lock_ino_after == lock_ino_before, (
|
||||||
|
"the locked file's inode changed across writes -- a locker that "
|
||||||
|
"opened it before this write and one that opens it after would "
|
||||||
|
"no longer contend for the same lock")
|
||||||
|
|
||||||
|
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
|
||||||
|
import threading
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
def add_app(app_id):
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
# Widen the window between read and write. Without the lock
|
||||||
|
# both threads read here before either writes, and whichever
|
||||||
|
# writes second overwrites the other's addition; with the
|
||||||
|
# lock, the second thread cannot even start its read until
|
||||||
|
# the first has written and released.
|
||||||
|
_time.sleep(0.05)
|
||||||
|
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||||
|
module._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=add_app, args=(app_id,))
|
||||||
|
for app_id in ('a', 'b')]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
assert set(manifest['apps']) == {'a', 'b'}, (
|
||||||
|
"a concurrent update was lost: %r" % (manifest,))
|
||||||
|
|
||||||
|
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
|
||||||
|
"""Not reentrant within one thread -- just that using it twice in a
|
||||||
|
row (the ordinary case: one request, then the next) works cleanly
|
||||||
|
and does not leak the lock file descriptor or leave it locked."""
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
for app_id in ('first', 'second'):
|
||||||
|
with module._starlark_manifest_lock():
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||||
|
module._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
manifest = module._read_starlark_manifest()
|
||||||
|
assert set(manifest['apps']) == {'first', 'second'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigAndManifestStayInSync:
|
||||||
|
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
|
||||||
|
loaded) writes config.json and then the manifest. If the manifest write
|
||||||
|
fails after config.json was already written, the two disagree about
|
||||||
|
what was saved unless config.json is rolled back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
one_app_dir = apps_dir / 'demo'
|
||||||
|
one_app_dir.mkdir()
|
||||||
|
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||||
|
return one_app_dir
|
||||||
|
|
||||||
|
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
|
||||||
|
config_file = app_dir / 'config.json'
|
||||||
|
config_file.write_text(json.dumps({'existing': 'value'}))
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
|
||||||
|
"config.json kept the new value even though the manifest write "
|
||||||
|
"that was supposed to follow it failed")
|
||||||
|
|
||||||
|
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
|
||||||
|
config_file = app_dir / 'config.json'
|
||||||
|
assert not config_file.exists()
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert not config_file.exists(), (
|
||||||
|
"config.json was left behind even though the manifest write "
|
||||||
|
"that was supposed to follow it failed")
|
||||||
|
|
||||||
|
def test_success_updates_both_config_and_manifest(self, client, app_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
||||||
|
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||||
|
json={'new_field': 'x'})
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
|
||||||
|
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
|
||||||
|
assert manifest['apps']['demo']['config']['new_field'] == 'x'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The store loaded, then stopped loading, and nothing anywhere said why.
|
# The store loaded, then stopped loading, and nothing anywhere said why.
|
||||||
#
|
#
|
||||||
@@ -748,3 +906,66 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
|||||||
client.get('/api/v3/starlark/repository/browse')
|
client.get('/api/v3/starlark/repository/browse')
|
||||||
|
|
||||||
repo.assert_called_once_with(github_token='ghp_configured')
|
repo.assert_called_once_with(github_token='ghp_configured')
|
||||||
|
|
||||||
|
|
||||||
|
class TestPixletEditorHostDefaultsButDoesNotOverride:
|
||||||
|
"""PIXLET_EDITOR_HOST must default to 0.0.0.0, never force it.
|
||||||
|
|
||||||
|
A browser reaching the editor is remote by definition, so a session with
|
||||||
|
nothing configured has to bind more than loopback to be reachable at
|
||||||
|
all -- but an operator who has deliberately pinned PIXLET_EDITOR_HOST to
|
||||||
|
loopback (e.g. in the systemd unit's Environment=, to edit only over an
|
||||||
|
SSH tunnel) must keep that setting. The previous unconditional
|
||||||
|
``env['PIXLET_EDITOR_HOST'] = '0.0.0.0'`` overrode it every time,
|
||||||
|
always exposing the unauthenticated ``pixlet serve`` dev process on the
|
||||||
|
LAN regardless (CodeQL CWE-1188).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_dir(self, tmp_path):
|
||||||
|
d = tmp_path / "demo_app"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "demo_app.star").write_text("def main():\n pass\n")
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _start(self, client, app_dir, tmp_path, operator_host):
|
||||||
|
from web_interface.blueprints.api_v3 import starlark as mod
|
||||||
|
|
||||||
|
script = tmp_path / "pixlet_config_editor.sh"
|
||||||
|
script.write_text("#!/bin/bash\n")
|
||||||
|
state_file = tmp_path / "pixlet_editor_state.json"
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
pid = 424242
|
||||||
|
|
||||||
|
def fake_popen(cmd, *args, env=None, **kwargs):
|
||||||
|
if env is not None:
|
||||||
|
captured['env'] = env
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
with patch.object(mod, '_validate_starlark_app_path',
|
||||||
|
return_value=(app_dir, None)), \
|
||||||
|
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
|
||||||
|
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
|
||||||
|
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
|
||||||
|
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
|
||||||
|
patch.dict(os.environ):
|
||||||
|
if operator_host is None:
|
||||||
|
os.environ.pop('PIXLET_EDITOR_HOST', None)
|
||||||
|
else:
|
||||||
|
os.environ['PIXLET_EDITOR_HOST'] = operator_host
|
||||||
|
resp = client.post('/api/v3/starlark/editor/start',
|
||||||
|
json={'app_id': app_dir.name})
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert 'env' in captured, "subprocess.Popen was never called"
|
||||||
|
return captured['env']
|
||||||
|
|
||||||
|
def test_defaults_to_0_0_0_0_when_operator_set_nothing(self, client, app_dir, tmp_path):
|
||||||
|
env = self._start(client, app_dir, tmp_path, operator_host=None)
|
||||||
|
assert env['PIXLET_EDITOR_HOST'] == '0.0.0.0'
|
||||||
|
|
||||||
|
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
|
||||||
|
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
|
||||||
|
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
|
||||||
|
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_v3_source() -> str:
|
||||||
|
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
||||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def test_every_sudo_systemctl_call_is_granted() -> None:
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||||
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||||
|
|
||||||
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||||
@@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
|||||||
def test_units_are_fully_qualified() -> None:
|
def test_units_are_fully_qualified() -> None:
|
||||||
"""Privileged systemctl calls must name the unit as <name>.service so they
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||||
match the sudoers grants, which use the fully-qualified unit name."""
|
match the sudoers grants, which use the fully-qualified unit name."""
|
||||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||||
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||||
assert not unqualified, (
|
assert not unqualified, (
|
||||||
"sudo systemctl calls must use fully-qualified .service unit names: "
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||||
|
|||||||
+52
-60
@@ -17,6 +17,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
|
|
||||||
from src.config_manager import ConfigManager
|
from src.config_manager import ConfigManager
|
||||||
from src.web_interface.error_handler import describe_exception
|
from src.web_interface.error_handler import describe_exception
|
||||||
|
from src.common.path_safety import (
|
||||||
|
resolve_under, safe_path_component, safe_relative_parts,
|
||||||
|
)
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
from src.exceptions import ConfigError
|
from src.exceptions import ConfigError
|
||||||
from src.plugin_system.plugin_manager import PluginManager
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
@@ -32,6 +35,8 @@ _JOURNALCTL = shutil.which('journalctl')
|
|||||||
_SYSTEMCTL = shutil.which('systemctl')
|
_SYSTEMCTL = shutil.which('systemctl')
|
||||||
_VCGENCMD = shutil.which('vcgencmd')
|
_VCGENCMD = shutil.which('vcgencmd')
|
||||||
|
|
||||||
|
from web_interface.system_metrics import collect_system_metrics
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
@@ -205,41 +210,37 @@ app.register_blueprint(api_v3, url_prefix='/api/v3')
|
|||||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||||
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
||||||
def serve_plugin_asset(plugin_id, filename):
|
def serve_plugin_asset(plugin_id, filename):
|
||||||
"""Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/"""
|
"""Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/
|
||||||
|
|
||||||
|
Both URL parts are validated before any path is built. The containment
|
||||||
|
check here used to compare the *asset directory* against the project root
|
||||||
|
rather than against assets/plugins, so a plugin_id of ``..`` moved the
|
||||||
|
served directory a level up and still passed.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Build the asset directory path
|
uploads_base = (project_root / 'assets' / 'plugins').resolve()
|
||||||
assets_dir = project_root / 'assets' / 'plugins' / plugin_id / 'uploads'
|
|
||||||
assets_dir = assets_dir.resolve()
|
safe_plugin_id = safe_path_component(plugin_id)
|
||||||
|
if not safe_plugin_id:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||||
|
|
||||||
|
safe_parts = safe_relative_parts(filename)
|
||||||
|
if not safe_parts:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||||
|
|
||||||
|
assets_dir = resolve_under(uploads_base, safe_plugin_id, 'uploads')
|
||||||
|
if assets_dir is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
||||||
|
|
||||||
# Security check: ensure the assets directory exists and is within project_root
|
# Security check: ensure the assets directory exists and is within project_root
|
||||||
if not assets_dir.exists() or not assets_dir.is_dir():
|
if not assets_dir.exists() or not assets_dir.is_dir():
|
||||||
return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404
|
return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404
|
||||||
|
|
||||||
# Ensure we're serving from within the assets directory (prevent directory traversal)
|
# Resolve the requested file path. resolve_under repeats the
|
||||||
# Use proper path resolution instead of string prefix matching to prevent bypasses
|
# containment check after resolving, which is what catches a symlink
|
||||||
assets_dir_resolved = assets_dir.resolve()
|
# inside the uploads directory pointing out of it.
|
||||||
project_root_resolved = project_root.resolve()
|
requested_file = resolve_under(assets_dir, *safe_parts)
|
||||||
|
if requested_file is None:
|
||||||
# Check that assets_dir is actually within project_root using commonpath
|
|
||||||
try:
|
|
||||||
common_path = os.path.commonpath([str(assets_dir_resolved), str(project_root_resolved)])
|
|
||||||
if common_path != str(project_root_resolved):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
|
||||||
except ValueError:
|
|
||||||
# commonpath raises ValueError if paths are on different drives (Windows)
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403
|
|
||||||
|
|
||||||
# Resolve the requested file path
|
|
||||||
requested_file = (assets_dir / filename).resolve()
|
|
||||||
|
|
||||||
# Security check: ensure file is within the assets directory using proper path comparison
|
|
||||||
# Use commonpath to ensure assets_dir is a true parent of requested_file
|
|
||||||
try:
|
|
||||||
common_path = os.path.commonpath([str(requested_file), str(assets_dir_resolved)])
|
|
||||||
if common_path != str(assets_dir_resolved):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
|
||||||
except ValueError:
|
|
||||||
# commonpath raises ValueError if paths are on different drives (Windows)
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||||
|
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
@@ -247,24 +248,28 @@ def serve_plugin_asset(plugin_id, filename):
|
|||||||
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
||||||
|
|
||||||
# Determine content type based on file extension
|
# Determine content type based on file extension
|
||||||
|
lowered = requested_file.name.lower()
|
||||||
content_type = 'application/octet-stream'
|
content_type = 'application/octet-stream'
|
||||||
if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
if lowered.endswith(('.png', '.jpg', '.jpeg')):
|
||||||
content_type = 'image/jpeg' if filename.lower().endswith(('.jpg', '.jpeg')) else 'image/png'
|
content_type = 'image/jpeg' if lowered.endswith(('.jpg', '.jpeg')) else 'image/png'
|
||||||
elif filename.lower().endswith('.gif'):
|
elif lowered.endswith('.gif'):
|
||||||
content_type = 'image/gif'
|
content_type = 'image/gif'
|
||||||
elif filename.lower().endswith('.bmp'):
|
elif lowered.endswith('.bmp'):
|
||||||
content_type = 'image/bmp'
|
content_type = 'image/bmp'
|
||||||
elif filename.lower().endswith('.webp'):
|
elif lowered.endswith('.webp'):
|
||||||
content_type = 'image/webp'
|
content_type = 'image/webp'
|
||||||
elif filename.lower().endswith('.svg'):
|
elif lowered.endswith('.svg'):
|
||||||
content_type = 'image/svg+xml'
|
content_type = 'image/svg+xml'
|
||||||
elif filename.lower().endswith('.json'):
|
elif lowered.endswith('.json'):
|
||||||
content_type = 'application/json'
|
content_type = 'application/json'
|
||||||
elif filename.lower().endswith('.txt'):
|
elif lowered.endswith('.txt'):
|
||||||
content_type = 'text/plain'
|
content_type = 'text/plain'
|
||||||
|
|
||||||
# Use send_from_directory to serve the file
|
# Use send_from_directory to serve the file. The path handed over is
|
||||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
# the validated one, rebuilt from the components that were checked.
|
||||||
|
return send_from_directory(
|
||||||
|
str(assets_dir), '/'.join(safe_parts), mimetype=content_type
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
app.logger.exception('Error serving plugin asset file')
|
app.logger.exception('Error serving plugin asset file')
|
||||||
@@ -607,26 +612,12 @@ def system_status_generator():
|
|||||||
"""Generate system status updates"""
|
"""Generate system status updates"""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Try to import psutil for system stats
|
metrics = collect_system_metrics()
|
||||||
try:
|
cpu_percent = metrics['cpu_percent']
|
||||||
import psutil
|
memory_used_percent = metrics['memory_used_percent']
|
||||||
# interval=None is non-blocking; primed at module startup above
|
memory_available_mb = metrics['memory_available_mb']
|
||||||
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
|
disk_used_percent = metrics['disk_used_percent']
|
||||||
memory = psutil.virtual_memory()
|
cpu_temp = metrics['cpu_temp']
|
||||||
memory_used_percent = round(memory.percent, 1)
|
|
||||||
|
|
||||||
# Try to get CPU temperature (Raspberry Pi specific)
|
|
||||||
cpu_temp = 0
|
|
||||||
try:
|
|
||||||
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
|
|
||||||
cpu_temp = round(float(f.read()) / 1000.0, 1)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
cpu_percent = 0
|
|
||||||
memory_used_percent = 0
|
|
||||||
cpu_temp = 0
|
|
||||||
|
|
||||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -647,8 +638,9 @@ def system_status_generator():
|
|||||||
'service_active': service_active,
|
'service_active': service_active,
|
||||||
'cpu_percent': cpu_percent,
|
'cpu_percent': cpu_percent,
|
||||||
'memory_used_percent': memory_used_percent,
|
'memory_used_percent': memory_used_percent,
|
||||||
|
'memory_available_mb': memory_available_mb,
|
||||||
'cpu_temp': cpu_temp,
|
'cpu_temp': cpu_temp,
|
||||||
'disk_used_percent': 0,
|
'disk_used_percent': disk_used_percent,
|
||||||
'power': _get_power_status()
|
'power': _get_power_status()
|
||||||
}
|
}
|
||||||
yield status
|
yield status
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
|||||||
|
"""Backup creation, listing and restore.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
|
||||||
|
datetime, json, jsonify, logger, os, plugin_store_manager, request,
|
||||||
|
tempfile,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/backup/preview', methods=['GET'])
|
||||||
|
def backup_preview():
|
||||||
|
"""Return a summary of what a new backup would include."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import preview_backup_contents
|
||||||
|
data = preview_backup_contents(PROJECT_ROOT)
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_preview failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/list', methods=['GET'])
|
||||||
|
def backup_list():
|
||||||
|
"""List backup ZIPs stored in the export directory."""
|
||||||
|
try:
|
||||||
|
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
entries = []
|
||||||
|
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||||
|
if not p.is_file() or p.suffix != '.zip':
|
||||||
|
continue
|
||||||
|
st = p.stat()
|
||||||
|
entries.append({
|
||||||
|
'filename': p.name,
|
||||||
|
'size': st.st_size,
|
||||||
|
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': entries})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_list failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/export', methods=['POST'])
|
||||||
|
def backup_export():
|
||||||
|
"""Create a new backup ZIP and return its filename."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import create_backup
|
||||||
|
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
|
||||||
|
return jsonify({'status': 'success', 'filename': zip_path.name})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_export failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/validate', methods=['POST'])
|
||||||
|
def backup_validate():
|
||||||
|
"""Validate an uploaded backup ZIP and return its manifest."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import validate_backup
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
ok, err_msg, manifest = validate_backup(Path(tmp_path))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Backup validation failed: %s", err_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
|
||||||
|
safe_manifest = {
|
||||||
|
'schema_version': manifest.get('schema_version'),
|
||||||
|
'created_at': manifest.get('created_at'),
|
||||||
|
'ledmatrix_version': manifest.get('ledmatrix_version'),
|
||||||
|
'hostname': manifest.get('hostname'),
|
||||||
|
'contents': manifest.get('contents', []),
|
||||||
|
'detected_contents': manifest.get('detected_contents', []),
|
||||||
|
'plugins': manifest.get('plugins', []),
|
||||||
|
'total_uncompressed': manifest.get('total_uncompressed'),
|
||||||
|
'file_count': manifest.get('file_count'),
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': safe_manifest})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_validate failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
|
||||||
|
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
|
||||||
|
#: leaving that flag at its True default -- restoring secrets a caller's
|
||||||
|
#: request clearly meant to exclude, with no indication anything was wrong.
|
||||||
|
_RESTORE_OPTION_KEYS = frozenset((
|
||||||
|
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
||||||
|
'restore_plugin_uploads', 'reinstall_plugins',
|
||||||
|
))
|
||||||
|
@api_v3.route('/backup/restore', methods=['POST'])
|
||||||
|
def backup_restore():
|
||||||
|
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||||
|
try:
|
||||||
|
from src.backup_manager import restore_backup, RestoreOptions
|
||||||
|
if 'backup_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
|
||||||
|
f = request.files['backup_file']
|
||||||
|
options_raw = request.form.get('options', '{}')
|
||||||
|
try:
|
||||||
|
opts_dict = json.loads(options_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
opts_dict = None
|
||||||
|
if not isinstance(opts_dict, dict):
|
||||||
|
# Every option defaults to True, so falling back to {} on a
|
||||||
|
# parse failure would silently perform a FULL restore —
|
||||||
|
# secrets and all — for a caller who asked for a narrow one
|
||||||
|
# and mis-serialized it. Refuse instead of guessing.
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid options: expected a JSON object',
|
||||||
|
}), 400
|
||||||
|
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
|
||||||
|
if unknown_keys:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
|
||||||
|
}), 400
|
||||||
|
# _coerce_to_bool (not bare bool()) because a request can send these
|
||||||
|
# as JSON strings: bool("false") is True in Python, so a caller who
|
||||||
|
# explicitly asked to skip secrets would have had them restored
|
||||||
|
# anyway.
|
||||||
|
options = RestoreOptions(
|
||||||
|
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
|
||||||
|
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
|
||||||
|
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
|
||||||
|
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
|
||||||
|
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
|
||||||
|
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
f.save(tmp_path)
|
||||||
|
try:
|
||||||
|
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reinstall plugins if requested and store manager available
|
||||||
|
if options.reinstall_plugins and result.plugins_to_install:
|
||||||
|
psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager
|
||||||
|
for plug in result.plugins_to_install:
|
||||||
|
pid = plug.get('plugin_id')
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if psm and hasattr(psm, 'install_plugin'):
|
||||||
|
ok = psm.install_plugin(pid)
|
||||||
|
if ok:
|
||||||
|
result.plugins_installed.append(pid)
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
|
||||||
|
else:
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
|
||||||
|
except Exception as pe:
|
||||||
|
logger.error(
|
||||||
|
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
|
||||||
|
)
|
||||||
|
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
|
||||||
|
|
||||||
|
# A restore that dropped files can still report success if the only
|
||||||
|
# failures were plugin reinstalls, since those don't touch result.errors.
|
||||||
|
if result.plugins_failed:
|
||||||
|
result.success = False
|
||||||
|
|
||||||
|
data = result.to_dict()
|
||||||
|
if not result.success:
|
||||||
|
# Name what failed, and what nonetheless landed. A restore is
|
||||||
|
# partial far more often than it is total -- a fresh install can
|
||||||
|
# leave config_secrets.json unwritable by the web service, so
|
||||||
|
# config restores and secrets do not. "Restore had errors" alone
|
||||||
|
# left the user unable to tell a wholly failed restore from one
|
||||||
|
# that quietly dropped their API keys.
|
||||||
|
failed_plugins = [
|
||||||
|
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
|
||||||
|
]
|
||||||
|
parts = []
|
||||||
|
if result.restored:
|
||||||
|
parts.append(f"restored: {', '.join(result.restored)}")
|
||||||
|
if result.errors:
|
||||||
|
parts.append(f"failed: {'; '.join(result.errors)}")
|
||||||
|
if failed_plugins:
|
||||||
|
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
|
||||||
|
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
|
||||||
|
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
|
||||||
|
return jsonify({'status': 'success', 'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("backup_restore failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
|
||||||
|
def backup_download(filename):
|
||||||
|
"""Stream a backup ZIP to the browser."""
|
||||||
|
from flask import send_from_directory
|
||||||
|
if _safe_backup_path(filename) is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
try:
|
||||||
|
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
||||||
|
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
||||||
|
def backup_delete(filename):
|
||||||
|
"""Delete a stored backup ZIP."""
|
||||||
|
safe = _safe_backup_path(filename)
|
||||||
|
if safe is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
|
# Enumerate the export directory and match by name so the unlink target is
|
||||||
|
# a filesystem-derived path rather than one constructed from user input.
|
||||||
|
try:
|
||||||
|
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
|
||||||
|
if entry.is_file() and entry.name == safe.name:
|
||||||
|
entry.unlink()
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("backup_delete failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
||||||
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
|||||||
|
"""Display control, on-demand playback and preview.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
_ensure_cache_manager, _ensure_display_service_running,
|
||||||
|
_get_display_service_status, _stop_display_service, api_v3,
|
||||||
|
describe_exception, jsonify, logger, os, request, uuid,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/display/current', methods=['GET'])
|
||||||
|
def get_display_current():
|
||||||
|
"""Get current display state"""
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||||
|
|
||||||
|
# Get display dimensions from config
|
||||||
|
try:
|
||||||
|
if api_v3.config_manager:
|
||||||
|
main_config = api_v3.config_manager.load_config()
|
||||||
|
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||||
|
cols = hardware_config.get('cols', 64)
|
||||||
|
chain_length = hardware_config.get('chain_length', 2)
|
||||||
|
rows = hardware_config.get('rows', 32)
|
||||||
|
parallel = hardware_config.get('parallel', 1)
|
||||||
|
width = cols * chain_length
|
||||||
|
height = rows * parallel
|
||||||
|
else:
|
||||||
|
width = 128
|
||||||
|
height = 64
|
||||||
|
except Exception:
|
||||||
|
width = 128
|
||||||
|
height = 64
|
||||||
|
|
||||||
|
# Try to read snapshot file
|
||||||
|
image_data = None
|
||||||
|
if os.path.exists(snapshot_path):
|
||||||
|
try:
|
||||||
|
with Image.open(snapshot_path) as img:
|
||||||
|
# Convert to PNG and encode as base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
except Exception as img_err:
|
||||||
|
# File might be being written or corrupted, return None
|
||||||
|
pass
|
||||||
|
|
||||||
|
display_data = {
|
||||||
|
'timestamp': _pkg.time.time(),
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'image': image_data # Base64 encoded image data or None if unavailable
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': display_data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/display/modes', methods=['GET'])
|
||||||
|
def get_display_modes():
|
||||||
|
"""Every display mode that can be requested on-demand, with its plugin.
|
||||||
|
|
||||||
|
/plugins/installed carries no mode information, so anything driving the
|
||||||
|
display from outside the web UI (the Home Assistant MQTT bridge, a script)
|
||||||
|
had to read each plugin's manifest.json off disk and reimplement the
|
||||||
|
fallbacks in PluginManager.get_plugin_display_modes to do it. This is the
|
||||||
|
same list the force-display dialog offers, from the source that owns it.
|
||||||
|
|
||||||
|
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||||
|
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
||||||
|
lookup only sees modes declared in a static manifest -- a plugin whose
|
||||||
|
modes are generated (each installed Starlark app is one) 404s there.
|
||||||
|
Sending the plugin_id from this list skips the lookup entirely.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||||
|
still be requested on-demand -- the controller enables the plugin
|
||||||
|
for the duration -- so they are reported with enabled: false
|
||||||
|
rather than omitted.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not api_v3.plugin_manager:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||||
|
|
||||||
|
# Discovery is lazy and normally triggered by whichever endpoint runs
|
||||||
|
# first, which is a person opening the dashboard. A caller that never
|
||||||
|
# visits it would otherwise see an empty list.
|
||||||
|
api_v3.plugin_manager.discover_plugins()
|
||||||
|
|
||||||
|
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
||||||
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
|
||||||
|
modes = []
|
||||||
|
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
||||||
|
# A hand-edited or migrated config.json can hold a non-dict under a
|
||||||
|
# plugin id; DisplayController._reconcile guards the same shape, so
|
||||||
|
# it happens in practice. Without this, .get() raises AttributeError,
|
||||||
|
# the loop aborts and the endpoint answers 500 with no modes at all
|
||||||
|
# -- one bad section would blank every entity the MQTT bridge builds
|
||||||
|
# from this list.
|
||||||
|
plugin_config = full_config.get(plugin_id)
|
||||||
|
if not isinstance(plugin_config, dict):
|
||||||
|
if plugin_config is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Config for plugin %r is %s, not an object; treating it as disabled",
|
||||||
|
plugin_id, type(plugin_config).__name__)
|
||||||
|
plugin_config = {}
|
||||||
|
enabled = bool(plugin_config.get('enabled', False))
|
||||||
|
if not enabled and not include_disabled:
|
||||||
|
continue
|
||||||
|
plugin_name = (manifest or {}).get('name') or plugin_id
|
||||||
|
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
||||||
|
for mode in plugin_modes:
|
||||||
|
# A single-mode plugin's mode is the plugin, so its own name is
|
||||||
|
# the readable label. Multi-mode plugins have no per-mode name
|
||||||
|
# anywhere, so the raw mode string is the only thing to show.
|
||||||
|
modes.append({
|
||||||
|
'mode': mode,
|
||||||
|
'plugin_id': plugin_id,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
||||||
|
'enabled': enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||||
|
except Exception as exc:
|
||||||
|
# describe_exception, not a bare message: test_web_error_detail.py
|
||||||
|
# enforces that every handler here returns it, because a device whose
|
||||||
|
# storage is failing otherwise answers "see logs for details" from the
|
||||||
|
# log viewer too. It redacts credentials out of the exception text.
|
||||||
|
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
||||||
|
# it is the project's deliberate, reviewed trade-off.
|
||||||
|
logger.error('Error in get_display_modes', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||||
|
def get_on_demand_status():
|
||||||
|
"""Return the current on-demand display state."""
|
||||||
|
try:
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
state = cache.get('display_on_demand_state', max_age=120)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'active': False,
|
||||||
|
'status': 'idle',
|
||||||
|
'last_updated': None
|
||||||
|
}
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'state': state,
|
||||||
|
'service': service_status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in get_on_demand_status', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||||
|
def start_on_demand_display():
|
||||||
|
"""Request the display controller to run a specific plugin on-demand."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
plugin_id = data.get('plugin_id')
|
||||||
|
mode = data.get('mode')
|
||||||
|
duration = data.get('duration')
|
||||||
|
pinned = bool(data.get('pinned', False))
|
||||||
|
start_service = data.get('start_service', True)
|
||||||
|
|
||||||
|
if not plugin_id and not mode:
|
||||||
|
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
||||||
|
|
||||||
|
resolved_plugin = plugin_id
|
||||||
|
resolved_mode = mode
|
||||||
|
|
||||||
|
if api_v3.plugin_manager:
|
||||||
|
if resolved_plugin and resolved_plugin not in api_v3.plugin_manager.plugin_manifests:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
||||||
|
|
||||||
|
if resolved_plugin and not resolved_mode:
|
||||||
|
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
||||||
|
resolved_mode = modes[0] if modes else resolved_plugin
|
||||||
|
elif resolved_mode and not resolved_plugin:
|
||||||
|
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
||||||
|
if not resolved_plugin:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||||
|
|
||||||
|
# Note: On-demand can work with disabled plugins - the display controller
|
||||||
|
# will temporarily enable them during initialization if needed
|
||||||
|
# We don't block the request here, but log it for debugging
|
||||||
|
if api_v3.config_manager and resolved_plugin:
|
||||||
|
config = api_v3.config_manager.load_config()
|
||||||
|
plugin_config = config.get(resolved_plugin, {})
|
||||||
|
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
||||||
|
logger.info(
|
||||||
|
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
||||||
|
resolved_plugin,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the on-demand request in cache FIRST (before starting service)
|
||||||
|
# This ensures the request is available when the service starts/restarts
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
|
request_payload = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'action': 'start',
|
||||||
|
'plugin_id': resolved_plugin,
|
||||||
|
'mode': resolved_mode,
|
||||||
|
'duration': duration,
|
||||||
|
'pinned': pinned,
|
||||||
|
'timestamp': _pkg.time.time()
|
||||||
|
}
|
||||||
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Check if display service is running (or will be started)
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
service_was_running = service_status.get('active', False)
|
||||||
|
|
||||||
|
# Stop the display service first to ensure clean state when we will restart it
|
||||||
|
if service_was_running and start_service:
|
||||||
|
import time as time_module
|
||||||
|
logger.debug("Stopping display service before starting on-demand mode")
|
||||||
|
_stop_display_service()
|
||||||
|
# Wait a brief moment for the service to fully stop
|
||||||
|
time_module.sleep(1.5)
|
||||||
|
logger.debug("Display service stopped, now starting with on-demand request")
|
||||||
|
|
||||||
|
if not service_status.get('active') and not start_service:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
|
'service_status': service_status
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if start_service:
|
||||||
|
service_result = _ensure_display_service_running()
|
||||||
|
# Check if service actually started
|
||||||
|
if service_result and not service_result.get('active'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
|
'service_result': service_result
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Service was restarted (or started fresh) with on-demand request in cache
|
||||||
|
# The display controller will read the request during initialization or when it polls
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'plugin_id': resolved_plugin,
|
||||||
|
'mode': resolved_mode,
|
||||||
|
'duration': duration,
|
||||||
|
'pinned': pinned,
|
||||||
|
'service': service_result
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': response_data})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in start_on_demand_display', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||||
|
def stop_on_demand_display():
|
||||||
|
"""Request the display controller to stop on-demand mode."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
stop_service = data.get('stop_service', False)
|
||||||
|
|
||||||
|
# Set the stop request in cache FIRST
|
||||||
|
# The display controller will poll this and restart without the on-demand filter
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
|
request_payload = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'action': 'stop',
|
||||||
|
'timestamp': _pkg.time.time()
|
||||||
|
}
|
||||||
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Note: The display controller's _clear_on_demand() will handle the restart
|
||||||
|
# to restore normal operation with all plugins
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if stop_service:
|
||||||
|
service_result = _stop_display_service()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'request_id': request_id,
|
||||||
|
'service': service_result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Error in stop_on_demand_display', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
||||||
|
@api_v3.route('/display/current-status', methods=['GET'])
|
||||||
|
def get_current_display_status():
|
||||||
|
"""Return the display mode/plugin currently intended to be shown.
|
||||||
|
|
||||||
|
Published by the display process (display_controller._publish_current_mode_state)
|
||||||
|
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
||||||
|
System Logs page) can show what's on screen without querying the display
|
||||||
|
process directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
state = cache.get('display_current_state', max_age=120)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'mode': None,
|
||||||
|
'plugin_id': None,
|
||||||
|
'last_updated': None,
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': state})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in get_current_display_status', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Font catalogue, upload, preview and deletion.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
|
||||||
|
jsonify, logger, os, re, request, validate_file_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||||
|
def get_fonts_catalog():
|
||||||
|
"""Get fonts catalog"""
|
||||||
|
try:
|
||||||
|
# Check cache first (5 minute TTL)
|
||||||
|
try:
|
||||||
|
from web_interface.cache import get_cached, set_cached
|
||||||
|
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
||||||
|
if cached_result is not None:
|
||||||
|
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
|
||||||
|
except ImportError:
|
||||||
|
# Cache not available, continue without caching
|
||||||
|
get_cached = None
|
||||||
|
set_cached = None
|
||||||
|
|
||||||
|
# Try to import freetype, but continue without it if unavailable
|
||||||
|
try:
|
||||||
|
import freetype
|
||||||
|
freetype_available = True
|
||||||
|
except ImportError:
|
||||||
|
freetype_available = False
|
||||||
|
|
||||||
|
# Scan assets/fonts directory for actual font files
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
catalog = {}
|
||||||
|
|
||||||
|
if fonts_dir.exists() and fonts_dir.is_dir():
|
||||||
|
for filename in os.listdir(fonts_dir):
|
||||||
|
if filename.endswith(('.ttf', '.otf', '.bdf')):
|
||||||
|
filepath = fonts_dir / filename
|
||||||
|
# Generate family name from filename (without extension)
|
||||||
|
family_name = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# Try to get font metadata using freetype (for TTF/OTF)
|
||||||
|
metadata = {}
|
||||||
|
if filename.endswith(('.ttf', '.otf')) and freetype_available:
|
||||||
|
try:
|
||||||
|
face = freetype.Face(str(filepath))
|
||||||
|
if face.valid:
|
||||||
|
# Get font family name from font file
|
||||||
|
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
|
||||||
|
metadata = {
|
||||||
|
'family': family_name_from_font,
|
||||||
|
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
|
||||||
|
'num_glyphs': face.num_glyphs,
|
||||||
|
'units_per_em': face.units_per_EM
|
||||||
|
}
|
||||||
|
# Use font's family name if available
|
||||||
|
if family_name_from_font:
|
||||||
|
family_name = family_name_from_font
|
||||||
|
except Exception:
|
||||||
|
# If freetype fails, use filename-based name
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Store relative path from project root
|
||||||
|
relative_path = str(filepath.relative_to(PROJECT_ROOT))
|
||||||
|
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
|
||||||
|
|
||||||
|
# Generate human-readable display name from family_name
|
||||||
|
display_name = family_name.replace('-', ' ').replace('_', ' ')
|
||||||
|
# Add space before capital letters for camelCase names
|
||||||
|
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
|
||||||
|
# Add space before numbers that follow letters
|
||||||
|
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
|
||||||
|
# Clean up multiple spaces
|
||||||
|
display_name = ' '.join(display_name.split())
|
||||||
|
|
||||||
|
# Use filename (without extension) as unique key to avoid collisions
|
||||||
|
# when multiple files share the same family_name from font metadata
|
||||||
|
catalog_key = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# Check if this is a system font (cannot be deleted)
|
||||||
|
is_system = catalog_key.lower() in SYSTEM_FONTS
|
||||||
|
|
||||||
|
# BDF files are fixed-size bitmap strikes: FreeType
|
||||||
|
# accepts only the pixel size baked into the file. The
|
||||||
|
# UI needs to know that before offering a size control,
|
||||||
|
# or it offers a number that cannot take effect.
|
||||||
|
native_size = None
|
||||||
|
if font_type == 'bdf':
|
||||||
|
try:
|
||||||
|
from src.element_style import _read_bdf_native_size
|
||||||
|
native_size = _read_bdf_native_size(str(filepath))
|
||||||
|
except Exception:
|
||||||
|
native_size = None
|
||||||
|
|
||||||
|
catalog[catalog_key] = {
|
||||||
|
'filename': filename,
|
||||||
|
'family_name': family_name,
|
||||||
|
'display_name': display_name,
|
||||||
|
'path': relative_path,
|
||||||
|
'type': font_type,
|
||||||
|
'is_system': is_system,
|
||||||
|
'scalable': font_type != 'bdf',
|
||||||
|
'native_size': native_size,
|
||||||
|
'metadata': metadata if metadata else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result (5 minute TTL) if available
|
||||||
|
if set_cached:
|
||||||
|
try:
|
||||||
|
set_cached('fonts_catalog', catalog, ttl_seconds=300)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/tokens', methods=['GET'])
|
||||||
|
def get_font_tokens():
|
||||||
|
"""Get font size tokens"""
|
||||||
|
try:
|
||||||
|
# This would integrate with the actual font system
|
||||||
|
# For now, return sample tokens
|
||||||
|
tokens = {
|
||||||
|
'xs': 6,
|
||||||
|
'sm': 8,
|
||||||
|
'md': 10,
|
||||||
|
'lg': 12,
|
||||||
|
'xl': 14,
|
||||||
|
'xxl': 16
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||||
|
def upload_font():
|
||||||
|
"""Upload font file"""
|
||||||
|
try:
|
||||||
|
if 'font_file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
||||||
|
|
||||||
|
font_file = request.files['font_file']
|
||||||
|
if font_file.filename == '':
|
||||||
|
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
||||||
|
|
||||||
|
# Validate filename. validate_file_upload takes max_size_mb but only
|
||||||
|
# checks the filename/extension with it -- it never looks at the
|
||||||
|
# actual upload size, so the size limit below is enforced separately
|
||||||
|
# before the file is saved (same pattern as the .star upload above).
|
||||||
|
MAX_FONT_SIZE_MB = 10
|
||||||
|
is_valid, error_msg = validate_file_upload(
|
||||||
|
font_file.filename,
|
||||||
|
max_size_mb=MAX_FONT_SIZE_MB,
|
||||||
|
allowed_extensions=['.ttf', '.otf', '.bdf']
|
||||||
|
)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
||||||
|
font_file.seek(0, 2) # Seek to end
|
||||||
|
file_size = font_file.tell()
|
||||||
|
font_file.seek(0) # Reset to beginning
|
||||||
|
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
||||||
|
if file_size > max_font_size_bytes:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
font_family = request.form.get('font_family', '')
|
||||||
|
|
||||||
|
if not font_family:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
||||||
|
|
||||||
|
# Validate font family name
|
||||||
|
if not font_family.replace('_', '').replace('-', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
||||||
|
|
||||||
|
# Save the font file to assets/fonts directory
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create filename from family name
|
||||||
|
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
||||||
|
safe_filename = f"{font_family}{original_ext}"
|
||||||
|
filepath = fonts_dir / safe_filename
|
||||||
|
|
||||||
|
# Check if file already exists
|
||||||
|
if filepath.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
font_file.save(str(filepath))
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontUpload] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {font_family} uploaded successfully',
|
||||||
|
'font_family': font_family,
|
||||||
|
'filename': safe_filename,
|
||||||
|
'path': f'assets/fonts/{safe_filename}'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||||
|
def get_font_preview() -> tuple[Response, int] | Response:
|
||||||
|
"""Generate a preview image of text rendered with a specific font"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Limits to prevent DoS via large image generation on constrained devices
|
||||||
|
MAX_TEXT_CHARS = 100
|
||||||
|
MAX_TEXT_LINES = 3
|
||||||
|
MAX_DIM = 1024 # Max width or height in pixels
|
||||||
|
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
||||||
|
|
||||||
|
font_filename = request.args.get('font', '')
|
||||||
|
text = request.args.get('text', 'Sample Text 123')
|
||||||
|
bg_color = request.args.get('bg', '000000')
|
||||||
|
fg_color = request.args.get('fg', 'ffffff')
|
||||||
|
|
||||||
|
# Validate text length and line count early
|
||||||
|
if len(text) > MAX_TEXT_CHARS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
||||||
|
if text.count('\n') >= MAX_TEXT_LINES:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
||||||
|
|
||||||
|
# Safe integer parsing for size
|
||||||
|
try:
|
||||||
|
size = int(request.args.get('size', 12))
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
||||||
|
|
||||||
|
if not font_filename:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
||||||
|
|
||||||
|
# Validate size
|
||||||
|
if size < 4 or size > 72:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
||||||
|
|
||||||
|
# Security: Validate font_filename to prevent path traversal
|
||||||
|
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
||||||
|
safe_name = Path(font_filename).name # Strip any directory components
|
||||||
|
if safe_name != font_filename or '..' in font_filename:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
||||||
|
|
||||||
|
# Validate extension
|
||||||
|
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
||||||
|
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
||||||
|
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
||||||
|
|
||||||
|
# Find the font file
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
if not fonts_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
|
font_path = fonts_dir / safe_name
|
||||||
|
|
||||||
|
if not font_path.exists() and not has_valid_ext:
|
||||||
|
# Try finding by family name (without extension)
|
||||||
|
for ext in allowed_extensions:
|
||||||
|
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
||||||
|
if potential_path.exists():
|
||||||
|
font_path = potential_path
|
||||||
|
break
|
||||||
|
|
||||||
|
# Final security check: ensure path is within fonts_dir
|
||||||
|
try:
|
||||||
|
font_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
||||||
|
|
||||||
|
if not font_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
||||||
|
|
||||||
|
# Parse colors
|
||||||
|
try:
|
||||||
|
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
bg_rgb = (0, 0, 0)
|
||||||
|
fg_rgb = (255, 255, 255)
|
||||||
|
|
||||||
|
# Load font
|
||||||
|
font = None
|
||||||
|
if str(font_path).endswith('.bdf'):
|
||||||
|
# BDF fonts require complex per-glyph rendering via freetype
|
||||||
|
# Return explicit error rather than showing misleading preview with default font
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
# TTF/OTF fonts
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(str(font_path), size)
|
||||||
|
except (IOError, OSError) as e:
|
||||||
|
# IOError/OSError raised for invalid/corrupt font files
|
||||||
|
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Calculate text size
|
||||||
|
temp_img = Image.new('RGB', (1, 1))
|
||||||
|
temp_draw = ImageDraw.Draw(temp_img)
|
||||||
|
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# Create image with padding
|
||||||
|
padding = 10
|
||||||
|
img_width = max(text_width + padding * 2, 100)
|
||||||
|
img_height = max(text_height + padding * 2, 30)
|
||||||
|
|
||||||
|
# Validate resulting image size to prevent memory/CPU spikes
|
||||||
|
if img_width > MAX_DIM or img_height > MAX_DIM:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
if img_width * img_height > MAX_PIXELS:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
|
||||||
|
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Center text
|
||||||
|
x = (img_width - text_width) // 2
|
||||||
|
y = (img_height - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, font=font, fill=fg_rgb)
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'image': f'data:image/png;base64,{img_base64}',
|
||||||
|
'width': img_width,
|
||||||
|
'height': img_height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||||
|
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||||
|
"""Delete a user-uploaded font file"""
|
||||||
|
try:
|
||||||
|
# Security: Validate font_family to prevent path traversal
|
||||||
|
# Reject if it contains path separators or ..
|
||||||
|
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
|
|
||||||
|
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
|
|
||||||
|
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
||||||
|
if font_family.lower() in SYSTEM_FONTS:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
||||||
|
|
||||||
|
# Find and delete the font file
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
|
||||||
|
# Ensure fonts directory exists
|
||||||
|
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
|
deleted = False
|
||||||
|
deleted_filename = None
|
||||||
|
|
||||||
|
# Only try valid font extensions (no empty string to avoid matching directories)
|
||||||
|
for ext in ['.ttf', '.otf', '.bdf']:
|
||||||
|
potential_path = fonts_dir / f"{font_family}{ext}"
|
||||||
|
|
||||||
|
# Security: Verify path is within fonts_dir
|
||||||
|
try:
|
||||||
|
potential_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue # Path escapes fonts_dir, skip
|
||||||
|
|
||||||
|
if potential_path.exists() and potential_path.is_file():
|
||||||
|
potential_path.unlink()
|
||||||
|
deleted = True
|
||||||
|
deleted_filename = f"{font_family}{ext}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
# Try case-insensitive match within fonts directory
|
||||||
|
font_family_lower = font_family.lower()
|
||||||
|
for filename in os.listdir(fonts_dir):
|
||||||
|
# Only consider files with valid font extensions
|
||||||
|
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name_without_ext = os.path.splitext(filename)[0]
|
||||||
|
if name_without_ext.lower() == font_family_lower:
|
||||||
|
filepath = fonts_dir / filename
|
||||||
|
|
||||||
|
# Security: Verify path is within fonts_dir
|
||||||
|
try:
|
||||||
|
filepath.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue # Path escapes fonts_dir, skip
|
||||||
|
|
||||||
|
if filepath.is_file():
|
||||||
|
filepath.unlink()
|
||||||
|
deleted = True
|
||||||
|
deleted_filename = filename
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontDelete] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {deleted_filename} deleted successfully'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""Routes with no larger group of their own: errors, integrations,
|
||||||
|
cache, sync, skins, logs, health and hardware.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
_coerce_to_bool,
|
||||||
|
ErrorCode, Path, _JOURNALCTL, _MQTT_BRIDGE_CONFIG, _MQTT_BRIDGE_DEFAULTS,
|
||||||
|
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||||
|
_get_display_service_status, _mqtt_bridge_service_state,
|
||||||
|
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||||
|
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
||||||
|
subprocess, success_response, tempfile,
|
||||||
|
)
|
||||||
|
from src.common.path_safety import safe_path_component
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/health', methods=['GET'])
|
||||||
|
def get_health():
|
||||||
|
"""Get system health status"""
|
||||||
|
try:
|
||||||
|
health_status = {
|
||||||
|
'status': 'healthy',
|
||||||
|
'timestamp': _pkg.time.time(),
|
||||||
|
'services': {},
|
||||||
|
'checks': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check web interface service
|
||||||
|
# Stamp the start _pkg.time before measuring against it -- reading it with a
|
||||||
|
# fallback of _pkg.time.time() and only assigning afterwards made the very
|
||||||
|
# first call subtract two separate clock reads, reporting a small
|
||||||
|
# negative uptime.
|
||||||
|
if not hasattr(get_health, '_start_time'):
|
||||||
|
get_health._start_time = _pkg.time.time()
|
||||||
|
health_status['services']['web_interface'] = {
|
||||||
|
'status': 'running',
|
||||||
|
'uptime_seconds': _pkg.time.time() - get_health._start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check display service
|
||||||
|
display_service_status = _get_display_service_status()
|
||||||
|
health_status['services']['display_service'] = {
|
||||||
|
'status': 'active' if display_service_status.get('active') else 'inactive',
|
||||||
|
'details': display_service_status
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check config file accessibility
|
||||||
|
try:
|
||||||
|
if api_v3.config_manager:
|
||||||
|
test_config = api_v3.config_manager.load_config()
|
||||||
|
health_status['checks']['config_file'] = {
|
||||||
|
'status': 'accessible',
|
||||||
|
'readable': True
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
health_status['checks']['config_file'] = {
|
||||||
|
'status': 'unknown',
|
||||||
|
'readable': False
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
health_status['checks']['config_file'] = {
|
||||||
|
'status': 'error',
|
||||||
|
'readable': False,
|
||||||
|
'error': 'see logs for details'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check plugin system
|
||||||
|
try:
|
||||||
|
if api_v3.plugin_manager:
|
||||||
|
# Try to discover plugins (lightweight check)
|
||||||
|
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
||||||
|
health_status['checks']['plugin_system'] = {
|
||||||
|
'status': 'operational',
|
||||||
|
'plugin_count': plugin_count
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
health_status['checks']['plugin_system'] = {
|
||||||
|
'status': 'not_initialized'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
health_status['checks']['plugin_system'] = {
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'see logs for details'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check hardware connectivity (if display manager available)
|
||||||
|
try:
|
||||||
|
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||||
|
if os.path.exists(snapshot_path):
|
||||||
|
# Check if snapshot is recent (updated in last 60 seconds)
|
||||||
|
mtime = os.path.getmtime(snapshot_path)
|
||||||
|
age_seconds = _pkg.time.time() - mtime
|
||||||
|
health_status['checks']['hardware'] = {
|
||||||
|
'status': 'connected' if age_seconds < 60 else 'stale',
|
||||||
|
'snapshot_age_seconds': round(age_seconds, 1)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
health_status['checks']['hardware'] = {
|
||||||
|
'status': 'no_snapshot',
|
||||||
|
'note': 'Display service may not be running'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
health_status['checks']['hardware'] = {
|
||||||
|
'status': 'unknown',
|
||||||
|
'error': 'see logs for details'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine overall health
|
||||||
|
all_healthy = all(
|
||||||
|
check.get('status') in ['accessible', 'operational', 'connected', 'running', 'active']
|
||||||
|
for check in health_status['checks'].values()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_healthy:
|
||||||
|
health_status['status'] = 'degraded'
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'data': health_status})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e),
|
||||||
|
'data': {'status': 'unhealthy'}
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/hardware/status', methods=['GET'])
|
||||||
|
def get_hardware_status():
|
||||||
|
"""Return LED matrix hardware initialization status written by display_manager at startup."""
|
||||||
|
status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
||||||
|
try:
|
||||||
|
with open(status_path) as f:
|
||||||
|
hw_data = json.load(f)
|
||||||
|
return jsonify({"status": "success", "data": hw_data})
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({"status": "success", "data": {"ok": None, "error": "Display service not yet started"}})
|
||||||
|
except PermissionError:
|
||||||
|
logger.warning("Permission denied reading hardware status file; display service may be running as a different user")
|
||||||
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status temporarily unavailable"}})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Failed to parse hardware status file", exc_info=True)
|
||||||
|
return jsonify({"status": "success", "data": {"ok": False, "error": "Hardware status file corrupted"}})
|
||||||
|
except Exception:
|
||||||
|
logger.error("Unexpected error reading hardware status", exc_info=True)
|
||||||
|
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
||||||
|
@api_v3.route('/skins', methods=['GET'])
|
||||||
|
def list_skins():
|
||||||
|
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
||||||
|
|
||||||
|
Optional ?plugin_id=... filters to skins matching that plugin.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
|
||||||
|
plugin_id = request.args.get('plugin_id')
|
||||||
|
if plugin_id:
|
||||||
|
skins = skin_runtime.skins_for_plugin(plugin_id)
|
||||||
|
else:
|
||||||
|
# The discovery cache self-invalidates on directory/manifest
|
||||||
|
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
||||||
|
skins = skin_runtime.discover_skins()
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for skin_id, manifest in sorted(skins.items()):
|
||||||
|
skin_dir = Path(manifest['_skin_dir'])
|
||||||
|
preview = manifest.get('preview')
|
||||||
|
payload.append({
|
||||||
|
'id': skin_id,
|
||||||
|
'name': manifest.get('name', skin_id),
|
||||||
|
'version': manifest.get('version'),
|
||||||
|
'author': manifest.get('author'),
|
||||||
|
'description': manifest.get('description', ''),
|
||||||
|
'skin_api_version': manifest.get('skin_api_version'),
|
||||||
|
'targets': manifest.get('targets', {}),
|
||||||
|
'modes': manifest.get('modes', []),
|
||||||
|
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in list_skins', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/logs', methods=['GET'])
|
||||||
|
def get_logs():
|
||||||
|
"""Get system logs from journalctl"""
|
||||||
|
try:
|
||||||
|
if not _JOURNALCTL:
|
||||||
|
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
||||||
|
# Get recent logs from journalctl
|
||||||
|
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
|
||||||
|
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||||
|
'-n', '100', '--no-pager', '--output=short-iso']
|
||||||
|
result = subprocess.run(
|
||||||
|
_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logs_text = result.stdout.strip()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to get logs: {result.stderr}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Timeout while fetching logs'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
# Multi-Display Sync Endpoints
|
||||||
|
@api_v3.route('/sync/status', methods=['GET'])
|
||||||
|
def get_sync_status():
|
||||||
|
"""Return live multi-display sync status written by the display process."""
|
||||||
|
import os as _os
|
||||||
|
status_file = "/tmp/led_matrix_sync_status.json"
|
||||||
|
# Also surface config so the UI can show the configured role even before
|
||||||
|
# the display process has written a status file.
|
||||||
|
cfg_role = "standalone"
|
||||||
|
cfg_port = 5765
|
||||||
|
if api_v3.config_manager:
|
||||||
|
try:
|
||||||
|
cfg = api_v3.config_manager.load_config().get("sync", {})
|
||||||
|
cfg_role = cfg.get("role", "standalone")
|
||||||
|
cfg_port = int(cfg.get("port", 5765))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _os.path.exists(status_file):
|
||||||
|
try:
|
||||||
|
with open(status_file) as f:
|
||||||
|
live = json.load(f)
|
||||||
|
return jsonify({"status": "success", "data": live})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Status file not yet written — return config-only placeholder
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"role": cfg_role,
|
||||||
|
"port": cfg_port,
|
||||||
|
"state": "starting",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@api_v3.route('/cache/list', methods=['GET'])
|
||||||
|
def list_cache_files():
|
||||||
|
"""List all cache files with metadata"""
|
||||||
|
try:
|
||||||
|
if not api_v3.cache_manager:
|
||||||
|
# Initialize cache manager if not already initialized
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
api_v3.cache_manager = CacheManager()
|
||||||
|
|
||||||
|
cache_files = api_v3.cache_manager.list_cache_files()
|
||||||
|
cache_dir = api_v3.cache_manager.get_cache_dir()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'cache_files': cache_files,
|
||||||
|
'cache_dir': cache_dir,
|
||||||
|
'total_files': len(cache_files)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in list_cache_files', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/cache/delete', methods=['POST'])
|
||||||
|
def delete_cache_file():
|
||||||
|
"""Delete a specific cache file by key"""
|
||||||
|
try:
|
||||||
|
if not api_v3.cache_manager:
|
||||||
|
# Initialize cache manager if not already initialized
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
api_v3.cache_manager = CacheManager()
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data or 'key' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
||||||
|
|
||||||
|
cache_key = data['key']
|
||||||
|
|
||||||
|
# The key names the file about to be removed. DiskCache refuses an
|
||||||
|
# unusable key on its own, but silently: say so here instead of
|
||||||
|
# reporting a deletion that never happened.
|
||||||
|
if safe_path_component(cache_key) is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid cache key'}), 400
|
||||||
|
|
||||||
|
# Delete the cache file
|
||||||
|
api_v3.cache_manager.clear_cache(cache_key)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in delete_cache_file', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/errors/summary', methods=['GET'])
|
||||||
|
def get_error_summary():
|
||||||
|
"""
|
||||||
|
Get summary of all errors for monitoring and debugging.
|
||||||
|
|
||||||
|
Returns error counts, detected patterns, and recent errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
aggregator = get_error_aggregator()
|
||||||
|
summary = aggregator.get_error_summary()
|
||||||
|
return success_response(data=summary, message="Error summary retrieved")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
|
message="Failed to retrieve error summary",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
@api_v3.route('/errors/plugin/<plugin_id>', methods=['GET'])
|
||||||
|
def get_plugin_errors(plugin_id):
|
||||||
|
"""
|
||||||
|
Get error health status for a specific plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
|
Returns health status and error statistics for the plugin.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
aggregator = get_error_aggregator()
|
||||||
|
health = aggregator.get_plugin_health(plugin_id)
|
||||||
|
return success_response(data=health, message="Plugin health retrieved")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
|
message=f"Failed to retrieve health for plugin {plugin_id}",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
@api_v3.route('/errors/clear', methods=['POST'])
|
||||||
|
def clear_old_errors():
|
||||||
|
"""
|
||||||
|
Clear error records older than specified age.
|
||||||
|
|
||||||
|
Request body (optional):
|
||||||
|
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_max_age = data.get('max_age_hours', 24)
|
||||||
|
|
||||||
|
# Validate and coerce max_age_hours
|
||||||
|
try:
|
||||||
|
max_age_hours = int(raw_max_age)
|
||||||
|
if max_age_hours < 1:
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be at least 1",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
if max_age_hours > 8760: # 1 year max
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be a valid integer",
|
||||||
|
context={'provided_value': str(raw_max_age)},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
aggregator = get_error_aggregator()
|
||||||
|
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={'cleared_count': cleared_count},
|
||||||
|
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
|
message="Failed to clear old errors",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/integrations/mqtt-bridge', methods=['GET'])
|
||||||
|
def get_mqtt_bridge():
|
||||||
|
"""Bridge service state and its settings, minus the password."""
|
||||||
|
try:
|
||||||
|
config = _read_mqtt_bridge_config()
|
||||||
|
password = config.get('mqtt_password')
|
||||||
|
safe = {key: config.get(key, default)
|
||||||
|
for key, default in _MQTT_BRIDGE_DEFAULTS.items()}
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'service': _mqtt_bridge_service_state(),
|
||||||
|
'config_exists': _MQTT_BRIDGE_CONFIG.is_file(),
|
||||||
|
'config_path': str(_MQTT_BRIDGE_CONFIG),
|
||||||
|
'config': safe,
|
||||||
|
# Enough to render "a password is set" without disclosing it.
|
||||||
|
'password_set': bool(password),
|
||||||
|
'env_override_prefix': 'LEDMATRIX_MQTT_',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error reading MQTT bridge settings')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not read bridge settings',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/integrations/mqtt-bridge/config', methods=['PUT'])
|
||||||
|
def update_mqtt_bridge_config():
|
||||||
|
"""Write bridge_config.json.
|
||||||
|
|
||||||
|
The password is write-only: omit it to leave whatever is stored alone, send
|
||||||
|
a value to replace it, or send clear_password to remove it. It is never
|
||||||
|
returned by the GET above, so a form that round-tripped it would otherwise
|
||||||
|
have to blank it on every save.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# No `or {}` here: get_json(silent=True) returns None for a missing or
|
||||||
|
# unparseable body, and `None or {}` produced an empty dict that then
|
||||||
|
# satisfied the isinstance check below -- so malformed JSON, `null`,
|
||||||
|
# `[]` and `false` all reported success while applying nothing.
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Body must be a JSON object'}), 400
|
||||||
|
|
||||||
|
config = _read_mqtt_bridge_config()
|
||||||
|
existing_password = config.get('mqtt_password')
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
for key in _MQTT_BRIDGE_DEFAULTS:
|
||||||
|
if key not in data:
|
||||||
|
continue
|
||||||
|
value, err = _coerce_mqtt_bridge_value(key, data[key])
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
updates[key] = value
|
||||||
|
|
||||||
|
config.update(updates)
|
||||||
|
|
||||||
|
# Coerced, not merely truthy: the string "false" is truthy in Python,
|
||||||
|
# so a client echoing the field back as a string would have wiped a
|
||||||
|
# stored password it meant to keep.
|
||||||
|
if _coerce_to_bool(data.get('clear_password')):
|
||||||
|
config['mqtt_password'] = None
|
||||||
|
elif 'mqtt_password' in data and str(data['mqtt_password']) != '':
|
||||||
|
new_password = str(data['mqtt_password'])
|
||||||
|
if len(new_password) > 300:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Password is too long'}), 400
|
||||||
|
config['mqtt_password'] = new_password
|
||||||
|
else:
|
||||||
|
config['mqtt_password'] = existing_password
|
||||||
|
|
||||||
|
# CWE-319: a password with TLS off is sent in the clear. On a trusted
|
||||||
|
# LAN that is a normal, deliberate setup, so this is refused rather
|
||||||
|
# than forbidden -- allow_insecure_mqtt is the explicit acknowledgement.
|
||||||
|
insecure = bool(config.get('mqtt_password')) and not config.get('mqtt_tls')
|
||||||
|
if insecure and not config.get('allow_insecure_mqtt'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'MQTT credentials would cross the network in cleartext '
|
||||||
|
'with TLS disabled. Enable mqtt_tls, or set '
|
||||||
|
'allow_insecure_mqtt to accept that on a trusted network.'
|
||||||
|
}), 400
|
||||||
|
if insecure:
|
||||||
|
logger.warning('MQTT bridge: a password is set without TLS and '
|
||||||
|
'allow_insecure_mqtt is on; credentials will cross the '
|
||||||
|
'network in cleartext')
|
||||||
|
|
||||||
|
_MQTT_BRIDGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Write via a temp file in the same directory so a crash mid-write
|
||||||
|
# cannot leave a half-written config the bridge would refuse to load.
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=str(_MQTT_BRIDGE_DIR), prefix='.bridge_config.')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(config, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write('\n')
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
os.replace(tmp_path, _MQTT_BRIDGE_CONFIG)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
service = _mqtt_bridge_service_state()
|
||||||
|
message = 'Bridge settings saved.'
|
||||||
|
if service['active']:
|
||||||
|
message += ' Restart the bridge for them to take effect.'
|
||||||
|
return jsonify({'status': 'success', 'message': message,
|
||||||
|
'data': {'password_set': bool(config.get('mqtt_password')),
|
||||||
|
'restart_required': service['active']}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error saving MQTT bridge settings')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not save bridge settings',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,958 @@
|
|||||||
|
"""Starlark / Tronbyte app management routes.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
PROJECT_ROOT, Path, _PIXLET_EDITOR_DEFAULT_PORT,
|
||||||
|
_PIXLET_EDITOR_DEFAULT_TIMEOUT, _PIXLET_EDITOR_MAX_TIMEOUT,
|
||||||
|
_PIXLET_EDITOR_SCRIPT, _PIXLET_EDITOR_STATE, _clear_pixlet_editor_state,
|
||||||
|
_find_pixlet_binary, _install_star_file, _pixlet_editor_alive,
|
||||||
|
_pixlet_editor_status, _read_pixlet_editor_state,
|
||||||
|
_STARLARK_APPS_DIR, _standalone_render_starlark_app,
|
||||||
|
_starlark_github_token, _starlark_manifest_lock,
|
||||||
|
_validate_and_sanitize_app_id,
|
||||||
|
_validate_starlark_app_path, _validate_timing_value, api_v3, contextlib,
|
||||||
|
describe_exception, json, jsonify, logger, os, request, shutil,
|
||||||
|
subprocess, tempfile,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/status', methods=['GET'])
|
||||||
|
def get_starlark_status():
|
||||||
|
"""Get Starlark plugin status and Pixlet availability."""
|
||||||
|
try:
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
info = starlark_plugin.get_info()
|
||||||
|
magnify_info = starlark_plugin.get_magnify_recommendation()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'pixlet_available': info.get('pixlet_available', False),
|
||||||
|
'pixlet_version': info.get('pixlet_version'),
|
||||||
|
'installed_apps': info.get('installed_apps', 0),
|
||||||
|
'enabled_apps': info.get('enabled_apps', 0),
|
||||||
|
'current_app': info.get('current_app'),
|
||||||
|
'plugin_enabled': starlark_plugin.enabled,
|
||||||
|
'display_info': magnify_info
|
||||||
|
})
|
||||||
|
|
||||||
|
# Plugin not loaded - check Pixlet availability via shared resolver
|
||||||
|
# (respects user-configured pixlet_path, bundled binary, and system PATH)
|
||||||
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path'))
|
||||||
|
pixlet_available = pixlet_path is not None
|
||||||
|
|
||||||
|
# Read app counts from manifest
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
apps = manifest.get('apps', {})
|
||||||
|
installed_count = len(apps)
|
||||||
|
enabled_count = sum(1 for a in apps.values() if a.get('enabled', True))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'pixlet_available': pixlet_available,
|
||||||
|
'pixlet_version': None,
|
||||||
|
'installed_apps': installed_count,
|
||||||
|
'enabled_apps': enabled_count,
|
||||||
|
'plugin_enabled': True,
|
||||||
|
'plugin_loaded': False,
|
||||||
|
'display_info': {}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_status failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark status', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/install-pixlet', methods=['POST'])
|
||||||
|
def install_pixlet():
|
||||||
|
"""Download and install Pixlet binary."""
|
||||||
|
try:
|
||||||
|
script_path = PROJECT_ROOT / 'scripts' / 'download_pixlet.sh'
|
||||||
|
if not script_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Installation script not found'}), 404
|
||||||
|
|
||||||
|
os.chmod(script_path, 0o755)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(script_path)],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("Pixlet downloaded successfully")
|
||||||
|
return jsonify({'status': 'success', 'message': 'Pixlet installed successfully!', 'output': result.stdout})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to download Pixlet: {result.stderr}'}), 500
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as err:
|
||||||
|
logger.exception("[Starlark] Pixlet download timed out")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Download timed out',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] install_pixlet failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install Pixlet', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps', methods=['GET'])
|
||||||
|
def get_starlark_apps():
|
||||||
|
"""List all installed Starlark apps."""
|
||||||
|
try:
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
apps_list = []
|
||||||
|
for app_id, app_instance in starlark_plugin.apps.items():
|
||||||
|
apps_list.append({
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_instance.manifest.get('name', app_id),
|
||||||
|
'enabled': app_instance.is_enabled(),
|
||||||
|
'has_frames': app_instance.frames is not None,
|
||||||
|
'render_interval': app_instance.get_render_interval(),
|
||||||
|
'display_duration': app_instance.get_display_duration(),
|
||||||
|
'config': app_instance.config,
|
||||||
|
'has_schema': app_instance.schema is not None,
|
||||||
|
'last_render_time': app_instance.last_render_time
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||||
|
|
||||||
|
# Standalone: read manifest from disk
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
apps_list = []
|
||||||
|
for app_id, app_data in manifest.get('apps', {}).items():
|
||||||
|
apps_list.append({
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_data.get('name', app_id),
|
||||||
|
'enabled': app_data.get('enabled', True),
|
||||||
|
'has_frames': False,
|
||||||
|
'render_interval': app_data.get('render_interval', 300),
|
||||||
|
'display_duration': app_data.get('display_duration', 15),
|
||||||
|
'config': app_data.get('config', {}),
|
||||||
|
'has_schema': False,
|
||||||
|
'last_render_time': None
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'apps': apps_list, 'count': len(apps_list)})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_apps failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark apps', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>', methods=['GET'])
|
||||||
|
def get_starlark_app(app_id):
|
||||||
|
"""Get details for a specific Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'app': {
|
||||||
|
'id': app_id,
|
||||||
|
'name': app.manifest.get('name', app_id),
|
||||||
|
'enabled': app.is_enabled(),
|
||||||
|
'config': app.config,
|
||||||
|
'schema': app.schema,
|
||||||
|
'render_interval': app.get_render_interval(),
|
||||||
|
'display_duration': app.get_display_duration(),
|
||||||
|
'has_frames': app.frames is not None,
|
||||||
|
'frame_count': len(app.frames) if app.frames else 0,
|
||||||
|
'last_render_time': app.last_render_time,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Standalone: read from manifest
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Load schema from schema.json if it exists (path already validated above)
|
||||||
|
schema = None
|
||||||
|
schema_file = app_dir / 'schema.json'
|
||||||
|
if schema_file.exists():
|
||||||
|
try:
|
||||||
|
with open(schema_file, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'app': {
|
||||||
|
'id': app_id,
|
||||||
|
'name': app_data.get('name', app_id),
|
||||||
|
'enabled': app_data.get('enabled', True),
|
||||||
|
'config': app_data.get('config', {}),
|
||||||
|
'schema': schema,
|
||||||
|
'render_interval': app_data.get('render_interval', 300),
|
||||||
|
'display_duration': app_data.get('display_duration', 15),
|
||||||
|
'has_frames': False,
|
||||||
|
'frame_count': 0,
|
||||||
|
'last_render_time': None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/upload', methods=['POST'])
|
||||||
|
def upload_starlark_app():
|
||||||
|
"""Upload and install a new Starlark app."""
|
||||||
|
try:
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No file uploaded'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if not file.filename or not file.filename.endswith('.star'):
|
||||||
|
return jsonify({'status': 'error', 'message': 'File must have .star extension'}), 400
|
||||||
|
|
||||||
|
# Check file size (limit to 5MB for .star files)
|
||||||
|
file.seek(0, 2) # Seek to end
|
||||||
|
file_size = file.tell()
|
||||||
|
file.seek(0) # Reset to beginning
|
||||||
|
MAX_STAR_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
if file_size > MAX_STAR_SIZE:
|
||||||
|
return jsonify({'status': 'error', 'message': f'File too large (max 5MB, got {file_size/1024/1024:.1f}MB)'}), 400
|
||||||
|
|
||||||
|
app_name = request.form.get('name')
|
||||||
|
app_id_input = request.form.get('app_id')
|
||||||
|
filename_base = file.filename.replace('.star', '') if file.filename else None
|
||||||
|
app_id, app_id_error = _validate_and_sanitize_app_id(app_id_input, fallback_source=filename_base)
|
||||||
|
if app_id_error:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||||
|
|
||||||
|
render_interval_input = request.form.get('render_interval')
|
||||||
|
render_interval = 300
|
||||||
|
if render_interval_input is not None:
|
||||||
|
render_interval, err = _validate_timing_value(render_interval_input, 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
render_interval = render_interval or 300
|
||||||
|
|
||||||
|
display_duration_input = request.form.get('display_duration')
|
||||||
|
display_duration = 15
|
||||||
|
if display_duration_input is not None:
|
||||||
|
display_duration, err = _validate_timing_value(display_duration_input, 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
display_duration = display_duration or 15
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||||
|
file.save(tmp.name)
|
||||||
|
temp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = {'name': app_name or app_id, 'render_interval': render_interval, 'display_duration': display_duration}
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.install_app(app_id, temp_path, metadata)
|
||||||
|
else:
|
||||||
|
success = _install_star_file(app_id, temp_path, metadata)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App installed: {app_id}', 'app_id': app_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except (OSError, IOError) as err:
|
||||||
|
# This used to withhold the detail because it names absolute paths on
|
||||||
|
# the device. A full disk and a bad permission are indistinguishable
|
||||||
|
# without it, though, and describe_exception redacts credentials and
|
||||||
|
# truncates -- the same trade-off every other handler here makes.
|
||||||
|
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'File error during upload',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
|
except ImportError as err:
|
||||||
|
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to load app module', 'details': describe_exception(err)}), 500
|
||||||
|
except Exception as err:
|
||||||
|
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to upload app', 'details': describe_exception(err)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>', methods=['DELETE'])
|
||||||
|
def uninstall_starlark_app(app_id):
|
||||||
|
"""Uninstall a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.uninstall_app(app_id)
|
||||||
|
else:
|
||||||
|
# Standalone: remove app dir and manifest entry. app_dir is the
|
||||||
|
# path _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
import shutil
|
||||||
|
if app_dir.exists():
|
||||||
|
shutil.rmtree(app_dir)
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
manifest.get('apps', {}).pop(app_id, None)
|
||||||
|
success = _pkg._write_starlark_manifest(manifest)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App uninstalled: {app_id}'})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to uninstall app'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] uninstall_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to uninstall Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['GET'])
|
||||||
|
def get_starlark_app_config(app_id):
|
||||||
|
"""Get configuration for a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
return jsonify({'status': 'success', 'config': app.config, 'schema': app.schema})
|
||||||
|
|
||||||
|
# Standalone: read from config.json. app_dir is the path
|
||||||
|
# _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
config_file = app_dir / "config.json"
|
||||||
|
|
||||||
|
if not app_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||||
|
|
||||||
|
# Load schema from schema.json
|
||||||
|
schema = None
|
||||||
|
schema_file = app_dir / "schema.json"
|
||||||
|
if schema_file.exists():
|
||||||
|
try:
|
||||||
|
with open(schema_file, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load schema for {app_id}: {e}")
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'config': config, 'schema': schema})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_starlark_app_config failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get Starlark app config', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/config', methods=['PUT'])
|
||||||
|
def update_starlark_app_config(app_id):
|
||||||
|
"""Update configuration for a Starlark app."""
|
||||||
|
try:
|
||||||
|
# Validate app_id before any filesystem access
|
||||||
|
app_dir, error_msg = _validate_starlark_app_path(app_id)
|
||||||
|
if error_msg:
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No configuration provided'}), 400
|
||||||
|
|
||||||
|
if 'render_interval' in data:
|
||||||
|
val, err = _validate_timing_value(data['render_interval'], 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
data['render_interval'] = val
|
||||||
|
|
||||||
|
if 'display_duration' in data:
|
||||||
|
val, err = _validate_timing_value(data['display_duration'], 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
data['display_duration'] = val
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Extract timing keys from data before updating config (they belong in manifest, not config)
|
||||||
|
render_interval = data.pop('render_interval', None)
|
||||||
|
display_duration = data.pop('display_duration', None)
|
||||||
|
|
||||||
|
# Snapshot before mutating. save_config() can fail, and the route
|
||||||
|
# answers 500 below when it does -- but the loaded app kept the new
|
||||||
|
# values anyway, so a later GET returned configuration that was
|
||||||
|
# never persisted and the plugin rendered with it.
|
||||||
|
prev_config = dict(app.config)
|
||||||
|
prev_manifest = dict(app.manifest)
|
||||||
|
|
||||||
|
# Update config with non-timing fields only
|
||||||
|
app.config.update(data)
|
||||||
|
|
||||||
|
# Update manifest with timing fields
|
||||||
|
timing_changed = False
|
||||||
|
if render_interval is not None:
|
||||||
|
app.manifest['render_interval'] = render_interval
|
||||||
|
timing_changed = True
|
||||||
|
if display_duration is not None:
|
||||||
|
app.manifest['display_duration'] = display_duration
|
||||||
|
timing_changed = True
|
||||||
|
saved = app.save_config()
|
||||||
|
if not saved:
|
||||||
|
app.config.clear(); app.config.update(prev_config)
|
||||||
|
app.manifest.clear(); app.manifest.update(prev_manifest)
|
||||||
|
if saved:
|
||||||
|
# Persist manifest if timing changed (same pattern as toggle endpoint)
|
||||||
|
if timing_changed:
|
||||||
|
try:
|
||||||
|
# Use safe manifest update to prevent race conditions
|
||||||
|
timing_updates = {}
|
||||||
|
if render_interval is not None:
|
||||||
|
timing_updates['render_interval'] = render_interval
|
||||||
|
if display_duration is not None:
|
||||||
|
timing_updates['display_duration'] = display_duration
|
||||||
|
|
||||||
|
def update_fn(manifest):
|
||||||
|
manifest['apps'][app_id].update(timing_updates)
|
||||||
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
||||||
|
starlark_plugin._render_app(app, force=True)
|
||||||
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save configuration'}), 500
|
||||||
|
|
||||||
|
# Standalone: update both config.json and manifest. Both live under
|
||||||
|
# the manifest lock (serialized against every other standalone
|
||||||
|
# manifest read-modify-write -- see _starlark_manifest_lock), and if
|
||||||
|
# the manifest write fails after config.json was already written,
|
||||||
|
# config.json is rolled back so the two do not end up out of sync.
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Extract timing keys (they go in manifest, not config.json)
|
||||||
|
render_interval = data.pop('render_interval', None)
|
||||||
|
display_duration = data.pop('display_duration', None)
|
||||||
|
|
||||||
|
# Update manifest with timing values
|
||||||
|
if render_interval is not None:
|
||||||
|
app_data['render_interval'] = render_interval
|
||||||
|
if display_duration is not None:
|
||||||
|
app_data['display_duration'] = display_duration
|
||||||
|
|
||||||
|
# Load current config from config.json. app_dir is the path
|
||||||
|
# _validate_starlark_app_path checked, not a fresh join.
|
||||||
|
config_file = app_dir / "config.json"
|
||||||
|
existed_before = config_file.exists()
|
||||||
|
previous_config_bytes = None
|
||||||
|
current_config = {}
|
||||||
|
if existed_before:
|
||||||
|
try:
|
||||||
|
previous_config_bytes = config_file.read_bytes()
|
||||||
|
current_config = json.loads(previous_config_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load config for {app_id}: {e}")
|
||||||
|
|
||||||
|
# Update config with new values (excluding timing keys)
|
||||||
|
current_config.update(data)
|
||||||
|
|
||||||
|
# Write updated config to config.json
|
||||||
|
try:
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump(current_config, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save config.json for {app_id}: {e}")
|
||||||
|
logger.exception("Failed to save Starlark configuration for %r", app_id)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save configuration',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
# Also update manifest for backward compatibility
|
||||||
|
app_data.setdefault('config', {}).update(data)
|
||||||
|
|
||||||
|
if _pkg._write_starlark_manifest(manifest):
|
||||||
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': current_config})
|
||||||
|
|
||||||
|
# The manifest write failed after config.json was already
|
||||||
|
# written -- roll config.json back rather than leave the two
|
||||||
|
# disagreeing about what was saved.
|
||||||
|
try:
|
||||||
|
if existed_before and previous_config_bytes is not None:
|
||||||
|
config_file.write_bytes(previous_config_bytes)
|
||||||
|
elif not existed_before:
|
||||||
|
config_file.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to roll back config.json for %r after manifest write failure", app_id)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save manifest'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] update_starlark_app_config failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to update Starlark app config', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/toggle', methods=['POST'])
|
||||||
|
def toggle_starlark_app(app_id):
|
||||||
|
"""Enable or disable a Starlark app."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
enabled = not app.is_enabled()
|
||||||
|
app.manifest['enabled'] = enabled
|
||||||
|
# Use safe manifest update to prevent race conditions
|
||||||
|
def update_fn(manifest):
|
||||||
|
manifest['apps'][app_id]['enabled'] = enabled
|
||||||
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||||
|
|
||||||
|
# Standalone: update manifest directly
|
||||||
|
with _starlark_manifest_lock():
|
||||||
|
manifest = _pkg._read_starlark_manifest()
|
||||||
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
|
if not app_data:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
enabled = data.get('enabled')
|
||||||
|
if enabled is None:
|
||||||
|
enabled = not app_data.get('enabled', True)
|
||||||
|
app_data['enabled'] = enabled
|
||||||
|
if _pkg._write_starlark_manifest(manifest):
|
||||||
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] toggle_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to toggle Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/apps/<app_id>/render', methods=['POST'])
|
||||||
|
def render_starlark_app(app_id):
|
||||||
|
"""Force render a Starlark app."""
|
||||||
|
try:
|
||||||
|
app_dir, err = _validate_starlark_app_path(app_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
app = starlark_plugin.apps.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
success = starlark_plugin._render_app(app, force=True)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': 'App rendered',
|
||||||
|
'frame_count': len(app.frames) if app.frames else 0})
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
|
||||||
|
|
||||||
|
# Web-service context: plugin not loaded, call pixlet directly
|
||||||
|
success, status_code, error = _standalone_render_starlark_app(app_id)
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code
|
||||||
|
return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] render_starlark_app failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/browse', methods=['GET'])
|
||||||
|
def browse_tronbyte_repository():
|
||||||
|
"""Browse all apps in the Tronbyte repository (bulk cached fetch).
|
||||||
|
|
||||||
|
Returns ALL apps with metadata, categories, and authors.
|
||||||
|
Filtering/sorting/pagination is handled client-side.
|
||||||
|
Results are cached server-side for 2 hours.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
result = repo.list_all_apps_cached()
|
||||||
|
|
||||||
|
rate_limit = repo.get_rate_limit_info()
|
||||||
|
|
||||||
|
# An upstream failure used to arrive here as an empty app list and go
|
||||||
|
# out as 'success', so the store drew an empty grid and said nothing.
|
||||||
|
# 502: the request was fine, GitHub was not.
|
||||||
|
if result.get('error'):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': result['error'],
|
||||||
|
'rate_limit': rate_limit,
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'apps': result['apps'],
|
||||||
|
'categories': result['categories'],
|
||||||
|
'authors': result['authors'],
|
||||||
|
'count': result['count'],
|
||||||
|
'cached': result['cached'],
|
||||||
|
'rate_limit': rate_limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] browse_tronbyte_repository failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to browse repository', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/install', methods=['POST'])
|
||||||
|
def install_from_tronbyte_repository():
|
||||||
|
"""Install an app from the Tronbyte repository."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data or 'app_id' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'app_id is required'}), 400
|
||||||
|
|
||||||
|
app_id, app_id_error = _validate_and_sanitize_app_id(data['app_id'])
|
||||||
|
if app_id_error:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid app_id: {app_id_error}'}), 400
|
||||||
|
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
success, metadata, error = repo.get_app_metadata(data['app_id'])
|
||||||
|
if not success:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to fetch app metadata: {error}'}), 404
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.star') as tmp:
|
||||||
|
temp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pass filename from metadata (e.g., "analog_clock.star" for analogclock app)
|
||||||
|
# Note: manifest uses 'fileName' (camelCase), not 'filename'
|
||||||
|
filename = metadata.get('fileName') if metadata else None
|
||||||
|
success, error = repo.download_star_file(data['app_id'], Path(temp_path), filename=filename)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Failed to download app: {error}'}), 500
|
||||||
|
|
||||||
|
# Download assets (images, sources, etc.) to a temp directory
|
||||||
|
import tempfile
|
||||||
|
temp_assets_dir = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
success_assets, error_assets = repo.download_app_assets(data['app_id'], Path(temp_assets_dir))
|
||||||
|
# Asset download is non-critical - log warning but continue if it fails
|
||||||
|
if not success_assets:
|
||||||
|
logger.warning(f"Failed to download assets for {data['app_id']}: {error_assets}")
|
||||||
|
|
||||||
|
render_interval = data.get('render_interval', 300)
|
||||||
|
ri, err = _validate_timing_value(render_interval, 'render_interval')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
render_interval = ri or 300
|
||||||
|
|
||||||
|
display_duration = data.get('display_duration', 15)
|
||||||
|
dd, err = _validate_timing_value(display_duration, 'display_duration')
|
||||||
|
if err:
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
display_duration = dd or 15
|
||||||
|
|
||||||
|
install_metadata = {
|
||||||
|
'name': metadata.get('name', app_id) if metadata else app_id,
|
||||||
|
'render_interval': render_interval,
|
||||||
|
'display_duration': display_duration
|
||||||
|
}
|
||||||
|
|
||||||
|
starlark_plugin = _pkg._get_starlark_plugin()
|
||||||
|
if starlark_plugin:
|
||||||
|
success = starlark_plugin.install_app(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||||
|
else:
|
||||||
|
success = _install_star_file(app_id, temp_path, install_metadata, assets_dir=temp_assets_dir)
|
||||||
|
finally:
|
||||||
|
# Clean up temp assets directory
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
shutil.rmtree(temp_assets_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': f'App installed: {metadata.get("name", app_id) if metadata else app_id}', 'app_id': app_id})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install app'}), 500
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||||
|
def get_tronbyte_categories():
|
||||||
|
"""Get list of available app categories (uses bulk cache)."""
|
||||||
|
try:
|
||||||
|
TronbyteRepository = _pkg._get_tronbyte_repository_class()
|
||||||
|
repo = TronbyteRepository(github_token=_starlark_github_token())
|
||||||
|
|
||||||
|
result = repo.list_all_apps_cached()
|
||||||
|
|
||||||
|
if result.get('error'):
|
||||||
|
return jsonify({'status': 'error', 'message': result['error']}), 502
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'categories': result['categories']})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Starlark] get_tronbyte_categories failed")
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to fetch categories', 'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/apps', methods=['GET'])
|
||||||
|
def list_pixlet_editor_apps():
|
||||||
|
"""Apps on disk that the editor can open.
|
||||||
|
|
||||||
|
Read from the directory rather than the loaded plugin: the editor works on
|
||||||
|
files, and the plugin may not be loaded in this process at all.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
apps = []
|
||||||
|
if _STARLARK_APPS_DIR.is_dir():
|
||||||
|
for entry in sorted(_STARLARK_APPS_DIR.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
star_files = sorted(entry.glob('*.star'))
|
||||||
|
name = entry.name
|
||||||
|
manifest = entry / 'manifest.json'
|
||||||
|
if manifest.is_file():
|
||||||
|
try:
|
||||||
|
with open(manifest, encoding='utf-8') as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
name = data.get('name') or name
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
apps.append({
|
||||||
|
'id': entry.name,
|
||||||
|
'name': name,
|
||||||
|
'editable': bool(star_files),
|
||||||
|
'has_config': (entry / 'config.json').is_file(),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': {
|
||||||
|
'apps': apps,
|
||||||
|
'apps_dir': str(_STARLARK_APPS_DIR),
|
||||||
|
'pixlet_available': _find_pixlet_binary() is not None,
|
||||||
|
}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error listing editor apps')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not list apps',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/status', methods=['GET'])
|
||||||
|
def get_pixlet_editor_status():
|
||||||
|
"""Whether a session is running, and how long it has left."""
|
||||||
|
try:
|
||||||
|
return jsonify({'status': 'success', 'data': _pixlet_editor_status()})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error reading editor status')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not read editor status',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
#: Flask runs threaded in the supported service, so two start requests can each
|
||||||
|
#: observe running=False, each launch an editor, and the second state write
|
||||||
|
#: replace the first PID -- orphaning a process that holds the display down with
|
||||||
|
#: nothing left recording it. The check-launch-write sequence takes this lock.
|
||||||
|
_EDITOR_START_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_editor_process(pid, wait_s=5):
|
||||||
|
"""Signal an editor's process group and wait briefly for it to exit."""
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
deadline = _pkg.time.time() + wait_s
|
||||||
|
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
||||||
|
_pkg.time.sleep(0.25)
|
||||||
|
if _pixlet_editor_alive(pid):
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/start', methods=['POST'])
|
||||||
|
def start_pixlet_editor():
|
||||||
|
"""Start an editing session for one app."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
app_id = data.get('app_id')
|
||||||
|
|
||||||
|
app_dir, err = _validate_starlark_app_path(app_id or '')
|
||||||
|
if err or not app_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': err or 'Invalid app_id'}), 400
|
||||||
|
if not app_dir.is_dir():
|
||||||
|
return jsonify({'status': 'error', 'message': f'No such app: {app_id}'}), 404
|
||||||
|
if not any(app_dir.glob('*.star')):
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f'{app_id} has no .star file to edit'}), 400
|
||||||
|
if not _PIXLET_EDITOR_SCRIPT.is_file():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Editor script not found'}), 404
|
||||||
|
if _find_pixlet_binary() is None:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'Pixlet is not installed - install it first'}), 503
|
||||||
|
|
||||||
|
with _EDITOR_START_LOCK:
|
||||||
|
current = _pixlet_editor_status()
|
||||||
|
if current.get('running'):
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f"An editor session for '{current.get('app_id')}' is "
|
||||||
|
f"already running; stop it first"}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout_s = int(data.get('timeout') or _PIXLET_EDITOR_DEFAULT_TIMEOUT)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'timeout must be a whole number'}), 400
|
||||||
|
if not 60 <= timeout_s <= _PIXLET_EDITOR_MAX_TIMEOUT:
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': f'timeout must be between 60 and '
|
||||||
|
f'{_PIXLET_EDITOR_MAX_TIMEOUT} seconds'}), 400
|
||||||
|
try:
|
||||||
|
port = int(data.get('port') or _PIXLET_EDITOR_DEFAULT_PORT)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'port must be a whole number'}), 400
|
||||||
|
if not 1024 <= port <= 65535:
|
||||||
|
return jsonify({'status': 'error', 'message': 'port must be between 1024 and 65535'}), 400
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env['PIXLET_EDITOR_PORT'] = str(port)
|
||||||
|
env['PIXLET_EDITOR_TIMEOUT'] = str(timeout_s)
|
||||||
|
# A browser reaching this endpoint is remote by definition, so the
|
||||||
|
# session needs to listen on more than loopback to be usable at all --
|
||||||
|
# but only as a *default*. An operator who has already set
|
||||||
|
# PIXLET_EDITOR_HOST (e.g. to keep it loopback-only even from the web
|
||||||
|
# UI) must not have that overridden here.
|
||||||
|
env.setdefault('PIXLET_EDITOR_HOST', '0.0.0.0')
|
||||||
|
|
||||||
|
log_path = Path(tempfile.gettempdir()) / 'ledmatrix_pixlet_editor.log'
|
||||||
|
log_handle = open(log_path, 'w', encoding='utf-8') # noqa: SIM115 - owned by the child
|
||||||
|
try:
|
||||||
|
# start_new_session so the script leads its own process group: the
|
||||||
|
# stop route signals the group, which is what lets the EXIT trap run
|
||||||
|
# and hand the display back.
|
||||||
|
process = subprocess.Popen( # nosec B603 - fixed script path, validated app_id
|
||||||
|
['/bin/bash', str(_PIXLET_EDITOR_SCRIPT), app_dir.name],
|
||||||
|
cwd=str(PROJECT_ROOT), env=env,
|
||||||
|
stdout=log_handle, stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True)
|
||||||
|
finally:
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
now = _pkg.time.time()
|
||||||
|
state = {'pid': process.pid, 'app_id': app_dir.name, 'port': port,
|
||||||
|
'timeout': timeout_s, 'started_at': now, 'deadline': now + timeout_s,
|
||||||
|
'host': env['PIXLET_EDITOR_HOST'], 'log': str(log_path)}
|
||||||
|
try:
|
||||||
|
with open(_PIXLET_EDITOR_STATE, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(state, handle)
|
||||||
|
except OSError as err:
|
||||||
|
# The process is up but nothing records its PID: status and stop
|
||||||
|
# would both report no session while the display stays down until
|
||||||
|
# the timeout expires. Take the editor with us instead.
|
||||||
|
# exc_info: this path answers 500, and the guard in
|
||||||
|
# test_web_error_detail.py requires the traceback to reach the log
|
||||||
|
# as well as the sanitized detail reaching the caller.
|
||||||
|
logger.error('Started an editor session but could not record it: %s',
|
||||||
|
err, exc_info=True)
|
||||||
|
_terminate_editor_process(process.pid)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'Could not record the editor session; '
|
||||||
|
'the editor was stopped.',
|
||||||
|
'details': describe_exception(err)}), 500
|
||||||
|
|
||||||
|
logger.info('Pixlet editor started for %s on port %s (pid %s, %ss limit)',
|
||||||
|
app_dir.name, port, process.pid, timeout_s)
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': f"Editing '{app_dir.name}'. The display is stopped until "
|
||||||
|
f"the session ends.",
|
||||||
|
'data': _pixlet_editor_status()})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error starting the pixlet editor')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not start the editor',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/starlark/editor/stop', methods=['POST'])
|
||||||
|
def stop_pixlet_editor():
|
||||||
|
"""End the running session and give the display back."""
|
||||||
|
try:
|
||||||
|
state = _read_pixlet_editor_state()
|
||||||
|
if not state or not _pixlet_editor_alive(state.get('pid')):
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
return jsonify({'status': 'success', 'message': 'No editor session was running.',
|
||||||
|
'data': {'running': False}})
|
||||||
|
|
||||||
|
pid = int(state['pid'])
|
||||||
|
# SIGTERM the group, not the pid: bash forwards nothing to `timeout` and
|
||||||
|
# its child on its own, and the trap needs to run to restart the display.
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError) as err:
|
||||||
|
logger.debug('Could not signal the editor process group: %s', err)
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
# Give the trap a moment to stop pixlet and restart ledmatrix.
|
||||||
|
deadline = _pkg.time.time() + 10
|
||||||
|
while _pkg.time.time() < deadline and _pixlet_editor_alive(pid):
|
||||||
|
_pkg.time.sleep(0.25)
|
||||||
|
|
||||||
|
escalated = False
|
||||||
|
if _pixlet_editor_alive(pid):
|
||||||
|
logger.warning('Editor session %s ignored SIGTERM; sending SIGKILL.', pid)
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
escalated = True
|
||||||
|
|
||||||
|
_clear_pixlet_editor_state()
|
||||||
|
|
||||||
|
if escalated:
|
||||||
|
# SIGKILL gives the script's EXIT trap no chance to run, so nothing
|
||||||
|
# has handed the display back. Reporting "the display is restarting"
|
||||||
|
# here was simply untrue: restart it, and if that fails say so
|
||||||
|
# rather than leave the panel dark behind a success response.
|
||||||
|
result = _run_systemctl_command(
|
||||||
|
['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
||||||
|
if result.get('returncode') != 0:
|
||||||
|
logger.error('Display restart after SIGKILL failed: %s',
|
||||||
|
(result.get('stderr') or '').strip())
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Editor force-stopped, but the display could not be '
|
||||||
|
'restarted automatically - start it manually.',
|
||||||
|
'details': (result.get('stderr') or '').strip(),
|
||||||
|
'data': {'running': False}}), 500
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': 'Editor force-stopped; the display has been '
|
||||||
|
'restarted.',
|
||||||
|
'data': {'running': False}})
|
||||||
|
|
||||||
|
return jsonify({'status': 'success',
|
||||||
|
'message': 'Editor stopped; the display is restarting.',
|
||||||
|
'data': {'running': False}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Error stopping the pixlet editor')
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not stop the editor',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""Service control, updates, versions and system status.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
Any, Dict, PROJECT_ROOT, Path, _GIT, _UPDATE_CHECK_TTL,
|
||||||
|
_describe_git_failure, _get_display_service_status, _git_current_branch,
|
||||||
|
_git_remote_branch_exists, _git_upstream, _pip_install_requirements,
|
||||||
|
_scrub_git_remote_url, _truncate_output, _update_check_cache,
|
||||||
|
_update_check_failed, api_v3, checkout_branch, describe_exception,
|
||||||
|
get_git_version, jsonify, logger, os, request, resolve_pull_command,
|
||||||
|
shutil, subprocess,
|
||||||
|
)
|
||||||
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
|
# Read through the module rather than bound by value: tests patch these
|
||||||
|
# as module attributes, and a value binding would not see the patch.
|
||||||
|
# Several are also called from helpers that live in __init__, so the
|
||||||
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/system/status', methods=['GET'])
|
||||||
|
def get_system_status():
|
||||||
|
"""Get system status"""
|
||||||
|
try:
|
||||||
|
# Check cache first (10 second TTL for system status)
|
||||||
|
try:
|
||||||
|
from web_interface.cache import get_cached, set_cached
|
||||||
|
cached_result = get_cached('system_status', ttl_seconds=10)
|
||||||
|
if cached_result is not None:
|
||||||
|
return jsonify({'status': 'success', 'data': cached_result})
|
||||||
|
except ImportError:
|
||||||
|
# Cache not available, continue without caching
|
||||||
|
get_cached = None
|
||||||
|
set_cached = None
|
||||||
|
|
||||||
|
# Import psutil for system monitoring
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
# Fallback if psutil not available
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'psutil not available for system monitoring'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
# Get system metrics using psutil
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
memory_percent = memory.percent
|
||||||
|
disk = psutil.disk_usage('/')
|
||||||
|
disk_percent = disk.percent
|
||||||
|
|
||||||
|
# Calculate uptime
|
||||||
|
boot_time = psutil.boot_time()
|
||||||
|
uptime_seconds = _pkg.time.time() - boot_time
|
||||||
|
uptime_hours = uptime_seconds / 3600
|
||||||
|
uptime_days = uptime_hours / 24
|
||||||
|
|
||||||
|
# Format uptime string
|
||||||
|
if uptime_days >= 1:
|
||||||
|
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
|
||||||
|
elif uptime_hours >= 1:
|
||||||
|
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
|
||||||
|
else:
|
||||||
|
uptime_str = f"{int(uptime_seconds / 60)}m"
|
||||||
|
|
||||||
|
# Get CPU temperature (Raspberry Pi)
|
||||||
|
cpu_temp = None
|
||||||
|
try:
|
||||||
|
temp_file = '/sys/class/thermal/thermal_zone0/temp'
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
with open(temp_file, 'r') as f:
|
||||||
|
temp_millidegrees = int(f.read().strip())
|
||||||
|
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
|
||||||
|
except (IOError, ValueError, OSError):
|
||||||
|
# Temperature sensor not available or error reading
|
||||||
|
cpu_temp = None
|
||||||
|
|
||||||
|
# Get display service status
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
|
||||||
|
status = {
|
||||||
|
'timestamp': _pkg.time.time(),
|
||||||
|
'uptime': uptime_str,
|
||||||
|
'uptime_seconds': int(uptime_seconds),
|
||||||
|
'service_active': service_status.get('active', False),
|
||||||
|
'cpu_percent': round(cpu_percent, 1),
|
||||||
|
'memory_used_percent': round(memory_percent, 1),
|
||||||
|
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
|
||||||
|
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
|
||||||
|
# MemAvailable, not total-minus-used: it accounts for reclaimable
|
||||||
|
# page cache, so it is what actually predicts memory trouble. A
|
||||||
|
# board can read 70% "used" and be fine, or read the same and be
|
||||||
|
# about to fail fork(), and only this number tells them apart.
|
||||||
|
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
||||||
|
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
|
||||||
|
'disk_used_percent': round(disk_percent, 1),
|
||||||
|
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
|
||||||
|
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result if available
|
||||||
|
if set_cached:
|
||||||
|
try:
|
||||||
|
set_cached('system_status', status, ttl_seconds=10)
|
||||||
|
except Exception:
|
||||||
|
pass # Cache write failed, but continue
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'data': status})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
@api_v3.route('/system/version', methods=['GET'])
|
||||||
|
def get_system_version():
|
||||||
|
"""Get LEDMatrix repository version"""
|
||||||
|
try:
|
||||||
|
version = get_git_version()
|
||||||
|
return jsonify({'status': 'success', 'data': {'version': version}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_system_version failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Unable to retrieve version'}), 500
|
||||||
|
@api_v3.route('/system/check-update', methods=['GET'])
|
||||||
|
def check_for_update():
|
||||||
|
"""Check whether a newer LEDMatrix commit is available on origin/main."""
|
||||||
|
now = _pkg.time.time()
|
||||||
|
if _update_check_cache['result'] and now - _update_check_cache['ts'] < _UPDATE_CHECK_TTL:
|
||||||
|
return jsonify(_update_check_cache['result'])
|
||||||
|
|
||||||
|
_safe: Dict[str, Any] = {'update_available': False, 'remote_sha': 'unknown', 'commits_behind': 0}
|
||||||
|
try:
|
||||||
|
cwd = str(PROJECT_ROOT)
|
||||||
|
fetch_result = subprocess.run(
|
||||||
|
['git', 'fetch', 'origin', 'main', '--quiet'],
|
||||||
|
capture_output=True, timeout=10, cwd=cwd,
|
||||||
|
)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
||||||
|
logger.warning("check-update: git fetch failed (rc=%d): %s",
|
||||||
|
fetch_result.returncode, stderr)
|
||||||
|
failed = _update_check_failed(_describe_git_failure(stderr))
|
||||||
|
_update_check_cache['result'] = failed
|
||||||
|
_update_check_cache['ts'] = now
|
||||||
|
return jsonify(failed)
|
||||||
|
local = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
remote = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if not local or not remote:
|
||||||
|
return jsonify(_safe)
|
||||||
|
|
||||||
|
if local == remote:
|
||||||
|
result: Dict[str, Any] = {'update_available': False, 'remote_sha': remote, 'commits_behind': 0}
|
||||||
|
else:
|
||||||
|
count_str = subprocess.run(
|
||||||
|
['git', 'rev-list', 'HEAD..origin/main', '--count'],
|
||||||
|
capture_output=True, text=True, timeout=5, cwd=cwd,
|
||||||
|
).stdout.strip()
|
||||||
|
count = int(count_str) if count_str.isdigit() else 0
|
||||||
|
result = {'update_available': count > 0, 'remote_sha': remote, 'commits_behind': count}
|
||||||
|
|
||||||
|
_update_check_cache['result'] = result
|
||||||
|
_update_check_cache['ts'] = now
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("check-update failed: %s", e)
|
||||||
|
return jsonify(_update_check_failed(
|
||||||
|
"Could not check for updates; see logs for details."))
|
||||||
|
#: sudo's own wording when it needs a password it cannot ask for. The web
|
||||||
|
#: interface runs unprivileged, so its systemctl/reboot/journalctl calls only
|
||||||
|
#: work once scripts/install/configure_web_sudo.sh has granted NOPASSWD --
|
||||||
|
#: which first_time_install.sh does not do. That makes this the common case on
|
||||||
|
#: a fresh device, and "Action failed; see logs for details" named none of it,
|
||||||
|
#: while the log viewer was broken for the very same reason.
|
||||||
|
_SUDO_NEEDS_PASSWORD = (
|
||||||
|
'a password is required',
|
||||||
|
'no tty present',
|
||||||
|
'a terminal is required',
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUDO_HINT = (
|
||||||
|
'Passwordless sudo is not configured for the web interface user, so this '
|
||||||
|
'action cannot run. Run scripts/install/configure_web_sudo.sh as that user, '
|
||||||
|
'then retry.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sudo_hint_for(text):
|
||||||
|
"""An actionable hint when `text` is sudo refusing to prompt, else None."""
|
||||||
|
lowered = (text or '').lower()
|
||||||
|
if any(marker in lowered for marker in _SUDO_NEEDS_PASSWORD):
|
||||||
|
return _SUDO_HINT
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/system/action', methods=['POST'])
|
||||||
|
def execute_system_action():
|
||||||
|
"""Execute system actions (start/stop/reboot/etc)"""
|
||||||
|
try:
|
||||||
|
# HTMX sends data as form data, not JSON
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not data:
|
||||||
|
# Try to get from form data if JSON fails
|
||||||
|
data = {
|
||||||
|
'action': request.form.get('action'),
|
||||||
|
'mode': request.form.get('mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
if not data or 'action' not in data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Action required'}), 400
|
||||||
|
|
||||||
|
action = data['action']
|
||||||
|
mode = data.get('mode') # For on-demand modes
|
||||||
|
|
||||||
|
# Map actions to subprocess calls (similar to original implementation)
|
||||||
|
if action == 'start_display':
|
||||||
|
if mode:
|
||||||
|
# For on-demand modes, we would need to integrate with the display controller
|
||||||
|
# For now, just start the display service
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||||
|
logger.info("start_display (%s) returned code %d", mode, result.returncode)
|
||||||
|
if result.returncode != 0 and result.stderr:
|
||||||
|
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
|
||||||
|
resp = {
|
||||||
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
|
# This branch returns before the shared nonzero-result
|
||||||
|
# response below, so it needs the hint of its own or an
|
||||||
|
# on-demand start reports "Failed to start display" and
|
||||||
|
# says nothing about the sudo that actually refused it.
|
||||||
|
'message': (
|
||||||
|
'Display started' if result.returncode == 0
|
||||||
|
else _sudo_hint_for(result.stderr) or 'Failed to start display'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if result.returncode != 0:
|
||||||
|
resp['returncode'] = result.returncode
|
||||||
|
resp['stderr'] = result.stderr.strip()
|
||||||
|
return jsonify(resp)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'stop_display':
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'enable_autostart':
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'disable_autostart':
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'reboot_system':
|
||||||
|
result = subprocess.run(['sudo', 'reboot'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'shutdown_system':
|
||||||
|
result = subprocess.run(['sudo', 'poweroff'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'git_pull':
|
||||||
|
# Use PROJECT_ROOT instead of hardcoded path
|
||||||
|
project_dir = str(PROJECT_ROOT)
|
||||||
|
|
||||||
|
# Decide how to pull BEFORE stashing. If this checkout cannot be
|
||||||
|
# updated at all, stashing first would put the user's local changes
|
||||||
|
# away for an update that was never going to run.
|
||||||
|
pull_args, upstream_note, pull_error = resolve_pull_command(project_dir)
|
||||||
|
if pull_error:
|
||||||
|
logger.warning("git pull not attempted: %s", pull_error)
|
||||||
|
return jsonify({'status': 'error', 'message': pull_error})
|
||||||
|
|
||||||
|
# Check if there are local changes that need to be stashed
|
||||||
|
# Exclude plugins directory - plugins are separate repos and shouldn't be stashed with base project
|
||||||
|
# Use --untracked-files=no to skip untracked files check (much faster with symlinked plugins)
|
||||||
|
try:
|
||||||
|
status_result = subprocess.run(
|
||||||
|
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
cwd=project_dir
|
||||||
|
)
|
||||||
|
# Filter out any changes in plugins directory - plugins are separate repositories
|
||||||
|
# Git status format: XY filename (where X is status of index, Y is status of work tree)
|
||||||
|
status_lines = [line for line in status_result.stdout.strip().split('\n')
|
||||||
|
if line.strip() and 'plugins/' not in line]
|
||||||
|
has_changes = bool('\n'.join(status_lines).strip())
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# If status check times out, assume there might be changes and proceed
|
||||||
|
# This is safer than failing the update
|
||||||
|
has_changes = True
|
||||||
|
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||||
|
|
||||||
|
stash_info = ""
|
||||||
|
|
||||||
|
# Stash local changes if they exist (excluding plugins)
|
||||||
|
# Plugins are separate repositories and shouldn't be stashed with base project updates
|
||||||
|
if has_changes:
|
||||||
|
try:
|
||||||
|
# Use pathspec to exclude plugins directory from stash
|
||||||
|
stash_result = subprocess.run(
|
||||||
|
['git', 'stash', 'push', '-m', 'LEDMatrix auto-stash before update', '--', ':!plugins'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
cwd=project_dir
|
||||||
|
)
|
||||||
|
if stash_result.returncode == 0:
|
||||||
|
logger.debug("git stash: stashed local changes before pull")
|
||||||
|
stash_info = " Local changes were stashed."
|
||||||
|
else:
|
||||||
|
logger.warning("git stash failed before pull (returncode=%d)", stash_result.returncode)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("git stash timed out, proceeding with pull")
|
||||||
|
|
||||||
|
# Record HEAD before the pull so dependency changes can be detected
|
||||||
|
old_head = None
|
||||||
|
try:
|
||||||
|
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||||
|
if _pre.returncode == 0:
|
||||||
|
old_head = _pre.stdout.strip()
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("git rev-parse timed out before pull")
|
||||||
|
|
||||||
|
# Whether the pull actually brought new code in. "Already up to
|
||||||
|
# date" is a success too, and prompting for a restart then would
|
||||||
|
# train users to ignore the prompt.
|
||||||
|
code_changed = False
|
||||||
|
|
||||||
|
# Perform the git pull. Branches without an upstream were given
|
||||||
|
# an explicit "origin <branch>" above so the update still works.
|
||||||
|
result = subprocess.run(
|
||||||
|
pull_args,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
cwd=project_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give the branch tracking information so the next pull is a plain
|
||||||
|
# `git pull` — otherwise every update repeats the fallback.
|
||||||
|
if result.returncode == 0 and upstream_note:
|
||||||
|
branch = _git_current_branch(project_dir)
|
||||||
|
if branch:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['git', 'branch', f'--set-upstream-to=origin/{branch}', branch],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||||
|
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||||
|
logger.debug("could not set upstream for %s: %s", branch, exc)
|
||||||
|
|
||||||
|
# Return custom response for git_pull
|
||||||
|
if result.returncode == 0:
|
||||||
|
pull_message = "Code updated successfully."
|
||||||
|
if has_changes:
|
||||||
|
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||||
|
if result.stdout and "Already up to date" not in result.stdout:
|
||||||
|
pull_message = f"Code updated successfully.{stash_info}"
|
||||||
|
if upstream_note:
|
||||||
|
pull_message = f"{pull_message} {upstream_note}"
|
||||||
|
|
||||||
|
# Keep Python dependencies in sync automatically: if the pull
|
||||||
|
# changed a requirements file, install it now — users updating
|
||||||
|
# from the web UI (most of them) never SSH in to pip install.
|
||||||
|
# Installs go through the same root-visible path as the
|
||||||
|
# Tools-tab buttons (_pip_install_requirements).
|
||||||
|
dep_notes = []
|
||||||
|
try:
|
||||||
|
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
||||||
|
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
||||||
|
new_head = _post.stdout.strip() if _post.returncode == 0 else None
|
||||||
|
if old_head and new_head and old_head != new_head:
|
||||||
|
code_changed = True
|
||||||
|
diff = subprocess.run(
|
||||||
|
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
|
||||||
|
capture_output=True, text=True, timeout=15, cwd=project_dir)
|
||||||
|
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
|
||||||
|
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
|
||||||
|
req_path = PROJECT_ROOT / rel
|
||||||
|
if rel not in changed or not req_path.exists():
|
||||||
|
continue
|
||||||
|
# Each file's install is isolated: a timeout or
|
||||||
|
# OSError (e.g. the sudo wrapper/interpreter
|
||||||
|
# missing) on one file must not abort the other.
|
||||||
|
try:
|
||||||
|
r = _pip_install_requirements(req_path, timeout=180)
|
||||||
|
if r.returncode == 0:
|
||||||
|
dep_notes.append(f"Dependencies from {rel} updated.")
|
||||||
|
else:
|
||||||
|
dep_notes.append(
|
||||||
|
f"Dependency install from {rel} failed — "
|
||||||
|
"run Install Base Requirements from the Tools tab.")
|
||||||
|
logger.warning("post-update pip install failed for %s: %s",
|
||||||
|
rel, _truncate_output(r.stdout, r.stderr))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
dep_notes.append(
|
||||||
|
f"Dependency install from {rel} timed out — "
|
||||||
|
"run Install Base Requirements from the Tools tab.")
|
||||||
|
logger.warning("post-update pip install timed out for %s", rel)
|
||||||
|
except OSError as install_err:
|
||||||
|
dep_notes.append(
|
||||||
|
f"Dependency install from {rel} failed — "
|
||||||
|
"run Install Base Requirements from the Tools tab.")
|
||||||
|
logger.warning("post-update pip install errored for %s: %s",
|
||||||
|
rel, install_err)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("post-update dependency sync timed out")
|
||||||
|
if dep_notes:
|
||||||
|
pull_message += " " + " ".join(dep_notes)
|
||||||
|
# A `git pull` restores built-in plugins (committed under
|
||||||
|
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||||
|
# any the user previously uninstalled so the update doesn't
|
||||||
|
# resurrect them.
|
||||||
|
if api_v3.plugin_store_manager:
|
||||||
|
try:
|
||||||
|
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||||
|
if purged:
|
||||||
|
logger.info(
|
||||||
|
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||||
|
len(purged), ", ".join(purged),
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError) as purge_err:
|
||||||
|
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||||
|
else:
|
||||||
|
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||||
|
# Show git's own first line: "check logs" leaves the user with
|
||||||
|
# nothing to act on, and these failures are usually actionable
|
||||||
|
# (conflicting local commits, no upstream, network).
|
||||||
|
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
||||||
|
if ln.strip()), '')
|
||||||
|
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
||||||
|
|
||||||
|
# Nothing here restarts anything: the pull replaces files on
|
||||||
|
# disk while the display and web services keep running the code
|
||||||
|
# they loaded at boot. Without this the user is told the update
|
||||||
|
# succeeded and sees no change until they happen to reboot.
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
|
'message': pull_message,
|
||||||
|
'restart_required': bool(result.returncode == 0 and code_changed),
|
||||||
|
})
|
||||||
|
elif action == 'checkout_branch':
|
||||||
|
# Switch branches from the Tools tab. Needed because a checkout
|
||||||
|
# that predates tracking (or a restored backup) can leave the pi
|
||||||
|
# on a branch the update button cannot pull.
|
||||||
|
result_payload, http_status = checkout_branch(
|
||||||
|
str(PROJECT_ROOT), data.get('branch') or '', stash=bool(data.get('stash')))
|
||||||
|
return jsonify(result_payload), http_status
|
||||||
|
|
||||||
|
elif action == 'restart_display_service':
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'restart_web_service':
|
||||||
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
elif action == 'install_base_requirements':
|
||||||
|
# Base + web interface requirements: flask-compress and friends
|
||||||
|
# live in web_interface/requirements.txt, not the root file.
|
||||||
|
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
|
||||||
|
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
|
||||||
|
if f.exists()]
|
||||||
|
if not req_files:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
|
outputs = []
|
||||||
|
all_ok = True
|
||||||
|
for req_file in req_files:
|
||||||
|
label = req_file.relative_to(PROJECT_ROOT)
|
||||||
|
# Isolate each file's install: a timeout or OSError on one
|
||||||
|
# (e.g. requirements.txt) must not abort the rest of the
|
||||||
|
# loop (e.g. web_interface/requirements.txt never attempted).
|
||||||
|
try:
|
||||||
|
result = _pip_install_requirements(req_file, timeout=120)
|
||||||
|
all_ok = all_ok and result.returncode == 0
|
||||||
|
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
all_ok = False
|
||||||
|
outputs.append(f"== {label} ==\nTimed out after 120s")
|
||||||
|
logger.warning("install_base_requirements timed out for %s", label)
|
||||||
|
except OSError as install_err:
|
||||||
|
all_ok = False
|
||||||
|
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
||||||
|
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if all_ok else 'error',
|
||||||
|
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
||||||
|
'output': "\n".join(outputs)
|
||||||
|
})
|
||||||
|
elif action == 'install_plugin_requirements':
|
||||||
|
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||||
|
if active_pm:
|
||||||
|
plugins_dir = Path(active_pm.plugins_dir)
|
||||||
|
else:
|
||||||
|
_cm = getattr(api_v3, 'config_manager', None)
|
||||||
|
_cfg = _cm.load_config() if _cm else {}
|
||||||
|
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
|
||||||
|
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
|
||||||
|
results = []
|
||||||
|
if plugins_dir.exists():
|
||||||
|
for p in sorted(plugins_dir.iterdir()):
|
||||||
|
req = p / 'requirements.txt'
|
||||||
|
if p.is_dir() and req.exists():
|
||||||
|
try:
|
||||||
|
r = _pip_install_requirements(req, timeout=60)
|
||||||
|
results.append({
|
||||||
|
'plugin': p.name,
|
||||||
|
'ok': r.returncode == 0,
|
||||||
|
'output': _truncate_output(r.stdout, r.stderr)
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
|
||||||
|
except OSError as exc:
|
||||||
|
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
|
||||||
|
ok_count = sum(1 for r in results if r['ok'])
|
||||||
|
all_ok = all(r['ok'] for r in results) if results else True
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if all_ok else 'error',
|
||||||
|
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
|
||||||
|
'details': results
|
||||||
|
})
|
||||||
|
elif action == 'force_git_reset':
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
project_dir = str(PROJECT_ROOT)
|
||||||
|
fetch = subprocess.run(
|
||||||
|
[_GIT, 'fetch', 'origin'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
|
||||||
|
reset = subprocess.run(
|
||||||
|
[_GIT, 'reset', '--hard', 'origin/main'],
|
||||||
|
capture_output=True, text=True, timeout=30, cwd=project_dir
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if reset.returncode == 0 else 'error',
|
||||||
|
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
|
||||||
|
'output': (reset.stdout + reset.stderr).strip()
|
||||||
|
})
|
||||||
|
elif action == 'clear_pycache':
|
||||||
|
cleared = 0
|
||||||
|
failed = 0
|
||||||
|
for d in PROJECT_ROOT.rglob('__pycache__'):
|
||||||
|
if d.is_dir():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(d)
|
||||||
|
cleared += 1
|
||||||
|
except OSError:
|
||||||
|
failed += 1
|
||||||
|
msg = f'Cleared {cleared} __pycache__ directories'
|
||||||
|
if failed:
|
||||||
|
msg += f' ({failed} could not be removed)'
|
||||||
|
return jsonify({'status': 'success', 'message': msg})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||||
|
|
||||||
|
logger.info("system action '%s' returncode=%d", action, result.returncode)
|
||||||
|
if result.returncode != 0 and result.stderr:
|
||||||
|
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
|
||||||
|
resp = {
|
||||||
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
|
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
|
||||||
|
}
|
||||||
|
if result.returncode != 0:
|
||||||
|
resp['returncode'] = result.returncode
|
||||||
|
resp['stderr'] = result.stderr.strip()
|
||||||
|
hint = _sudo_hint_for(result.stderr)
|
||||||
|
if hint:
|
||||||
|
resp['message'] = hint
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
logger.error("system action '%s' timed out: %s", action, e)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
|
detail = describe_exception(e)
|
||||||
|
resp = {
|
||||||
|
'status': 'error',
|
||||||
|
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
|
||||||
|
'details': detail,
|
||||||
|
}
|
||||||
|
return jsonify(resp), 500
|
||||||
|
@api_v3.route('/system/git-info', methods=['GET'])
|
||||||
|
def get_git_info():
|
||||||
|
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
d = str(PROJECT_ROOT)
|
||||||
|
try:
|
||||||
|
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
if branch.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
|
||||||
|
if status.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
|
||||||
|
|
||||||
|
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
|
||||||
|
branch_name = branch.stdout.strip()
|
||||||
|
upstream = _git_upstream(d)
|
||||||
|
return jsonify({
|
||||||
|
'branch': branch_name,
|
||||||
|
'dirty': bool(status.stdout.strip()),
|
||||||
|
'status': status.stdout.strip(),
|
||||||
|
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
|
||||||
|
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
|
||||||
|
# Surfaced so the Tools tab can warn before the user clicks Pull
|
||||||
|
# Latest, rather than after it fails.
|
||||||
|
'upstream': upstream,
|
||||||
|
'can_pull': bool(upstream) or _git_remote_branch_exists(d, branch_name),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_git_info failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
|
||||||
|
@api_v3.route('/system/git-branches', methods=['GET'])
|
||||||
|
def get_git_branches():
|
||||||
|
"""List branches available to switch to, for the Tools tab picker."""
|
||||||
|
if not _GIT:
|
||||||
|
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
|
||||||
|
d = str(PROJECT_ROOT)
|
||||||
|
try:
|
||||||
|
# Refresh remote refs so a branch created since the last fetch shows up.
|
||||||
|
subprocess.run([_GIT, 'fetch', 'origin', '--prune'],
|
||||||
|
capture_output=True, text=True, timeout=60, cwd=d)
|
||||||
|
|
||||||
|
local = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'],
|
||||||
|
capture_output=True, text=True, timeout=15, cwd=d)
|
||||||
|
remote = subprocess.run([_GIT, 'for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'],
|
||||||
|
capture_output=True, text=True, timeout=15, cwd=d)
|
||||||
|
if local.returncode != 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Could not list branches'}), 500
|
||||||
|
|
||||||
|
local_names = [b for b in local.stdout.split() if b]
|
||||||
|
remote_names = []
|
||||||
|
for ref in remote.stdout.split() if remote.returncode == 0 else []:
|
||||||
|
name = ref.split('origin/', 1)[-1]
|
||||||
|
# origin/HEAD is a symbolic alias, not a branch a user can pick.
|
||||||
|
if name and name != 'HEAD' and name not in local_names:
|
||||||
|
remote_names.append(name)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'current': _git_current_branch(d),
|
||||||
|
'upstream': _git_upstream(d),
|
||||||
|
'local': sorted(local_names),
|
||||||
|
'remote_only': sorted(remote_names),
|
||||||
|
})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Timed out talking to the remote'}), 504
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("get_git_branches failed: %s", e, exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to list branches'}), 500
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""Wi-Fi scanning, connection and status routes.
|
||||||
|
|
||||||
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
|
endpoint names are unchanged by living here.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
api_v3, describe_exception, jsonify, logger, request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool_ish(value):
|
||||||
|
"""Coerce a JSON value that is supposed to be a boolean.
|
||||||
|
|
||||||
|
A JSON boolean arrives as a real Python bool, but these routes are a
|
||||||
|
public HTTP contract and not every caller sends one. `bool(value)` gets
|
||||||
|
two common cases wrong: `bool("false")` is True (a non-empty string is
|
||||||
|
always truthy), and a plain int does not match an `is True` check
|
||||||
|
(`1 is True` is False, since `True` is a distinct singleton from the int
|
||||||
|
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
|
||||||
|
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
|
||||||
|
case-insensitively, and int 1/0.
|
||||||
|
|
||||||
|
Returns None for anything else, rather than guessing. A supplied-but-
|
||||||
|
unrecognized value (e.g. a typo) used to silently become False here,
|
||||||
|
which for `enabled` on the radio route could disconnect Wi-Fi the caller
|
||||||
|
never asked to turn off -- callers must treat None as a validation
|
||||||
|
error, not a real False.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
if lowered in ('true', '1', 'yes'):
|
||||||
|
return True
|
||||||
|
if lowered in ('false', '0', 'no'):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
if value == 1:
|
||||||
|
return True
|
||||||
|
if value == 0:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# WiFi Management Endpoints
|
||||||
|
@api_v3.route('/wifi/status', methods=['GET'])
|
||||||
|
def get_wifi_status():
|
||||||
|
"""Get current WiFi connection status"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
status = wifi_manager.get_wifi_status()
|
||||||
|
|
||||||
|
# Get auto-enable setting from config
|
||||||
|
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'connected': status.connected,
|
||||||
|
'ssid': status.ssid,
|
||||||
|
'ip_address': status.ip_address,
|
||||||
|
'signal': status.signal,
|
||||||
|
'ap_mode_active': status.ap_mode_active,
|
||||||
|
'auto_enable_ap_mode': auto_enable_ap
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||||
|
def scan_wifi_networks():
|
||||||
|
"""Scan for available WiFi networks
|
||||||
|
|
||||||
|
If AP mode is active, it will be temporarily disabled during scanning
|
||||||
|
and automatically re-enabled afterward. Users connected to the AP will
|
||||||
|
be briefly disconnected during this process.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
|
||||||
|
# Check if AP mode is active before scanning (for user notification)
|
||||||
|
ap_was_active = wifi_manager._is_ap_mode_active()
|
||||||
|
|
||||||
|
# Perform the scan (this will handle AP mode disabling/enabling internally)
|
||||||
|
networks, _was_cached = wifi_manager.scan_networks()
|
||||||
|
|
||||||
|
# Convert to dict format
|
||||||
|
networks_data = [
|
||||||
|
{
|
||||||
|
'ssid': net.ssid,
|
||||||
|
'signal': net.signal,
|
||||||
|
'security': net.security,
|
||||||
|
'frequency': net.frequency
|
||||||
|
}
|
||||||
|
for net in networks
|
||||||
|
]
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'status': 'success',
|
||||||
|
'data': networks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inform user if AP mode was temporarily disabled
|
||||||
|
if ap_was_active:
|
||||||
|
response_data['message'] = (
|
||||||
|
f'Found {len(networks_data)} networks. '
|
||||||
|
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
|
||||||
|
'If you were connected to the setup network, you may need to reconnect.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error scanning WiFi networks", exc_info=True)
|
||||||
|
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
|
||||||
|
|
||||||
|
# Provide more specific error messages for common issues
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if 'permission' in error_str or 'sudo' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'Permission error while scanning. '
|
||||||
|
'The WiFi scan requires appropriate permissions. '
|
||||||
|
'Please ensure the application has necessary privileges.'
|
||||||
|
)
|
||||||
|
elif 'timeout' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'WiFi scan timed out. '
|
||||||
|
'The scan took too long to complete. '
|
||||||
|
'This may happen if the WiFi interface is busy or in use.'
|
||||||
|
)
|
||||||
|
elif 'no wifi' in error_str or 'not available' in error_str:
|
||||||
|
error_message = (
|
||||||
|
'WiFi scanning tools are not available. '
|
||||||
|
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': error_message
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/connect', methods=['POST'])
|
||||||
|
def connect_wifi():
|
||||||
|
"""Connect to a WiFi network"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Request body is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if 'ssid' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = data['ssid']
|
||||||
|
if not ssid or not ssid.strip():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID cannot be empty'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = ssid.strip()
|
||||||
|
password = data.get('password', '') or ''
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message or 'Failed to connect to network'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error connecting to WiFi", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||||
|
def disconnect_wifi():
|
||||||
|
"""Disconnect from the current WiFi network"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.disconnect_from_network()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message or 'Failed to disconnect from network'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error disconnecting from WiFi", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||||
|
def enable_ap_mode():
|
||||||
|
"""Enable access point mode"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
||||||
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
||||||
|
success, message = wifi_manager.enable_ap_mode(force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||||
|
def disable_ap_mode():
|
||||||
|
"""Disable access point mode"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message = wifi_manager.disable_ap_mode()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||||
|
def get_auto_enable_ap_mode():
|
||||||
|
"""Get auto-enable AP mode setting"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'auto_enable_ap_mode': auto_enable
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||||
|
def set_auto_enable_ap_mode():
|
||||||
|
"""Set auto-enable AP mode setting"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if data is None or 'auto_enable_ap_mode' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'auto_enable_ap_mode is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
|
||||||
|
if auto_enable is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'auto_enable_ap_mode must be a boolean'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
||||||
|
wifi_manager._save_config()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Auto-enable AP mode set to {auto_enable}',
|
||||||
|
'data': {
|
||||||
|
'auto_enable_ap_mode': auto_enable
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||||
|
def get_wifi_radio():
|
||||||
|
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
state = wifi_manager.get_wifi_radio_state()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': state
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
|
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||||
|
def set_wifi_radio():
|
||||||
|
"""Turn the WiFi radio on or off.
|
||||||
|
|
||||||
|
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
||||||
|
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||||
|
of this web interface.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if 'enabled' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'enabled is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Parse defensively: bool("false") is True and a plain int never
|
||||||
|
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
||||||
|
# int 1/0 — the endpoint is a public contract, not just the shipped
|
||||||
|
# UI (which always sends real JSON booleans). An unrecognized value
|
||||||
|
# must be rejected, not silently disable the radio: this is the
|
||||||
|
# route that can drop the caller's own connection to this interface.
|
||||||
|
enabled = _parse_bool_ish(data['enabled'])
|
||||||
|
if enabled is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'enabled must be a boolean'
|
||||||
|
}), 400
|
||||||
|
force = _parse_bool_ish(data.get('force', False))
|
||||||
|
if force is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'force must be a boolean'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'data': wifi_manager.get_wifi_radio_state()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message,
|
||||||
|
'reason': reason
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error setting WiFi radio state", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
||||||
|
}), 500
|
||||||
@@ -4,8 +4,6 @@ from markupsafe import escape
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import os.path
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -15,6 +13,7 @@ _SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
|||||||
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -274,7 +273,15 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
||||||
|
|
||||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
and loads Tailwind CSS so the fragment runs correctly inside the iframe
|
||||||
|
that plugin_config.html embeds it in.
|
||||||
|
|
||||||
|
That iframe carries no ``sandbox`` attribute, so the fragment runs with
|
||||||
|
the interface's own origin. That is deliberate rather than an oversight:
|
||||||
|
the fragment is a file from an installed plugin, and an installed plugin
|
||||||
|
already runs Python on the device. The trust boundary is plugin install,
|
||||||
|
not this route. It is worth knowing when reading the code, which is why
|
||||||
|
it says so here instead of claiming a sandbox that is not there.
|
||||||
"""
|
"""
|
||||||
# Validate URL-derived values against strict allowlists before any path or
|
# Validate URL-derived values against strict allowlists before any path or
|
||||||
# script operations.
|
# script operations.
|
||||||
@@ -283,11 +290,12 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||||
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
# The allowlists above already forbid a separator, but the value that gets
|
||||||
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
# joined has to be the checked one, not the argument -- see
|
||||||
# chain even though the allowlist above already prevents path separators.
|
# src/common/path_safety.py. safe_path_component rejects rather than
|
||||||
safe_id = os.path.basename(plugin_id)
|
# truncates, so these cannot disagree.
|
||||||
safe_fn = os.path.basename(filename)
|
safe_id = safe_path_component(plugin_id)
|
||||||
|
safe_fn = safe_path_component(filename)
|
||||||
if not safe_id or not safe_fn:
|
if not safe_id or not safe_fn:
|
||||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
@@ -297,22 +305,19 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
try:
|
try:
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
|
|
||||||
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
_plugin_dir = resolve_under(_plugins_base, safe_id)
|
||||||
_plugin_dir = (_plugins_base / safe_id).resolve()
|
if _plugin_dir is None:
|
||||||
_plugin_dir.relative_to(_plugins_base) # containment guard
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
# Mirror PluginManager's ledmatrix- prefix fallback.
|
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||||
if not _plugin_dir.exists():
|
if not _plugin_dir.exists():
|
||||||
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
_alt = resolve_under(_plugins_base, f'ledmatrix-{safe_id}')
|
||||||
_alt = (_plugins_base / _alt_id).resolve()
|
if _alt is not None:
|
||||||
try:
|
|
||||||
_alt.relative_to(_plugins_base)
|
|
||||||
_plugin_dir = _alt
|
_plugin_dir = _alt
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
web_ui_path = resolve_under(_plugin_dir / 'web_ui', safe_fn)
|
||||||
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
if web_ui_path is None:
|
||||||
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
if not web_ui_path.exists():
|
if not web_ui_path.exists():
|
||||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
@@ -369,17 +374,14 @@ def _plugin_dir_for(safe_id):
|
|||||||
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
|
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
|
||||||
"""
|
"""
|
||||||
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
plugin_dir = (plugins_base / safe_id).resolve()
|
plugin_dir = resolve_under(plugins_base, safe_id)
|
||||||
plugin_dir.relative_to(plugins_base) # containment guard
|
if plugin_dir is None:
|
||||||
|
raise ValueError('plugin id escapes the plugins directory')
|
||||||
|
|
||||||
if not plugin_dir.exists():
|
if not plugin_dir.exists():
|
||||||
alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}')
|
||||||
alt = (plugins_base / alt_id).resolve()
|
if alt is not None:
|
||||||
try:
|
|
||||||
alt.relative_to(plugins_base)
|
|
||||||
plugin_dir = alt
|
plugin_dir = alt
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return plugin_dir
|
return plugin_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -432,11 +434,11 @@ def serve_plugin_widget(plugin_id, widget_name):
|
|||||||
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
|
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
|
||||||
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
|
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
# os.path.basename() is the CodeQL-recognised path sanitizer used
|
# safe_path_component is this codebase's sanitiser (src/common/
|
||||||
# throughout this codebase; the allowlists above already exclude
|
# path_safety.py): it rejects rather than mangles, so a name that is not
|
||||||
# separators, this breaks the taint chain.
|
# a plain path component never reaches the filesystem.
|
||||||
safe_id = os.path.basename(plugin_id)
|
safe_id = safe_path_component(plugin_id)
|
||||||
safe_widget = os.path.basename(widget_name)
|
safe_widget = safe_path_component(widget_name)
|
||||||
if not safe_id or not safe_widget:
|
if not safe_id or not safe_widget:
|
||||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
@@ -455,10 +457,10 @@ def serve_plugin_widget(plugin_id, widget_name):
|
|||||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
widgets_dir = (plugin_dir / 'widgets').resolve()
|
widgets_dir = (plugin_dir / 'widgets').resolve()
|
||||||
script_path = (widgets_dir / os.path.basename(script)).resolve()
|
# The script name comes from the plugin's manifest, not the request,
|
||||||
script_path.relative_to(widgets_dir) # second containment guard
|
# so it gets the same containment treatment the URL parts got.
|
||||||
|
script_path = resolve_under(widgets_dir, script)
|
||||||
if not script_path.is_file():
|
if script_path is None or not script_path.is_file():
|
||||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
body = script_path.read_text(encoding='utf-8')
|
body = script_path.read_text(encoding='utf-8')
|
||||||
@@ -759,9 +761,11 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
Load plugin configuration partial - server-side rendered form.
|
Load plugin configuration partial - server-side rendered form.
|
||||||
This replaces the client-side generateConfigForm() JavaScript.
|
This replaces the client-side generateConfigForm() JavaScript.
|
||||||
"""
|
"""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
# Refuse an id that is not a plain directory name, rather than quietly
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
# basename-ing it down to one: "../weather" used to become "weather" and
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
# render a partial the caller never asked for.
|
||||||
|
plugin_id = safe_path_component(plugin_id)
|
||||||
|
if not plugin_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -774,10 +778,8 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
|
|
||||||
# Resolve and validate all plugin paths against the plugins base directory
|
# Resolve and validate all plugin paths against the plugins base directory
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
_plugin_dir = resolve_under(_plugins_base, plugin_id)
|
||||||
try:
|
if _plugin_dir is None:
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
|
||||||
except ValueError:
|
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
||||||
|
|
||||||
# Try to get plugin info first
|
# Try to get plugin info first
|
||||||
@@ -801,19 +803,17 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
config = full_config.get(plugin_id, {})
|
config = full_config.get(plugin_id, {})
|
||||||
|
|
||||||
# Load uploaded images from metadata file if images field exists in schema
|
# Load uploaded images from metadata file if images field exists in schema
|
||||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
schema_path_temp = resolve_under(_plugin_dir, "config_schema.json")
|
||||||
if schema_path_temp.exists():
|
if schema_path_temp is not None and schema_path_temp.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||||
temp_schema = json.load(f)
|
temp_schema = json.load(f)
|
||||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
||||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
metadata_file = resolve_under(
|
||||||
try:
|
_assets_base, plugin_id, 'uploads', '.metadata.json'
|
||||||
metadata_file.relative_to(_assets_base)
|
)
|
||||||
except ValueError:
|
|
||||||
metadata_file = None
|
|
||||||
if metadata_file and metadata_file.exists():
|
if metadata_file and metadata_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||||
@@ -858,8 +858,10 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
logger.warning("SchemaManager could not load schema for %s: %s",
|
logger.warning("SchemaManager could not load schema for %s: %s",
|
||||||
plugin_id, e)
|
plugin_id, e)
|
||||||
if not schema:
|
if not schema:
|
||||||
schema_path = _plugin_dir / "config_schema.json"
|
# resolve_under keeps the containment guard main added here; the
|
||||||
if schema_path.exists():
|
# SchemaManager path above does its own.
|
||||||
|
schema_path = resolve_under(_plugin_dir, "config_schema.json")
|
||||||
|
if schema_path is not None and schema_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
@@ -868,8 +870,8 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
|
|
||||||
# Get web UI actions from plugin manifest
|
# Get web UI actions from plugin manifest
|
||||||
web_ui_actions = []
|
web_ui_actions = []
|
||||||
manifest_path = _plugin_dir / "manifest.json"
|
manifest_path = resolve_under(_plugin_dir, "manifest.json")
|
||||||
if manifest_path.exists():
|
if manifest_path is not None and manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
@@ -917,9 +919,10 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
|
|
||||||
def _load_starlark_config_partial(app_id):
|
def _load_starlark_config_partial(app_id):
|
||||||
"""Load configuration partial for a Starlark app."""
|
"""Load configuration partial for a Starlark app."""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
# Refuse an id that is not a plain directory name rather than basename-ing
|
||||||
app_id = os.path.basename(app_id or '')
|
# it down to one -- see _load_plugin_config_partial for why.
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
app_id = safe_path_component(app_id)
|
||||||
|
if not app_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
||||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -958,11 +961,7 @@ def _load_starlark_config_partial(app_id):
|
|||||||
|
|
||||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
||||||
schema = None
|
schema = None
|
||||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
schema_file = resolve_under(starlark_base, app_id, 'schema.json')
|
||||||
try:
|
|
||||||
schema_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
schema_file = None
|
|
||||||
if schema_file and schema_file.exists():
|
if schema_file and schema_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_file, 'r') as f:
|
with open(schema_file, 'r') as f:
|
||||||
@@ -972,11 +971,7 @@ def _load_starlark_config_partial(app_id):
|
|||||||
|
|
||||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
# Load config from config.json if it exists — validate path stays within starlark_base
|
||||||
config = {}
|
config = {}
|
||||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
config_file = resolve_under(starlark_base, app_id, 'config.json')
|
||||||
try:
|
|
||||||
config_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
config_file = None
|
|
||||||
if config_file and config_file.exists():
|
if config_file and config_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
|
|||||||
Regular → Executable
@@ -602,9 +602,9 @@
|
|||||||
};
|
};
|
||||||
// Build the <i class="..."> + label as DOM nodes so a
|
// Build the <i class="..."> + label as DOM nodes so a
|
||||||
// hostile plugin.icon (e.g. containing a quote) can't
|
// hostile plugin.icon (e.g. containing a quote) can't
|
||||||
// break out of the attribute. escapeHtml only escapes
|
// break out of the attribute. escapeHtml now escapes
|
||||||
// <, >, &, not ", so attribute-context interpolation
|
// quotes too, but setting the property directly cannot
|
||||||
// would be unsafe.
|
// be got wrong at all, so it stays.
|
||||||
const iconEl = document.createElement('i');
|
const iconEl = document.createElement('i');
|
||||||
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
|
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||||
const labelNode = document.createTextNode(plugin.name || plugin.id);
|
const labelNode = document.createTextNode(plugin.name || plugin.id);
|
||||||
@@ -643,10 +643,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Quotes too, so the result is safe inside a quoted attribute
|
||||||
|
// value -- the textContent/innerHTML round-trip alone only
|
||||||
|
// escapes &, < and >.
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshPlugins() {
|
async refreshPlugins() {
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ function closeErrorModal() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML to prevent XSS.
|
* Escape HTML to prevent XSS.
|
||||||
|
*
|
||||||
|
* Quotes are escaped as well so the result is safe inside a quoted attribute
|
||||||
|
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
|
||||||
*/
|
*/
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (typeof text !== 'string') {
|
if (typeof text !== 'string') {
|
||||||
@@ -267,7 +270,9 @@ function escapeHtml(text) {
|
|||||||
}
|
}
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -118,15 +118,27 @@
|
|||||||
/**
|
/**
|
||||||
* Escape HTML to prevent XSS
|
* Escape HTML to prevent XSS
|
||||||
* Always escapes the input, even for non-strings, by coercing to string first
|
* Always escapes the input, even for non-strings, by coercing to string first
|
||||||
|
*
|
||||||
|
* The result is safe in text content AND inside quoted attribute values.
|
||||||
|
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
|
||||||
|
* the HTML serializer leaves quotes alone because they are harmless in a
|
||||||
|
* text node. Every widget here interpolates the result into attributes
|
||||||
|
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
|
||||||
|
* attribute and lets the value inject its own, so the quotes have to go
|
||||||
|
* too. Each widget's standalone fallback already did this; the shared
|
||||||
|
* implementation they all prefer did not.
|
||||||
|
*
|
||||||
* @param {*} text - Text to escape (will be coerced to string)
|
* @param {*} text - Text to escape (will be coerced to string)
|
||||||
* @returns {string} Escaped text
|
* @returns {string} Escaped text, safe for text and attribute contexts
|
||||||
*/
|
*/
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
// Always coerce to string first, then escape
|
// Always coerce to string first, then escape
|
||||||
const textStr = String(text);
|
const textStr = String(text);
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = textStr;
|
div.textContent = textStr;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,10 +50,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Escape HTML to prevent XSS (for HTML contexts)
|
// Escape HTML to prevent XSS (for HTML contexts)
|
||||||
|
// Quotes too: the result lands in quoted attribute values below, and
|
||||||
|
// the textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
const escapeHtml = (text) => {
|
const escapeHtml = (text) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = String(text ?? '');
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use validated/sanitized hex for style attribute and input values
|
// Use validated/sanitized hex for style attribute and input values
|
||||||
|
|||||||
@@ -804,10 +804,14 @@
|
|||||||
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
||||||
|
|
||||||
// Escape HTML helper
|
// Escape HTML helper
|
||||||
|
// Quotes too: the result lands in quoted attribute values below, and the
|
||||||
|
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
const escapeHtml = (text) => {
|
const escapeHtml = (text) => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = String(text ?? '');
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use sanitizedId for all ID references in the schedule HTML
|
// Use sanitizedId for all ID references in the schedule HTML
|
||||||
|
|||||||
@@ -738,10 +738,15 @@
|
|||||||
delete btn._jfmOrigText;
|
delete btn._jfmOrigText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quotes too: the result lands in quoted attribute values (title=,
|
||||||
|
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
|
||||||
|
// only escapes &, < and >.
|
||||||
_esc(str) {
|
_esc(str) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = String(str ?? '');
|
d.textContent = String(str ?? '');
|
||||||
return d.innerHTML;
|
return d.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
_fmtSize(bytes) {
|
_fmtSize(bytes) {
|
||||||
|
|||||||
@@ -206,10 +206,15 @@
|
|||||||
else console.log(`[PFM][${type}] ${msg}`);
|
else console.log(`[PFM][${type}] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quotes too: the result lands in quoted attribute values (id=, value=,
|
||||||
|
// data-col=), and the textContent/innerHTML round-trip only escapes
|
||||||
|
// &, < and >.
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = String(s ?? '');
|
d.textContent = String(s ?? '');
|
||||||
return d.innerHTML;
|
return d.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
@@ -383,7 +388,7 @@
|
|||||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||||
<i class="fas fa-save mr-1"></i>Save
|
<i class="fas fa-save mr-1"></i>Save
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`);
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
@@ -411,7 +416,7 @@
|
|||||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -431,6 +436,20 @@
|
|||||||
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
||||||
|
|
||||||
const cols = Object.keys(entries[0][1]);
|
const cols = Object.keys(entries[0][1]);
|
||||||
|
|
||||||
|
// Delegated listener: day/col reach _pfmCellEdit only through data-*
|
||||||
|
// attributes, never through a JS string spliced into an inline
|
||||||
|
// handler -- the browser HTML-decodes attribute values before
|
||||||
|
// running them as script, which undoes escHtml's quote escaping and
|
||||||
|
// reopens the exact injection it exists to close. `container` is a
|
||||||
|
// fresh element per modal open (see _pfmOpenEdit), so this attaches
|
||||||
|
// exactly once per table, even though buildPage() re-renders below.
|
||||||
|
container.addEventListener('input', (e) => {
|
||||||
|
const cell = e.target.closest('input[data-day], textarea[data-day]');
|
||||||
|
if (!cell) return;
|
||||||
|
window._pfmCellEdit(fieldId, cell.dataset.day, cell.dataset.col, cell.value);
|
||||||
|
});
|
||||||
|
|
||||||
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||||
const total = entries.length;
|
const total = entries.length;
|
||||||
@@ -459,18 +478,16 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${pageEntries.map(([day, val]) => `
|
${pageEntries.map(([day, val]) => `
|
||||||
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
<tr data-day="${escHtml(day)}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
||||||
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
||||||
${cols.map(col => {
|
${cols.map(col => {
|
||||||
const v = val[col] ?? '';
|
const v = val[col] ?? '';
|
||||||
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
||||||
return isLong
|
return isLong
|
||||||
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
|
? `<td><textarea data-day="${escHtml(day)}" data-col="${escHtml(col)}" rows="2"
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
|
|
||||||
>${escHtml(String(v))}</textarea></td>`
|
>${escHtml(String(v))}</textarea></td>`
|
||||||
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
|
: `<td><input type="text" data-day="${escHtml(day)}" data-col="${escHtml(col)}"
|
||||||
value="${escHtml(String(v))}"
|
value="${escHtml(String(v))}"></td>`;
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
|
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -489,7 +506,7 @@
|
|||||||
${page >= totalPages ? 'disabled' : ''}
|
${page >= totalPages ? 'disabled' : ''}
|
||||||
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`);
|
||||||
st._tablePage = page;
|
st._tablePage = page;
|
||||||
st._tableEntries = entries;
|
st._tableEntries = entries;
|
||||||
st._tableCols = cols;
|
st._tableCols = cols;
|
||||||
@@ -578,7 +595,7 @@
|
|||||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
<i class="fas fa-trash mr-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`);
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
@@ -631,7 +648,7 @@
|
|||||||
<i class="fas fa-plus mr-1"></i>Create
|
<i class="fas fa-plus mr-1"></i>Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`);
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
@@ -784,7 +801,7 @@
|
|||||||
<div class="pfm-grid">
|
<div class="pfm-grid">
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`);
|
||||||
|
|
||||||
loadFiles(fieldId);
|
loadFiles(fieldId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,9 +54,16 @@
|
|||||||
// RFC 3986 scheme pattern: starts with letter, then letters/digits/+/./-
|
// RFC 3986 scheme pattern: starts with letter, then letters/digits/+/./-
|
||||||
const RFC_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
const RFC_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
||||||
|
|
||||||
|
// Schemes that execute script when navigated to. These can never be
|
||||||
|
// allowed, whatever a schema's allowedProtocols asks for: the validated
|
||||||
|
// value is written straight into an <a href>, so allowing "javascript"
|
||||||
|
// here would turn a config field into script execution.
|
||||||
|
const SCRIPTABLE_SCHEMES = ['javascript', 'data', 'vbscript', 'blob', 'filesystem'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize and validate protocol list against RFC 3986 scheme pattern.
|
* Normalize and validate protocol list against RFC 3986 scheme pattern.
|
||||||
* Accepts schemes like "http", "https", "git+ssh", "android-app", etc.
|
* Accepts schemes like "http", "https", "git+ssh", "android-app", etc.
|
||||||
|
* Scriptable schemes are dropped -- see SCRIPTABLE_SCHEMES.
|
||||||
* @param {Array|string} protocols - Protocol list (array or comma-separated string)
|
* @param {Array|string} protocols - Protocol list (array or comma-separated string)
|
||||||
* @returns {Array} Normalized lowercase protocols, defaults to ['http', 'https']
|
* @returns {Array} Normalized lowercase protocols, defaults to ['http', 'https']
|
||||||
*/
|
*/
|
||||||
@@ -70,15 +77,25 @@
|
|||||||
const normalized = list
|
const normalized = list
|
||||||
.map(p => String(p).trim())
|
.map(p => String(p).trim())
|
||||||
.filter(p => RFC_SCHEME_PATTERN.test(p))
|
.filter(p => RFC_SCHEME_PATTERN.test(p))
|
||||||
.map(p => p.toLowerCase());
|
.map(p => p.toLowerCase())
|
||||||
|
.filter(p => !SCRIPTABLE_SCHEMES.includes(p));
|
||||||
return normalized.length > 0 ? normalized : ['http', 'https'];
|
return normalized.length > 0 ? normalized : ['http', 'https'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `string` parses as a URL whose scheme is allowed AND is not
|
||||||
|
* one that executes script. The scriptable check is repeated here rather
|
||||||
|
* than trusted to normalizeProtocols so that a caller passing its own
|
||||||
|
* protocol list cannot re-open the hole.
|
||||||
|
*/
|
||||||
function isValidUrl(string, allowedProtocols) {
|
function isValidUrl(string, allowedProtocols) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(string);
|
const url = new URL(string);
|
||||||
|
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||||
|
if (SCRIPTABLE_SCHEMES.includes(protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (allowedProtocols && allowedProtocols.length > 0) {
|
if (allowedProtocols && allowedProtocols.length > 0) {
|
||||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
|
||||||
return allowedProtocols.includes(protocol);
|
return allowedProtocols.includes(protocol);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -87,6 +104,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value safe to use as an <a href>: the URL itself when it validates,
|
||||||
|
* and '' otherwise. Keeps the "render an href for whatever is stored"
|
||||||
|
* path from emitting a javascript: URL that was never validated.
|
||||||
|
*/
|
||||||
|
function safeHref(value, allowedProtocols) {
|
||||||
|
return isValidUrl(value, allowedProtocols) ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('url-input', {
|
window.LEDMatrixWidgets.register('url-input', {
|
||||||
name: 'URL Input Widget',
|
name: 'URL Input Widget',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -139,7 +165,7 @@
|
|||||||
html += `
|
html += `
|
||||||
<div id="${fieldId}_preview" class="mt-2 ${currentValue && isValidUrl(currentValue, allowedProtocols) ? '' : 'hidden'}">
|
<div id="${fieldId}_preview" class="mt-2 ${currentValue && isValidUrl(currentValue, allowedProtocols) ? '' : 'hidden'}">
|
||||||
<a id="${fieldId}_preview_link"
|
<a id="${fieldId}_preview_link"
|
||||||
href="${escapeHtml(currentValue)}"
|
href="${escapeHtml(safeHref(currentValue, allowedProtocols))}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
|
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
|
||||||
@@ -231,10 +257,23 @@
|
|||||||
const protocols = normalizeProtocols(widgetEl?.dataset.protocols);
|
const protocols = normalizeProtocols(widgetEl?.dataset.protocols);
|
||||||
|
|
||||||
if (previewEl && previewLink) {
|
if (previewEl && previewLink) {
|
||||||
if (value && isValidUrl(value, protocols)) {
|
// Scheme checked inline, right where the value reaches the DOM sink,
|
||||||
|
// rather than through safeHref/isValidUrl -- CodeQL's DOM-based-XSS
|
||||||
|
// sanitizer recognition does not trace a boolean-returning helper two
|
||||||
|
// calls deep, so it kept flagging this assignment even though the
|
||||||
|
// scriptable-scheme check (see SCRIPTABLE_SCHEMES) already covered it.
|
||||||
|
let scheme = '';
|
||||||
|
try {
|
||||||
|
scheme = value ? new URL(value).protocol.replace(':', '').toLowerCase() : '';
|
||||||
|
} catch (_) {
|
||||||
|
scheme = '';
|
||||||
|
}
|
||||||
|
const schemeIsSafe = !!scheme && !SCRIPTABLE_SCHEMES.includes(scheme) && protocols.includes(scheme);
|
||||||
|
if (schemeIsSafe) {
|
||||||
previewLink.href = value;
|
previewLink.href = value;
|
||||||
previewEl.classList.remove('hidden');
|
previewEl.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
|
previewLink.removeAttribute('href');
|
||||||
previewEl.classList.add('hidden');
|
previewEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4370,12 +4370,15 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape HTML helper
|
// Escape HTML helper. Quotes too: the result lands in quoted attribute
|
||||||
|
// values, and the textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
const escapeHtml = (text) => {
|
const escapeHtml = (text) => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings
|
// Helper function to escape for JavaScript strings
|
||||||
@@ -4471,11 +4474,15 @@ function isGithubUrl(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML
|
// Utility function to escape HTML. Quotes too: most call sites interpolate the
|
||||||
|
// result into a quoted attribute value, and the textContent/innerHTML
|
||||||
|
// round-trip only escapes &, < and >.
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape text for use in HTML attributes
|
// Utility function to escape text for use in HTML attributes
|
||||||
@@ -5623,11 +5630,15 @@ document.addEventListener('htmx:afterSettle', function() {
|
|||||||
let starlarkDataLoaded = false;
|
let starlarkDataLoaded = false;
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
// Quotes too: the result lands in quoted attribute values (data-app-id=,
|
||||||
|
// title=), and the textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStarlarkInstalled(appId) {
|
function isStarlarkInstalled(appId) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""System metrics for the status stream.
|
||||||
|
|
||||||
|
Deliberately free of Flask and app imports: importing web_interface.app
|
||||||
|
constructs the Flask application and a CacheManager, and the latter claims the
|
||||||
|
cache directory with a cleanup thread. Reading a CPU percentage should not do
|
||||||
|
that, and neither should testing it.
|
||||||
|
|
||||||
|
Every value is best-effort. A number that cannot be taken comes back as None so
|
||||||
|
the UI can render '--', because a confident wrong number is worse than a blank:
|
||||||
|
``disk_used_percent`` used to be hardcoded to 0, which read as "plenty of room"
|
||||||
|
on a card that was filling up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
_THERMAL_ZONE = '/sys/class/thermal/thermal_zone0/temp'
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_temp_c() -> Optional[float]:
|
||||||
|
"""CPU temperature in degrees C, or None off a Raspberry Pi."""
|
||||||
|
try:
|
||||||
|
with open(_THERMAL_ZONE, 'r') as f:
|
||||||
|
return round(float(f.read()) / 1000.0, 1)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_system_metrics() -> Dict[str, Any]:
|
||||||
|
"""Collect the numbers that predict trouble on a small board.
|
||||||
|
|
||||||
|
``memory_available_mb`` is MemAvailable rather than a used percentage: it
|
||||||
|
accounts for reclaimable page cache, so it is what separates a board at 70%
|
||||||
|
"used" that is fine from one at 70% that is about to fail fork(). A 1GB Pi
|
||||||
|
can sit at either and only this number tells them apart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
'cpu_percent': 0,
|
||||||
|
'memory_used_percent': 0,
|
||||||
|
'memory_available_mb': None,
|
||||||
|
'disk_used_percent': None,
|
||||||
|
'cpu_temp': _cpu_temp_c() or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# interval=None is non-blocking; app startup primes psutil's internal state.
|
||||||
|
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
|
||||||
|
try:
|
||||||
|
disk_used_percent: Optional[float] = round(psutil.disk_usage('/').percent, 1)
|
||||||
|
except OSError:
|
||||||
|
disk_used_percent = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cpu_percent': cpu_percent,
|
||||||
|
'memory_used_percent': round(memory.percent, 1),
|
||||||
|
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
||||||
|
'disk_used_percent': disk_used_percent,
|
||||||
|
'cpu_temp': _cpu_temp_c() or 0,
|
||||||
|
}
|
||||||
@@ -152,7 +152,8 @@ function displayCacheFiles(data) {
|
|||||||
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
|
<span class="text-sm text-gray-600">${escapeHtml(modifiedStr)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button onclick="deleteCacheFile('${escapeHtml(cacheFile.key)}')"
|
<button onclick="deleteCacheFile(this.dataset.cacheKey)"
|
||||||
|
data-cache-key="${escapeHtml(cacheFile.key)}"
|
||||||
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
|
class="text-red-600 hover:text-red-900 px-3 py-1 rounded hover:bg-red-50 transition-colors"
|
||||||
title="Delete cache file">
|
title="Delete cache file">
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
<i class="fas fa-trash mr-1"></i>Delete
|
||||||
@@ -220,10 +221,14 @@ function showError(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML
|
// Utility function to escape HTML
|
||||||
|
// Quotes too: the result is interpolated into quoted attribute values, and the
|
||||||
|
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make deleteCacheFile available globally for onclick handlers
|
// Make deleteCacheFile available globally for onclick handlers
|
||||||
|
|||||||
@@ -711,11 +711,15 @@ function showError(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML
|
// Utility function to escape HTML. Quotes too: the result is interpolated into
|
||||||
|
// quoted attribute values (option value=, title=), and the
|
||||||
|
// textContent/innerHTML round-trip only escapes &, < and >.
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshCurrentPluginStatus() {
|
function refreshCurrentPluginStatus() {
|
||||||
|
|||||||
@@ -30,10 +30,33 @@
|
|||||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
// These are opposite problems and need opposite advice. Lumping
|
||||||
document.getElementById('reconciliation-banner-text').textContent =
|
// them together told users to delete config.json entries for
|
||||||
'Stale plugin config entries found: ' + ids +
|
// plugins that were installed and correctly configured.
|
||||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
function idsOfType(t) {
|
||||||
|
return d.unresolved.filter(function (i) { return i.type === t; })
|
||||||
|
.map(function (i) { return i.plugin_id; });
|
||||||
|
}
|
||||||
|
var notConfigured = idsOfType('plugin_missing_in_config');
|
||||||
|
var notInstalled = idsOfType('plugin_missing_on_disk');
|
||||||
|
var other = d.unresolved.filter(function (i) {
|
||||||
|
return i.type !== 'plugin_missing_in_config'
|
||||||
|
&& i.type !== 'plugin_missing_on_disk';
|
||||||
|
}).map(function (i) { return i.plugin_id; });
|
||||||
|
|
||||||
|
var parts = [];
|
||||||
|
if (notConfigured.length) {
|
||||||
|
parts.push('Installed but missing from config: ' + notConfigured.join(', ')
|
||||||
|
+ '. Configure them in the Plugin Store - do not remove anything from config.json.');
|
||||||
|
}
|
||||||
|
if (notInstalled.length) {
|
||||||
|
parts.push('In config but not installed: ' + notInstalled.join(', ')
|
||||||
|
+ '. Reinstall via the Plugin Store, or remove these entries from config.json.');
|
||||||
|
}
|
||||||
|
if (other.length) {
|
||||||
|
parts.push('Needs attention: ' + other.join(', ') + '.');
|
||||||
|
}
|
||||||
|
document.getElementById('reconciliation-banner-text').textContent = parts.join(' ');
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
var banner = document.getElementById('reconciliation-banner');
|
||||||
banner.dataset.dismissKey = key;
|
banner.dataset.dismissKey = key;
|
||||||
banner.style.setProperty('display', 'flex', 'important');
|
banner.style.setProperty('display', 'flex', 'important');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user