mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 13:23:00 +00:00
feat(starlark): schema-driven config forms + critical security fixes
## Schema-Driven Config UI - Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location) - Pre-populate config.json with schema defaults on install - Auto-merge schema defaults when loading existing apps (handles schema updates) - Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON - Toggle fields: support both boolean and string "true"/"false" values - Unsupported field types (oauth2, photo_select) show warning banners - Fallback to raw key/value inputs for apps without schema ## Critical Security Fixes (P0) - **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU - **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations - **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess ## Major Logic Fixes (P1) - **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest - **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON - **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load - **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution ## Files Changed - web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering - plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge - plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization - web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import fcntl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -43,10 +44,13 @@ class StarlarkApp:
|
|||||||
self.schema_file = app_dir / "schema.json"
|
self.schema_file = app_dir / "schema.json"
|
||||||
self.cache_file = app_dir / "cached_render.webp"
|
self.cache_file = app_dir / "cached_render.webp"
|
||||||
|
|
||||||
# Load app configuration
|
# Load app configuration and schema
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
self.schema = self._load_schema()
|
self.schema = self._load_schema()
|
||||||
|
|
||||||
|
# Merge schema defaults into config for any missing fields
|
||||||
|
self._merge_schema_defaults()
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self.frames: Optional[List[Tuple[Image.Image, int]]] = None
|
self.frames: Optional[List[Tuple[Image.Image, int]]] = None
|
||||||
self.current_frame_index = 0
|
self.current_frame_index = 0
|
||||||
@@ -73,9 +77,70 @@ class StarlarkApp:
|
|||||||
logger.warning(f"Could not load schema for {self.app_id}: {e}")
|
logger.warning(f"Could not load schema for {self.app_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _merge_schema_defaults(self) -> None:
|
||||||
|
"""
|
||||||
|
Merge schema default values into config for any missing fields.
|
||||||
|
This ensures existing apps get defaults when schemas are updated with new fields.
|
||||||
|
"""
|
||||||
|
if not self.schema:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get fields from schema (handles both 'fields' and 'schema' keys)
|
||||||
|
fields = self.schema.get('fields') or self.schema.get('schema') or []
|
||||||
|
defaults_added = False
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
if isinstance(field, dict) and 'id' in field and 'default' in field:
|
||||||
|
field_id = field['id']
|
||||||
|
# Only add if not already present in config
|
||||||
|
if field_id not in self.config:
|
||||||
|
self.config[field_id] = field['default']
|
||||||
|
defaults_added = True
|
||||||
|
logger.debug(f"Added default value for {self.app_id}.{field_id}: {field['default']}")
|
||||||
|
|
||||||
|
# Save config if we added any defaults
|
||||||
|
if defaults_added:
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
def _validate_config(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Validate config values to prevent injection and ensure data integrity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error message if validation fails, None if valid
|
||||||
|
"""
|
||||||
|
for key, value in self.config.items():
|
||||||
|
# Validate key format
|
||||||
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]{0,63}$', key):
|
||||||
|
return f"Invalid config key format: {key}"
|
||||||
|
|
||||||
|
# Validate location fields (JSON format)
|
||||||
|
if isinstance(value, str) and value.strip().startswith('{'):
|
||||||
|
try:
|
||||||
|
loc = json.loads(value)
|
||||||
|
if 'lat' in loc:
|
||||||
|
lat = float(loc['lat'])
|
||||||
|
if not -90 <= lat <= 90:
|
||||||
|
return f"Latitude {lat} out of range [-90, 90] for key {key}"
|
||||||
|
if 'lng' in loc:
|
||||||
|
lng = float(loc['lng'])
|
||||||
|
if not -180 <= lng <= 180:
|
||||||
|
return f"Longitude {lng} out of range [-180, 180] for key {key}"
|
||||||
|
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
||||||
|
# Not a location field, that's fine
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def save_config(self) -> bool:
|
def save_config(self) -> bool:
|
||||||
"""Save current configuration to file."""
|
"""Save current configuration to file with validation."""
|
||||||
try:
|
try:
|
||||||
|
# Validate config before saving
|
||||||
|
error = self._validate_config()
|
||||||
|
if error:
|
||||||
|
logger.error(f"Config validation failed for {self.app_id}: {error}")
|
||||||
|
return False
|
||||||
|
|
||||||
with open(self.config_file, 'w') as f:
|
with open(self.config_file, 'w') as f:
|
||||||
json.dump(self.config, f, indent=2)
|
json.dump(self.config, f, indent=2)
|
||||||
return True
|
return True
|
||||||
@@ -484,13 +549,63 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.logger.exception(f"Error loading apps manifest: {e}")
|
self.logger.exception(f"Error loading apps manifest: {e}")
|
||||||
|
|
||||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||||
"""Save apps manifest to file."""
|
"""
|
||||||
|
Save apps manifest to file with file locking to prevent race conditions.
|
||||||
|
Uses exclusive lock during write to prevent concurrent modifications.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(self.manifest_file, 'w') as f:
|
# Use atomic write pattern: write to temp file, then rename
|
||||||
json.dump(manifest, f, indent=2)
|
temp_file = self.manifest_file.with_suffix('.tmp')
|
||||||
|
|
||||||
|
with open(temp_file, 'w') as f:
|
||||||
|
# Acquire exclusive lock during write
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno()) # Ensure data is written to disk
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
# Atomic rename (overwrites destination)
|
||||||
|
temp_file.replace(self.manifest_file)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error saving manifest: {e}")
|
self.logger.error(f"Error saving manifest: {e}")
|
||||||
|
# Clean up temp file if it exists
|
||||||
|
if temp_file.exists():
|
||||||
|
try:
|
||||||
|
temp_file.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _update_manifest_safe(self, updater_fn) -> bool:
|
||||||
|
"""
|
||||||
|
Safely update manifest with file locking to prevent race conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
updater_fn: Function that takes manifest dict and modifies it in-place
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Read current manifest with shared lock
|
||||||
|
with open(self.manifest_file, 'r') as f:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||||
|
try:
|
||||||
|
manifest = json.load(f)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
updater_fn(manifest)
|
||||||
|
|
||||||
|
# Write back with exclusive lock (handled by _save_manifest)
|
||||||
|
return self._save_manifest(manifest)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error updating manifest: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
@@ -585,10 +700,14 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
magnify = self._get_effective_magnify()
|
magnify = self._get_effective_magnify()
|
||||||
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
||||||
|
|
||||||
|
# Filter out LEDMatrix-internal timing keys before passing to pixlet
|
||||||
|
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
||||||
|
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||||
|
|
||||||
success, error = self.pixlet.render(
|
success, error = self.pixlet.render(
|
||||||
star_file=str(app.star_file),
|
star_file=str(app.star_file),
|
||||||
output_path=str(app.cache_file),
|
output_path=str(app.cache_file),
|
||||||
config=app.config,
|
config=pixlet_config,
|
||||||
magnify=magnify
|
magnify=magnify
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -697,10 +816,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
|
|
||||||
# Create app directory with resolved path
|
# Create app directory with resolved path
|
||||||
app_dir = (self.apps_dir / safe_app_id).resolve()
|
app_dir = (self.apps_dir / safe_app_id).resolve()
|
||||||
app_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Verify path safety after mkdir
|
# Verify path safety BEFORE creating directories
|
||||||
self._verify_path_safety(app_dir, self.apps_dir)
|
self._verify_path_safety(app_dir, self.apps_dir)
|
||||||
|
app_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Copy .star file with sanitized app_id
|
# Copy .star file with sanitized app_id
|
||||||
star_dest = app_dir / f"{safe_app_id}.star"
|
star_dest = app_dir / f"{safe_app_id}.star"
|
||||||
@@ -727,8 +846,13 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
with open(schema_path, 'w') as f:
|
with open(schema_path, 'w') as f:
|
||||||
json.dump(schema, f, indent=2)
|
json.dump(schema, f, indent=2)
|
||||||
|
|
||||||
# Create default config
|
# Create default config — pre-populate with schema defaults
|
||||||
default_config = {}
|
default_config = {}
|
||||||
|
if schema:
|
||||||
|
fields = schema.get('fields') or schema.get('schema') or []
|
||||||
|
for field in fields:
|
||||||
|
if isinstance(field, dict) and 'id' in field and 'default' in field:
|
||||||
|
default_config[field['id']] = field['default']
|
||||||
config_path = app_dir / "config.json"
|
config_path = app_dir / "config.json"
|
||||||
# Verify config path safety
|
# Verify config path safety
|
||||||
self._verify_path_safety(config_path, self.apps_dir)
|
self._verify_path_safety(config_path, self.apps_dir)
|
||||||
@@ -736,11 +860,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
json.dump(default_config, f, indent=2)
|
json.dump(default_config, f, indent=2)
|
||||||
|
|
||||||
# Update manifest (use safe_app_id as key to match directory)
|
# Update manifest (use safe_app_id as key to match directory)
|
||||||
with open(self.manifest_file, 'r') as f:
|
def update_fn(manifest):
|
||||||
manifest = json.load(f)
|
manifest["apps"][safe_app_id] = app_manifest
|
||||||
|
|
||||||
manifest["apps"][safe_app_id] = app_manifest
|
self._update_manifest_safe(update_fn)
|
||||||
self._save_manifest(manifest)
|
|
||||||
|
|
||||||
# Create app instance (use safe_app_id for internal key, original for display)
|
# Create app instance (use safe_app_id for internal key, original for display)
|
||||||
app = StarlarkApp(safe_app_id, app_dir, app_manifest)
|
app = StarlarkApp(safe_app_id, app_dir, app_manifest)
|
||||||
@@ -782,12 +905,11 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
shutil.rmtree(app.app_dir)
|
shutil.rmtree(app.app_dir)
|
||||||
|
|
||||||
# Update manifest
|
# Update manifest
|
||||||
with open(self.manifest_file, 'r') as f:
|
def update_fn(manifest):
|
||||||
manifest = json.load(f)
|
if app_id in manifest["apps"]:
|
||||||
|
del manifest["apps"][app_id]
|
||||||
|
|
||||||
if app_id in manifest["apps"]:
|
self._update_manifest_safe(update_fn)
|
||||||
del manifest["apps"][app_id]
|
|
||||||
self._save_manifest(manifest)
|
|
||||||
|
|
||||||
self.logger.info(f"Uninstalled Starlark app: {app_id}")
|
self.logger.info(f"Uninstalled Starlark app: {app_id}")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -250,11 +251,23 @@ class PixletRenderer:
|
|||||||
# Add configuration parameters
|
# Add configuration parameters
|
||||||
if config:
|
if config:
|
||||||
for key, value in config.items():
|
for key, value in config.items():
|
||||||
|
# Validate key format (alphanumeric + underscore only)
|
||||||
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
|
||||||
|
logger.warning(f"Skipping invalid config key: {key}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Convert value to string for CLI
|
# Convert value to string for CLI
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
value_str = "true" if value else "false"
|
value_str = "true" if value else "false"
|
||||||
else:
|
else:
|
||||||
value_str = str(value)
|
value_str = str(value)
|
||||||
|
|
||||||
|
# Validate value doesn't contain shell metacharacters
|
||||||
|
# Allow alphanumeric, spaces, and common safe chars: .-_:/@#,
|
||||||
|
if not re.match(r'^[a-zA-Z0-9 .\-_:/@#,{}"\[\]]*$', value_str):
|
||||||
|
logger.warning(f"Skipping config value with unsafe characters for key {key}: {value_str}")
|
||||||
|
continue
|
||||||
|
|
||||||
cmd.extend(["-c", f"{key}={value_str}"])
|
cmd.extend(["-c", f"{key}={value_str}"])
|
||||||
|
|
||||||
logger.debug(f"Executing Pixlet: {' '.join(cmd)}")
|
logger.debug(f"Executing Pixlet: {' '.join(cmd)}")
|
||||||
|
|||||||
@@ -7374,8 +7374,38 @@ def update_starlark_app_config(app_id):
|
|||||||
app = starlark_plugin.apps.get(app_id)
|
app = starlark_plugin.apps.get(app_id)
|
||||||
if not app:
|
if not app:
|
||||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
|
|
||||||
|
# Extract timing keys from data before updating config (they belong in manifest, not config)
|
||||||
|
render_interval = data.pop('render_interval', None)
|
||||||
|
display_duration = data.pop('display_duration', None)
|
||||||
|
|
||||||
|
# Update config with non-timing fields only
|
||||||
app.config.update(data)
|
app.config.update(data)
|
||||||
|
|
||||||
|
# Update manifest with timing fields
|
||||||
|
timing_changed = False
|
||||||
|
if render_interval is not None:
|
||||||
|
app.manifest['render_interval'] = render_interval
|
||||||
|
timing_changed = True
|
||||||
|
if display_duration is not None:
|
||||||
|
app.manifest['display_duration'] = display_duration
|
||||||
|
timing_changed = True
|
||||||
if app.save_config():
|
if app.save_config():
|
||||||
|
# Persist manifest if timing changed (same pattern as toggle endpoint)
|
||||||
|
if timing_changed:
|
||||||
|
try:
|
||||||
|
# Use safe manifest update to prevent race conditions
|
||||||
|
timing_updates = {}
|
||||||
|
if render_interval is not None:
|
||||||
|
timing_updates['render_interval'] = render_interval
|
||||||
|
if display_duration is not None:
|
||||||
|
timing_updates['display_duration'] = display_duration
|
||||||
|
|
||||||
|
def update_fn(manifest):
|
||||||
|
manifest['apps'][app_id].update(timing_updates)
|
||||||
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist timing to manifest for {app_id}: {e}")
|
||||||
starlark_plugin._render_app(app, force=True)
|
starlark_plugin._render_app(app, force=True)
|
||||||
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
|
return jsonify({'status': 'success', 'message': 'Configuration updated', 'config': app.config})
|
||||||
else:
|
else:
|
||||||
@@ -7418,10 +7448,10 @@ def toggle_starlark_app(app_id):
|
|||||||
if enabled is None:
|
if enabled is None:
|
||||||
enabled = not app.is_enabled()
|
enabled = not app.is_enabled()
|
||||||
app.manifest['enabled'] = enabled
|
app.manifest['enabled'] = enabled
|
||||||
with open(starlark_plugin.manifest_file, 'r') as f:
|
# Use safe manifest update to prevent race conditions
|
||||||
manifest = json.load(f)
|
def update_fn(manifest):
|
||||||
manifest['apps'][app_id]['enabled'] = enabled
|
manifest['apps'][app_id]['enabled'] = enabled
|
||||||
starlark_plugin._save_manifest(manifest)
|
starlark_plugin._update_manifest_safe(update_fn)
|
||||||
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
|
||||||
|
|
||||||
# Standalone: update manifest directly
|
# Standalone: update manifest directly
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timing Settings (always shown) -->
|
<!-- Configuration -->
|
||||||
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
<div class="bg-white rounded-lg p-4 border border-gray-200">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Timing Settings</h4>
|
<h4 class="text-sm font-semibold text-gray-700 mb-3">Timing Settings</h4>
|
||||||
<div id="starlark-config-form" class="space-y-4">
|
<div id="starlark-config-form" class="space-y-4">
|
||||||
@@ -76,18 +76,239 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if schema or config %}
|
{# ── Schema-driven App Settings ── #}
|
||||||
|
{% set fields = [] %}
|
||||||
|
{% if schema %}
|
||||||
|
{% if schema.fields is defined %}
|
||||||
|
{% set fields = schema.fields %}
|
||||||
|
{% elif schema.schema is defined and schema.schema is iterable and schema.schema is not string %}
|
||||||
|
{% set fields = schema.schema %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if fields %}
|
||||||
|
<hr class="border-gray-200 my-2">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4>
|
||||||
|
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if field.typeOf is defined and field.id is defined %}
|
||||||
|
{% set field_id = field.id %}
|
||||||
|
{% set field_type = field.typeOf %}
|
||||||
|
{% set field_name = field.name or field_id %}
|
||||||
|
{% set field_desc = field.desc or '' %}
|
||||||
|
{% set field_default = field.default if field.default is defined else '' %}
|
||||||
|
{% set current_val = config.get(field_id, field_default) if config else field_default %}
|
||||||
|
|
||||||
|
{# ── text ── #}
|
||||||
|
{% if field_type == 'text' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
value="{{ current_val }}"
|
||||||
|
placeholder="{{ field_desc }}"
|
||||||
|
data-starlark-config="{{ field_id }}">
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── dropdown ── #}
|
||||||
|
{% elif field_type == 'dropdown' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
|
||||||
|
data-starlark-config="{{ field_id }}">
|
||||||
|
{% for opt in (field.options or []) %}
|
||||||
|
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
|
||||||
|
{{ opt.display }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── toggle ── #}
|
||||||
|
{% elif field_type == 'toggle' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
data-starlark-config="{{ field_id }}"
|
||||||
|
data-starlark-type="toggle"
|
||||||
|
{{ 'checked' if (current_val is sameas true or current_val|string|lower in ('true', '1', 'yes')) else '' }}>
|
||||||
|
{{ field_name }}
|
||||||
|
</label>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1 ml-6">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── color ── #}
|
||||||
|
{% elif field_type == 'color' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="color"
|
||||||
|
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
|
||||||
|
value="{{ current_val or '#FFFFFF' }}"
|
||||||
|
data-starlark-color-picker="{{ field_id }}"
|
||||||
|
oninput="document.querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
|
||||||
|
value="{{ current_val or '#FFFFFF' }}"
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
data-starlark-config="{{ field_id }}"
|
||||||
|
oninput="var cp = document.querySelector('[data-starlark-color-picker={{ field_id }}]'); if(this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
|
||||||
|
</div>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── datetime ── #}
|
||||||
|
{% elif field_type == 'datetime' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<input type="datetime-local"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
value="{{ current_val }}"
|
||||||
|
data-starlark-config="{{ field_id }}">
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── location (mini-form) ── #}
|
||||||
|
{% elif field_type == 'location' %}
|
||||||
|
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<input type="number" step="any" min="-90" max="90"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
placeholder="Latitude"
|
||||||
|
data-starlark-location-field="{{ field_id }}"
|
||||||
|
data-starlark-location-key="lat">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="number" step="any" min="-180" max="180"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
placeholder="Longitude"
|
||||||
|
data-starlark-location-field="{{ field_id }}"
|
||||||
|
data-starlark-location-key="lng">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
placeholder="Timezone (e.g. America/New_York)"
|
||||||
|
data-starlark-location-field="{{ field_id }}"
|
||||||
|
data-starlark-location-key="timezone">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── oauth2 (unsupported) ── #}
|
||||||
|
{% elif field_type == 'oauth2' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm text-yellow-800" data-starlark-unsupported>
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
This app requires OAuth2 authentication, which is not supported in standalone mode.
|
||||||
|
</div>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── photo_select (unsupported) ── #}
|
||||||
|
{% elif field_type == 'photo_select' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm text-yellow-800" data-starlark-unsupported>
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
Photo upload is not supported in this interface.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── generated (hidden meta-field, skip) ── #}
|
||||||
|
{% elif field_type == 'generated' %}
|
||||||
|
{# Invisible — generated fields are handled server-side by Pixlet #}
|
||||||
|
|
||||||
|
{# ── typeahead / location_based (text fallback with note) ── #}
|
||||||
|
{% elif field_type in ('typeahead', 'location_based') %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
value="{{ current_val }}"
|
||||||
|
placeholder="{{ field_desc }}"
|
||||||
|
data-starlark-config="{{ field_id }}">
|
||||||
|
<p class="text-xs text-yellow-600 mt-1">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
This field normally uses autocomplete which requires a Pixlet server. Enter the value manually.
|
||||||
|
</p>
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── unknown type (text fallback) ── #}
|
||||||
|
{% else %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
value="{{ current_val }}"
|
||||||
|
placeholder="{{ field_desc }}"
|
||||||
|
data-starlark-config="{{ field_id }}">
|
||||||
|
{% if field_desc %}
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}{# end field.typeOf and field.id check #}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Also show any config keys NOT in the schema (user-added or legacy) #}
|
||||||
|
{% if config %}
|
||||||
|
{% set schema_ids = [] %}
|
||||||
|
{% for f in fields %}
|
||||||
|
{% if f.id is defined %}
|
||||||
|
{% if schema_ids.append(f.id) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for key, value in config.items() %}
|
||||||
|
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
|
||||||
|
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
value="{{ value }}"
|
||||||
|
data-starlark-config="{{ key }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── No schema: fall back to raw config key/value pairs ── #}
|
||||||
|
{% elif config %}
|
||||||
<hr class="border-gray-200 my-2">
|
<hr class="border-gray-200 my-2">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4>
|
<h4 class="text-sm font-semibold text-gray-700 mb-2">App Settings</h4>
|
||||||
{% for key, value in config.items() %}
|
{% for key, value in config.items() %}
|
||||||
{% if key not in ('render_interval', 'display_duration') %}
|
{% if key not in ('render_interval', 'display_duration') %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
|
||||||
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
name="{{ key }}" value="{{ value }}"
|
value="{{ value }}"
|
||||||
data-starlark-config="{{ key }}">
|
data-starlark-config="{{ key }}">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -102,15 +323,25 @@
|
|||||||
<script>
|
<script>
|
||||||
function forceRenderStarlarkApp(appId) {
|
function forceRenderStarlarkApp(appId) {
|
||||||
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/render', {method: 'POST'})
|
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/render', {method: 'POST'})
|
||||||
.then(r => r.json())
|
.then(function(r) { return r.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
|
if (typeof showNotification === 'function') {
|
||||||
|
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
|
||||||
|
} else {
|
||||||
|
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Render failed: ' + (data.message || 'Unknown error'));
|
var msg = 'Render failed: ' + (data.message || 'Unknown error');
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert('Render failed: ' + err.message));
|
.catch(function(err) {
|
||||||
|
var msg = 'Render failed: ' + err.message;
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStarlarkApp(appId, enabled) {
|
function toggleStarlarkApp(appId, enabled) {
|
||||||
@@ -119,35 +350,55 @@ function toggleStarlarkApp(appId, enabled) {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({enabled: enabled})
|
body: JSON.stringify({enabled: enabled})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(function(r) { return r.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
// Reload the config partial to reflect new state
|
|
||||||
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
||||||
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
||||||
// Reload this partial
|
var container = document.getElementById('plugin-config-starlark:' + appId);
|
||||||
const container = document.getElementById('plugin-config-starlark:' + appId);
|
|
||||||
if (container && window.htmx) {
|
if (container && window.htmx) {
|
||||||
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Toggle failed: ' + (data.message || 'Unknown error'));
|
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert('Toggle failed: ' + err.message));
|
.catch(function(err) {
|
||||||
|
var msg = 'Toggle failed: ' + err.message;
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStarlarkConfig(appId) {
|
function saveStarlarkConfig(appId) {
|
||||||
const inputs = document.querySelectorAll('[data-starlark-config]');
|
var config = {};
|
||||||
const config = {};
|
|
||||||
inputs.forEach(input => {
|
// Collect standard inputs (text, number, select, datetime, color text companion)
|
||||||
const key = input.getAttribute('data-starlark-config');
|
document.querySelectorAll('[data-starlark-config]').forEach(function(input) {
|
||||||
const val = input.value;
|
var key = input.getAttribute('data-starlark-config');
|
||||||
// Send timing fields as integers
|
var type = input.getAttribute('data-starlark-type');
|
||||||
|
|
||||||
if (key === 'render_interval' || key === 'display_duration') {
|
if (key === 'render_interval' || key === 'display_duration') {
|
||||||
config[key] = parseInt(val, 10) || 0;
|
config[key] = parseInt(input.value, 10) || 0;
|
||||||
|
} else if (type === 'toggle') {
|
||||||
|
config[key] = input.checked ? 'true' : 'false';
|
||||||
} else {
|
} else {
|
||||||
config[key] = val;
|
config[key] = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect location mini-form groups
|
||||||
|
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
|
||||||
|
var fieldId = group.getAttribute('data-starlark-location-group');
|
||||||
|
var loc = {};
|
||||||
|
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {
|
||||||
|
var locKey = sub.getAttribute('data-starlark-location-key');
|
||||||
|
if (sub.value) loc[locKey] = sub.value;
|
||||||
|
});
|
||||||
|
if (Object.keys(loc).length > 0) {
|
||||||
|
config[fieldId] = JSON.stringify(loc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,14 +407,43 @@ function saveStarlarkConfig(appId) {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(function(r) { return r.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
alert('Configuration saved!');
|
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
|
||||||
|
else alert('Configuration saved!');
|
||||||
|
// Reload partial to reflect updated status
|
||||||
|
var container = document.getElementById('plugin-config-starlark:' + appId);
|
||||||
|
if (container && window.htmx) {
|
||||||
|
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Save failed: ' + (data.message || 'Unknown error'));
|
var msg = 'Save failed: ' + (data.message || 'Unknown error');
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => alert('Save failed: ' + err.message));
|
.catch(function(err) {
|
||||||
|
var msg = 'Save failed: ' + err.message;
|
||||||
|
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
||||||
|
else alert(msg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-fill location fields from stored JSON config values
|
||||||
|
(function() {
|
||||||
|
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
|
||||||
|
var fieldId = group.getAttribute('data-starlark-location-group');
|
||||||
|
// Find the hidden or stored value — look for a data attribute with the raw JSON
|
||||||
|
var rawVal = group.getAttribute('data-starlark-location-value');
|
||||||
|
if (!rawVal) return;
|
||||||
|
try {
|
||||||
|
var loc = JSON.parse(rawVal);
|
||||||
|
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {
|
||||||
|
var locKey = sub.getAttribute('data-starlark-location-key');
|
||||||
|
if (loc[locKey] !== undefined) sub.value = loc[locKey];
|
||||||
|
});
|
||||||
|
} catch(e) { /* not valid JSON, ignore */ }
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user