mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(security): refuse unsafe plugin ids, keep secrets private, validate bodies - install_from_url and the registry install's manifest rename refuse a plugin id that is not a single safe name (no ../ out of plugins_dir). - Uninstall and config reset refuse core config sections and ids with path parts; uninstall of a plugin whose directory is gone still works. - separate_secrets checks a field's own x-secret marker before recursing, so object/array secrets no longer land in config.json. - Backup restore creates missing secrets/wifi/ytm files with mode 640; export skips non-object manifests and no longer collides on same-second exports. - SYSTEM_FONTS includes every bundled font from BUNDLED_FONTS. - Raw config/secrets saves and validate_request_json require a JSON object. - A blank max_dynamic_duration_seconds keeps the stored value; other values are validated to 30-1800 instead of raising a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(security): validate the id before install_plugin moves anything; claim backup names atomically - install_plugin set aside plugins_dir / plugin_id before any id check, so "../x" moved a directory outside the plugins dir (the rollback moved it back, but only if the install path got that far) - two exports finishing in the same second could both see a free name and the later os.replace destroyed the first archive; the name is now claimed with O_EXCL before the archive is swapped in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
4.5 KiB
Python
153 lines
4.5 KiB
Python
"""
|
|
Standardized API response helpers.
|
|
|
|
Provides consistent API response formatting across all endpoints.
|
|
"""
|
|
|
|
import time
|
|
from typing import Any, Optional, Dict, Tuple
|
|
from flask import jsonify, request
|
|
|
|
from src.web_interface.error_handler import create_error_response, create_success_response
|
|
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
|
|
|
|
|
def success_response(
|
|
data: Any = None,
|
|
message: Optional[str] = None,
|
|
metadata: Optional[Dict] = None
|
|
):
|
|
"""
|
|
Create a standardized success response.
|
|
|
|
Args:
|
|
data: Response data
|
|
message: Optional success message
|
|
metadata: Optional metadata (timing, version, etc.)
|
|
|
|
Returns:
|
|
Flask jsonify response
|
|
"""
|
|
response_data = create_success_response(data, message, metadata)
|
|
|
|
# Timing is merged into whatever the caller passed, without inventing a
|
|
# metadata block for responses that have neither.
|
|
enriched = dict(metadata) if metadata is not None else {}
|
|
if hasattr(request, 'start_time'):
|
|
# request_logging stamps start_time from perf_counter, not the wall clock.
|
|
enriched['response_time_ms'] = int((time.perf_counter() - request.start_time) * 1000)
|
|
|
|
if metadata is not None or enriched:
|
|
response_data['metadata'] = enriched
|
|
|
|
return jsonify(response_data)
|
|
|
|
|
|
def error_response(
|
|
error_code: ErrorCode,
|
|
message: str,
|
|
details: Optional[str] = None,
|
|
context: Optional[Dict] = None,
|
|
suggested_fixes: Optional[list] = None,
|
|
status_code: int = 500
|
|
):
|
|
"""
|
|
Create a standardized error response.
|
|
|
|
Args:
|
|
error_code: Error code
|
|
message: Error message
|
|
details: Optional detailed error information
|
|
context: Optional context dictionary
|
|
suggested_fixes: Optional list of suggested fixes
|
|
status_code: HTTP status code
|
|
|
|
Returns:
|
|
Flask jsonify response with status code
|
|
"""
|
|
return create_error_response(
|
|
error_code=error_code,
|
|
message=message,
|
|
details=details,
|
|
context=context,
|
|
suggested_fixes=suggested_fixes,
|
|
status_code=status_code
|
|
)
|
|
|
|
|
|
def exception_error_response(
|
|
exc: Exception,
|
|
error_code: ErrorCode,
|
|
*,
|
|
with_context: bool = True,
|
|
status_code: int = 500
|
|
):
|
|
"""
|
|
error_response() for a caught exception, built by WebInterfaceError.
|
|
|
|
The message is the code's fixed, user-facing one -- never the exception
|
|
text. `details` comes from the exception's own `context` dict when it has
|
|
one, and `context` records the exception type. with_context=False leaves
|
|
the context out, as the operation-history routes always have.
|
|
|
|
Args:
|
|
exc: The exception being reported
|
|
error_code: Error code
|
|
with_context: Whether to include the context (exception type)
|
|
status_code: HTTP status code
|
|
|
|
Returns:
|
|
Flask jsonify response with status code
|
|
"""
|
|
error = WebInterfaceError.from_exception(exc, error_code)
|
|
return error_response(
|
|
error.error_code,
|
|
error.message,
|
|
details=error.details,
|
|
context=error.context if with_context else None,
|
|
status_code=status_code
|
|
)
|
|
|
|
|
|
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
|
"""
|
|
Validate request JSON has required fields.
|
|
|
|
Args:
|
|
required_fields: List of required field names
|
|
data: Optional data dict (if None, reads from request)
|
|
|
|
Returns:
|
|
Tuple of (data_dict, error_response) or (data_dict, None) if valid
|
|
"""
|
|
if data is None:
|
|
data = request.get_json(silent=True)
|
|
|
|
if not data:
|
|
return None, error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
"Request body must be valid JSON",
|
|
status_code=400
|
|
)
|
|
|
|
# A JSON array passes the check above, and ``field in data`` then tests
|
|
# list membership: ["plugin_id"] "had" every required field and the
|
|
# handler's data['plugin_id'] raised TypeError -- a 500, not a 400.
|
|
if not isinstance(data, dict):
|
|
return None, error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
"Request body must be a JSON object",
|
|
status_code=400
|
|
)
|
|
|
|
missing_fields = [field for field in required_fields if field not in data]
|
|
if missing_fields:
|
|
return None, error_response(
|
|
ErrorCode.INVALID_INPUT,
|
|
f"Missing required fields: {', '.join(missing_fields)}",
|
|
context={'missing_fields': missing_fields},
|
|
status_code=400
|
|
)
|
|
|
|
return data, None
|