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:
+57
-65
@@ -17,6 +17,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
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 src.exceptions import ConfigError
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
@@ -32,6 +35,8 @@ _JOURNALCTL = shutil.which('journalctl')
|
||||
_SYSTEMCTL = shutil.which('systemctl')
|
||||
_VCGENCMD = shutil.which('vcgencmd')
|
||||
|
||||
from web_interface.system_metrics import collect_system_metrics
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -205,66 +210,66 @@ app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
# 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'])
|
||||
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:
|
||||
# Build the asset directory path
|
||||
assets_dir = project_root / 'assets' / 'plugins' / plugin_id / 'uploads'
|
||||
assets_dir = assets_dir.resolve()
|
||||
|
||||
uploads_base = (project_root / 'assets' / 'plugins').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
|
||||
if not assets_dir.exists() or not assets_dir.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404
|
||||
|
||||
# Ensure we're serving from within the assets directory (prevent directory traversal)
|
||||
# Use proper path resolution instead of string prefix matching to prevent bypasses
|
||||
assets_dir_resolved = assets_dir.resolve()
|
||||
project_root_resolved = project_root.resolve()
|
||||
|
||||
# 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)
|
||||
|
||||
# Resolve the requested file path. resolve_under repeats the
|
||||
# containment check after resolving, which is what catches a symlink
|
||||
# inside the uploads directory pointing out of it.
|
||||
requested_file = resolve_under(assets_dir, *safe_parts)
|
||||
if requested_file is None:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
|
||||
|
||||
|
||||
# Check if file exists
|
||||
if not requested_file.exists() or not requested_file.is_file():
|
||||
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
||||
|
||||
|
||||
# Determine content type based on file extension
|
||||
lowered = requested_file.name.lower()
|
||||
content_type = 'application/octet-stream'
|
||||
if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
||||
content_type = 'image/jpeg' if filename.lower().endswith(('.jpg', '.jpeg')) else 'image/png'
|
||||
elif filename.lower().endswith('.gif'):
|
||||
if lowered.endswith(('.png', '.jpg', '.jpeg')):
|
||||
content_type = 'image/jpeg' if lowered.endswith(('.jpg', '.jpeg')) else 'image/png'
|
||||
elif lowered.endswith('.gif'):
|
||||
content_type = 'image/gif'
|
||||
elif filename.lower().endswith('.bmp'):
|
||||
elif lowered.endswith('.bmp'):
|
||||
content_type = 'image/bmp'
|
||||
elif filename.lower().endswith('.webp'):
|
||||
elif lowered.endswith('.webp'):
|
||||
content_type = 'image/webp'
|
||||
elif filename.lower().endswith('.svg'):
|
||||
elif lowered.endswith('.svg'):
|
||||
content_type = 'image/svg+xml'
|
||||
elif filename.lower().endswith('.json'):
|
||||
elif lowered.endswith('.json'):
|
||||
content_type = 'application/json'
|
||||
elif filename.lower().endswith('.txt'):
|
||||
elif lowered.endswith('.txt'):
|
||||
content_type = 'text/plain'
|
||||
|
||||
# Use send_from_directory to serve the file
|
||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||
|
||||
# Use send_from_directory to serve the file. The path handed over is
|
||||
# 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:
|
||||
app.logger.exception('Error serving plugin asset file')
|
||||
@@ -607,26 +612,12 @@ def system_status_generator():
|
||||
"""Generate system status updates"""
|
||||
while True:
|
||||
try:
|
||||
# Try to import psutil for system stats
|
||||
try:
|
||||
import psutil
|
||||
# interval=None is non-blocking; primed at module startup above
|
||||
cpu_percent = round(psutil.cpu_percent(interval=None), 1)
|
||||
memory = psutil.virtual_memory()
|
||||
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
|
||||
metrics = collect_system_metrics()
|
||||
cpu_percent = metrics['cpu_percent']
|
||||
memory_used_percent = metrics['memory_used_percent']
|
||||
memory_available_mb = metrics['memory_available_mb']
|
||||
disk_used_percent = metrics['disk_used_percent']
|
||||
cpu_temp = metrics['cpu_temp']
|
||||
|
||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||
now = time.time()
|
||||
@@ -647,8 +638,9 @@ def system_status_generator():
|
||||
'service_active': service_active,
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_used_percent': memory_used_percent,
|
||||
'memory_available_mb': memory_available_mb,
|
||||
'cpu_temp': cpu_temp,
|
||||
'disk_used_percent': 0,
|
||||
'disk_used_percent': disk_used_percent,
|
||||
'power': _get_power_status()
|
||||
}
|
||||
yield status
|
||||
|
||||
Reference in New Issue
Block a user