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:
Chuck
2025-12-27 20:19:34 -05:00
committed by GitHub
parent 33e4f3680c
commit d14b5ffb8f
3 changed files with 77 additions and 50 deletions

View File

@@ -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)

View File

@@ -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():

View File

@@ -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>