From 6ed2d01fa72fb039e1d3019751f90f67101fe210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Fri, 9 Jan 2026 21:25:07 +0100 Subject: [PATCH] UnfoldBundle: theme assets --- config/services.yaml | 5 ++ .../Controller/ThemeAssetController.php | 72 +++++++++++++++++++ src/UnfoldBundle/Resources/config/routes.yaml | 10 +++ src/UnfoldBundle/Theme/HandlebarsRenderer.php | 27 ++++--- 4 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 src/UnfoldBundle/Controller/ThemeAssetController.php diff --git a/config/services.yaml b/config/services.yaml index fd8b1dd..2fbebd2 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -148,6 +148,11 @@ services: App\UnfoldBundle\Controller\SiteController: tags: ['controller.service_arguments'] + App\UnfoldBundle\Controller\ThemeAssetController: + arguments: + $projectDir: '%kernel.project_dir%' + tags: ['controller.service_arguments'] + App\UnfoldBundle\Controller\UnfoldAdminController: tags: ['controller.service_arguments'] diff --git a/src/UnfoldBundle/Controller/ThemeAssetController.php b/src/UnfoldBundle/Controller/ThemeAssetController.php new file mode 100644 index 0000000..c6d65a2 --- /dev/null +++ b/src/UnfoldBundle/Controller/ThemeAssetController.php @@ -0,0 +1,72 @@ +themesBasePath = $projectDir . '/src/UnfoldBundle/Resources/themes'; + $this->mimeTypes = new MimeTypes(); + } + + public function __invoke(Request $request, string $theme, string $path): Response + { + // Sanitize theme name - only allow alphanumeric, dash, underscore + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $theme)) { + throw new NotFoundHttpException('Invalid theme name'); + } + + // Prevent directory traversal + $path = ltrim($path, '/'); + if (str_contains($path, '..') || str_starts_with($path, '/')) { + throw new NotFoundHttpException('Invalid path'); + } + + // Build full file path + $filePath = $this->themesBasePath . '/' . $theme . '/assets/' . $path; + + // Verify file exists and is within theme directory + $realPath = realpath($filePath); + $realThemePath = realpath($this->themesBasePath . '/' . $theme); + + if ($realPath === false || $realThemePath === false) { + throw new NotFoundHttpException('Asset not found'); + } + + if (!str_starts_with($realPath, $realThemePath)) { + throw new NotFoundHttpException('Invalid asset path'); + } + + if (!is_file($realPath)) { + throw new NotFoundHttpException('Asset not found'); + } + + // Determine content type + $extension = pathinfo($realPath, PATHINFO_EXTENSION); + $mimeType = $this->mimeTypes->getMimeTypes($extension)[0] ?? 'application/octet-stream'; + + $response = new BinaryFileResponse($realPath); + $response->headers->set('Content-Type', $mimeType); + + // Cache for 1 week in production + $response->setMaxAge(604800); + $response->setPublic(); + + return $response; + } +} + diff --git a/src/UnfoldBundle/Resources/config/routes.yaml b/src/UnfoldBundle/Resources/config/routes.yaml index 2412ca1..52670a9 100644 --- a/src/UnfoldBundle/Resources/config/routes.yaml +++ b/src/UnfoldBundle/Resources/config/routes.yaml @@ -1,6 +1,16 @@ # Unfold Bundle Routes # Mount these routes for subdomain-based site rendering +# Theme assets route - must come before catch-all +unfold_theme_assets: + path: /assets/themes/{theme}/{path} + controller: App\UnfoldBundle\Controller\ThemeAssetController + requirements: + theme: '[a-zA-Z0-9_-]+' + path: '.+' + methods: [GET] + +# Main site controller - catch-all unfold_site: path: /{path} controller: App\UnfoldBundle\Controller\SiteController diff --git a/src/UnfoldBundle/Theme/HandlebarsRenderer.php b/src/UnfoldBundle/Theme/HandlebarsRenderer.php index 6d8aa2c..91af3ed 100644 --- a/src/UnfoldBundle/Theme/HandlebarsRenderer.php +++ b/src/UnfoldBundle/Theme/HandlebarsRenderer.php @@ -2,6 +2,7 @@ namespace App\UnfoldBundle\Theme; +use LightnCandy\Flags; use LightnCandy\LightnCandy; use Psr\Log\LoggerInterface; @@ -86,6 +87,9 @@ class HandlebarsRenderer $this->setTheme($theme); } + // Add asset path prefix to context for runtime use + $context['@assetPath'] = '/assets/themes/' . $this->currentTheme; + $renderer = $this->getCompiledTemplate($templateName); try { @@ -188,7 +192,7 @@ class HandlebarsRenderer /** * Compile template in memory (fallback) */ - private function compileInMemory(string $templateFile): callable + private function compileInMemory(string $templateFile): \Closure { if (!file_exists($templateFile)) { // Return a basic fallback renderer @@ -198,14 +202,19 @@ class HandlebarsRenderer $template = file_get_contents($templateFile); $phpCode = LightnCandy::compile($template, [ - 'flags' => LightnCandy::FLAG_HANDLEBARS - | LightnCandy::FLAG_ERROR_EXCEPTION - | LightnCandy::FLAG_RUNTIMEPARTIAL, + 'flags' => Flags::FLAG_HANDLEBARS + | Flags::FLAG_ERROR_EXCEPTION + | Flags::FLAG_RUNTIMEPARTIAL, 'partials' => $this->loadPartials(), 'helpers' => $this->getHelpers(), ]); - return LightnCandy::prepare($phpCode); + $tmpFile = tempnam(sys_get_temp_dir(), 'lc_'); + file_put_contents($tmpFile, ' function ($path) { - return '/themes/default/assets/' . ltrim($path, '/'); + // Asset URL helper - uses @assetPath from runtime context + 'asset' => function ($path, $options = null) { + // Get asset path from context (passed in render method) + $assetPath = $options['data']['root']['@assetPath'] ?? '/assets/themes/default'; + return $assetPath . '/' . ltrim($path, '/'); }, // Truncate helper