fix(starlark): code review fixes - security, robustness, and schema parsing

## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters

## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts

## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks

## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)

## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
  - How Starlark apps work
  - That apps come from Tronbyte (not LEDMatrix)
  - Installation, configuration, troubleshooting
  - Links to upstream projects

All changes improve security, reliability, and user experience.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-19 16:58:22 -05:00
parent 5d213b5747
commit 441b3c56e9
8 changed files with 667 additions and 43 deletions

500
docs/STARLARK_APPS_GUIDE.md Normal file
View File

@@ -0,0 +1,500 @@
# Starlark Apps Guide
## Overview
The Starlark Apps plugin for LEDMatrix enables you to run **Tidbyt/Tronbyte community apps** on your LED matrix display without modification. This integration allows you to access hundreds of pre-built widgets and apps from the vibrant Tidbyt community ecosystem.
## Important: Third-Party Content
**⚠️ Apps are NOT managed by the LEDMatrix project**
- Starlark apps are developed and maintained by the **Tidbyt/Tronbyte community**
- LEDMatrix provides the runtime environment but does **not** create, maintain, or support these apps
- All apps originate from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps)
- App quality, functionality, and security are the responsibility of individual app authors
- LEDMatrix is not affiliated with Tidbyt Inc. or the Tronbyte project
## What is Starlark?
[Starlark](https://github.com/bazelbuild/starlark) is a Python-like language originally developed by Google for the Bazel build system. Tidbyt adopted Starlark for building LED display apps because it's:
- **Sandboxed**: Apps run in a safe, restricted environment
- **Simple**: Python-like syntax that's easy to learn
- **Deterministic**: Apps produce consistent output
- **Fast**: Compiled and optimized for performance
## How It Works
### Architecture
```
┌─────────────────────────────────────────────────────────┐
│ LEDMatrix System │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Starlark Apps Plugin (manager.py) │ │
│ │ • Manages app lifecycle (install/uninstall) │ │
│ │ • Handles app configuration │ │
│ │ • Schedules app rendering │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ Pixlet Renderer (pixlet_renderer.py) │ │
│ │ • Executes .star files using Pixlet CLI │ │
│ │ • Extracts configuration schemas │ │
│ │ • Outputs WebP animations │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ Frame Extractor (frame_extractor.py) │ │
│ │ • Decodes WebP animations into frames │ │
│ │ • Scales/centers output for display size │ │
│ │ • Manages frame timing │ │
│ └─────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ LED Matrix Display │ │
│ │ • Renders final output to physical display │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Downloads apps from
┌───────────────────┴─────────────────────────────────────┐
│ Tronbyte Apps Repository (GitHub) │
│ • 974+ community-built apps │
│ • Weather, sports, stocks, games, clocks, etc. │
│ • https://github.com/tronbyt/apps │
└──────────────────────────────────────────────────────────┘
```
### Rendering Pipeline
1. **User installs app** from the Tronbyte repository via web UI
2. **Plugin downloads** the `.star` file (and any assets like images/fonts)
3. **Schema extraction** parses configuration options from the `.star` source
4. **User configures** the app through the web UI (timezone, location, API keys, etc.)
5. **Pixlet renders** the app with user config → produces WebP animation
6. **Frame extraction** decodes WebP → individual PIL Image frames
7. **Display scaling** adapts 64x32 Tidbyt output to your matrix size
8. **Rotation** cycles through your installed apps based on schedule
## Getting Started
### 1. Install Pixlet
Pixlet is the rendering engine that executes Starlark apps. The plugin will attempt to use:
1. **Bundled binary** (recommended): Downloaded to `bin/pixlet/pixlet-{platform}-{arch}`
2. **System installation**: If `pixlet` is available in your PATH
#### Auto-Install via Web UI
Navigate to: **Plugins → Starlark Apps → Status → Install Pixlet**
This runs the bundled installation script which downloads the appropriate binary for your platform.
#### Manual Installation
```bash
cd /path/to/LEDMatrix
bash scripts/download_pixlet.sh
```
Verify installation:
```bash
./bin/pixlet/pixlet-linux-amd64 version
# Pixlet 0.50.2 (or later)
```
### 2. Enable the Starlark Apps Plugin
1. Open the web UI
2. Navigate to **Plugins**
3. Find **Starlark Apps** in the installed plugins list
4. Enable the plugin
5. Configure settings:
- **Magnify**: Auto-calculated based on your display size (or set manually)
- **Render Interval**: How often apps re-render (default: 300s)
- **Display Duration**: How long each app shows (default: 15s)
- **Cache Output**: Enable to reduce re-rendering (recommended)
### 3. Browse and Install Apps
1. Navigate to **Plugins → Starlark Apps → App Store**
2. Browse available apps (974+ options)
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
4. Click **Install** on desired apps
5. Configure each app:
- Set location/timezone
- Enter API keys if required
- Customize display preferences
### 4. Configure Apps
Each app may have different configuration options:
#### Common Configuration Types
- **Location** (lat/lng/timezone): For weather, clocks, transit
- **API Keys**: For services like weather, stocks, sports scores
- **Display Preferences**: Colors, units, layouts
- **Dropdown Options**: Team selections, language, themes
- **Toggles**: Enable/disable features
Configuration is stored in `starlark-apps/{app-id}/config.json` and persists across app updates.
## App Sources and Categories
All apps are sourced from the [Tronbyte Apps Repository](https://github.com/tronbyt/apps). Popular categories include:
### 🌤️ Weather
- Analog Clock (with weather)
- Current Weather
- Weather Forecast
- Air Quality Index
### 🏈 Sports
- NFL Scores
- NBA Scores
- MLB Scores
- NHL Scores
- Soccer/Football Scores
- Formula 1 Results
### 💰 Finance
- Stock Tickers
- Cryptocurrency Prices
- Market Indices
### 🎮 Games & Fun
- Conway's Game of Life
- Pong
- Nyan Cat
- Retro Animations
### 🕐 Clocks
- Analog Clock
- Fuzzy Clock
- Binary Clock
- Word Clock
### 📰 Information
- News Headlines
- RSS Feeds
- GitHub Activity
- Reddit Feed
### 🚌 Transit & Travel
- Transit Arrivals
- Flight Tracker
- Train Schedules
## Display Size Compatibility
Tronbyte/Tidbyt apps are designed for **64×32 displays**. LEDMatrix automatically adapts content for different display sizes:
### Magnification
The plugin calculates optimal magnification based on your display:
```
magnify = floor(min(display_width / 64, display_height / 32))
```
Examples:
- **64×32**: magnify = 1 (native, pixel-perfect)
- **128×64**: magnify = 2 (2x scaling, crisp)
- **192×64**: magnify = 2 (2x + horizontal centering)
- **256×64**: magnify = 2 (2x + centering)
### Scaling Modes
**Config → Starlark Apps → Scale Method:**
- `nearest` (default): Sharp pixels, retro look
- `bilinear`: Smooth scaling, slight blur
- `bicubic`: Higher quality smooth scaling
- `lanczos`: Best quality, most processing
**Center vs Scale:**
- `scale_output=true`: Stretch to fill display (may distort aspect ratio)
- `center_small_output=true`: Center output without stretching (preserves aspect ratio)
## Configuration Schema Extraction
LEDMatrix automatically extracts configuration schemas from Starlark apps by parsing the `get_schema()` function in the `.star` source code.
### Supported Field Types
| Starlark Type | Web UI Rendering |
|--------------|------------------|
| `schema.Location` | Lat/Lng/Timezone picker |
| `schema.Text` | Text input field |
| `schema.Toggle` | Checkbox/switch |
| `schema.Dropdown` | Select dropdown |
| `schema.Color` | Color picker |
| `schema.DateTime` | Date/time picker |
| `schema.OAuth2` | Warning message (not supported) |
| `schema.PhotoSelect` | Warning message (not supported) |
| `schema.LocationBased` | Text fallback with note |
| `schema.Typeahead` | Text fallback with note |
### Schema Coverage
- **90-95%** of apps: Full schema support
- **5%**: Partial extraction (complex/dynamic schemas)
- **<1%**: No schema (apps without configuration)
Apps without extracted schemas can still run with default settings.
## File Structure
```
LEDMatrix/
├── plugin-repos/starlark-apps/ # Plugin source code
│ ├── manager.py # Main plugin logic
│ ├── pixlet_renderer.py # Pixlet CLI wrapper
│ ├── frame_extractor.py # WebP decoder
│ ├── tronbyte_repository.py # GitHub API client
│ └── requirements.txt # Python dependencies
├── starlark-apps/ # Installed apps (user data)
│ ├── manifest.json # App registry
│ │
│ └── analogclock/ # Example app
│ ├── analogclock.star # Starlark source
│ ├── config.json # User configuration
│ ├── schema.json # Extracted schema
│ ├── cached_render.webp # Rendered output cache
│ └── images/ # App assets (if any)
│ ├── hour_hand.png
│ └── minute_hand.png
├── bin/pixlet/ # Pixlet binaries
│ ├── pixlet-linux-amd64
│ ├── pixlet-linux-arm64
│ └── pixlet-darwin-arm64
└── scripts/
└── download_pixlet.sh # Pixlet installer
```
## API Keys and External Services
Many apps require API keys for external services:
### Common API Services
- **Weather**: OpenWeatherMap, Weather.gov, Dark Sky
- **Sports**: ESPN, The Sports DB, SportsData.io
- **Finance**: Alpha Vantage, CoinGecko, Yahoo Finance
- **Transit**: TransitLand, NextBus, local transit APIs
- **News**: NewsAPI, Reddit, RSS feeds
### Security Note
- API keys are stored in `config.json` files on disk
- The LEDMatrix web interface does NOT encrypt API keys
- Ensure your Raspberry Pi is on a trusted network
- Use read-only or limited-scope API keys when possible
- **Never commit `starlark-apps/*/config.json` to version control**
## Troubleshooting
### Pixlet Not Found
**Symptom**: "Pixlet binary not found" error
**Solutions**:
1. Run auto-installer: **Plugins → Starlark Apps → Install Pixlet**
2. Manual install: `bash scripts/download_pixlet.sh`
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
4. Verify architecture: `uname -m` matches binary name
### App Fails to Render
**Symptom**: "Rendering failed" error in logs
**Solutions**:
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
2. Verify config: Ensure all required fields are filled
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
4. Missing assets: Some apps need images/fonts that may fail to download
5. API issues: Check API keys and rate limits
### Schema Not Extracted
**Symptom**: App installs but shows no configuration options
**Solutions**:
1. App may not have a `get_schema()` function (normal for some apps)
2. Schema extraction failed: Check logs for parse errors
3. Manual config: Edit `starlark-apps/{app-id}/config.json` directly
4. Report issue: File bug with app details at LEDMatrix GitHub
### Apps Show Distorted/Wrong Size
**Symptom**: Content appears stretched, squished, or cropped
**Solutions**:
1. Check magnify setting: **Plugins → Starlark Apps → Config**
2. Try `center_small_output=true` to preserve aspect ratio
3. Adjust `magnify` manually (1-8) for your display size
4. Some apps assume 64×32 - may not scale perfectly to all sizes
### App Shows Outdated Data
**Symptom**: Weather, sports scores, etc. don't update
**Solutions**:
1. Check render interval: **App Config → Render Interval** (300s default)
2. Force re-render: **Plugins → Starlark Apps → {App} → Render Now**
3. Clear cache: Restart LEDMatrix service
4. API rate limits: Some services throttle requests
5. Check app logs for API errors
## Performance Considerations
### Render Intervals
- Apps re-render on a schedule (default: 300s = 5 minutes)
- Lower intervals = more CPU/API usage
- Recommended minimums:
- Static content (clocks): 30-60s
- Weather: 300s (5min)
- Sports scores: 60-120s
- Stock tickers: 60s
### Caching
Enable caching to reduce CPU load:
- `cache_rendered_output=true` (recommended)
- `cache_ttl=300` (5 minutes)
Cached WebP files are stored in `starlark-apps/{app-id}/cached_render.webp`
### Display Rotation
Balance number of enabled apps with display duration:
- 5 apps × 15s = 75s full cycle
- 20 apps × 15s = 300s (5min) cycle
Long cycles may cause apps to render before being displayed.
## Limitations
### Unsupported Features
- **OAuth2 Authentication**: Apps requiring OAuth login won't work
- **PhotoSelect**: Image upload from mobile device not supported
- **Push Notifications**: Apps can't receive real-time events
- **Background Jobs**: No persistent background tasks
### API Rate Limits
Many apps use free API tiers with rate limits:
- Rendering too frequently may exceed limits
- Use appropriate `render_interval` settings
- Consider paid API tiers for heavy usage
### Display Size Constraints
Apps designed for 64×32 may not utilize larger displays fully:
- Content may appear small on 128×64+ displays
- Magnification helps but doesn't add detail
- Some apps hard-code 64×32 dimensions
## Advanced Usage
### Manual App Installation
Upload custom `.star` files:
1. Navigate to **Starlark Apps → Upload**
2. Select `.star` file from disk
3. Configure app ID and metadata
4. Set render/display timing
### Custom App Development
While LEDMatrix runs Tronbyte apps, you can also create your own:
1. **Learn Starlark**: [Tidbyt Developer Docs](https://tidbyt.dev/)
2. **Write `.star` file**: Use Pixlet APIs for rendering
3. **Test locally**: `pixlet render myapp.star`
4. **Upload**: Use LEDMatrix web UI to install
5. **Share**: Contribute to [Tronbyte Apps](https://github.com/tronbyt/apps) repo
### Configuration Reference
**Plugin Config** (`config/config.json``plugins.starlark-apps`):
```json
{
"enabled": true,
"magnify": 0, // 0 = auto, 1-8 = manual
"render_timeout": 30, // Max seconds for Pixlet render
"cache_rendered_output": true, // Cache WebP files
"cache_ttl": 300, // Cache duration (seconds)
"scale_output": true, // Scale to display size
"scale_method": "nearest", // nearest|bilinear|bicubic|lanczos
"center_small_output": false, // Center instead of scale
"default_frame_delay": 50, // Frame timing (ms)
"max_frames": null, // Limit frames (null = unlimited)
"auto_refresh_apps": true // Auto re-render on interval
}
```
**App Config** (`starlark-apps/{app-id}/config.json`):
```json
{
"location": "{\"lat\":\"40.7128\",\"lng\":\"-74.0060\",\"timezone\":\"America/New_York\"}",
"units": "imperial",
"api_key": "your-api-key-here",
"render_interval": 300, // App-specific override
"display_duration": 15 // App-specific override
}
```
## Resources
### Official Documentation
- **Tidbyt Developer Docs**: https://tidbyt.dev/
- **Starlark Language**: https://github.com/bazelbuild/starlark
- **Pixlet Repository**: https://github.com/tidbyt/pixlet
- **Tronbyte Apps**: https://github.com/tronbyt/apps
### LEDMatrix Documentation
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
- [REST API Reference](REST_API_REFERENCE.md)
- [Troubleshooting Guide](TROUBLESHOOTING.md)
### Community
- **Tidbyt Community**: https://discuss.tidbyt.com/
- **Tronbyte Apps Issues**: https://github.com/tronbyt/apps/issues
- **LEDMatrix Issues**: https://github.com/ChuckBuilds/LEDMatrix/issues
## License and Legal
- **LEDMatrix**: MIT License (see project root)
- **Starlark Apps Plugin**: MIT License (part of LEDMatrix)
- **Pixlet**: Apache 2.0 License (Tidbyt Inc.)
- **Tronbyte Apps**: Various licenses (see individual app headers)
- **Starlark Language**: Apache 2.0 License (Google/Bazel)
**Disclaimer**: LEDMatrix is an independent project and is not affiliated with, endorsed by, or sponsored by Tidbyt Inc. The Starlark Apps plugin enables interoperability with Tidbyt's open-source ecosystem but does not imply any official relationship.
## Support
For issues with:
- **LEDMatrix integration**: File issues at [LEDMatrix GitHub](https://github.com/ChuckBuilds/LEDMatrix/issues)
- **Specific apps**: File issues at [Tronbyte Apps](https://github.com/tronbyt/apps/issues)
- **Pixlet rendering**: File issues at [Pixlet Repository](https://github.com/tidbyt/pixlet/issues)
---
**Ready to get started?** Install the Starlark Apps plugin and explore 974+ community apps! 🎨

View File

@@ -553,6 +553,7 @@ class StarlarkAppsPlugin(BasePlugin):
Save apps manifest to file with file locking to prevent race conditions.
Uses exclusive lock during write to prevent concurrent modifications.
"""
temp_file = None
try:
# Use atomic write pattern: write to temp file, then rename
temp_file = self.manifest_file.with_suffix('.tmp')
@@ -573,10 +574,10 @@ class StarlarkAppsPlugin(BasePlugin):
except Exception as e:
self.logger.error(f"Error saving manifest: {e}")
# Clean up temp file if it exists
if temp_file.exists():
if temp_file and temp_file.exists():
try:
temp_file.unlink()
except:
except Exception:
pass
return False
@@ -879,7 +880,9 @@ class StarlarkAppsPlugin(BasePlugin):
def update_fn(manifest):
manifest["apps"][safe_app_id] = app_manifest
self._update_manifest_safe(update_fn)
if not self._update_manifest_safe(update_fn):
self.logger.error(f"Failed to update manifest for {app_id}")
return False
# Create app instance (use safe_app_id for internal key, original for display)
app = StarlarkApp(safe_app_id, app_dir, app_manifest)
@@ -913,19 +916,24 @@ class StarlarkAppsPlugin(BasePlugin):
if self.current_app and self.current_app.app_id == app_id:
self.current_app = None
# Remove from apps dict
app = self.apps.pop(app_id)
# Get app reference before removing from dict
app = self.apps.get(app_id)
# Remove directory
if app.app_dir.exists():
shutil.rmtree(app.app_dir)
# Update manifest
# Update manifest FIRST (before modifying filesystem)
def update_fn(manifest):
if app_id in manifest["apps"]:
del manifest["apps"][app_id]
self._update_manifest_safe(update_fn)
if not self._update_manifest_safe(update_fn):
self.logger.error(f"Failed to update manifest when uninstalling {app_id}")
return False
# Remove from apps dict
self.apps.pop(app_id)
# Remove directory (after manifest update succeeds)
if app and app.app_dir.exists():
shutil.rmtree(app.app_dir)
self.logger.info(f"Uninstalled Starlark app: {app_id}")
return True

View File

@@ -264,10 +264,11 @@ class PixletRenderer:
else:
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}")
# Validate value doesn't contain dangerous shell metacharacters
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
# Allow: most printable chars including spaces, quotes, brackets, braces
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
continue
# Add as positional argument (not -c flag)
@@ -469,7 +470,7 @@ class PixletRenderer:
def _extract_get_schema_body(self, content: str) -> Optional[str]:
"""
Extract get_schema() function body.
Extract get_schema() function body using indentation-aware parsing.
Args:
content: .star file content
@@ -477,12 +478,45 @@ class PixletRenderer:
Returns:
Function body text, or None if not found
"""
# Find def get_schema():
pattern = r'def\s+get_schema\s*\(\s*\)\s*:(.*?)(?=\ndef\s|\Z)'
match = re.search(pattern, content, re.DOTALL)
# Find def get_schema(): line
pattern = r'^(\s*)def\s+get_schema\s*\(\s*\)\s*:'
match = re.search(pattern, content, re.MULTILINE)
if match:
return match.group(1)
if not match:
return None
# Get the indentation level of the function definition
func_indent = len(match.group(1))
func_start = match.end()
# Split content into lines starting after the function definition
lines_after = content[func_start:].split('\n')
body_lines = []
for line in lines_after:
# Skip empty lines
if not line.strip():
body_lines.append(line)
continue
# Calculate indentation of current line
stripped = line.lstrip()
line_indent = len(line) - len(stripped)
# If line has same or less indentation than function def, check if it's a top-level def
if line_indent <= func_indent:
# This is a line at the same or outer level - check if it's a function
if re.match(r'def\s+\w+', stripped):
# Found next top-level function, stop here
break
# Otherwise it might be a comment or other top-level code, stop anyway
break
# Line is indented more than function def, so it's part of the body
body_lines.append(line)
if body_lines:
return '\n'.join(body_lines)
return None
def _parse_schema_field(self, field_type: str, params_text: str, var_table: Dict) -> Optional[Dict[str, Any]]:
@@ -545,15 +579,24 @@ class PixletRenderer:
field_dict['icon'] = icon_match.group(1)
# default (can be string, bool, or variable reference)
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
# First try to match quoted strings (which may contain commas)
default_match = re.search(r'default\s*=\s*"([^"]*)"', params_text)
if not default_match:
# Try single quotes
default_match = re.search(r"default\s*=\s*'([^']*)'", params_text)
if not default_match:
# Fall back to unquoted value (stop at comma or closing paren)
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
if default_match:
default_value = default_match.group(1).strip()
# Handle boolean
if default_value in ('True', 'False'):
field_dict['default'] = default_value.lower()
# Handle string literal
elif default_value.startswith('"') and default_value.endswith('"'):
field_dict['default'] = default_value.strip('"')
# Handle string literal from first two patterns (already extracted without quotes)
elif re.search(r'default\s*=\s*["\']', params_text):
# This was a quoted string, use the captured content directly
field_dict['default'] = default_value
# Handle variable reference (can't resolve, use as-is)
else:
# Try to extract just the value if it's like options[0].value

View File

@@ -1,3 +1,3 @@
Pillow>=10.0.0
PyYAML>=6.0
requests>=2.31.0
Pillow>=10.4.0
PyYAML>=6.0.2
requests>=2.32.0

View File

@@ -462,6 +462,12 @@ class TronbyteRepository:
for file_item in dir_data:
if file_item.get('type') == 'file':
file_name = file_item.get('name')
# Ensure file_name is a non-empty string before validation
if not file_name or not isinstance(file_name, str):
logger.warning(f"Skipping file with invalid name in {dir_name}: {file_item}")
continue
# Validate filename for path traversal
if '..' in file_name or '/' in file_name or '\\' in file_name:
logger.warning(f"Skipping potentially unsafe file: {file_name}")

View File

@@ -7078,14 +7078,29 @@ def _read_starlark_manifest() -> dict:
def _write_starlark_manifest(manifest: dict) -> bool:
"""Write the starlark-apps manifest.json to disk."""
"""Write the starlark-apps manifest.json to disk with atomic write."""
temp_file = None
try:
_STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True)
with open(_STARLARK_MANIFEST_FILE, 'w') as f:
# Atomic write pattern: write to temp file, then rename
temp_file = _STARLARK_MANIFEST_FILE.with_suffix('.tmp')
with open(temp_file, 'w') as f:
json.dump(manifest, f, indent=2)
f.flush()
os.fsync(f.fileno()) # Ensure data is written to disk
# Atomic rename (overwrites destination)
temp_file.replace(_STARLARK_MANIFEST_FILE)
return True
except OSError as e:
logger.error(f"Error writing starlark manifest: {e}")
# Clean up temp file if it exists
if temp_file and temp_file.exists():
try:
temp_file.unlink()
except Exception:
pass
return False
@@ -7398,7 +7413,15 @@ def uninstall_starlark_app(app_id):
else:
# Standalone: remove app dir and manifest entry
import shutil
app_dir = _STARLARK_APPS_DIR / app_id
app_dir = (_STARLARK_APPS_DIR / app_id).resolve()
# Path traversal check - ensure app_dir is within _STARLARK_APPS_DIR
try:
app_dir.relative_to(_STARLARK_APPS_DIR.resolve())
except ValueError:
logger.warning(f"Path traversal attempt in uninstall: {app_id}")
return jsonify({'status': 'error', 'message': 'Invalid app_id'}), 400
if app_dir.exists():
shutil.rmtree(app_dir)
manifest = _read_starlark_manifest()

View File

@@ -1,6 +1,43 @@
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
const safeLocalStorage = {
getItem(key) {
try {
if (typeof localStorage !== 'undefined') {
return safeLocalStorage.getItem(key);
}
} catch (e) {
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
}
return null;
},
setItem(key, value) {
try {
if (typeof localStorage !== 'undefined') {
safeLocalStorage.setItem(key, value);
return true;
}
} catch (e) {
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
}
return false;
},
removeItem(key) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
return true;
}
} catch (e) {
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
}
return false;
}
};
// Define critical functions immediately so they're available before any HTML is rendered
// Debug logging controlled by localStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
@@ -865,7 +902,7 @@ window.currentPluginConfig = null;
// Store filter/sort state
const storeFilterState = {
sort: localStorage.getItem('storeSort') || 'a-z',
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
filterVerified: false,
filterNew: false,
filterInstalled: null, // null = all, true = installed only, false = not installed only
@@ -873,7 +910,7 @@ window.currentPluginConfig = null;
filterCategories: [],
persist() {
localStorage.setItem('storeSort', this.sort);
safeLocalStorage.setItem('storeSort', this.sort);
},
reset() {
@@ -898,7 +935,7 @@ window.currentPluginConfig = null;
};
// Installed plugins sort state
let installedSort = localStorage.getItem('installedSort') || 'a-z';
let installedSort = safeLocalStorage.getItem('installedSort') || 'a-z';
// Shared on-demand status store (mirrors Alpine store when available)
window.__onDemandStore = window.__onDemandStore || {
@@ -1251,8 +1288,8 @@ const pluginLoadCache = {
}
};
// Debug flag - set via localStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
function pluginLog(...args) {
if (PLUGIN_DEBUG) console.log(...args);
}
@@ -5269,7 +5306,7 @@ function setupStoreFilterListeners() {
installedSortSelect.value = installedSort;
installedSortSelect.addEventListener('change', () => {
installedSort = installedSortSelect.value;
localStorage.setItem('installedSort', installedSort);
safeLocalStorage.setItem('installedSort', installedSort);
const plugins = window.installedPlugins || [];
if (plugins.length > 0) {
sortAndRenderInstalledPlugins(plugins);

View File

@@ -155,13 +155,13 @@
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">
oninput="this.closest('.space-y-6').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;">
oninput="var cp = this.closest('.space-y-6').querySelector('[data-starlark-color-picker={{ field_id }}]'); if(cp && 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>
@@ -375,8 +375,15 @@ function toggleStarlarkApp(appId, enabled) {
function saveStarlarkConfig(appId) {
var config = {};
// Get container to scope queries (prevents conflicts if multiple modals open)
var container = document.getElementById('plugin-config-starlark:' + appId);
if (!container) {
console.error('Container not found for appId:', appId);
return;
}
// Collect standard inputs (text, number, select, datetime, color text companion)
document.querySelectorAll('[data-starlark-config]').forEach(function(input) {
container.querySelectorAll('[data-starlark-config]').forEach(function(input) {
var key = input.getAttribute('data-starlark-config');
var type = input.getAttribute('data-starlark-type');
@@ -390,7 +397,7 @@ function saveStarlarkConfig(appId) {
});
// Collect location mini-form groups
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
container.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) {