mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-25 13:43:31 +00:00
* fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning - base.html: add htmx:afterSettle listener to set data-loaded on tab containers after HTMX swaps their content, preventing the overview partial from being re-fetched (and handlers lost) on every tab switch - base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect fallbacks so buttons get HTMX handlers even if HTMX finished its initial body scan before the fallback fetch completed - overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText) so quick action toast notifications actually fire - plugins_manager.js: add guarded htmx:afterSettle listener that only calls attachInstallButtonHandler when #install-plugin-from-url is in the DOM, eliminating the spurious console warning on non-plugin tab loads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a pattern that sets default message/status before the try block and calls showNotification(m,s) unconditionally after it, so a fallback toast is shown whenever xhr is absent or responseText is not valid JSON. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses In the catch block of all 11 hx-on:htmx:after-request handlers, check xhr.status >= 400 and downgrade s to 'error' so a failed action that returns an HTML error page (or other non-JSON body) surfaces as an error toast instead of the optimistic 'success'/'info' default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler The 500ms fallback setTimeout was calling attachInstallButtonHandler() unconditionally even when the plugins partial wasn't in the DOM, causing a spurious console.warn on every page load. Add the same element-existence check already present on the htmx:afterSettle listener. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix backup API 404s, hardware status 500, and HTMX loading race - Add all backup API routes to api_v3.py: preview, list, export, validate, restore (with plugin reinstall), download, delete - Fix PermissionError on /hardware/status: return graceful 200 instead of 500 when the status file is owned by a different user; also fix root cause by writing the file world-readable (0o644) in display_manager - Fix HTMX race: dispatch htmx:ready window event from HTMX onload callback; loadTabContent now waits for that event instead of immediately falling back to direct fetch (eliminating the "HTMX not available" console warning on initial load) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Cancel HTMX fallback timers when htmx:ready fires The 5-second setTimeout fallbacks for plugins and overview were firing before the htmx:ready event arrived, logging spurious warnings. Each timer now self-cancels via htmx:ready so the fallback only triggers when HTMX genuinely fails to load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address review feedback: error leaks, ok:false, htmx:ready coverage - Backup endpoints: replace raw str(e) in user-facing responses with a generic message; full exception still logged via exc_info=True - hardware/status: change ok:null to ok:false for PermissionError and json.JSONDecodeError so the UI's hw.ok===false check triggers correctly - base.html: dispatch htmx:ready from the fallback load path so any deferred listeners fire on CDN-fallback loads too - loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins fall back to direct fetch when HTMX is completely unavailable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Treat system-managed pip packages as satisfied for dependency marker When a plugin's requirements.txt includes a package installed via the system package manager (dnf/apt), pip fails with 'uninstall-no-record-file' because it can't replace the system-tracked copy. The package is present and functional, but the missing marker caused the install to be retried on every service restart. Detect this specific error pattern: if the only pip failure is uninstall-no-record-file, write the .dependencies_installed marker and log a warning instead of returning False, suppressing the repeated warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix uninstall-no-record-file detection condition The previous check used a string replacement that left 'error:' in the remaining text, causing the condition to always evaluate false. Simplify to a direct substring check: if 'uninstall-no-record-file' appears in pip stderr the affected package is installed at the system level and we write the marker, suppressing the repeated warning on every restart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve CodeQL security findings in backup API Path traversal (CWE-22): - backup_download: switch from send_file(user-tainted-path) to send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses werkzeug safe_join internally which CodeQL recognises as a sanitizer - backup_delete: enumerate the export directory and match by name so entry.unlink() operates on a filesystem-derived Path rather than one constructed from user input; _safe_backup_path still guards first Information exposure through exceptions (CWE-209): - backup_validate: err_msg from validate_backup() can embed exception strings containing temp-file paths; log the detail, return a generic 'Invalid or corrupted backup file' to the client - Other backup endpoints: already fixed (str(e) -> generic message); CodeQL alerts will clear on next scan plugin_loader.py:185 (path traversal): false positive — requirements_file is constructed from plugin_dir returned by find_plugin_directory() (a filesystem scan), not from raw HTTP request input; no change needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pre-existing information exposure in version and action endpoints - get_system_version (alert #218): replaced str(e) with generic message; exception still logged via logger.error(exc_info=True) - execute_system_action (alert #216): removed str(e) and full traceback.format_exc() from the HTTP response — the full stack trace was being sent directly to clients; replaced with generic message and proper logger.error call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix remaining GitHub CodeQL security alerts - py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace with generic messages and logger.error(exc_info=True) - py/reflective-xss: Escape partial_name via markupsafe.escape in the load_partial 404 response - py/path-injection: Add regex validation of plugin_id before filesystem use in _load_plugin_config_partial - py/incomplete-url-substring-sanitization: Replace 'github.com' in substring checks with urlparse hostname comparison in store_manager.py - py/clear-text-logging-sensitive-data: Remove football-scoreboard debug prints and sensitive request-body prints from update endpoint - js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue with DOM-based textContent stripping that removes all HTML - js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ', <, > using HTML entities instead of backslash escaping - js/prototype-pollution-utility: Add __proto__/constructor/prototype key guards to deepMerge function in plugins_manager.js - app.py error handlers: Always return generic messages; remove debug-mode branches that could expose tracebacks in production Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix three remaining CodeQL path-injection and info-exposure alerts - plugin_loader.py: resolve plugin_dir with strict=True and validate marker_path with relative_to() before any filesystem writes, giving CodeQL the positive sanitization pattern it requires (py/path-injection) - api_v3.py _safe_backup_path: replace substring negative checks with a strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that CodeQL recognises as sanitising the user-supplied filename (py/path-injection) - api_v3.py backup_validate: whitelist known-safe manifest fields before returning JSON, preventing any exception strings captured inside validate_backup() from reaching the HTTP response (py/stack-trace-exposure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve 29 open CodeQL security alerts across 5 files py/flask-debug (#214): - debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var instead of hardcoded True py/stack-trace-exposure (#216, #218): - api_v3.py execute_system_action: remove subprocess stdout/stderr from HTTP responses; log via logger instead - api_v3.py get_git_version: validate output matches safe ref format (^[a-zA-Z0-9._-]+$) before including in response - api_v3.py: remove all remaining traceback.format_exc() dead variables and print() debug calls (replaced with logger.debug/warning) py/reflective-xss (#207, #208, #209, #210, #211, #212): - api_v3.py: remove plugin_id from all error/success response messages (uninstall, install, update, health, not-found responses) - pages_v3.py load_partial: return static "Partial not found" message instead of echoing partial_name - pages_v3.py _load_starlark_config_partial: add app_id regex validation, use static error messages instead of f-strings with app_id py/path-injection (#187–#206): - pages_v3.py _load_plugin_config_partial: resolve plugins_base and validate _plugin_dir with relative_to() before all file operations; same for assets metadata directory - pages_v3.py _load_starlark_config_partial: resolve starlark_base and validate schema_file/config_file paths with relative_to() - plugin_loader.py _find_plugin_directory: resolve plugins_dir and validate strategy-2 candidates with relative_to() - plugin_loader.py install_dependencies: resolve plugin_dir first, then construct requirements_file and marker_path from resolved base - plugin_loader.py load_module: resolve plugin_dir with strict=True and validate entry_file with relative_to() before exec_module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts Switch from resolve()+relative_to() to os.path.basename() reassignment, which CodeQL recognizes as a path sanitizer that breaks the taint chain. Also remove exception objects from backup_manager validate_backup return strings to eliminate the stack-trace-exposure taint source. Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241, #242, #243, #244, #245, #246, #247. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix broken logger format string and leaked exception in config save error - pages_v3.py: plain string was used instead of %-style substitution, so every manifest-read failure logged the literal "{plugin_id}" - api_v3.py save_main_config: exception message was still leaking through the error response; replace with generic message (consistent with the rest of the CodeQL sweep in this PR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
575 lines
25 KiB
Python
575 lines
25 KiB
Python
from flask import Blueprint, render_template, flash
|
|
from markupsafe import escape
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from src.web_interface.secret_helpers import mask_secret_fields
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Will be initialized when blueprint is registered
|
|
config_manager = None
|
|
plugin_manager = None
|
|
plugin_store_manager = None
|
|
|
|
pages_v3 = Blueprint('pages_v3', __name__)
|
|
|
|
@pages_v3.route('/')
|
|
def index():
|
|
"""Main v3 interface page"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
# Load configuration data
|
|
main_config = pages_v3.config_manager.load_config()
|
|
schedule_config = main_config.get('schedule', {})
|
|
|
|
# Get raw config files for JSON editor
|
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
|
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
|
|
main_config_json = json.dumps(main_config_data, indent=4)
|
|
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
|
else:
|
|
raise Exception("Config manager not initialized")
|
|
|
|
except Exception as e:
|
|
flash(f"Error loading configuration: {e}", "error")
|
|
schedule_config = {}
|
|
main_config_json = "{}"
|
|
secrets_config_json = "{}"
|
|
main_config_data = {}
|
|
secrets_config_data = {}
|
|
|
|
return render_template('v3/index.html',
|
|
schedule_config=schedule_config,
|
|
main_config_json=main_config_json,
|
|
secrets_config_json=secrets_config_json,
|
|
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
|
|
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
|
|
main_config=main_config_data,
|
|
secrets_config=secrets_config_data)
|
|
|
|
@pages_v3.route('/partials/<partial_name>')
|
|
def load_partial(partial_name):
|
|
"""Load HTMX partials dynamically"""
|
|
try:
|
|
# Map partial names to specific data loading
|
|
if partial_name == 'overview':
|
|
return _load_overview_partial()
|
|
elif partial_name == 'general':
|
|
return _load_general_partial()
|
|
elif partial_name == 'display':
|
|
return _load_display_partial()
|
|
elif partial_name == 'durations':
|
|
return _load_durations_partial()
|
|
elif partial_name == 'schedule':
|
|
return _load_schedule_partial()
|
|
elif partial_name == 'weather':
|
|
return _load_weather_partial()
|
|
elif partial_name == 'stocks':
|
|
return _load_stocks_partial()
|
|
elif partial_name == 'plugins':
|
|
return _load_plugins_partial()
|
|
elif partial_name == 'fonts':
|
|
return _load_fonts_partial()
|
|
elif partial_name == 'logs':
|
|
return _load_logs_partial()
|
|
elif partial_name == 'raw-json':
|
|
return _load_raw_json_partial()
|
|
elif partial_name == 'backup-restore':
|
|
return _load_backup_restore_partial()
|
|
elif partial_name == 'wifi':
|
|
return _load_wifi_partial()
|
|
elif partial_name == 'cache':
|
|
return _load_cache_partial()
|
|
elif partial_name == 'operation-history':
|
|
return _load_operation_history_partial()
|
|
else:
|
|
return "Partial not found", 404
|
|
|
|
except Exception as e:
|
|
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
|
def load_plugin_config_partial(plugin_id):
|
|
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
|
try:
|
|
return _load_plugin_config_partial(plugin_id)
|
|
except Exception:
|
|
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
|
|
def _load_overview_partial():
|
|
"""Load overview partial with system stats"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
# This would be populated with real system stats via SSE
|
|
return render_template('v3/partials/overview.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_general_partial():
|
|
"""Load general settings partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/general.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_display_partial():
|
|
"""Load display settings partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/display.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_durations_partial():
|
|
"""Load display durations partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/durations.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_schedule_partial():
|
|
"""Load schedule settings partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
schedule_config = main_config.get('schedule', {})
|
|
dim_schedule_config = main_config.get('dim_schedule', {})
|
|
# Get normal brightness for display in dim schedule UI
|
|
normal_brightness = main_config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
|
return render_template('v3/partials/schedule.html',
|
|
schedule_config=schedule_config,
|
|
dim_schedule_config=dim_schedule_config,
|
|
normal_brightness=normal_brightness)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
def _load_weather_partial():
|
|
"""Load weather configuration partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/weather.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_stocks_partial():
|
|
"""Load stocks configuration partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/stocks.html',
|
|
main_config=main_config)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_plugins_partial():
|
|
"""Load plugins management partial"""
|
|
try:
|
|
import json
|
|
from pathlib import Path
|
|
|
|
# Load plugin data from the plugin system
|
|
plugins_data = []
|
|
|
|
# Get installed plugins if managers are available
|
|
if pages_v3.plugin_manager and pages_v3.plugin_store_manager:
|
|
try:
|
|
# Get all installed plugin info
|
|
all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info()
|
|
|
|
# Load config once before the loop (not per-plugin)
|
|
full_config = pages_v3.config_manager.load_config() if pages_v3.config_manager else {}
|
|
|
|
# Format for the web interface
|
|
for plugin_info in all_plugin_info:
|
|
plugin_id = plugin_info.get('id')
|
|
|
|
# Re-read manifest from disk to ensure we have the latest metadata
|
|
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
|
if manifest_path.exists():
|
|
try:
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
fresh_manifest = json.load(f)
|
|
# Update plugin_info with fresh manifest data
|
|
plugin_info.update(fresh_manifest)
|
|
except Exception as e:
|
|
# If we can't read the fresh manifest, use the cached one
|
|
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
|
|
|
# Get enabled status from config (source of truth)
|
|
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
|
enabled = None
|
|
if pages_v3.config_manager:
|
|
plugin_config = full_config.get(plugin_id, {})
|
|
# Check if 'enabled' key exists in config (even if False)
|
|
if 'enabled' in plugin_config:
|
|
enabled = bool(plugin_config['enabled'])
|
|
|
|
# Fallback to plugin instance if config doesn't have enabled key
|
|
if enabled is None:
|
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin_instance:
|
|
enabled = plugin_instance.enabled
|
|
else:
|
|
# Default to True if no config key and plugin not loaded (matches BasePlugin default)
|
|
enabled = True
|
|
|
|
# Get verified status from store registry (no GitHub API calls needed)
|
|
store_info = pages_v3.plugin_store_manager.get_registry_info(plugin_id)
|
|
verified = store_info.get('verified', False) if store_info else False
|
|
|
|
last_updated = plugin_info.get('last_updated')
|
|
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
|
branch = plugin_info.get('branch')
|
|
|
|
if store_info:
|
|
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
|
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
|
branch = branch or store_info.get('branch') or store_info.get('default_branch')
|
|
|
|
plugins_data.append({
|
|
'id': plugin_id,
|
|
'name': plugin_info.get('name', plugin_id),
|
|
'author': plugin_info.get('author', 'Unknown'),
|
|
'category': plugin_info.get('category', 'General'),
|
|
'description': plugin_info.get('description', 'No description available'),
|
|
'tags': plugin_info.get('tags', []),
|
|
'enabled': enabled,
|
|
'verified': verified,
|
|
'loaded': plugin_info.get('loaded', False),
|
|
'last_updated': last_updated,
|
|
'last_commit': last_commit,
|
|
'branch': branch
|
|
})
|
|
except Exception as e:
|
|
logger.error("Error loading plugin data", exc_info=True)
|
|
|
|
return render_template('v3/partials/plugins.html',
|
|
plugins=plugins_data)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_fonts_partial():
|
|
"""Load fonts management partial"""
|
|
try:
|
|
# This would load font data from the font system
|
|
fonts_data = {} # Placeholder for font data
|
|
return render_template('v3/partials/fonts.html',
|
|
fonts=fonts_data)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_logs_partial():
|
|
"""Load logs viewer partial"""
|
|
try:
|
|
return render_template('v3/partials/logs.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_raw_json_partial():
|
|
"""Load raw JSON editor partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
|
secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets')
|
|
main_config_json = json.dumps(main_config_data, indent=4)
|
|
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
|
|
|
return render_template('v3/partials/raw_json.html',
|
|
main_config_json=main_config_json,
|
|
secrets_config_json=secrets_config_json,
|
|
main_config_path=pages_v3.config_manager.get_config_path(),
|
|
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_backup_restore_partial():
|
|
"""Load backup & restore partial."""
|
|
try:
|
|
return render_template('v3/partials/backup_restore.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
@pages_v3.route('/setup')
|
|
def captive_setup():
|
|
"""Lightweight captive portal setup page — self-contained, no frameworks."""
|
|
return render_template('v3/captive_setup.html')
|
|
|
|
def _load_wifi_partial():
|
|
"""Load WiFi setup partial"""
|
|
try:
|
|
return render_template('v3/partials/wifi.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_cache_partial():
|
|
"""Load cache management partial"""
|
|
try:
|
|
return render_template('v3/partials/cache.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_operation_history_partial():
|
|
"""Load operation history partial"""
|
|
try:
|
|
return render_template('v3/partials/operation_history.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
def _load_plugin_config_partial(plugin_id):
|
|
"""
|
|
Load plugin configuration partial - server-side rendered form.
|
|
This replaces the client-side generateConfigForm() JavaScript.
|
|
"""
|
|
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
plugin_id = os.path.basename(plugin_id or '')
|
|
if 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
|
|
|
|
try:
|
|
if not pages_v3.plugin_manager:
|
|
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
|
|
|
# Handle starlark app config (starlark:<app_id>)
|
|
if plugin_id.startswith('starlark:'):
|
|
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
|
|
|
# Resolve and validate all plugin paths against the plugins base directory
|
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
|
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
|
try:
|
|
_plugin_dir.relative_to(_plugins_base)
|
|
except ValueError:
|
|
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
|
|
# Try to get plugin info first
|
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
|
|
|
# If not found, re-discover plugins (handles plugins added after startup)
|
|
if not plugin_info:
|
|
pages_v3.plugin_manager.discover_plugins()
|
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
|
|
|
if not plugin_info:
|
|
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
|
|
|
# Get plugin instance (may be None if not loaded)
|
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
|
|
|
# Get plugin configuration from config file
|
|
config = {}
|
|
if pages_v3.config_manager:
|
|
full_config = pages_v3.config_manager.load_config()
|
|
config = full_config.get(plugin_id, {})
|
|
|
|
# Load uploaded images from metadata file if images field exists in schema
|
|
schema_path_temp = _plugin_dir / "config_schema.json"
|
|
if schema_path_temp.exists():
|
|
try:
|
|
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
|
temp_schema = json.load(f)
|
|
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
|
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
|
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
|
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
|
try:
|
|
metadata_file.relative_to(_assets_base)
|
|
except ValueError:
|
|
metadata_file = None
|
|
if metadata_file and metadata_file.exists():
|
|
try:
|
|
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
|
metadata = json.load(mf)
|
|
images_from_metadata = list(metadata.values())
|
|
if not config.get('images') or len(config.get('images', [])) == 0:
|
|
config['images'] = images_from_metadata
|
|
else:
|
|
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
|
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
|
if new_images:
|
|
config['images'] = config.get('images', []) + new_images
|
|
except Exception as e:
|
|
logger.warning("Could not load plugin upload metadata: %s", e)
|
|
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
|
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
|
|
|
# Get plugin schema
|
|
schema = {}
|
|
schema_path = _plugin_dir / "config_schema.json"
|
|
if schema_path.exists():
|
|
try:
|
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
schema = json.load(f)
|
|
except Exception as e:
|
|
logger.warning("Could not load schema for plugin: %s", e)
|
|
|
|
# Get web UI actions from plugin manifest
|
|
web_ui_actions = []
|
|
manifest_path = _plugin_dir / "manifest.json"
|
|
if manifest_path.exists():
|
|
try:
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
manifest = json.load(f)
|
|
web_ui_actions = manifest.get('web_ui_actions', [])
|
|
except Exception as e:
|
|
logger.warning("Could not load manifest for plugin: %s", e)
|
|
|
|
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
|
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
|
if not isinstance(schema_properties, dict):
|
|
return '<div class="text-red-500 p-4">Error loading plugin config securely: schema unavailable.</div>', 500
|
|
config = mask_secret_fields(config, schema_properties)
|
|
|
|
# Determine enabled status
|
|
enabled = config.get('enabled', True)
|
|
if plugin_instance:
|
|
enabled = plugin_instance.enabled
|
|
|
|
# Build plugin data for template
|
|
plugin_data = {
|
|
'id': plugin_id,
|
|
'name': plugin_info.get('name', plugin_id),
|
|
'author': plugin_info.get('author', 'Unknown'),
|
|
'version': plugin_info.get('version', ''),
|
|
'description': plugin_info.get('description', ''),
|
|
'category': plugin_info.get('category', 'General'),
|
|
'tags': plugin_info.get('tags', []),
|
|
'enabled': enabled,
|
|
'last_commit': plugin_info.get('last_commit') or plugin_info.get('last_commit_sha', ''),
|
|
'branch': plugin_info.get('branch', ''),
|
|
}
|
|
|
|
return render_template(
|
|
'v3/partials/plugin_config.html',
|
|
plugin=plugin_data,
|
|
config=config,
|
|
schema=schema,
|
|
web_ui_actions=web_ui_actions
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
|
|
|
|
def _load_starlark_config_partial(app_id):
|
|
"""Load configuration partial for a Starlark app."""
|
|
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
app_id = os.path.basename(app_id or '')
|
|
if 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
|
|
|
|
try:
|
|
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
|
|
|
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
|
app = starlark_plugin.apps.get(app_id)
|
|
if not app:
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
return render_template(
|
|
'v3/partials/starlark_config.html',
|
|
app_id=app_id,
|
|
app_name=app.manifest.get('name', app_id),
|
|
app_enabled=app.is_enabled(),
|
|
render_interval=app.get_render_interval(),
|
|
display_duration=app.get_display_duration(),
|
|
config=app.config,
|
|
schema=app.schema,
|
|
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 file
|
|
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
|
manifest_file = starlark_base / 'manifest.json'
|
|
if not manifest_file.exists():
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
|
|
with open(manifest_file, 'r') as f:
|
|
manifest = json.load(f)
|
|
|
|
app_data = manifest.get('apps', {}).get(app_id)
|
|
if not app_data:
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
|
|
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
|
schema = None
|
|
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
|
try:
|
|
schema_file.relative_to(starlark_base)
|
|
except ValueError:
|
|
schema_file = None
|
|
if schema_file and schema_file.exists():
|
|
try:
|
|
with open(schema_file, 'r') as f:
|
|
schema = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning("Could not load starlark schema for app: %s", e)
|
|
|
|
# Load config from config.json if it exists — validate path stays within starlark_base
|
|
config = {}
|
|
config_file = (starlark_base / app_id / 'config.json').resolve()
|
|
try:
|
|
config_file.relative_to(starlark_base)
|
|
except ValueError:
|
|
config_file = None
|
|
if config_file and config_file.exists():
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
config = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning("Could not load starlark config for app: %s", e)
|
|
|
|
return render_template(
|
|
'v3/partials/starlark_config.html',
|
|
app_id=app_id,
|
|
app_name=app_data.get('name', app_id),
|
|
app_enabled=app_data.get('enabled', True),
|
|
render_interval=app_data.get('render_interval', 300),
|
|
display_duration=app_data.get('display_duration', 15),
|
|
config=config,
|
|
schema=schema,
|
|
has_frames=False,
|
|
frame_count=0,
|
|
last_render_time=None,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|