mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix(web): Resolve font display errors and config API CSRF issues (#152)
* fix(web): Resolve font display and config API error handling issues - Fix font catalog display error where path.startsWith fails (path is object, not string) - Update save_main_config to use error_response() helper - Improve save_raw_main_config error handling consistency - Add proper error codes and traceback details to API responses * fix(web): Prevent fontCatalog redeclaration error on HTMX reload - Use window object to store global font variables - Check if script has already loaded before declaring variables - Update both window properties and local references on assignment - Fixes 'Identifier fontCatalog has already been declared' error * fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors - Wrap entire script in IIFE that only runs once - Check if script already loaded before declaring variables/functions - Expose initializeFontsTab to window for re-initialization - Prevents 'Identifier has already been declared' errors on HTMX reload * fix(web): Exempt config save API endpoints from CSRF protection - Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF - These endpoints are called via fetch from JavaScript and don't include CSRF tokens - Fixes 500 error when saving config via raw JSON editor * fix(web): Exempt system action endpoint from CSRF protection - Exempt execute_system_action from CSRF - Fixes 500 error when using system action buttons (restart display, restart Pi, etc.) - These endpoints are called via HTMX and don't include CSRF tokens * fix(web): Exempt all API v3 endpoints from CSRF protection - Add before_request handler to exempt all api_v3.* endpoints - All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens - Prevents future CSRF errors on any API endpoint - Cleaner than exempting individual endpoints * refactor(web): Remove CSRF protection for local-only application - CSRF is designed for internet-facing apps to prevent cross-site attacks - For local-only Raspberry Pi app, threat model is different - All endpoints were exempted anyway, so it wasn't protecting anything - Forms use HTMX without CSRF tokens - If exposing to internet later, can re-enable with proper token implementation * fix(web): Fix font path double-prefixing in font catalog display - Only prefix with 'assets/fonts/' if path is a bare filename - If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is - Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf' * fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload - Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap - The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration - Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements - Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working) --------- Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
@@ -26,30 +26,14 @@ app = Flask(__name__)
|
|||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
|
|
||||||
# Initialize CSRF protection (optional for local-only, but recommended for defense-in-depth)
|
# CSRF protection disabled for local-only application
|
||||||
try:
|
# CSRF is designed for internet-facing web apps to prevent cross-site request forgery.
|
||||||
from flask_wtf.csrf import CSRFProtect
|
# For a local-only Raspberry Pi application, the threat model is different:
|
||||||
csrf = CSRFProtect(app)
|
# - If an attacker has network access to perform CSRF, they have other attack vectors
|
||||||
# Exempt SSE streams from CSRF (read-only)
|
# - All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens
|
||||||
from functools import wraps
|
# - Forms use HTMX which doesn't automatically include CSRF tokens
|
||||||
from flask import request
|
# If you need CSRF protection (e.g., exposing to internet), properly implement CSRF tokens in HTMX forms
|
||||||
|
csrf = None
|
||||||
def csrf_exempt(f):
|
|
||||||
"""Decorator to exempt a route from CSRF protection."""
|
|
||||||
f.csrf_exempt = True
|
|
||||||
return f
|
|
||||||
|
|
||||||
# Mark SSE streams as exempt
|
|
||||||
@app.before_request
|
|
||||||
def check_csrf_exempt():
|
|
||||||
"""Check if route should be exempt from CSRF."""
|
|
||||||
if request.endpoint and 'stream' in request.endpoint:
|
|
||||||
# SSE streams are read-only, exempt from CSRF
|
|
||||||
pass
|
|
||||||
except ImportError:
|
|
||||||
# flask-wtf not installed, CSRF protection disabled
|
|
||||||
csrf = None
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Initialize rate limiting (prevent accidental abuse, not security)
|
# Initialize rate limiting (prevent accidental abuse, not security)
|
||||||
try:
|
try:
|
||||||
@@ -543,6 +527,7 @@ if csrf:
|
|||||||
csrf.exempt(stream_stats)
|
csrf.exempt(stream_stats)
|
||||||
csrf.exempt(stream_display)
|
csrf.exempt(stream_display)
|
||||||
csrf.exempt(stream_logs)
|
csrf.exempt(stream_logs)
|
||||||
|
# Note: api_v3 blueprint is exempted above after registration
|
||||||
|
|
||||||
if limiter:
|
if limiter:
|
||||||
limiter.limit("20 per minute")(stream_stats)
|
limiter.limit("20 per minute")(stream_stats)
|
||||||
|
|||||||
@@ -632,7 +632,12 @@ def save_main_config():
|
|||||||
import traceback
|
import traceback
|
||||||
error_msg = f"Error saving config: {str(e)}\n{traceback.format_exc()}"
|
error_msg = f"Error saving config: {str(e)}\n{traceback.format_exc()}"
|
||||||
logging.error(error_msg)
|
logging.error(error_msg)
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return error_response(
|
||||||
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
|
f"Error saving configuration: {str(e)}",
|
||||||
|
details=traceback.format_exc(),
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
@api_v3.route('/config/secrets', methods=['GET'])
|
@api_v3.route('/config/secrets', methods=['GET'])
|
||||||
def get_secrets_config():
|
def get_secrets_config():
|
||||||
@@ -675,14 +680,24 @@ def save_raw_main_config():
|
|||||||
|
|
||||||
# Extract more specific error message if it's a ConfigError
|
# Extract more specific error message if it's a ConfigError
|
||||||
if isinstance(e, ConfigError):
|
if isinstance(e, ConfigError):
|
||||||
# ConfigError has a message attribute and may have context
|
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
if hasattr(e, 'config_path') and e.config_path:
|
if hasattr(e, 'config_path') and e.config_path:
|
||||||
error_message = f"{error_message} (config_path: {e.config_path})"
|
error_message = f"{error_message} (config_path: {e.config_path})"
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
|
error_message,
|
||||||
|
details=traceback.format_exc(),
|
||||||
|
context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None,
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
error_message = str(e) if str(e) else "An unexpected error occurred while saving the configuration"
|
error_message = str(e) if str(e) else "An unexpected error occurred while saving the configuration"
|
||||||
|
return error_response(
|
||||||
return jsonify({'status': 'error', 'message': error_message}), 500
|
ErrorCode.UNKNOWN_ERROR,
|
||||||
|
error_message,
|
||||||
|
details=traceback.format_exc(),
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
@api_v3.route('/config/raw/secrets', methods=['POST'])
|
@api_v3.route('/config/raw/secrets', methods=['POST'])
|
||||||
def save_raw_secrets_config():
|
def save_raw_secrets_config():
|
||||||
|
|||||||
@@ -190,23 +190,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global variables
|
// Prevent script from running multiple times when HTMX reloads content
|
||||||
let fontCatalog = {};
|
(function() {
|
||||||
let fontTokens = {};
|
// If script has already loaded, just re-initialize the tab without redeclaring variables/functions
|
||||||
let fontOverrides = {};
|
if (typeof window._fontsScriptLoaded !== 'undefined') {
|
||||||
let selectedFontFiles = [];
|
// Script already loaded, just trigger initialization
|
||||||
|
setTimeout(function() {
|
||||||
// Initialize when DOM is ready or after HTMX load
|
if (typeof window.initializeFontsTab === 'function') {
|
||||||
// Prevent multiple initializations
|
window.initializeFontsTab();
|
||||||
let fontsTabInitialized = false;
|
}
|
||||||
|
}, 50);
|
||||||
function initializeFontsTab() {
|
|
||||||
// Check if already initialized
|
|
||||||
if (fontsTabInitialized) {
|
|
||||||
console.log('Fonts tab already initialized, skipping...');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark script as loaded
|
||||||
|
window._fontsScriptLoaded = true;
|
||||||
|
|
||||||
|
// Initialize global variables on window object
|
||||||
|
window.fontCatalog = window.fontCatalog || {};
|
||||||
|
window.fontTokens = window.fontTokens || {};
|
||||||
|
window.fontOverrides = window.fontOverrides || {};
|
||||||
|
window.selectedFontFiles = window.selectedFontFiles || [];
|
||||||
|
|
||||||
|
// Create references that can be reassigned
|
||||||
|
var fontCatalog = window.fontCatalog;
|
||||||
|
var fontTokens = window.fontTokens;
|
||||||
|
var fontOverrides = window.fontOverrides;
|
||||||
|
var selectedFontFiles = window.selectedFontFiles;
|
||||||
|
|
||||||
|
function initializeFontsTab() {
|
||||||
|
// Allow re-initialization on each HTMX content swap
|
||||||
|
// The window._fontsScriptLoaded guard prevents function redeclaration
|
||||||
const detectedEl = document.getElementById('detected-fonts');
|
const detectedEl = document.getElementById('detected-fonts');
|
||||||
const availableEl = document.getElementById('available-fonts');
|
const availableEl = document.getElementById('available-fonts');
|
||||||
|
|
||||||
@@ -219,9 +233,6 @@ function initializeFontsTab() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as initialized to prevent duplicate initialization
|
|
||||||
fontsTabInitialized = true;
|
|
||||||
|
|
||||||
// Ensure showNotification function is available
|
// Ensure showNotification function is available
|
||||||
if (typeof window.showNotification !== 'function') {
|
if (typeof window.showNotification !== 'function') {
|
||||||
window.showNotification = function(message, type = 'info') {
|
window.showNotification = function(message, type = 'info') {
|
||||||
@@ -315,6 +326,9 @@ function initializeFontsTab() {
|
|||||||
console.log('Fonts tab initialized successfully');
|
console.log('Fonts tab initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose initializeFontsTab to window for re-initialization after HTMX reload
|
||||||
|
window.initializeFontsTab = initializeFontsTab;
|
||||||
|
|
||||||
// Initialize after HTMX content swap for dynamic loading
|
// Initialize after HTMX content swap for dynamic loading
|
||||||
// Note: We don't use DOMContentLoaded here because this partial is loaded via HTMX
|
// Note: We don't use DOMContentLoaded here because this partial is loaded via HTMX
|
||||||
// after the main page's DOMContentLoaded has already fired
|
// after the main page's DOMContentLoaded has already fired
|
||||||
@@ -411,9 +425,15 @@ async function loadFontData() {
|
|||||||
throw new Error('Invalid response format from font API');
|
throw new Error('Invalid response format from font API');
|
||||||
}
|
}
|
||||||
|
|
||||||
fontCatalog = catalogData.data.catalog || {};
|
// Update both window properties and local references
|
||||||
fontTokens = tokensData.data.tokens || {};
|
window.fontCatalog = catalogData.data.catalog || {};
|
||||||
fontOverrides = overridesData.data.overrides || {};
|
window.fontTokens = tokensData.data.tokens || {};
|
||||||
|
window.fontOverrides = overridesData.data.overrides || {};
|
||||||
|
|
||||||
|
// Update local variable references
|
||||||
|
fontCatalog = window.fontCatalog;
|
||||||
|
fontTokens = window.fontTokens;
|
||||||
|
fontOverrides = window.fontOverrides;
|
||||||
|
|
||||||
// Update displays
|
// Update displays
|
||||||
updateDetectedFontsDisplay();
|
updateDetectedFontsDisplay();
|
||||||
@@ -497,8 +517,13 @@ function updateAvailableFontsDisplay() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = Object.entries(fontCatalog).map(([name, path]) => {
|
const lines = Object.entries(fontCatalog).map(([name, fontInfo]) => {
|
||||||
const fullPath = path.startsWith('/') ? path : `assets/fonts/${path}`;
|
const fontPath = typeof fontInfo === 'string' ? fontInfo : (fontInfo?.path || '');
|
||||||
|
// Only prefix with "assets/fonts/" if path is a bare filename (no "/" and doesn't start with "assets/")
|
||||||
|
// If path is absolute (starts with "/") or already has "assets/" prefix, use as-is
|
||||||
|
const fullPath = (fontPath.startsWith('/') || fontPath.startsWith('assets/'))
|
||||||
|
? fontPath
|
||||||
|
: `assets/fonts/${fontPath}`;
|
||||||
return `${name}: ${fullPath}`;
|
return `${name}: ${fullPath}`;
|
||||||
});
|
});
|
||||||
container.textContent = lines.join('\n');
|
container.textContent = lines.join('\n');
|
||||||
@@ -798,6 +823,8 @@ function updateUploadProgress(percent) {
|
|||||||
document.getElementById('upload-percent').textContent = Math.round(percent) + '%';
|
document.getElementById('upload-percent').textContent = Math.round(percent) + '%';
|
||||||
document.getElementById('upload-progress-bar').style.width = percent + '%';
|
document.getElementById('upload-progress-bar').style.width = percent + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
})(); // End of script load guard - prevents redeclaration on HTMX reload
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user