diff --git a/plugins/ledmatrix-music b/plugins/ledmatrix-music index 3bcae7bf8..ded4fe91e 160000 --- a/plugins/ledmatrix-music +++ b/plugins/ledmatrix-music @@ -1 +1 @@ -Subproject commit 3bcae7bf8471a4525430d1f690fa99e05d37c2c4 +Subproject commit ded4fe91e4fa337c53641bc3174197eac86d246a diff --git a/web_interface/app.py b/web_interface/app.py index 6fdf7067f..5c03d0c27 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -1,4 +1,4 @@ -from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response +from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory import json import os import sys @@ -141,6 +141,89 @@ app.register_blueprint(pages_v3, url_prefix='/v3') app.register_blueprint(api_v3, url_prefix='/api/v3') +# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) +@app.route('/assets/plugins//uploads/', methods=['GET']) +def serve_plugin_asset(plugin_id, filename): + """Serve uploaded asset files from assets/plugins/{plugin_id}/uploads/""" + try: + # Build the asset directory path + assets_dir = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' + assets_dir = assets_dir.resolve() + + # Security check: ensure the assets directory exists and is within project_root + if not assets_dir.exists() or not assets_dir.is_dir(): + return jsonify({'status': 'error', 'message': 'Asset directory not found'}), 404 + + # Ensure we're serving from within the assets directory (prevent directory traversal) + # Use proper path resolution instead of string prefix matching to prevent bypasses + assets_dir_resolved = assets_dir.resolve() + project_root_resolved = project_root.resolve() + + # Check that assets_dir is actually within project_root using commonpath + try: + common_path = os.path.commonpath([str(assets_dir_resolved), str(project_root_resolved)]) + if common_path != str(project_root_resolved): + return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403 + except ValueError: + # commonpath raises ValueError if paths are on different drives (Windows) + return jsonify({'status': 'error', 'message': 'Invalid asset path'}), 403 + + # Resolve the requested file path + requested_file = (assets_dir / filename).resolve() + + # Security check: ensure file is within the assets directory using proper path comparison + # Use commonpath to ensure assets_dir is a true parent of requested_file + try: + common_path = os.path.commonpath([str(requested_file), str(assets_dir_resolved)]) + if common_path != str(assets_dir_resolved): + return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403 + except ValueError: + # commonpath raises ValueError if paths are on different drives (Windows) + 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 based on file extension + content_type = 'application/octet-stream' + if filename.lower().endswith(('.png', '.jpg', '.jpeg')): + content_type = 'image/jpeg' if filename.lower().endswith(('.jpg', '.jpeg')) else 'image/png' + elif filename.lower().endswith('.gif'): + content_type = 'image/gif' + elif filename.lower().endswith('.bmp'): + content_type = 'image/bmp' + elif filename.lower().endswith('.webp'): + content_type = 'image/webp' + elif filename.lower().endswith('.svg'): + content_type = 'image/svg+xml' + elif filename.lower().endswith('.json'): + content_type = 'application/json' + elif filename.lower().endswith('.txt'): + content_type = 'text/plain' + + # Use send_from_directory to serve the file + return send_from_directory(str(assets_dir), filename, mimetype=content_type) + + except Exception as e: + # Log the exception with full traceback server-side + import traceback + app.logger.exception('Error serving plugin asset file') + + # Return generic error message to client (avoid leaking internal details) + # Only include detailed error information when in debug mode + if app.debug: + return jsonify({ + 'status': 'error', + 'message': str(e), + 'traceback': traceback.format_exc() + }), 500 + else: + return jsonify({ + 'status': 'error', + 'message': 'Internal server error' + }), 500 + # Helper function to check if AP mode is active def is_ap_mode_active(): """ diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 73867d1ed..dcc4653be 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, jsonify, Response +from flask import Blueprint, request, jsonify, Response, send_from_directory import json import os import sys @@ -5319,6 +5319,7 @@ def serve_plugin_static(plugin_id, file_path): import traceback return jsonify({'status': 'error', 'message': str(e), 'traceback': traceback.format_exc()}), 500 + @api_v3.route('/plugins/calendar/upload-credentials', methods=['POST']) def upload_calendar_credentials(): """Upload credentials.json file for calendar plugin""" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 5c0c8f2e8..43ce33247 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -336,6 +336,40 @@ def _load_plugin_config_partial(plugin_id): full_config = pages_v3.config_manager.load_config() config = full_config.get(plugin_id, {}) + # Load uploaded images from metadata file if images field exists in schema + # This ensures uploaded images appear even if config hasn't been saved yet + schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json" + if schema_path_temp.exists(): + try: + with open(schema_path_temp, 'r', encoding='utf-8') as f: + temp_schema = json.load(f) + # Check if schema has an images field with x-widget: file-upload + if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or + temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'): + # Load metadata file + # Get PROJECT_ROOT relative to this file + project_root = Path(__file__).parent.parent.parent + metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json' + if metadata_file.exists(): + try: + with open(metadata_file, 'r', encoding='utf-8') as mf: + metadata = json.load(mf) + # Convert metadata dict to list of image objects + images_from_metadata = list(metadata.values()) + # Only use metadata images if config doesn't have images or config images is empty + if not config.get('images') or len(config.get('images', [])) == 0: + config['images'] = images_from_metadata + else: + # Merge: add metadata images that aren't already in config + config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')} + new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids] + if new_images: + config['images'] = config.get('images', []) + new_images + except Exception as e: + print(f"Warning: Could not load metadata for {plugin_id}: {e}") + except Exception: + pass # Will load schema properly below + # Get plugin schema schema = {} schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json" diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 9b2d325f1..4f918c928 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2161,6 +2161,8 @@ function generatePluginConfigForm(pluginId, config) { schema: schemaData.data.schema, webUiActions: webUiActions }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; // Also update state currentPluginConfigState.schema = schemaData.data.schema; console.log('[DEBUG] Calling generateFormFromSchema...'); @@ -2168,12 +2170,16 @@ function generatePluginConfigForm(pluginId, config) { } else { // Fallback to simple form if no schema currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, webUiActions); } }) .catch(error => { console.error('Error loading schema:', error); currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] }; + // Also assign to window for global access in template interpolations + window.currentPluginConfig = currentPluginConfig; return generateSimpleConfigForm(config, []); }); } diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e27105e47..e765533fc 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -2509,17 +2509,75 @@

// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering) if (!this._configFormLogged || this._configFormLogged !== pluginId) { console.log('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions'); + // Debug: Check if image_config.images has x-widget in schema + if (schema && schema.properties && schema.properties.image_config) { + const imgConfig = schema.properties.image_config; + if (imgConfig.properties && imgConfig.properties.images) { + const imagesProp = imgConfig.properties.images; + console.log('[DEBUG] Schema check - image_config.images:', { + type: imagesProp.type, + 'x-widget': imagesProp['x-widget'], + 'has x-widget': 'x-widget' in imagesProp, + keys: Object.keys(imagesProp) + }); + } + } this._configFormLogged = pluginId; } if (!schema || !schema.properties) { return this.generateSimpleConfigForm(config, webUiActions, pluginId); } + // Helper function to get schema property by full key path + const getSchemaProperty = (schemaObj, keyPath) => { + if (!schemaObj || !schemaObj.properties) return null; + const keys = keyPath.split('.'); + let current = schemaObj.properties; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (!current || !current[k]) { + return null; + } + + const prop = current[k]; + // If this is the last key, return the property + if (i === keys.length - 1) { + return prop; + } + + // If this property has nested properties, navigate deeper + if (prop && typeof prop === 'object' && prop.properties) { + current = prop.properties; + } else { + // Can't navigate deeper + return null; + } + } + return null; + }; + const generateFieldHtml = (key, prop, value, prefix = '') => { const fullKey = prefix ? `${prefix}.${key}` : key; const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const description = prop.description || ''; let html = ''; + + // Debug: Log property structure for arrays to help diagnose file-upload widget issues + if (prop.type === 'array') { + // Also check schema directly as fallback + const schemaProp = getSchemaProperty(schema, fullKey); + const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; + + console.log('[DEBUG generateFieldHtml] Array property:', fullKey, { + 'prop.x-widget': prop['x-widget'], + 'prop.x_widget': prop['x_widget'], + 'schema.x-widget': xWidgetFromSchema, + 'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'), + 'x-widget in prop': 'x-widget' in prop, + 'all prop keys': Object.keys(prop), + 'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null' + }); + } // Handle nested objects if (prop.type === 'object' && prop.properties) { @@ -2575,6 +2633,12 @@

${sectionLabel}

const nestedValue = hasValue ? nestedConfig[nestedKey] : (nestedProp.default !== undefined ? nestedProp.default : (isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : '')))); + + // Debug logging for file-upload widgets + if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) { + console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp); + } + html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey); }); @@ -2651,11 +2715,59 @@

${sectionLabel}

html += helpText; } } else if (prop.type === 'array') { - // Check if this is a file upload widget - if (prop['x-widget'] === 'file-upload') { + // AGGRESSIVE file upload widget detection + // For 'images' field in static-image plugin, always check schema directly + let isFileUpload = false; + let uploadConfig = {}; + + // Direct check: if this is the 'images' field and schema has it with x-widget + if (fullKey === 'images' && schema && schema.properties && schema.properties.images) { + const imagesSchema = schema.properties.images; + if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') { + isFileUpload = true; + uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig); + } + } + + // Fallback: check prop object (should have x-widget if schema loaded correctly) + if (!isFileUpload) { + const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget; + if (xWidgetFromProp === 'file-upload') { + isFileUpload = true; + uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Detection via prop object'); + } + } + + // Fallback: schema property lookup + if (!isFileUpload) { + let schemaProp = getSchemaProperty(schema, fullKey); + if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) { + schemaProp = schema.properties.images; + } + const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; + if (xWidgetFromSchema === 'file-upload') { + isFileUpload = true; + uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {}; + console.log('[DEBUG] ✅ Detection via schema lookup'); + } + } + + // Debug logging for ALL array fields to diagnose + console.log('[DEBUG] Array field check:', fullKey, { + 'isFileUpload': isFileUpload, + 'prop keys': Object.keys(prop), + 'prop.x-widget': prop['x-widget'], + 'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images), + 'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null, + 'uploadConfig': uploadConfig + }); + + if (isFileUpload) { + console.log('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig); // Use the file upload widget from plugins.html // We'll need to call a function that exists in the global scope - const uploadConfig = prop['x-upload-config'] || {}; const maxFiles = uploadConfig.max_files || 10; const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; const maxSizeMB = uploadConfig.max_size_mb || 5; diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 292aee4f6..7b8f76192 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -58,16 +58,111 @@ {% if field_type == 'integer' %}step="1"{% else %}step="any"{% endif %} class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500"> - {# Array of strings (comma-separated) #} + {# Array - check if it's a file upload widget #} {% elif field_type == 'array' %} - {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} - -

Separate multiple values with commas

+ {% set x_widget = prop.get('x-widget') or prop.get('x_widget') %} + {% if x_widget == 'file-upload' %} + {# File upload widget for arrays #} + {% set upload_config = prop.get('x-upload-config') or {} %} + {% set max_files = upload_config.get('max_files', 10) %} + {% set allowed_types = upload_config.get('allowed_types', ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']) %} + {% set max_size_mb = upload_config.get('max_size_mb', 5) %} + {% set plugin_id_from_config = upload_config.get('plugin_id', plugin_id) %} + {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} + +
+ +
+ + +

Drag and drop images here or click to browse

+

Max {{ max_files }} files, {{ max_size_mb }}MB each (PNG, JPG, GIF, BMP)

+
+

+ + Remember to save configuration after upload +

+ + +
+ {% for img in array_value %} + {% set img_id = img.get('id', loop.index0) %} + {% set img_schedule = img.get('schedule', {}) %} + {% set has_schedule = img_schedule.get('enabled', false) and img_schedule.get('mode') and img_schedule.get('mode') != 'always' %} +
+
+
+ {{ img.get('filename', '') }} + +
+

{{ img.get('original_filename') or img.get('filename', 'Image') }}

+

+ {% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %} + {% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %} +

+ {% if has_schedule %} +

+ Scheduled +

+ {% endif %} +
+
+
+ + +
+
+ +
+ {% endfor %} +
+ + + +
+ {% else %} + {# Regular array input (comma-separated) #} + {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} + +

Separate multiple values with commas

+ {% endif %} {# Text input (default) #} {% else %}