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:
Chuck
2026-09-11 19:27:06 -04:00
co-authored by Claude Opus 5
101 changed files with 16851 additions and 10432 deletions
+57 -65
View File
@@ -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