diff --git a/Dockerfile b/Dockerfile index 57a919f..4e50553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ ENV APP_VERSION=${APP_VERSION} ENV AUTORUN_ENABLED="true" +# Mark trmnl-liquid-cli as installed +ENV TRMNL_LIQUID_ENABLED=1 + # Switch to the root user so we can do root things USER root @@ -49,5 +52,6 @@ FROM base AS production COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules +COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:latest /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ # Drop back to the www-data user USER www-data diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 33a29d5..ab83514 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; +use App\Services\PluginImportService; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,6 +20,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; @@ -40,6 +42,7 @@ class Plugin extends Model 'configuration_template' => 'json', 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', + 'preferred_renderer' => 'string', ]; protected static function boot() @@ -363,6 +366,53 @@ class Plugin extends Model return $liquidTemplate->render($context); } + /** + * Render template using external Ruby liquid renderer + * + * @param string $template The liquid template string + * @param array $context The render context data + * @return string The rendered HTML + * + * @throws Exception + */ + private function renderWithExternalLiquidRenderer(string $template, array $context): string + { + $liquidPath = config('services.trmnl.liquid_path'); + + if (empty($liquidPath)) { + throw new Exception('External liquid renderer path is not configured'); + } + + // HTML encode the template + $encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8'); + + // Encode context as JSON + $jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($jsonContext === false) { + throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg()); + } + + // Validate argument sizes + app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath); + + // Execute the external renderer + $process = Process::run([ + $liquidPath, + '--template', + $encodedTemplate, + '--context', + $jsonContext, + ]); + + if (! $process->successful()) { + $errorOutput = $process->errorOutput() ?: $process->output(); + throw new Exception('External liquid renderer failed: '.$errorOutput); + } + + return $process->output(); + } + /** * Render the plugin's markup * @@ -374,59 +424,67 @@ class Plugin extends Model $renderedContent = ''; if ($this->markup_language === 'liquid') { - // Create a custom environment with inline templates support - $inlineFileSystem = new InlineTemplatesFileSystem(); - $environment = new \Keepsuit\Liquid\Environment( - fileSystem: $inlineFileSystem, - extensions: [new StandardExtension(), new LaravelLiquidExtension()] - ); - - // Register all custom filters - $environment->filterRegistry->register(Data::class); - $environment->filterRegistry->register(Date::class); - $environment->filterRegistry->register(Localization::class); - $environment->filterRegistry->register(Numbers::class); - $environment->filterRegistry->register(StringMarkup::class); - $environment->filterRegistry->register(Uniqueness::class); - - // Register the template tag for inline templates - $environment->tagRegistry->register(TemplateTag::class); - - // Apply Liquid replacements (including 'with' syntax conversion) - $processedMarkup = $this->applyLiquidReplacements($this->render_markup); - - $template = $environment->parseString($processedMarkup); - $context = $environment->newRenderContext( - data: [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ...(is_array($this->data_payload) ? $this->data_payload : []), - 'trmnl' => [ - 'system' => [ - 'timestamp_utc' => now()->utc()->timestamp, - ], - 'user' => [ - 'utc_offset' => '0', - 'name' => $this->user->name ?? 'Unknown User', - 'locale' => 'en', - 'time_zone_iana' => config('app.timezone'), - ], - 'plugin_settings' => [ - 'instance_name' => $this->name, - 'strategy' => $this->data_strategy, - 'dark_mode' => $this->dark_mode ? 'yes' : 'no', - 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no', - 'polling_headers' => $this->polling_header, - 'polling_url' => $this->polling_url, - 'custom_fields_values' => [ - ...(is_array($this->configuration) ? $this->configuration : []), - ], + // Build render context + $context = [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ...(is_array($this->data_payload) ? $this->data_payload : []), + 'trmnl' => [ + 'system' => [ + 'timestamp_utc' => now()->utc()->timestamp, + ], + 'user' => [ + 'utc_offset' => '0', + 'name' => $this->user->name ?? 'Unknown User', + 'locale' => 'en', + 'time_zone_iana' => config('app.timezone'), + ], + 'plugin_settings' => [ + 'instance_name' => $this->name, + 'strategy' => $this->data_strategy, + 'dark_mode' => $this->dark_mode ? 'yes' : 'no', + 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no', + 'polling_headers' => $this->polling_header, + 'polling_url' => $this->polling_url, + 'custom_fields_values' => [ + ...(is_array($this->configuration) ? $this->configuration : []), ], ], - ] - ); - $renderedContent = $template->render($context); + ], + ]; + + // Check if external renderer should be used + if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) { + // Use external Ruby renderer - pass raw template without preprocessing + $renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context); + } else { + // Use PHP keepsuit/liquid renderer + // Create a custom environment with inline templates support + $inlineFileSystem = new InlineTemplatesFileSystem(); + $environment = new \Keepsuit\Liquid\Environment( + fileSystem: $inlineFileSystem, + extensions: [new StandardExtension(), new LaravelLiquidExtension()] + ); + + // Register all custom filters + $environment->filterRegistry->register(Data::class); + $environment->filterRegistry->register(Date::class); + $environment->filterRegistry->register(Localization::class); + $environment->filterRegistry->register(Numbers::class); + $environment->filterRegistry->register(StringMarkup::class); + $environment->filterRegistry->register(Uniqueness::class); + + // Register the template tag for inline templates + $environment->tagRegistry->register(TemplateTag::class); + + // Apply Liquid replacements (including 'with' syntax conversion) + $processedMarkup = $this->applyLiquidReplacements($this->render_markup); + + $template = $environment->parseString($processedMarkup); + $liquidContext = $environment->newRenderContext(data: $context); + $renderedContent = $template->render($liquidContext); + } } else { $renderedContent = Blade::render($this->render_markup, [ 'size' => $size, diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index a9d93b3..06e6092 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -139,11 +139,13 @@ class PluginImportService * @param string $zipUrl The URL to the ZIP file * @param User $user The user importing the plugin * @param string|null $zipEntryPath Optional path to specific plugin in monorepo + * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') + * @param string|null $iconUrl Optional icon URL to set on the plugin * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -232,6 +234,8 @@ class PluginImportService 'render_markup' => $fullLiquid, 'configuration_template' => $configurationTemplate, 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + 'preferred_renderer' => $preferredRenderer, + 'icon_url' => $iconUrl, ]); if (! $plugin_updated) { @@ -380,4 +384,58 @@ class PluginImportService 'sharedLiquidPath' => $sharedLiquidPath, ]; } + + /** + * Validate that template and context are within command-line argument limits + * + * @param string $template The liquid template string + * @param string $jsonContext The JSON-encoded context + * @param string $liquidPath The path to the liquid renderer executable + * + * @throws Exception If the template or context exceeds argument limits + */ + public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void + { + // MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments + // ARG_MAX is the total size of all arguments (typically 2MB on modern systems) + $maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit + $maxTotalArgLength = $this->getMaxArgumentLength(); + + // Check individual argument sizes (template and context are the largest) + if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) { + throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); + } + + // Calculate total size of all arguments (path + flags + template + context) + // Add overhead for path, flags, and separators (conservative estimate: ~200 bytes) + $totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template) + + mb_strlen('--context') + mb_strlen($jsonContext) + 200; + + if ($totalArgSize > $maxTotalArgLength) { + throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); + } + } + + /** + * Get the maximum argument length for command-line arguments + * + * @return int Maximum argument length in bytes + */ + private function getMaxArgumentLength(): int + { + // Try to get ARG_MAX from system using getconf + $argMax = null; + if (function_exists('shell_exec')) { + $result = @shell_exec('getconf ARG_MAX 2>/dev/null'); + if ($result !== null && is_numeric(mb_trim($result))) { + $argMax = (int) mb_trim($result); + } + } + + // Use conservative fallback if ARG_MAX cannot be determined + // ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB) + // We use 200KB as a conservative limit that works on both systems + // Note: ARG_MAX includes environment variables, so we leave headroom + return $argMax !== null ? min($argMax, 204800) : 204800; + } } diff --git a/composer.lock b/composer.lock index dd58bf9..da08026 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.359.10", + "version": "3.359.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "10989892e99083c73e8421b85b5d6f7d2ca0f2f5" + "reference": "c04a8b3c40bca26da591a8ff14bcc390d26c1644" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/10989892e99083c73e8421b85b5d6f7d2ca0f2f5", - "reference": "10989892e99083c73e8421b85b5d6f7d2ca0f2f5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c04a8b3c40bca26da591a8ff14bcc390d26c1644", + "reference": "c04a8b3c40bca26da591a8ff14bcc390d26c1644", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.359.10" + "source": "https://github.com/aws/aws-sdk-php/tree/3.359.11" }, - "time": "2025-11-11T19:08:54+00:00" + "time": "2025-11-12T19:18:02+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.38.0", + "version": "v12.38.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1c30f547a3117bac99dc62a0afe767810cb112fa" + "reference": "7f3012af6059f5f64a12930701cd8caed6cf7c17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1c30f547a3117bac99dc62a0afe767810cb112fa", - "reference": "1c30f547a3117bac99dc62a0afe767810cb112fa", + "url": "https://api.github.com/repos/laravel/framework/zipball/7f3012af6059f5f64a12930701cd8caed6cf7c17", + "reference": "7f3012af6059f5f64a12930701cd8caed6cf7c17", "shasum": "" }, "require": { @@ -1832,7 +1832,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-11-12T16:51:30+00:00" + "time": "2025-11-13T02:12:47+00:00" }, { "name": "laravel/prompts", @@ -10427,21 +10427,21 @@ }, { "name": "rector/rector", - "version": "2.2.7", + "version": "2.2.8", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "022038537838bc8a4e526af86c2d6e38eaeff7ef" + "reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/022038537838bc8a4e526af86c2d6e38eaeff7ef", - "reference": "022038537838bc8a4e526af86c2d6e38eaeff7ef", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/303aa811649ccd1d32e51e62d5c85949d01b5f1b", + "reference": "303aa811649ccd1d32e51e62d5c85949d01b5f1b", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.26" + "phpstan/phpstan": "^2.1.32" }, "conflict": { "rector/rector-doctrine": "*", @@ -10475,7 +10475,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.7" + "source": "https://github.com/rectorphp/rector/tree/2.2.8" }, "funding": [ { @@ -10483,7 +10483,7 @@ "type": "github" } ], - "time": "2025-10-29T15:46:12+00:00" + "time": "2025-11-12T18:38:00+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/services.php b/config/services.php index 5cb8a74..d97255a 100644 --- a/config/services.php +++ b/config/services.php @@ -41,6 +41,8 @@ return [ 'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'), 'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false), 'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices + 'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false), + 'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'), ], 'webhook' => [ diff --git a/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php new file mode 100644 index 0000000..a998420 --- /dev/null +++ b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php @@ -0,0 +1,28 @@ +string('preferred_renderer')->nullable()->after('markup_language'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('preferred_renderer'); + }); + } +}; diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 5bdae10..d796d4f 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -23,7 +23,7 @@ new class extends Component { $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { try { - $response = Http::get($catalogUrl); + $response = Http::timeout(10)->get($catalogUrl); $catalogContent = $response->body(); $catalog = Yaml::parse($catalogContent); @@ -83,7 +83,13 @@ new class extends Component { $this->installingPlugin = $pluginId; try { - $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null); + $importedPlugin = $pluginImportService->importFromUrl( + $plugin['zip_url'], + auth()->user(), + $plugin['zip_entry_path'] ?? null, + null, + $plugin['logo_url'] ?? null + ); $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); @@ -113,7 +119,7 @@ new class extends Component {
@if($plugin['logo_url']) - {{ $plugin['name'] }} + {{ $plugin['name'] }} @else
diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php new file mode 100644 index 0000000..034bf3b --- /dev/null +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -0,0 +1,233 @@ +loadNewest(); + } + + private function loadNewest(): void + { + try { + $this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { + $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ + 'sort-by' => 'newest', + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to fetch TRMNL recipes'); + } + + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); + }); + } catch (\Throwable $e) { + Log::error('TRMNL catalog load error: ' . $e->getMessage()); + $this->recipes = []; + } + } + + private function searchRecipes(string $term): void + { + $this->isSearching = true; + try { + $cacheKey = 'trmnl_recipes_search_' . md5($term); + $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { + $response = Http::get('https://usetrmnl.com/recipes.json', [ + 'search' => $term, + 'sort-by' => 'newest', + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to search TRMNL recipes'); + } + + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); + }); + } catch (\Throwable $e) { + Log::error('TRMNL catalog search error: ' . $e->getMessage()); + $this->recipes = []; + } finally { + $this->isSearching = false; + } + } + + public function updatedSearch(): void + { + $term = trim($this->search); + if ($term === '') { + $this->loadNewest(); + return; + } + + if (strlen($term) < 2) { + // Require at least 2 chars to avoid noisy calls + return; + } + + $this->searchRecipes($term); + } + + public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $this->installingPlugin = $recipeId; + + try { + $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; + + $recipe = collect($this->recipes)->firstWhere('id', $recipeId); + + $plugin = $pluginImportService->importFromUrl( + $zipUrl, + auth()->user(), + null, + config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, + $recipe['icon_url'] ?? null + ); + + $this->dispatch('plugin-installed'); + Flux::modal('import-from-trmnl-catalog')->close(); + + } catch (\Exception $e) { + Log::error('Plugin installation failed: ' . $e->getMessage()); + $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } finally { + $this->installingPlugin = ''; + } + } + + /** + * @param array> $items + * @return array> + */ + private function mapRecipes(array $items): array + { + return collect($items) + ->map(function (array $item) { + return [ + 'id' => $item['id'] ?? null, + 'name' => $item['name'] ?? 'Untitled', + 'icon_url' => $item['icon_url'] ?? null, + 'screenshot_url' => $item['screenshot_url'] ?? null, + 'author_bio' => is_array($item['author_bio'] ?? null) + ? strip_tags($item['author_bio']['description'] ?? null) + : null, + 'stats' => [ + 'installs' => data_get($item, 'stats.installs'), + 'forks' => data_get($item, 'stats.forks'), + ], + 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null, + ]; + }) + ->toArray(); + } +}; ?> + +
+
+
+ +
+ Newest +
+ + @error('installation') + + @enderror + + @if(empty($recipes)) +
+ + No recipes found + Try a different search term +
+ @else +
+ @foreach($recipes as $recipe) +
+
+ @php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) + @if($thumb) + {{ $recipe['name'] }} + @else +
+ +
+ @endif + +
+
+
+

{{ $recipe['name'] }}

+ @if(data_get($recipe, 'stats.installs')) +

Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}

+ @endif +
+
+ @if($recipe['detail_url']) + + + + @endif +
+
+ + @if($recipe['author_bio']) +

{{ $recipe['author_bio'] }}

+ @endif + +
+ @if($recipe['id']) + @if($installingPlugin === $recipe['id']) + + + + @else + + Install + + @endif + @endif + + @if($recipe['detail_url']) + + View on TRMNL + + @endif +
+
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index ab42b67..4f4da8b 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -156,6 +156,7 @@ new class extends Component {

Plugins & Recipes

+
+ + + + Add Recipe + - - - Add Recipe - - - - - - - Import Recipe Archive - - - Import from Catalog - - Seed Example Recipes - - - + + + + + Import from OSS Catalog + + @if(config('services.trmnl.liquid_enabled')) + + Import from TRMNL Catalog + + @endif + + + Import Recipe Archive + + + Seed Example Recipes + + + +
-
+