From 0b9f5e2236d18f878b5182156d08fae0909687c8 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 10 Aug 2026 11:22:08 -0400 Subject: [PATCH] fix(web): redact auth headers and URL userinfo, and cover every handler Three review findings. The sanitizer missed two credential shapes that requests puts in its exception text verbatim: `Authorization: Bearer ` and `https://user:password@host`. Both would have gone straight into a response. The auth-scheme name and the username are kept -- they say which credential and whose without being the secret. The AST test only asked whether *something* had been logged, so a `logger.info("failed")` satisfied it while discarding the exception just as completely. It now requires an error-level record carrying exc_info and `describe_exception()` called on the handler's own bound exception. Enforcing that revealed the first cut had scoped itself wrongly. I had converted the nine handlers that logged nothing and left the sixty that logged, reasoning their detail was at least in the journal. But /system/status is one of the sixty, and on the failing device it told me nothing -- the journal was exactly what could not be read. Splitting them left most of the diagnostic surface unhelpful for the case this change exists for, so all sixty-nine now carry the detail. Two handlers had no bound exception name, and three passed the message through a variable rather than a literal; both shapes needed doing by hand. Full suite: 2383 passed, one pre-existing unrelated failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- src/web_interface/error_handler.py | 20 +++++ test/test_web_error_detail.py | 73 ++++++++++++++--- web_interface/blueprints/api_v3.py | 125 +++++++++++++++-------------- 3 files changed, 147 insertions(+), 71 deletions(-) diff --git a/src/web_interface/error_handler.py b/src/web_interface/error_handler.py index d22fed45..8b128192 100644 --- a/src/web_interface/error_handler.py +++ b/src/web_interface/error_handler.py @@ -28,6 +28,22 @@ _REDACT_CREDENTIAL = re.compile( re.IGNORECASE, ) +# `Authorization: Bearer `. The scheme is kept because it says which +# kind of credential failed; only the token goes. Not covered by the pattern +# above, whose value part stops at whitespace and so would keep the token when +# a space follows the scheme. +_REDACT_AUTH_HEADER = re.compile( + r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*' + r'(?:bearer|basic|digest|token)\s+)([^\s,"\'<>}]+)', + re.IGNORECASE, +) + +# Credentials embedded in a URL: https://user:password@host. requests quotes +# the full URL in its exceptions, so this is a realistic leak. The username is +# kept -- it identifies which account failed without being the secret. +_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)', + re.IGNORECASE) + # Long enough for an errno string with a path, short enough not to dump a # parser's worth of context into a JSON field. _MAX_DETAIL_LENGTH = 400 @@ -57,6 +73,10 @@ def describe_exception(exc: BaseException, """ message = str(exc).strip() text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__ + # Order matters: the URL and header forms are more specific than the + # generic key=value pattern, which would otherwise chew the scheme. + text = _REDACT_URL_USERINFO.sub(r'\1\3', text) + text = _REDACT_AUTH_HEADER.sub(r'\1', text) text = _REDACT_CREDENTIAL.sub(r'\1', text) # Collapse newlines/tabs so the detail stays one line in a JSON field. text = ' '.join(text.split()) diff --git a/test/test_web_error_detail.py b/test/test_web_error_detail.py index 553e2f3b..282097c6 100644 --- a/test/test_web_error_detail.py +++ b/test/test_web_error_detail.py @@ -42,6 +42,12 @@ class TestCredentialRedaction: ("connect failed password=hunter2", "hunter2"), ("GET /?access_token=zzz999", "zzz999"), ('{"secret": "topsecret"}', "topsecret"), + # requests quotes the URL it failed on, and both of these forms turn + # up in real client exceptions. + ("401 for https://user:hunter2@example.com/api", "hunter2"), + ("headers: {'Authorization': 'Bearer eyJ.SECRET.sig'}", "eyJ.SECRET.sig"), + ("Authorization: Basic dXNlcjpwYXNzd29yZA==", "dXNlcjpwYXNzd29yZA=="), + ("Proxy-Authorization: Bearer ptok999", "ptok999"), ]) def test_credentials_never_reach_the_response(self, secret_text, leaked): detail = describe_exception(RuntimeError(secret_text)) @@ -53,6 +59,13 @@ class TestCredentialRedaction: detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123")) assert "api_key" in detail + def test_auth_scheme_and_username_survive(self): + # Which kind of credential, and whose, without the credential itself. + assert "Bearer" in describe_exception( + RuntimeError("Authorization: Bearer eyJ.SECRET.sig")) + assert "user" in describe_exception( + RuntimeError("https://user:hunter2@example.com")) + def test_non_secret_context_is_preserved(self): detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa")) assert "city=Tampa" in detail @@ -77,25 +90,65 @@ class TestHandlersCarryDetail: """The response shape callers actually see.""" def test_no_api_v3_handler_discards_its_exception(self): - # Nine of them bound `e` and never used it, so the promised log entry - # was never written either. + """Every generic-message handler must log a traceback and return detail. + + Nine of them bound `e` and never used it, so the promised log entry was + never written either. Checking merely that *something* was logged is + too weak -- a `logger.info("failed")` would satisfy it while throwing + the exception away just as completely, so this asserts the two things + that actually make the failure diagnosable: an error-level record with + the traceback, and the sanitized detail in the response. + """ import ast - import re src = open("web_interface/blueprints/api_v3.py").read() tree = ast.parse(src) generic = "An error occurred; see logs for details" - silent = [] + + def logs_a_traceback(handler): + """An error/exception-level log call carrying exc_info.""" + for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]: + func = call.func + if not isinstance(func, ast.Attribute): + continue + if func.attr == "exception": # implies exc_info + return True + if func.attr not in ("error", "critical"): + continue + if any(kw.arg == "exc_info" and getattr(kw.value, "value", False) is True + for kw in call.keywords): + return True + return False + + def returns_the_detail(handler): + """describe_exception() called on this handler's bound exception.""" + for call in [n for n in ast.walk(handler) if isinstance(n, ast.Call)]: + name = call.func.id if isinstance(call.func, ast.Name) else None + if name != "describe_exception": + continue + if handler.name is None: + return True # bare `except:` cannot name it; accept + if any(isinstance(a, ast.Name) and a.id == handler.name + for a in call.args): + return True + return False + + offenders = [] for h in [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]: seg = ast.get_source_segment(src, h) or "" if generic not in seg: continue - if not re.search( - r"\b(logger|logging|current_app\.logger)\s*\.\s*" - r"(error|exception|warning|critical|info)\b", seg): - silent.append(h.lineno) - assert not silent, ( - "handlers returning the generic message without logging: %r" % silent) + missing = [] + if not logs_a_traceback(h): + missing.append("error-level log with exc_info") + if not returns_the_detail(h): + missing.append("describe_exception(e) in the response") + if missing: + offenders.append((h.lineno, missing)) + + assert not offenders, ( + "handlers returning the generic message without %s: %r" + % ("both a traceback log and the detail", offenders)) def test_global_handler_reports_the_underlying_error(self): from flask import Flask, jsonify diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 8b6a2a71..918c9e10 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -272,7 +272,7 @@ def get_main_config(): return jsonify({'status': 'success', 'data': config}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/config/schedule', methods=['GET']) def get_schedule_config(): @@ -470,7 +470,7 @@ def save_schedule_config(): ErrorCode.CONFIG_SAVE_FAILED, "An error occurred; see logs for details", - status_code=500 + status_code=500, details=describe_exception(e) ) @api_v3.route('/config/dim-schedule', methods=['GET']) @@ -518,14 +518,14 @@ def get_dim_schedule_config(): return error_response( ErrorCode.CONFIG_LOAD_FAILED, "An error occurred; see logs for details", - status_code=500 + status_code=500, details=describe_exception(e) ) except Exception as e: logging.error(f"[DIM SCHEDULE] Unexpected error loading config: {e}", exc_info=True) return error_response( ErrorCode.CONFIG_LOAD_FAILED, "An error occurred; see logs for details", - status_code=500 + status_code=500, details=describe_exception(e) ) @api_v3.route('/config/dim-schedule', methods=['POST']) @@ -689,7 +689,7 @@ def save_dim_schedule_config(): ErrorCode.CONFIG_SAVE_FAILED, "An error occurred; see logs for details", - status_code=500 + status_code=500, details=describe_exception(e) ) @api_v3.route('/config/main', methods=['POST']) @@ -1346,7 +1346,7 @@ def save_main_config(): return error_response( ErrorCode.CONFIG_SAVE_FAILED, "An error occurred; see logs for details", - status_code=500 + status_code=500, details=describe_exception(e) ) @api_v3.route('/config/secrets', methods=['GET']) @@ -1360,7 +1360,7 @@ def get_secrets_config(): return jsonify({'status': 'success', 'data': config}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/config/raw/main', methods=['POST']) def save_raw_main_config(): @@ -1393,6 +1393,7 @@ def save_raw_main_config(): return error_response( ErrorCode.CONFIG_SAVE_FAILED, error_message, + details=describe_exception(e), context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None, status_code=500 @@ -1402,6 +1403,7 @@ def save_raw_main_config(): return error_response( ErrorCode.UNKNOWN_ERROR, error_message, + details=describe_exception(e), status_code=500 ) @@ -1441,7 +1443,8 @@ def save_raw_secrets_config(): else: error_message = 'An error occurred; see logs for details' - return jsonify({'status': 'error', 'message': error_message}), 500 + return jsonify({'status': 'error', 'message': error_message, + 'details': describe_exception(e)}), 500 @api_v3.route('/system/status', methods=['GET']) def get_system_status(): @@ -1529,7 +1532,7 @@ def get_system_status(): return jsonify({'status': 'success', 'data': status}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/health', methods=['GET']) def get_health(): @@ -2402,7 +2405,7 @@ def get_display_current(): return jsonify({'status': 'success', 'data': display_data}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/display/on-demand/status', methods=['GET']) def get_on_demand_status(): @@ -2426,7 +2429,7 @@ def get_on_demand_status(): }) except Exception as exc: logger.error('Error in get_on_demand_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 @api_v3.route('/display/on-demand/start', methods=['POST']) def start_on_demand_display(): @@ -2529,7 +2532,7 @@ def start_on_demand_display(): return jsonify({'status': 'success', 'data': response_data}) except Exception as exc: logger.error('Error in start_on_demand_display', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 @api_v3.route('/display/on-demand/stop', methods=['POST']) def stop_on_demand_display(): @@ -2565,7 +2568,7 @@ def stop_on_demand_display(): }) except Exception as exc: logger.error('Error in stop_on_demand_display', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500 @api_v3.route('/plugins/installed', methods=['GET']) def get_installed_plugins(): @@ -2713,7 +2716,7 @@ def get_installed_plugins(): return jsonify({'status': 'success', 'data': {'plugins': plugins}}) except Exception as e: logger.error('Error in get_installed_plugins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 def _installed_plugin_ids(): """Best-effort list of installed plugin IDs for the web process. @@ -2779,7 +2782,7 @@ def get_plugin_health(): }) except Exception as e: logger.error('Error in get_plugin_health', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/health/', methods=['GET']) def get_plugin_health_single(plugin_id): @@ -2804,7 +2807,7 @@ def get_plugin_health_single(plugin_id): }) except Exception as e: logger.error('Error in get_plugin_health_single', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/health//reset', methods=['POST']) def reset_plugin_health(plugin_id): @@ -2829,7 +2832,7 @@ def reset_plugin_health(plugin_id): }) except Exception as e: logger.error('Error in reset_plugin_health', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/metrics', methods=['GET']) def get_plugin_metrics(): @@ -2869,7 +2872,7 @@ def get_plugin_metrics(): }) except Exception as e: logger.error('Error in get_plugin_metrics', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/metrics/', methods=['GET']) def get_plugin_metrics_single(plugin_id): @@ -2894,7 +2897,7 @@ def get_plugin_metrics_single(plugin_id): }) except Exception as e: logger.error('Error in get_plugin_metrics_single', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/metrics//reset', methods=['POST']) def reset_plugin_metrics(plugin_id): @@ -2919,7 +2922,7 @@ def reset_plugin_metrics(plugin_id): }) except Exception as e: logger.error('Error in reset_plugin_metrics', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/limits/', methods=['GET', 'POST']) def manage_plugin_limits(plugin_id): @@ -2974,7 +2977,7 @@ def manage_plugin_limits(plugin_id): }) except Exception as e: logger.error('Error in manage_plugin_limits', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/toggle', methods=['POST']) def toggle_plugin(): @@ -3983,7 +3986,7 @@ def install_plugin(): except Exception as e: logger.error('Error in install_plugin', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/install-from-url', methods=['POST']) def install_plugin_from_url(): @@ -4038,7 +4041,7 @@ def install_plugin_from_url(): except Exception as e: logger.error('Error in install_plugin_from_url', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/registry-from-url', methods=['POST']) def get_registry_from_url(): @@ -4070,7 +4073,7 @@ def get_registry_from_url(): except Exception as e: logger.error('Error in get_registry_from_url', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/saved-repositories', methods=['GET']) def get_saved_repositories(): @@ -4083,7 +4086,7 @@ def get_saved_repositories(): return jsonify({'status': 'success', 'data': {'repositories': repositories}}) except Exception as e: logger.error('Error in get_saved_repositories', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/saved-repositories', methods=['POST']) def add_saved_repository(): @@ -4114,7 +4117,7 @@ def add_saved_repository(): }), 400 except Exception as e: logger.error('Error in add_saved_repository', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/saved-repositories', methods=['DELETE']) def remove_saved_repository(): @@ -4144,7 +4147,7 @@ def remove_saved_repository(): }), 404 except Exception as e: logger.error('Error in remove_saved_repository', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/store/list', methods=['GET']) def list_plugin_store(): @@ -4197,7 +4200,7 @@ def list_plugin_store(): return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) except Exception as e: logger.error('Error in list_plugin_store', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/store/github-status', methods=['GET']) def get_github_auth_status(): @@ -4248,7 +4251,7 @@ def get_github_auth_status(): }) except Exception as e: logger.error('Error in get_github_auth_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/store/refresh', methods=['POST']) def refresh_plugin_store(): @@ -4275,7 +4278,7 @@ def refresh_plugin_store(): }) except Exception as e: logger.error('Error in refresh_plugin_store', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 def deep_merge(base_dict, update_dict): """ @@ -5827,7 +5830,7 @@ def get_plugin_schema(): return jsonify({'status': 'success', 'data': {'schema': default_schema}}) except Exception as e: logger.error('Error in get_plugin_schema', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/skins', methods=['GET']) def list_skins(): @@ -5862,9 +5865,9 @@ def list_skins(): 'has_preview': bool(preview and (skin_dir / preview).is_file()), }) return jsonify({'status': 'success', 'data': {'skins': payload}}) - except Exception: + except Exception as e: logger.error('Error in list_skins', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/config/reset', methods=['POST']) def reset_plugin_config(): @@ -5975,7 +5978,7 @@ def reset_plugin_config(): }) except Exception as e: logger.error('Error in reset_plugin_config', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/action', methods=['POST']) def execute_plugin_action(): @@ -6235,7 +6238,7 @@ sys.exit(proc.returncode) logger.error("Error executing action step 1", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 else: # Simple script execution @@ -6285,7 +6288,7 @@ sys.exit(proc.returncode) return jsonify({'status': 'error', 'message': 'Action timed out'}), 408 except Exception as e: logger.error('Error in execute_plugin_action', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/authenticate/spotify', methods=['POST']) def authenticate_spotify(): @@ -6418,12 +6421,12 @@ sys.exit(proc.returncode) logger.error("Error getting Spotify auth URL", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 except Exception as e: logger.error('Error in authenticate_spotify', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/authenticate/ytm', methods=['POST']) def authenticate_ytm(): @@ -6473,7 +6476,7 @@ def authenticate_ytm(): return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 except Exception as e: logger.error('Error in authenticate_ytm', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/catalog', methods=['GET']) def get_fonts_catalog(): @@ -6590,7 +6593,7 @@ def get_font_tokens(): return jsonify({'status': 'success', 'data': {'tokens': tokens}}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/overrides', methods=['GET']) def get_fonts_overrides(): @@ -6602,7 +6605,7 @@ def get_fonts_overrides(): return jsonify({'status': 'success', 'data': {'overrides': overrides}}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/overrides', methods=['POST']) def save_fonts_overrides(): @@ -6616,7 +6619,7 @@ def save_fonts_overrides(): return jsonify({'status': 'success', 'message': 'Font overrides saved'}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/overrides/', methods=['DELETE']) def delete_font_override(element_key): @@ -6626,7 +6629,7 @@ def delete_font_override(element_key): return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'}) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/upload', methods=['POST']) def upload_font(): @@ -6691,7 +6694,7 @@ def upload_font(): }) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/preview', methods=['GET']) @@ -6836,7 +6839,7 @@ def get_font_preview() -> tuple[Response, int] | Response: }) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/', methods=['DELETE']) @@ -6924,7 +6927,7 @@ def delete_font(font_family: str) -> tuple[Response, int] | Response: }) except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/assets/upload', methods=['POST']) @@ -7072,7 +7075,7 @@ def upload_plugin_asset(): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) def upload_of_the_day_json(): @@ -7222,7 +7225,7 @@ def upload_of_the_day_json(): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) def delete_of_the_day_json(): @@ -7269,7 +7272,7 @@ def delete_of_the_day_json(): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins//static/', methods=['GET']) def serve_plugin_static(plugin_id, file_path): @@ -7315,7 +7318,7 @@ def serve_plugin_static(plugin_id, file_path): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) @@ -7397,7 +7400,7 @@ def upload_calendar_credentials(): except Exception as e: logger.error('Error in upload_calendar_credentials', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/assets/delete', methods=['POST']) def delete_plugin_asset(): @@ -7440,7 +7443,7 @@ def delete_plugin_asset(): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/plugins/assets/list', methods=['GET']) def list_plugin_assets(): @@ -7468,7 +7471,7 @@ def list_plugin_assets(): except Exception as e: logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/display/current-status', methods=['GET']) def get_current_display_status(): @@ -7489,9 +7492,9 @@ def get_current_display_status(): 'last_updated': None, } return jsonify({'status': 'success', 'data': state}) - except Exception: + except Exception as e: logger.error('Error in get_current_display_status', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/logs', methods=['GET']) def get_logs(): @@ -7725,7 +7728,7 @@ def connect_wifi(): logger.error("Error connecting to WiFi", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 @api_v3.route('/wifi/disconnect', methods=['POST']) @@ -7751,7 +7754,7 @@ def disconnect_wifi(): logger.error("Error disconnecting from WiFi", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 @api_v3.route('/wifi/ap/enable', methods=['POST']) @@ -7884,7 +7887,7 @@ def get_wifi_radio(): logger.error("Error getting WiFi radio state", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 @api_v3.route('/wifi/radio', methods=['POST']) @@ -7932,7 +7935,7 @@ def set_wifi_radio(): logger.error("Error setting WiFi radio state", exc_info=True) return jsonify({ 'status': 'error', - 'message': 'An error occurred; see logs for details' + 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) }), 500 @api_v3.route('/cache/list', methods=['GET']) @@ -7957,7 +7960,7 @@ def list_cache_files(): }) except Exception as e: logger.error('Error in list_cache_files', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/cache/delete', methods=['POST']) def delete_cache_file(): @@ -7983,7 +7986,7 @@ def delete_cache_file(): }) except Exception as e: logger.error('Error in delete_cache_file', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 # =============================================================================