diff --git a/Dockerfile b/Dockerfile index aba3f90..57a919f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,14 +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 -COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ - # Set the working directory WORKDIR /var/www/html @@ -53,5 +48,6 @@ FROM base AS production # Copy the assets from the assets image 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 + # Drop back to the www-data user USER www-data diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 5df7205..33a29d5 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,7 +11,6 @@ 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; @@ -20,7 +19,6 @@ 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; @@ -42,7 +40,6 @@ class Plugin extends Model 'configuration_template' => 'json', 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', - 'preferred_renderer' => 'string', ]; protected static function boot() @@ -130,10 +127,9 @@ class Plugin extends Model } } - // Resolve Liquid variables in the entire polling_url field first, then split by newline - $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); + // Split URLs by newline and filter out empty lines $urls = array_filter( - array_map('trim', explode("\n", $resolvedPollingUrls)), + array_map('trim', explode("\n", $this->polling_url)), fn ($url): bool => ! empty($url) ); @@ -148,8 +144,8 @@ class Plugin extends Model $httpRequest = $httpRequest->withBody($resolvedBody); } - // URL is already resolved, use it directly - $resolvedUrl = $url; + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); try { // Make the request based on the verb @@ -184,8 +180,8 @@ class Plugin extends Model $httpRequest = $httpRequest->withBody($resolvedBody); } - // URL is already resolved, use it directly - $resolvedUrl = $url; + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); try { // Make the request based on the verb @@ -242,10 +238,10 @@ class Plugin extends Model try { // Attempt to parse it into JSON $json = $httpResponse->json(); - if ($json !== null) { + if($json !== null) { return $json; } - + // Response doesn't seem to be JSON, wrap the response body text as a JSON object return ['data' => $httpResponse->body()]; } catch (Exception $e) { @@ -345,48 +341,19 @@ class Plugin extends Model return $template; } - /** - * Check if a template contains a Liquid for loop pattern - * - * @param string $template The template string to check - * @return bool True if the template contains a for loop pattern - */ - private function containsLiquidForLoop(string $template): bool - { - return preg_match('/{%-?\s*for\s+/i', $template) === 1; - } - /** * Resolve Liquid variables in a template string using the Liquid template engine * - * Uses the external trmnl-liquid renderer when: - * - preferred_renderer is 'trmnl-liquid' - * - External renderer is enabled in config - * - Template contains a Liquid for loop pattern - * - * Otherwise uses the internal PHP-based Liquid renderer. - * * @param string $template The template string containing Liquid variables * @return string The resolved template with variables replaced with their values * * @throws LiquidException - * @throws Exception */ public function resolveLiquidVariables(string $template): string { // Get configuration variables - make them available at root level $variables = $this->configuration ?? []; - // Check if external renderer should be used - $useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid' - && config('services.trmnl.liquid_enabled') - && $this->containsLiquidForLoop($template); - - if ($useExternalRenderer) { - // Use external Ruby liquid renderer - return $this->renderWithExternalLiquidRenderer($template, $variables); - } - // Use the Liquid template engine to resolve variables $environment = App::make('liquid.environment'); $environment->filterRegistry->register(StandardFilters::class); @@ -396,53 +363,6 @@ 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 * @@ -454,67 +374,59 @@ class Plugin extends Model $renderedContent = ''; if ($this->markup_language === 'liquid') { - // 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 : []), + // 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 : []), + ], ], ], - ], - ]; - - // 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); - } + ] + ); + $renderedContent = $template->render($context); } else { $renderedContent = Blade::render($this->render_markup, [ 'size' => $size, diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 06e6092..a9d93b3 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -139,13 +139,11 @@ 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, $preferredRenderer = null, ?string $iconUrl = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -234,8 +232,6 @@ 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) { @@ -384,58 +380,4 @@ 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/config/app.php b/config/app.php index c7cb051..73bcaaf 100644 --- a/config/app.php +++ b/config/app.php @@ -130,7 +130,7 @@ return [ 'force_https' => env('FORCE_HTTPS', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), - 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true), + 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false), 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'notifications' => [ diff --git a/config/services.php b/config/services.php index d97255a..5cb8a74 100644 --- a/config/services.php +++ b/config/services.php @@ -41,8 +41,6 @@ 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 deleted file mode 100644 index a998420..0000000 --- a/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php +++ /dev/null @@ -1,28 +0,0 @@ -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 201ee7e..5bdae10 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -1,7 +1,6 @@ loadCatalogPlugins(); } - public function placeholder() - { - return <<<'HTML' -
-
-
- - Loading recipes... -
-
-
- HTML; - } - private function loadCatalogPlugins(): void { $catalogUrl = config('app.catalog_url'); $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { try { - $response = Http::timeout(10)->get($catalogUrl); + $response = Http::get($catalogUrl); $catalogContent = $response->body(); $catalog = Yaml::parse($catalogContent); @@ -100,13 +83,7 @@ class extends Component { $this->installingPlugin = $pluginId; try { - $importedPlugin = $pluginImportService->importFromUrl( - $plugin['zip_url'], - auth()->user(), - $plugin['zip_entry_path'] ?? null, - null, - $plugin['logo_url'] ?? null - ); + $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null); $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); @@ -136,7 +113,7 @@ 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 deleted file mode 100644 index 8e9c7af..0000000 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ /dev/null @@ -1,236 +0,0 @@ -loadNewest(); - } - - public function placeholder() - { - return <<<'HTML' -
-
-
- - Loading recipes... -
-
-
- HTML; - } - - 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); - - 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()); - } - } - - /** - * @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']) - - Install - - @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 469365c..ab42b67 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -156,7 +156,6 @@ new class extends Component {

Plugins & Recipes

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