Files
LEDMatrix/web_interface/blueprints/api_v3/plugin_assets.py
T
ChuckandClaude Opus 5.5 09103a8a7d refactor(web): split api_v3/plugins.py by area (#658)
* refactor(web): split api_v3/plugins.py by area

web_interface/blueprints/api_v3/plugins.py (3,285 lines) becomes:
- plugins.py: installed list, enable/disable, plugin actions
- plugin_store.py: install, update, uninstall, store, saved repositories
- plugin_config.py: config get/save, schema, reset
- plugin_assets.py: asset uploads and plugin static files
- plugin_health.py: health, metrics, limits
- plugin_operations.py: operation history, state reconciliation
- plugin_calendar.py: calendar credentials and auth

Pure move: all 44 functions and 38 route decorators are byte-identical
(checked with ast), URLs and endpoint names are unchanged (url-map test).
Each module imports only what it uses. Tests and config.py that reached
into plugins.py for moved names now import from the new module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep exception text out of calendar responses; annotate moved code

The split made scanners report existing findings in the moved code as new:
- CodeQL: the calendar auth and calendar-list routes returned exception
  text (redacted, but still derived from the exception). Both now log the
  exception and return a fixed message pointing at the log.
- MD5 in the asset upload only makes a filename unique: usedforsecurity=False.
- pickle reads/writes the calendar plugin's own OAuth token (as before):
  annotated. Token-status labels and a log line naming the secrets path are
  false positives: annotated with the repo's nosec/nosemgrep convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): name uploaded assets with SHA-256 instead of MD5

The hash only makes an uploaded image's filename unique. Codacy flags MD5
even with usedforsecurity=False, and SHA-256 does the job as well; existing
files keep their names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep the redacted exception detail in calendar errors

Reverts the calendar part of 5e695b7c. The project's policy
(test_no_api_v3_handler_discards_its_exception) is that an API error
carries the redacted exception detail -- describe_exception runs it
through the credential redactor -- so a failure is diagnosable from the web
UI. Dropping it for CodeQL broke that; CodeQL can't see the redaction, so
its two alerts here are false positives, like the existing ones on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:58:53 -04:00

329 lines
12 KiB
Python

"""Plugin asset uploads (list, upload, delete) and plugin static files.
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names do not depend on which module they live in.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Response, _plugin_directory, api_v3, datetime, hashlib,
json, jsonify, logger, os, request, uuid,
)
from src.common.path_safety import (
resolve_under, safe_path_component, safe_relative_parts,
)
from src.config_manager_atomic import atomic_write_text
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.
def _plugin_uploads_dir(plugin_id):
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
Same guard as the serving route in app.py: one plain path segment,
resolved and contained under assets/plugins.
"""
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
def _write_upload_metadata(metadata_file, metadata):
"""Replace an uploads directory's .metadata.json atomically.
The plugin config page reads this file to list a plugin's images; one cut
off mid-write (a power loss on a Pi) failed to parse, and every upload it
recorded dropped out of the list while the files stayed on disk.
"""
atomic_write_text(metadata_file, json.dumps(metadata, indent=2))
@api_v3.route('/plugins/assets/upload', methods=['POST'])
def upload_plugin_asset():
"""Upload asset files for a plugin"""
plugin_id = request.form.get('plugin_id')
if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
if 'files' not in request.files:
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
files = request.files.getlist('files')
if not files or all(not f.filename for f in files):
return jsonify({'status': 'error', 'message': 'No files provided'}), 400
# Validate file count
if len(files) > 10:
return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400
# Setup plugin assets directory. plugin_id is a form field: without
# the guard '../../config' created, listed and wrote into directories
# outside assets/plugins (the serving route was fixed in #561).
assets_dir = _plugin_uploads_dir(plugin_id)
if assets_dir is None:
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
plugin_id = safe_path_component(plugin_id)
assets_dir.mkdir(parents=True, exist_ok=True)
# Load metadata file
metadata_file = assets_dir / '.metadata.json'
if metadata_file.exists():
with open(metadata_file, 'r') as f:
metadata = json.load(f)
else:
metadata = {}
uploaded_files = []
total_size = 0
max_size_per_file = 5 * 1024 * 1024 # 5MB
max_total_size = 50 * 1024 * 1024 # 50MB
# Calculate current total size
for entry in metadata.values():
if 'size' in entry:
total_size += entry.get('size', 0)
# Every file is checked before any is saved. Checking and saving in one
# loop meant a bad third file answered 400 after the first two were
# already written -- on disk and in the metadata the UI lists, though
# the user was told the upload failed.
accepted = []
for file in files:
if not file.filename:
continue
# Validate file type
allowed_extensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif']
file_ext = '.' + file.filename.lower().split('.')[-1]
if file_ext not in allowed_extensions:
return jsonify({
'status': 'error',
'message': f'Invalid file type: {file_ext}. Allowed: {allowed_extensions}'
}), 400
# Read file to check size and validate
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > max_size_per_file:
return jsonify({
'status': 'error',
'message': f'File {file.filename} exceeds 5MB limit'
}), 400
if total_size + file_size > max_total_size:
return jsonify({
'status': 'error',
'message': 'Upload would exceed 50MB total storage limit'
}), 400
# Validate file is actually an image (check magic bytes)
file_content = file.read(8)
file.seek(0)
is_valid_image = False
if file_content.startswith(b'\x89PNG\r\n\x1a\n'): # PNG
is_valid_image = True
elif file_content[:2] == b'\xff\xd8': # JPEG
is_valid_image = True
elif file_content[:2] == b'BM': # BMP
is_valid_image = True
elif file_content[:6] in [b'GIF87a', b'GIF89a']: # GIF
is_valid_image = True
if not is_valid_image:
return jsonify({
'status': 'error',
'message': f'File {file.filename} is not a valid image file'
}), 400
total_size += file_size
accepted.append((file, file_ext, file_size, file_content))
for file, file_ext, file_size, file_content in accepted:
# Generate unique filename
timestamp = int(_pkg.time.time())
# Only makes the filename unique; nothing is verified with it.
file_hash = hashlib.sha256(file_content + file.filename.encode()).hexdigest()[:8]
safe_filename = f"image_{timestamp}_{file_hash}{file_ext}"
file_path = assets_dir / safe_filename
# Ensure filename is unique
counter = 1
while file_path.exists():
safe_filename = f"image_{timestamp}_{file_hash}_{counter}{file_ext}"
file_path = assets_dir / safe_filename
counter += 1
# Save file
file.save(str(file_path))
# Make file readable
os.chmod(file_path, 0o644)
# Generate unique ID
image_id = str(uuid.uuid4())
# Store metadata
relative_path = f"assets/plugins/{plugin_id}/uploads/{safe_filename}"
metadata[image_id] = {
'id': image_id,
'filename': safe_filename,
'path': relative_path,
'size': file_size,
'uploaded_at': datetime.utcnow().isoformat() + 'Z',
'original_filename': file.filename
}
uploaded_files.append({
'id': image_id,
'filename': safe_filename,
'path': relative_path,
'size': file_size,
'uploaded_at': metadata[image_id]['uploaded_at']
})
_write_upload_metadata(metadata_file, metadata)
return jsonify({
'status': 'success',
'uploaded_files': uploaded_files,
'total_files': len(metadata)
})
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
def serve_plugin_static(plugin_id, file_path):
"""Serve static files from plugin directory.
Both URL parts are validated before anything is opened. This handler used
to read whatever the path resolved to as long as ``str(file).startswith``
the plugin directory, which let two different things through:
* ``plugin_id`` of ``..`` -- Flask's default converter forbids a slash but
not dots, and ``get_plugin_directory('..')`` happily returned the parent
of the plugins directory because it exists. Every file under the project
root then "started with" that directory, ``config/config_secrets.json``
included.
* a sibling directory sharing a prefix: with the plugin directory
``plugin-repos/foo``, ``../foo-evil/x`` resolves to
``plugin-repos/foo-evil/x``, whose string does start with
``plugin-repos/foo``.
"""
safe_plugin_id = safe_path_component(plugin_id)
if not safe_plugin_id:
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
safe_parts = safe_relative_parts(file_path)
if not safe_parts:
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 400
plugin_dir = _plugin_directory(safe_plugin_id)
if not plugin_dir:
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
# Containment is still checked after resolving: name validation cannot
# see a symlink inside the plugin directory that points out of it.
requested_file = resolve_under(plugin_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
content_type = 'text/plain'
name = requested_file.name
if name.endswith('.html'):
content_type = 'text/html'
elif name.endswith('.js'):
content_type = 'application/javascript'
elif name.endswith('.css'):
content_type = 'text/css'
elif name.endswith('.json'):
content_type = 'application/json'
# Read and return file
with open(requested_file, 'r', encoding='utf-8') as f:
content = f.read()
return Response(content, mimetype=content_type)
@api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset():
"""Delete an asset file for a plugin"""
# silent=True: without it a missing or non-JSON body raised inside
# get_json() and came back as a 415 in the generic error shape, or, for
# a JSON array, an AttributeError 500.
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
plugin_id = data.get('plugin_id')
image_id = data.get('image_id')
if not plugin_id or not image_id:
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
# Get asset directory
assets_dir = _plugin_uploads_dir(plugin_id)
if assets_dir is None:
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
metadata_file = assets_dir / '.metadata.json'
if not metadata_file.exists():
return jsonify({'status': 'error', 'message': 'Metadata file not found'}), 404
# Load metadata
with open(metadata_file, 'r') as f:
metadata = json.load(f)
if image_id not in metadata:
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
# Delete file. The stored path is data, not a trusted location: only
# unlink it when it resolves to a file directly inside this plugin's
# uploads. An entry pointing anywhere else is dropped from the
# metadata without touching the file it names.
entry = metadata[image_id] if isinstance(metadata[image_id], dict) else {}
parts = safe_relative_parts(entry.get('path'))
file_path = resolve_under(PROJECT_ROOT, *parts) if parts else None
if file_path is None or file_path.parent != assets_dir:
logger.warning('Asset %s has a path outside its uploads directory; '
'removing the entry without deleting a file', image_id)
elif file_path.exists():
file_path.unlink()
# Remove from metadata
del metadata[image_id]
_write_upload_metadata(metadata_file, metadata)
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
@api_v3.route('/plugins/assets/list', methods=['GET'])
def list_plugin_assets():
"""List asset files for a plugin"""
plugin_id = request.args.get('plugin_id')
if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
# Get asset directory
assets_dir = _plugin_uploads_dir(plugin_id)
if assets_dir is None:
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
metadata_file = assets_dir / '.metadata.json'
if not metadata_file.exists():
return jsonify({'status': 'success', 'data': {'assets': []}})
# Load metadata
with open(metadata_file, 'r') as f:
metadata = json.load(f)
# Convert to list
assets = list(metadata.values())
return jsonify({'status': 'success', 'data': {'assets': assets}})