diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index dd87ade2cb2..9be9f7e6c80 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -767,6 +767,16 @@ Moved the following controllers: - Deprecated `craft\services\Tokens`. `CraftCms\Cms\RouteToken\RouteTokens` should be used instead. - Deprecated `craft\records\Token`. `CraftCms\Cms\RouteToken\Models\RouteToken` should be used instead. +## Twig + +- Added `CraftCms\Cms\Twig\TemplateResolver`. +- Added `CraftCms\Cms\Twig\TemplateLoader`. +- Added `CraftCms\Cms\Twig\Exceptions\TemplateLoaderException`. +- Deprecated `craft\web\View::doesTemplateExist()`. `CraftCms\Cms\Twig\TemplateResolver::doesTemplateExist()` should be used instead. +- Deprecated `craft\web\View::resolveTemplate()`. `CraftCms\Cms\Twig\TemplateResolver::resolveTemplate()` should be used instead. +- Deprecated `craft\web\twig\TemplateLoader`. `CraftCms\Cms\Twig\TemplateLoader` should be used instead. +- Deprecated `craft\web\twig\TemplateLoaderException`. `CraftCms\Cms\Twig\Exceptions\TemplateLoaderException` should be used instead. + ## Translations - Deprecated `craft\i18n\FormatConverter`. `CraftCms\Cms\Translation\FormatConverter` should be used instead. @@ -810,6 +820,7 @@ Moved the following controllers: ## View +- Added `CraftCms\Cms\View\TwigEngine`. - Added `CraftCms\Cms\View\AssetRegistry`. - Added `CraftCms\Cms\Support\Facades\AssetRegistry`. - Added `CraftCms\Cms\View\Enums\Position` enum. diff --git a/src/Console/Commands/Twig/TwigCacheCommand.php b/src/Console/Commands/Twig/TwigCacheCommand.php index ef2d81d3260..8069ae48247 100644 --- a/src/Console/Commands/Twig/TwigCacheCommand.php +++ b/src/Console/Commands/Twig/TwigCacheCommand.php @@ -5,9 +5,8 @@ namespace CraftCms\Cms\Console\Commands\Twig; use Craft; -use craft\web\twig\TemplateLoaderException; -use craft\web\View; use CraftCms\Cms\Console\CraftCommand; +use CraftCms\Cms\Twig\Exceptions\TemplateLoaderException; use CraftCms\Cms\View\TemplateMode; use Illuminate\Console\Command; use Illuminate\Support\Collection; diff --git a/src/Deprecator/Deprecator.php b/src/Deprecator/Deprecator.php index 3c40f669fe6..a7b60347b60 100644 --- a/src/Deprecator/Deprecator.php +++ b/src/Deprecator/Deprecator.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Deprecator\Models\DeprecationError; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Twig\TemplateResolver; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -204,7 +205,7 @@ private function findOrigin(array $traces): array if ($template instanceof TwigTemplate) { $templateName = $template->getTemplateName(); - $file = Craft::$app->getView()->resolveTemplate($templateName) ?: $templateName; + $file = app(TemplateResolver::class)->resolve($templateName) ?: $templateName; $line = $this->findTemplateLine($template, $templateCodeLine); return [$file, $line]; diff --git a/src/Element/Concerns/Renderable.php b/src/Element/Concerns/Renderable.php index 3c6e8041e03..21b54af6099 100644 --- a/src/Element/Concerns/Renderable.php +++ b/src/Element/Concerns/Renderable.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Element\Events\Render; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\View\TemplateMode; use Twig\Markup; @@ -46,7 +47,7 @@ public function render(array $variables = []): Markup if (! empty($templates)) { $view = Craft::$app->getView(); foreach (Arr::sort($templates, 'priority') as $template) { - if (! $view->doesTemplateExist($template['template'], TemplateMode::Site->value)) { + if (! app(TemplateResolver::class)->exists($template['template'], TemplateMode::Site)) { continue; } diff --git a/src/Http/Controllers/Users/PhotoController.php b/src/Http/Controllers/Users/PhotoController.php index c4f9f05ea4d..59450c21f5b 100644 --- a/src/Http/Controllers/Users/PhotoController.php +++ b/src/Http/Controllers/Users/PhotoController.php @@ -8,6 +8,7 @@ use craft\helpers\Assets; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\User\Elements\User; use CraftCms\Cms\User\Users; use CraftCms\Cms\View\TemplateMode; @@ -109,7 +110,7 @@ private function renderPhotoTemplate(Request $request, User $user): JsonResponse $view = Craft::$app->getView(); $templateMode = TemplateMode::get(); - if (TemplateMode::is(TemplateMode::Site) && ! $view->doesTemplateExist('users/_photo.twig')) { + if (TemplateMode::is(TemplateMode::Site) && ! app(TemplateResolver::class)->exists('users/_photo.twig')) { $templateMode = TemplateMode::Cp; } diff --git a/src/Twig/Exceptions/TemplateLoaderException.php b/src/Twig/Exceptions/TemplateLoaderException.php new file mode 100644 index 00000000000..f2bffd4ebb3 --- /dev/null +++ b/src/Twig/Exceptions/TemplateLoaderException.php @@ -0,0 +1,17 @@ +resolver->exists($name); + } + + /** + * {@inheritdoc} + */ + public function getSourceContext(string $name): Source + { + $template = $this->resolveTemplate($name); + + if (! is_readable($template)) { + throw new TemplateLoaderException($name, t('Tried to read the template at {path}, but could not. Check the permissions.', ['path' => $template])); + } + + return new Source(file_get_contents($template), $name, $template); + } + + /** + * Gets the cache key to use for the cache for a given template. + * + * @param string $name The name of the template to load + * @return string The cache key (the path to the template) + * + * @throws TemplateLoaderException if the template doesn’t exist + */ + public function getCacheKey(string $name): string + { + return $this->resolveTemplate($name); + } + + /** + * Returns whether the cached template is still up to date with the latest template. + * + * @param string $name The template name + * @param int $time The last modification time of the cached template + * + * @throws TemplateLoaderException if the template doesn’t exist + */ + public function isFresh(string $name, int $time): bool + { + // If this is a control panel request and a DB update is needed, force a recompile. + if (request()->isCpRequest() && app(Updates::class)->isCraftUpdatePending()) { + return false; + } + + $sourceModifiedTime = filemtime($this->resolveTemplate($name)); + + return $sourceModifiedTime <= $time; + } + + /** + * Returns the path to a given template, or throws a TemplateLoaderException. + * + * @throws TemplateLoaderException if the template doesn’t exist + */ + private function resolveTemplate(string $name): string + { + $template = $this->resolver->resolve($name); + + if ($template !== false) { + return $template; + } + + throw new TemplateLoaderException($name, t('Unable to find the template “{template}”.', ['template' => $name])); + } +} diff --git a/src/Twig/TemplateResolver.php b/src/Twig/TemplateResolver.php new file mode 100644 index 00000000000..427d2b81853 --- /dev/null +++ b/src/Twig/TemplateResolver.php @@ -0,0 +1,259 @@ +resolve($name, $templateMode, $publicOnly) !== false; + } catch (LoaderError) { + // _validateTemplateName() had an issue with it + + return false; + } + } + + /** + * Finds a template on the file system and returns its path. + * + * All of the following files will be searched for, in this order: + * + * - TemplateName + * - TemplateName.html + * - TemplateName.twig + * - TemplateName/index.html + * - TemplateName/index.twig + * + * If this is a front-end request, the actual list of file extensions and + * index filenames are configurable via the + * and config settings. + * + * For example if you set the following in config/general.php: + * + * ```php + * 'defaultTemplateExtensions' => ['htm'], + * 'indexTemplateFilenames' => ['default'], + * ``` + * + * then the following files would be searched for instead: + * + * - TemplateName + * - TemplateName.htm + * - TemplateName/default.htm + * + * The actual directory that those files will depend on the current [[setTemplateMode()|template mode]] + * (probably `templates/` if it’s a front-end site request, and `vendor/craftcms/cms/resources/templates/` if it’s a Control + * Panel request). + * + * If this is a front-end site request, a folder named after the current site handle will be checked first. + * + * - templates/SiteHandle/... + * - templates/... + * + * And finally, if this is a control panel request _and_ the template name includes multiple segments _and_ the first + * segment of the template name matches a plugin’s handle, then Craft will look for a template named with the + * remaining segments within that plugin’s templates/ subfolder. + * + * To put it all together, here’s where Craft would look for a template named “foo/bar”, depending on the type of + * request it is: + * + * - Front-end site requests: + * - templates/SiteHandle/foo/bar + * - templates/SiteHandle/foo/bar.html + * - templates/SiteHandle/foo/bar.twig + * - templates/SiteHandle/foo/bar/index.html + * - templates/SiteHandle/foo/bar/index.twig + * - templates/foo/bar + * - templates/foo/bar.html + * - templates/foo/bar.twig + * - templates/foo/bar/index.html + * - templates/foo/bar/index.twig + * - Control panel requests: + * - vendor/craftcms/cms/src/templates/foo/bar + * - vendor/craftcms/cms/src/templates/foo/bar.html + * - vendor/craftcms/cms/src/templates/foo/bar.twig + * - vendor/craftcms/cms/src/templates/foo/bar/index.html + * - vendor/craftcms/cms/src/templates/foo/bar/index.twig + * - path/to/fooplugin/templates/bar + * - path/to/fooplugin/templates/bar.html + * - path/to/fooplugin/templates/bar.twig + * - path/to/fooplugin/templates/bar/index.html + * - path/to/fooplugin/templates/bar/index.twig + * + * @param string $name The name of the template. + * @param ?TemplateMode $templateMode The template mode to use. + * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). + * @return string|false The path to the template if it exists, or `false`. + * + * @throws LoaderError + */ + public function resolve(string $name, ?TemplateMode $templateMode = null, bool $publicOnly = false): string|false + { + return TemplateMode::with($templateMode ?? TemplateMode::get(), fn () => $this->resolveInternal($name, $publicOnly)); + } + + /** + * Finds a template on the file system and returns its path. + * + * @param string $name The name of the template. + * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). + * @return string|false The path to the template if it exists, or `false`. + * + * @throws LoaderError + */ + private function resolveInternal(string $name, bool $publicOnly): string|false + { + // Normalize the template name + $name = trim((string) preg_replace('#/{2,}#', '/', str_replace('\\', '/', Str::convertToUtf8($name))), '/'); + + $key = TemplateMode::get()->templatesPath().':'.$name; + + // Is this template path already cached? + if (isset($this->templatePaths[$key])) { + return $this->templatePaths[$key]; + } + + // Validate the template name + $this->validateTemplateName($name); + + // Look for the template in the main templates folder + $basePaths = []; + + // Should we be looking for a localized version of the template? + if (TemplateMode::is(TemplateMode::Site) && Cms::isInstalled()) { + /** @noinspection PhpUnhandledExceptionInspection */ + $sitePath = TemplateMode::get()->templatesPath().DIRECTORY_SEPARATOR.Sites::getCurrentSite()->handle; + if (is_dir($sitePath)) { + $basePaths[] = $sitePath; + } + } + + $basePaths[] = TemplateMode::get()->templatesPath(); + + foreach ($basePaths as $basePath) { + if (($path = $this->resolveFromPath($basePath, $name, $publicOnly)) !== null) { + return $this->templatePaths[$key] = $path; + } + } + + unset($basePaths); + + // Check any registered template roots + $roots = TemplateMode::get()->templateRoots(); + + foreach ($roots as $templateRoot => $basePaths) { + /** @var string[] $basePaths */ + $templateRootLen = strlen((string) $templateRoot); + if ($templateRoot === '' || strncasecmp($templateRoot.'/', $name.'/', $templateRootLen + 1) === 0) { + $subName = $templateRoot === '' ? $name : (strlen($name) === $templateRootLen ? '' : substr($name, $templateRootLen + 1)); + foreach ($basePaths as $basePath) { + if (($path = $this->resolveFromPath($basePath, $subName, $publicOnly)) !== null) { + return $this->templatePaths[$key] = $path; + } + } + } + } + + return false; + } + + /** + * Searches for template files, and returns the first match if there is one. + * + * @param string $basePath The base path to be looking in. + * @param string $name The name of the template to be looking for. + * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). + * @return string|null The matching file path, or `null`. + */ + private function resolveFromPath(string $basePath, string $name, bool $publicOnly): ?string + { + $templateMode = TemplateMode::get(); + + // Normalize the path and name + $basePath = FileHelper::normalizePath($basePath); + $name = trim(FileHelper::normalizePath($name), '/'); + + // $name could be an empty string (e.g. to load the homepage template) + if ($name !== '') { + if ($publicOnly && preg_match(sprintf('/(^|\/)%s/', preg_quote($templateMode->privateTemplateTrigger(), '/')), $name)) { + return null; + } + + // Maybe $name is already the full file path + $testPath = $basePath.DIRECTORY_SEPARATOR.$name; + + if (is_file($testPath)) { + return $testPath; + } + + foreach ($templateMode->defaultTemplateExtensions() as $extension) { + $testPath = $basePath.DIRECTORY_SEPARATOR.$name.'.'.$extension; + + if (is_file($testPath)) { + return $testPath; + } + } + } + + foreach ($templateMode->indexTemplateFilenames() as $filename) { + foreach ($templateMode->defaultTemplateExtensions() as $extension) { + $testPath = $basePath.($name !== '' ? DIRECTORY_SEPARATOR.$name : '').DIRECTORY_SEPARATOR.$filename.'.'.$extension; + + if (is_file($testPath)) { + return $testPath; + } + } + } + + return null; + } + + /** + * Ensures that a template name isn't null and that it doesn't lead outside the template folder. + * + * @throws LoaderError + */ + private function validateTemplateName(string $name): void + { + if (str_contains($name, "\0")) { + throw new LoaderError(t('A template name cannot contain NUL bytes.')); + } + + if (Path::ensurePathIsContained($name) === false) { + Log::info('Someone tried to load a template outside the templates folder: '.$name); + + throw new LoaderError(t('Looks like you are trying to load a template outside the template folder.')); + } + } +} diff --git a/src/Twig/TwigMapper.php b/src/Twig/TwigExceptionMapper.php similarity index 98% rename from src/Twig/TwigMapper.php rename to src/Twig/TwigExceptionMapper.php index 8766dbc6867..11b7b655c05 100644 --- a/src/Twig/TwigMapper.php +++ b/src/Twig/TwigExceptionMapper.php @@ -12,7 +12,7 @@ use Twig\Error\RuntimeError; use Twig\Template; -final readonly class TwigMapper +final readonly class TwigExceptionMapper { /** * Maps an exception and replaces all references to compiled Twig diff --git a/src/Twig/TwigServiceProvider.php b/src/Twig/TwigServiceProvider.php index 81506056b26..ecf5b3d307b 100644 --- a/src/Twig/TwigServiceProvider.php +++ b/src/Twig/TwigServiceProvider.php @@ -18,7 +18,7 @@ public function register(): void $handler = $this->app->make(ExceptionHandler::class); if ($handler instanceof Handler) { - $handler->map(Exception::class, fn (Exception $e) => $this->app->make(TwigMapper::class)->map($e)); + $handler->map(Exception::class, fn (Exception $e) => $this->app->make(TwigExceptionMapper::class)->map($e)); } } } diff --git a/src/Twig/Engine.php b/src/View/TwigEngine.php similarity index 74% rename from src/Twig/Engine.php rename to src/View/TwigEngine.php index bed8d71f123..99c55ed67f4 100644 --- a/src/Twig/Engine.php +++ b/src/View/TwigEngine.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace CraftCms\Cms\Twig; +namespace CraftCms\Cms\View; use Craft; use craft\helpers\FileHelper; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\View\TemplateMode; +use Illuminate\Contracts\View\Engine; -class Engine implements \Illuminate\Contracts\View\Engine +class TwigEngine implements Engine { public function get($path, array $data = []): string { diff --git a/src/View/ViewServiceProvider.php b/src/View/ViewServiceProvider.php index ff10363c1dc..c32c6c98bc2 100644 --- a/src/View/ViewServiceProvider.php +++ b/src/View/ViewServiceProvider.php @@ -4,7 +4,6 @@ namespace CraftCms\Cms\View; -use CraftCms\Cms\Twig\Engine; use CraftCms\Cms\View\Hooks\PrepareElementIndexVariables; use CraftCms\Cms\View\Hooks\PrepareElementSourcesVariables; use CraftCms\Cms\View\Hooks\PrepareElementToolbarVariables; @@ -26,7 +25,7 @@ public function register(): void $this->app->make(ViewFactory::class)->addExtension( 'twig', 'twig', - fn () => $this->app->make(Engine::class) + fn () => $this->app->make(TwigEngine::class) ); Vite::useHotFile("{$this->root}/resources/hot"); diff --git a/tests/Unit/Twig/TemplateLoaderTest.php b/tests/Unit/Twig/TemplateLoaderTest.php new file mode 100644 index 00000000000..d178a9e33c2 --- /dev/null +++ b/tests/Unit/Twig/TemplateLoaderTest.php @@ -0,0 +1,157 @@ +tempDir = sys_get_temp_dir().'/craft-template-loader-test-'.uniqid(); + File::ensureDirectoryExists($this->tempDir); + + Aliases::set('@templates', $this->tempDir); + TemplateMode::set(TemplateMode::Site); + Cms::setIsInstalled(false); + + $this->resolver = new TemplateResolver; + $this->loader = new TemplateLoader($this->resolver); +}); + +afterEach(function () { + File::deleteDirectory($this->tempDir); +}); + +describe('exists', function () { + it('returns true when template exists', function () { + file_put_contents($this->tempDir.'/page.twig', 'content'); + + expect($this->loader->exists('page'))->toBeTrue(); + }); + + it('returns false when template does not exist', function () { + expect($this->loader->exists('nonexistent'))->toBeFalse(); + }); +}); + +describe('getSourceContext', function () { + it('returns a Source object with template content', function () { + file_put_contents($this->tempDir.'/hello.twig', 'Hello {{ name }}'); + + $source = $this->loader->getSourceContext('hello'); + + expect($source)->toBeInstanceOf(Source::class) + ->and($source->getCode())->toBe('Hello {{ name }}') + ->and($source->getName())->toBe('hello') + ->and($source->getPath())->toBe($this->tempDir.'/hello.twig'); + }); + + it('throws TemplateLoaderException when template does not exist', function () { + $this->loader->getSourceContext('nonexistent'); + })->throws(TemplateLoaderException::class, 'Unable to find the template'); + + it('throws TemplateLoaderException when template is not readable', function () { + $path = $this->tempDir.'/unreadable.twig'; + file_put_contents($path, 'content'); + chmod($path, 0000); + + $this->loader->getSourceContext('unreadable'); + })->throws(TemplateLoaderException::class, 'could not'); + + it('includes the template name in TemplateLoaderException', function () { + try { + $this->loader->getSourceContext('missing-template'); + } catch (TemplateLoaderException $e) { + expect($e->template)->toBe('missing-template'); + + return; + } + + test()->fail('Expected TemplateLoaderException was not thrown'); + }); +}); + +describe('getCacheKey', function () { + it('returns the resolved template path as cache key', function () { + file_put_contents($this->tempDir.'/cached.twig', 'content'); + + $cacheKey = $this->loader->getCacheKey('cached'); + + expect($cacheKey)->toBe($this->tempDir.'/cached.twig'); + }); + + it('throws TemplateLoaderException when template does not exist', function () { + $this->loader->getCacheKey('nonexistent'); + })->throws(TemplateLoaderException::class, 'Unable to find the template'); +}); + +describe('isFresh', function () { + it('returns true when cached template is newer than source', function () { + $path = $this->tempDir.'/fresh.twig'; + file_put_contents($path, 'content'); + + // Cache time is in the future + expect($this->loader->isFresh('fresh', time() + 3600))->toBeTrue(); + }); + + it('returns false when source is newer than cache', function () { + $path = $this->tempDir.'/stale.twig'; + file_put_contents($path, 'content'); + + // Cache time is far in the past + expect($this->loader->isFresh('stale', 0))->toBeFalse(); + }); + + it('returns false when a craft update is pending on CP request', function () { + $path = $this->tempDir.'/update-check.twig'; + file_put_contents($path, 'content'); + + // Make request()->isCpRequest() return true + Cms::config()->cpTrigger = ''; + + $updates = Mockery::mock(Updates::class); + $updates->shouldReceive('isCraftUpdatePending')->andReturn(true); + app()->instance(Updates::class, $updates); + + expect($this->loader->isFresh('update-check', time() + 3600))->toBeFalse(); + }); + + it('returns true when no craft update is pending on CP request', function () { + $path = $this->tempDir.'/no-update.twig'; + file_put_contents($path, 'content'); + + // Make request()->isCpRequest() return true + Cms::config()->cpTrigger = ''; + + $updates = Mockery::mock(Updates::class); + $updates->shouldReceive('isCraftUpdatePending')->andReturn(false); + app()->instance(Updates::class, $updates); + + expect($this->loader->isFresh('no-update', time() + 3600))->toBeTrue(); + }); + + it('does not check for updates on site requests', function () { + $path = $this->tempDir.'/site-fresh.twig'; + file_put_contents($path, 'content'); + + TemplateMode::set(TemplateMode::Site); + + // Even if an update is pending, site requests should not force recompile + // The Updates mock should NOT be called + $updates = Mockery::mock(Updates::class); + $updates->shouldNotReceive('isCraftUpdatePending'); + app()->instance(Updates::class, $updates); + + expect($this->loader->isFresh('site-fresh', time() + 3600))->toBeTrue(); + }); + + it('throws TemplateLoaderException when template does not exist', function () { + $this->loader->isFresh('nonexistent', time()); + })->throws(TemplateLoaderException::class, 'Unable to find the template'); +}); diff --git a/tests/Unit/Twig/TemplateResolverTest.php b/tests/Unit/Twig/TemplateResolverTest.php new file mode 100644 index 00000000000..5067f7f34a2 --- /dev/null +++ b/tests/Unit/Twig/TemplateResolverTest.php @@ -0,0 +1,305 @@ +tempDir = sys_get_temp_dir().'/craft-template-resolver-test-'.uniqid(); + File::ensureDirectoryExists($this->tempDir); + + $this->resolver = new TemplateResolver; + + Aliases::set('@templates', $this->tempDir); + TemplateMode::set(TemplateMode::Site); + Cms::setIsInstalled(false); +}); + +afterEach(function () { + File::deleteDirectory($this->tempDir); +}); + +describe('exists', function () { + it('returns true when template file exists', function () { + file_put_contents($this->tempDir.'/my-template.twig', 'hello'); + + expect($this->resolver->exists('my-template'))->toBeTrue(); + }); + + it('returns false when template does not exist', function () { + expect($this->resolver->exists('nonexistent'))->toBeFalse(); + }); + + it('returns false for templates with NUL bytes', function () { + expect($this->resolver->exists("bad\0template"))->toBeFalse(); + }); + + it('returns false for path traversal attempts', function () { + expect($this->resolver->exists('../../../etc/passwd'))->toBeFalse(); + }); + + it('accepts explicit template mode parameter', function () { + file_put_contents($this->tempDir.'/site-template.html', 'site content'); + + TemplateMode::set(TemplateMode::Cp); + + expect($this->resolver->exists('site-template', TemplateMode::Site))->toBeTrue(); + }); + + it('filters private templates when publicOnly is true', function () { + mkdir($this->tempDir.'/_private', 0777, true); + file_put_contents($this->tempDir.'/_private/secret.twig', 'secret'); + + // publicOnly: true should not find private templates + expect($this->resolver->exists('_private/secret', publicOnly: true))->toBeFalse(); + + // publicOnly: false should find private templates + // Using a fresh resolver to avoid cache from the previous call + $resolver = new TemplateResolver; + expect($resolver->exists('_private/secret', publicOnly: false))->toBeTrue(); + }); +}); + +describe('resolve', function () { + it('resolves exact file path', function () { + file_put_contents($this->tempDir.'/exact-file.html', 'content'); + + expect($this->resolver->resolve('exact-file.html'))->toBe($this->tempDir.'/exact-file.html'); + }); + + it('resolves template with .twig extension', function () { + file_put_contents($this->tempDir.'/my-page.twig', 'content'); + + expect($this->resolver->resolve('my-page'))->toBe($this->tempDir.'/my-page.twig'); + }); + + it('resolves template with .html extension', function () { + file_put_contents($this->tempDir.'/my-page.html', 'content'); + + expect($this->resolver->resolve('my-page'))->toBe($this->tempDir.'/my-page.html'); + }); + + it('prefers .twig over .html for site mode', function () { + Cms::config()->defaultTemplateExtensions = ['twig', 'html']; + + file_put_contents($this->tempDir.'/page.twig', 'twig content'); + file_put_contents($this->tempDir.'/page.html', 'html content'); + + $result = $this->resolver->resolve('page'); + + expect($result)->toBe($this->tempDir.'/page.twig'); + }); + + it('resolves index template in directory', function () { + mkdir($this->tempDir.'/section', 0777, true); + file_put_contents($this->tempDir.'/section/index.twig', 'index content'); + + expect($this->resolver->resolve('section'))->toBe($this->tempDir.'/section/index.twig'); + }); + + it('resolves index.html template in directory', function () { + mkdir($this->tempDir.'/section', 0777, true); + file_put_contents($this->tempDir.'/section/index.html', 'index content'); + + expect($this->resolver->resolve('section'))->toBe($this->tempDir.'/section/index.html'); + }); + + it('resolves empty name to index template (homepage)', function () { + file_put_contents($this->tempDir.'/index.twig', 'homepage'); + + expect($this->resolver->resolve(''))->toBe($this->tempDir.'/index.twig'); + }); + + it('returns false when template does not exist', function () { + expect($this->resolver->resolve('nonexistent'))->toBeFalse(); + }); + + it('normalizes backslashes in template names', function () { + mkdir($this->tempDir.'/sub', 0777, true); + file_put_contents($this->tempDir.'/sub/page.twig', 'content'); + + expect($this->resolver->resolve('sub\\page'))->toBe($this->tempDir.'/sub/page.twig'); + }); + + it('normalizes multiple slashes in template names', function () { + mkdir($this->tempDir.'/sub', 0777, true); + file_put_contents($this->tempDir.'/sub/page.twig', 'content'); + + expect($this->resolver->resolve('sub///page'))->toBe($this->tempDir.'/sub/page.twig'); + }); + + it('caches resolved paths for the same template', function () { + file_put_contents($this->tempDir.'/cached.twig', 'content'); + + $first = $this->resolver->resolve('cached'); + $second = $this->resolver->resolve('cached'); + + expect($first)->toBe($second) + ->and($first)->toBe($this->tempDir.'/cached.twig'); + }); + + it('strips NUL bytes during name normalization', function () { + file_put_contents($this->tempDir.'/badtemplate.twig', 'content'); + + // NUL bytes are stripped by Str::convertToUtf8, so "bad\0template" resolves as "badtemplate" + expect($this->resolver->resolve("bad\0template"))->toBe($this->tempDir.'/badtemplate.twig'); + }); + + it('throws LoaderError for path traversal', function () { + $this->resolver->resolve('../../../etc/passwd'); + })->throws(Twig\Error\LoaderError::class, 'outside the template folder'); + + it('returns false for private templates when publicOnly is true', function () { + file_put_contents($this->tempDir.'/_partial.twig', 'private'); + + expect($this->resolver->resolve('_partial', publicOnly: true))->toBeFalse(); + }); + + it('resolves private templates when publicOnly is false', function () { + file_put_contents($this->tempDir.'/_partial.twig', 'private'); + + expect($this->resolver->resolve('_partial', publicOnly: false))->toBe($this->tempDir.'/_partial.twig'); + }); + + it('uses custom private template trigger from config', function () { + Cms::config()->privateTemplateTrigger = '.'; + + file_put_contents($this->tempDir.'/.hidden.twig', 'hidden'); + + expect($this->resolver->resolve('.hidden', publicOnly: true))->toBeFalse(); + expect($this->resolver->resolve('.hidden', publicOnly: false))->toBe($this->tempDir.'/.hidden.twig'); + }); +}); + +describe('custom template extensions', function () { + it('resolves templates with custom extensions', function () { + Cms::config()->defaultTemplateExtensions = ['htm']; + + file_put_contents($this->tempDir.'/page.htm', 'content'); + + expect($this->resolver->resolve('page'))->toBe($this->tempDir.'/page.htm'); + }); + + it('does not resolve templates with non-configured extensions', function () { + Cms::config()->defaultTemplateExtensions = ['htm']; + + file_put_contents($this->tempDir.'/page.twig', 'content'); + + // Should not find .twig since only .htm is configured + // But exact file match still works + expect($this->resolver->resolve('page'))->toBeFalse(); + }); +}); + +describe('custom index filenames', function () { + it('resolves custom index filenames', function () { + Cms::config()->indexTemplateFilenames = ['default']; + + mkdir($this->tempDir.'/section', 0777, true); + file_put_contents($this->tempDir.'/section/default.twig', 'default content'); + + expect($this->resolver->resolve('section'))->toBe($this->tempDir.'/section/default.twig'); + }); + + it('does not resolve standard index when custom filenames are set', function () { + Cms::config()->indexTemplateFilenames = ['default']; + + mkdir($this->tempDir.'/section', 0777, true); + file_put_contents($this->tempDir.'/section/index.twig', 'index content'); + + // New resolver to avoid cache + $resolver = new TemplateResolver; + + expect($resolver->resolve('section'))->toBeFalse(); + }); +}); + +describe('template roots', function () { + it('resolves templates from registered template roots', function () { + $rootDir = $this->tempDir.'/custom-root'; + mkdir($rootDir, 0777, true); + file_put_contents($rootDir.'/page.twig', 'root content'); + + Event::listen(RegisterCpTemplateRoots::class, function (RegisterCpTemplateRoots $event) use ($rootDir) { + $event->roots['myroot'] = $rootDir; + }); + + TemplateMode::set(TemplateMode::Cp); + + // New resolver to avoid cache + $resolver = new TemplateResolver; + + expect($resolver->resolve('myroot/page'))->toBe($rootDir.'/page.twig'); + }); + + it('resolves template root with empty prefix', function () { + $rootDir = $this->tempDir.'/fallback-root'; + mkdir($rootDir, 0777, true); + file_put_contents($rootDir.'/fallback.twig', 'fallback content'); + + Event::listen(RegisterCpTemplateRoots::class, function (RegisterCpTemplateRoots $event) use ($rootDir) { + $event->roots[''] = $rootDir; + }); + + TemplateMode::set(TemplateMode::Cp); + + $resolver = new TemplateResolver; + + expect($resolver->resolve('fallback'))->toBe($rootDir.'/fallback.twig'); + }); +}); + +describe('template mode', function () { + it('resolves templates in CP mode', function () { + TemplateMode::set(TemplateMode::Cp); + + $cpTemplatesPath = TemplateMode::Cp->templatesPath(); + + // CP templates path should contain actual Craft CP templates + $resolver = new TemplateResolver; + + // The _layouts directory exists in CP templates + expect($resolver->exists('_layouts/cp'))->toBeTrue(); + }); + + it('resolves templates using the specified template mode', function () { + file_put_contents($this->tempDir.'/site-only.twig', 'site content'); + + TemplateMode::set(TemplateMode::Cp); + + // Should find it in Site mode even though current mode is CP + expect($this->resolver->resolve('site-only', TemplateMode::Site))->toBe($this->tempDir.'/site-only.twig'); + }); + + it('restores template mode after resolve with explicit mode', function () { + TemplateMode::set(TemplateMode::Cp); + + file_put_contents($this->tempDir.'/test.twig', 'content'); + + $this->resolver->resolve('test', TemplateMode::Site); + + expect(TemplateMode::get())->toBe(TemplateMode::Cp); + }); +}); + +describe('nested templates', function () { + it('resolves deeply nested templates', function () { + mkdir($this->tempDir.'/a/b/c', 0777, true); + file_put_contents($this->tempDir.'/a/b/c/deep.twig', 'deep content'); + + expect($this->resolver->resolve('a/b/c/deep'))->toBe($this->tempDir.'/a/b/c/deep.twig'); + }); + + it('resolves nested index templates', function () { + mkdir($this->tempDir.'/a/b', 0777, true); + file_put_contents($this->tempDir.'/a/b/index.twig', 'nested index'); + + expect($this->resolver->resolve('a/b'))->toBe($this->tempDir.'/a/b/index.twig'); + }); +}); diff --git a/yii2-adapter/legacy/controllers/TemplatesController.php b/yii2-adapter/legacy/controllers/TemplatesController.php index c9432a3bec0..760a53e7d54 100644 --- a/yii2-adapter/legacy/controllers/TemplatesController.php +++ b/yii2-adapter/legacy/controllers/TemplatesController.php @@ -17,6 +17,7 @@ use craft\web\View; use CraftCms\Cms\Cms; use CraftCms\Cms\Support\PHP; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\View\TemplateMode; use ErrorException; use Illuminate\Support\Facades\Cache; @@ -96,7 +97,7 @@ public function actionRender(string $template, array $variables = []): Response $this->request->getIsSiteRequest() ) || !Path::ensurePathIsContained($template) || // avoid the Craft::warning() from View::_validateTemplateName() - !$this->getView()->doesTemplateExist($template) + !app(TemplateResolver::class)->exists($template) ) { throw new NotFoundHttpException('Template not found: ' . $template); } @@ -119,7 +120,7 @@ public function actionRender(string $template, array $variables = []): Response public function actionOffline(): Response { // If this is a site request, make sure the offline template exists - if ($this->request->getIsSiteRequest() && !$this->getView()->doesTemplateExist('offline')) { + if ($this->request->getIsSiteRequest() && !app(TemplateResolver::class)->exists('offline')) { $templateMode = TemplateMode::Cp->value; } @@ -204,22 +205,22 @@ public function actionRenderError(): Response if ($this->request->getIsSiteRequest()) { $prefix = Cms::config()->errorTemplatePrefix; + $resolver = app(TemplateResolver::class); - if ($this->getView()->doesTemplateExist($prefix . $statusCode)) { + if ($resolver->exists($prefix . $statusCode)) { $template = $prefix . $statusCode; - } elseif ($statusCode == 503 && $this->getView()->doesTemplateExist($prefix . 'offline')) { + } elseif ($statusCode == 503 && $resolver->exists($prefix . 'offline')) { $template = $prefix . 'offline'; - } elseif ($this->getView()->doesTemplateExist($prefix . 'error')) { + } elseif ($resolver->exists($prefix . 'error')) { $template = $prefix . 'error'; } } /** @noinspection UnSafeIsSetOverArrayInspection - FP */ if (!isset($template)) { - $view = $this->getView(); TemplateMode::set(TemplateMode::Cp); - if ($view->doesTemplateExist($statusCode)) { + if (app(TemplateResolver::class)->exists($statusCode)) { $template = $statusCode; } else { $template = 'error'; diff --git a/yii2-adapter/legacy/helpers/Cp.php b/yii2-adapter/legacy/helpers/Cp.php index e71519a9f1d..970e7f720c6 100644 --- a/yii2-adapter/legacy/helpers/Cp.php +++ b/yii2-adapter/legacy/helpers/Cp.php @@ -15,8 +15,6 @@ use craft\events\DefineElementHtmlEvent; use craft\events\DefineElementInnerHtmlEvent; use craft\events\RegisterCpAlertsEvent; -use craft\web\twig\TemplateLoaderException; -use craft\web\View; use CraftCms\Aliases\Aliases; use CraftCms\Cms\Address\Addresses; use CraftCms\Cms\Address\Elements\Address; @@ -59,6 +57,7 @@ use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json as JsonHelper; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Twig\Exceptions\TemplateLoaderException; use CraftCms\Cms\Utility\Utilities; use CraftCms\Cms\Utility\Utilities\ProjectConfig as ProjectConfigUtility; use CraftCms\Cms\Utility\Utilities\Updates; @@ -140,8 +139,9 @@ class Cp * * @param string $template * @param array $variables + * * @return string - * @throws TemplateLoaderException if `$template` is an invalid template path + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException if `$template` is an invalid template path */ public static function renderTemplate(string $template, array $variables = []): string { @@ -1644,8 +1644,9 @@ private static function contextIsAdministrative(string $context): bool * * @param string|callable $input The input HTML or template path. If passing a template path, it must begin with `template:`. * @param array $config + * * @return string - * @throws TemplateLoaderException if $input begins with `template:` and is followed by an invalid template path + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException if $input begins with `template:` and is followed by an invalid template path * @throws InvalidArgumentException if `$config['siteId']` is invalid * @since 3.5.8 */ @@ -2004,8 +2005,9 @@ public static function checkboxGroupFieldHtml(array $config): string * Renders a color input’s HTML. * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 5.6.0 */ public static function colorHtml(array $config): string @@ -2097,8 +2099,9 @@ public static function editableTableFieldHtml(array $config): string * Renders a lightswitch input’s HTML. * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 4.0.0 */ public static function lightswitchHtml(array $config): string @@ -2172,8 +2175,9 @@ public static function moneyInputHtml(array $config): string * Renders a money field’s HTML. * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 5.0.0 */ public static function moneyFieldHtml(array $config): string @@ -2316,8 +2320,9 @@ public static function textFieldHtml(array $config): string * Renders a textarea input’s HTML. * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 4.0.0 */ public static function textareaHtml(array $config): string @@ -2343,8 +2348,9 @@ public static function textareaFieldHtml(array $config): string * Returns a date input’s HTML. * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 4.0.0 */ public static function dateHtml(array $config): string @@ -2414,8 +2420,9 @@ public static function dateTimeFieldHtml(array $config): string * Renders an element select input’s HTML * * @param array $config + * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException * @since 4.0.0 */ public static function elementSelectHtml(array $config): string @@ -2976,7 +2983,7 @@ private static function cardThumbOptionsInternal( * @param array $config * * @return string - * @throws TemplateLoaderException + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException */ private static function _thumbManagementHtml(FieldLayout $fieldLayout, array $config): string { diff --git a/yii2-adapter/legacy/helpers/Template.php b/yii2-adapter/legacy/helpers/Template.php index 261a230d1af..21b981959b4 100644 --- a/yii2-adapter/legacy/helpers/Template.php +++ b/yii2-adapter/legacy/helpers/Template.php @@ -14,7 +14,7 @@ use CraftCms\Cms\Shared\BaseModel; use CraftCms\Cms\Support\Facades\AssetRegistry; use CraftCms\Cms\Support\Facades\Entries; -use CraftCms\Cms\Twig\TwigMapper; +use CraftCms\Cms\Twig\TwigExceptionMapper; use CraftCms\Cms\View\Enums\Position; use Illuminate\Support\Facades\Auth; use Stringable; @@ -359,14 +359,15 @@ public static function js(string $js, array $options = [], ?string $key = null): * * @param string $path The compiled template path * @param int|null $line The line number from the compiled template + * * @return array|false The resolved template path and line number, or `false` if the path couldn’t be determined. * If a template path could be determined but not the template line number, the line number will be null. * @since 4.1.5 - * @deprecated 6.0.0 use {@see TwigMapper::resolveTemplatePathAndLine()} instead. + * @deprecated 6.0.0 use {@see TwigExceptionMapper::resolveTemplatePathAndLine()} instead. */ public static function resolveTemplatePathAndLine(string $path, ?int $line) { - return app(TwigMapper::class)->resolveTemplatePathAndLine($path, $line); + return app(TwigExceptionMapper::class)->resolveTemplatePathAndLine($path, $line); } /** diff --git a/yii2-adapter/legacy/services/Categories.php b/yii2-adapter/legacy/services/Categories.php index 62ef584d0be..dcc2c373897 100644 --- a/yii2-adapter/legacy/services/Categories.php +++ b/yii2-adapter/legacy/services/Categories.php @@ -27,6 +27,7 @@ use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Facades\Structures; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\View\TemplateMode; use CraftCms\Yii2Adapter\Yii2ServiceProvider; use Illuminate\Database\Query\Builder; @@ -591,7 +592,7 @@ public function isGroupTemplateValid(CategoryGroup $group, int $siteId): bool } $template = (string)$categoryGroupSiteSettings[$siteId]->template; - return Craft::$app->getView()->doesTemplateExist($template, TemplateMode::Site->value); + return app(TemplateResolver::class)->exists($template, TemplateMode::Site); } /** diff --git a/yii2-adapter/legacy/validators/TemplateValidator.php b/yii2-adapter/legacy/validators/TemplateValidator.php index a01d364333b..2b165f6f425 100644 --- a/yii2-adapter/legacy/validators/TemplateValidator.php +++ b/yii2-adapter/legacy/validators/TemplateValidator.php @@ -7,8 +7,8 @@ namespace craft\validators; -use Craft; use craft\web\View; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\View\TemplateMode; use yii\validators\Validator; use function CraftCms\Cms\t; @@ -44,7 +44,7 @@ public function init(): void */ public function validateValue($value): ?array { - if (Craft::$app->getView()->resolveTemplate($value, $this->templateMode) === false) { + if (app(TemplateResolver::class)->resolve($value, TemplateMode::from($this->templateMode)) === false) { return [$this->message, []]; } diff --git a/yii2-adapter/legacy/web/TemplateResponseFormatter.php b/yii2-adapter/legacy/web/TemplateResponseFormatter.php index 72303ca3802..c33ac89ad7f 100644 --- a/yii2-adapter/legacy/web/TemplateResponseFormatter.php +++ b/yii2-adapter/legacy/web/TemplateResponseFormatter.php @@ -13,6 +13,7 @@ use craft\web\assets\iframeresizer\ContentWindowAsset; use CraftCms\Cms\Cms; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Twig\TemplateResolver; use Throwable; use yii\base\Component; use yii\base\ExitException as YiiExitException; @@ -80,7 +81,7 @@ public function format($response) // Set the MIME type for the request based on the matched template's file extension (unless the // Content-Type header was already set, perhaps by the template via the {% header %} tag) if (!$headers->has('content-type')) { - $templateFile = Str::chopEnd(strtolower($view->resolveTemplate($behavior->template)), '.twig'); + $templateFile = Str::chopEnd(strtolower(app(TemplateResolver::class)->resolve($behavior->template)), '.twig'); $mimeType = FileHelper::getMimeTypeByExtension($templateFile) ?? 'text/html'; $headers->set('content-type', $mimeType . '; charset=' . $response->charset); } diff --git a/yii2-adapter/legacy/web/UrlManager.php b/yii2-adapter/legacy/web/UrlManager.php index cac8c90cadc..cbb92684857 100644 --- a/yii2-adapter/legacy/web/UrlManager.php +++ b/yii2-adapter/legacy/web/UrlManager.php @@ -19,6 +19,7 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Json; +use CraftCms\Cms\Twig\TemplateResolver; use Illuminate\Support\Facades\Log; use yii\web\UrlRule as YiiUrlRule; use function CraftCms\Cms\backTraceAsString; @@ -466,7 +467,7 @@ private function _isPublicTemplatePath(Request $request): bool return false; } - return Craft::$app->getView()->doesTemplateExist($request->getPathInfo(), publicOnly: true); + return app(TemplateResolver::class)->exists($request->getPathInfo(), publicOnly: true); } /** diff --git a/yii2-adapter/legacy/web/View.php b/yii2-adapter/legacy/web/View.php index 3d7a898d795..0964a4ca2f4 100644 --- a/yii2-adapter/legacy/web/View.php +++ b/yii2-adapter/legacy/web/View.php @@ -14,8 +14,6 @@ use craft\events\RegisterTemplateRootsEvent; use craft\events\TemplateEvent; use craft\helpers\Cp; -use craft\helpers\FileHelper; -use craft\helpers\Path; use craft\web\twig\CpExtension; use craft\web\twig\Environment; use craft\web\twig\Extension; @@ -23,16 +21,15 @@ use craft\web\twig\SafeHtml; use craft\web\twig\SecurityPolicy; use craft\web\twig\SinglePreloaderExtension; -use craft\web\twig\TemplateLoader; use CraftCms\Cms\Cms; -use CraftCms\Cms\Shared\Models\Info; use CraftCms\Cms\Support\Facades\DeltaRegistry; use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\InputNamespace; -use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Twig\TemplateLoader; +use CraftCms\Cms\Twig\TemplateResolver; use CraftCms\Cms\View\AssetRegistry; use CraftCms\Cms\View\Enums\Position; use CraftCms\Cms\View\Events\RegisterCpTemplateRoots; @@ -410,7 +407,7 @@ public function createTwig(): Environment Log::warning('Twig instantiated before Craft is fully initialized.', [__METHOD__]); } - $twig = new Environment(new TemplateLoader($this), $this->_getTwigOptions()); + $twig = new Environment(new TemplateLoader(app(TemplateResolver::class)), $this->_getTwigOptions()); // Mark SafeHtml as a safe interface $safeClass = SafeHtml::class; @@ -924,15 +921,11 @@ function(array $matches) use (&$tokens) { * @param string|null $templateMode The template mode to use. * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). * @return bool Whether the template exists. + * @deprecated 6.0.0 use {@see TemplateResolver::exists()} instead. */ public function doesTemplateExist(string $name, ?string $templateMode = null, bool $publicOnly = false): bool { - try { - return ($this->resolveTemplate($name, $templateMode, $publicOnly) !== false); - } catch (TwigLoaderError) { - // _validateTemplateName() had an issue with it - return false; - } + return app(TemplateResolver::class)->exists($name, $templateMode ? TemplateMode::from($templateMode) : null, $publicOnly); } /** @@ -1007,87 +1000,11 @@ public function doesTemplateExist(string $name, ?string $templateMode = null, bo * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). * @return string|false The path to the template if it exists, or `false`. * @throws TwigLoaderError + * @deprecated 6.0.0 use {@see TemplateResolver::resolve()} instead. */ public function resolveTemplate(string $name, ?string $templateMode = null, bool $publicOnly = false): string|false { - if ($templateMode !== null) { - $oldTemplateMode = TemplateMode::get(); - TemplateMode::set(TemplateMode::from($templateMode)); - } - - try { - return $this->_resolveTemplateInternal($name, $publicOnly); - } finally { - if (isset($oldTemplateMode)) { - TemplateMode::set($oldTemplateMode); - } - } - } - - /** - * Finds a template on the file system and returns its path. - * - * @param string $name The name of the template. - * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). - * @return string|false The path to the template if it exists, or `false`. - * @throws TwigLoaderError - */ - private function _resolveTemplateInternal(string $name, bool $publicOnly): string|false - { - // Normalize the template name - $name = trim(preg_replace('#/{2,}#', '/', str_replace('\\', '/', Str::convertToUtf8($name))), '/'); - - $key = TemplateMode::get()->templatesPath() . ':' . $name; - - // Is this template path already cached? - if (isset($this->_templatePaths[$key])) { - return $this->_templatePaths[$key]; - } - - // Validate the template name - $this->_validateTemplateName($name); - - // Look for the template in the main templates folder - $basePaths = []; - - // Should we be looking for a localized version of the template? - if (TemplateMode::is(TemplateMode::Site) && Cms::isInstalled()) { - /** @noinspection PhpUnhandledExceptionInspection */ - $sitePath = TemplateMode::get()->templatesPath() . DIRECTORY_SEPARATOR . Sites::getCurrentSite()->handle; - if (is_dir($sitePath)) { - $basePaths[] = $sitePath; - } - } - - $basePaths[] = TemplateMode::get()->templatesPath(); - - foreach ($basePaths as $basePath) { - if (($path = $this->_resolveTemplate($basePath, $name, $publicOnly)) !== null) { - return $this->_templatePaths[$key] = $path; - } - } - - unset($basePaths); - - // Check any registered template roots - $roots = TemplateMode::get()->templateRoots(); - - if (!empty($roots)) { - foreach ($roots as $templateRoot => $basePaths) { - /** @var string[] $basePaths */ - $templateRootLen = strlen($templateRoot); - if ($templateRoot === '' || strncasecmp($templateRoot . '/', $name . '/', $templateRootLen + 1) === 0) { - $subName = $templateRoot === '' ? $name : (strlen($name) === $templateRootLen ? '' : substr($name, $templateRootLen + 1)); - foreach ($basePaths as $basePath) { - if (($path = $this->_resolveTemplate($basePath, $subName, $publicOnly)) !== null) { - return $this->_templatePaths[$key] = $path; - } - } - } - } - } - - return false; + return app(TemplateResolver::class)->resolve($name, $templateMode ? TemplateMode::from($templateMode) : null, $publicOnly); } /** @@ -2552,74 +2469,6 @@ public function registerAssetBundle($name, $position = null) return $bundle; } - /** - * Ensures that a template name isn't null, and that it doesn't lead outside the template folder. Borrowed from - * [[\Twig\Loader\FilesystemLoader]]. - * - * @param string $name - * @throws TwigLoaderError - */ - private function _validateTemplateName(string $name): void - { - if (str_contains($name, "\0")) { - throw new TwigLoaderError(t('A template name cannot contain NUL bytes.')); - } - - if (Path::ensurePathIsContained($name) === false) { - Log::info('Someone tried to load a template outside the templates folder: ' . $name); - throw new TwigLoaderError(t('Looks like you are trying to load a template outside the template folder.')); - } - } - - /** - * Searches for a template files, and returns the first match if there is one. - * - * @param string $basePath The base path to be looking in. - * @param string $name The name of the template to be looking for. - * @param bool $publicOnly Whether to only look for public templates (template paths that don’t start with the private template trigger). - * @return string|null The matching file path, or `null`. - */ - private function _resolveTemplate(string $basePath, string $name, bool $publicOnly): ?string - { - // Normalize the path and name - $basePath = FileHelper::normalizePath($basePath); - $name = trim(FileHelper::normalizePath($name), '/'); - - // $name could be an empty string (e.g. to load the homepage template) - if ($name !== '') { - if ($publicOnly && preg_match(sprintf('/(^|\/)%s/', preg_quote(TemplateMode::get()->privateTemplateTrigger(), '/')), $name)) { - return null; - } - - // Maybe $name is already the full file path - $testPath = $basePath . DIRECTORY_SEPARATOR . $name; - - if (is_file($testPath)) { - return $testPath; - } - - foreach (TemplateMode::get()->defaultTemplateExtensions() as $extension) { - $testPath = $basePath . DIRECTORY_SEPARATOR . $name . '.' . $extension; - - if (is_file($testPath)) { - return $testPath; - } - } - } - - foreach (TemplateMode::get()->indexTemplateFilenames() as $filename) { - foreach (TemplateMode::get()->defaultTemplateExtensions() as $extension) { - $testPath = $basePath . ($name !== '' ? DIRECTORY_SEPARATOR . $name : '') . DIRECTORY_SEPARATOR . $filename . '.' . $extension; - - if (is_file($testPath)) { - return $testPath; - } - } - } - - return null; - } - /** * Returns the Twig environment options * diff --git a/yii2-adapter/legacy/web/twig/TemplateLoader.php b/yii2-adapter/legacy/web/twig/TemplateLoader.php index 0bda0a014c6..a3dccf466fc 100644 --- a/yii2-adapter/legacy/web/twig/TemplateLoader.php +++ b/yii2-adapter/legacy/web/twig/TemplateLoader.php @@ -1,112 +1,19 @@ - * @since 3.0.0 - */ -class TemplateLoader implements LoaderInterface -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @var View|null - */ - protected ?View $view = null; - - /** - * Constructor + * Loads Craft templates into Twig. * - * @param View $view - */ - public function __construct(View $view) - { - $this->view = $view; - } - - /** - * @inheritdoc - */ - public function exists(string $name): bool - { - return $this->view->doesTemplateExist($name); - } - - /** - * @inheritdoc + * @author Pixel & Tonic, Inc. + * @since 3.0.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Twig\TemplateLoader} instead. */ - public function getSourceContext(string $name): Source + class TemplateLoader { - $template = $this->_resolveTemplate($name); - - if (!is_readable($template)) { - throw new TemplateLoaderException($name, t('Tried to read the template at {path}, but could not. Check the permissions.', ['path' => $template])); - } - - return new Source(file_get_contents($template), $name, $template); - } - - /** - * Gets the cache key to use for the cache for a given template. - * - * @param string $name The name of the template to load - * @return string The cache key (the path to the template) - * @throws TemplateLoaderException if the template doesn’t exist - */ - public function getCacheKey(string $name): string - { - return $this->_resolveTemplate($name); - } - - /** - * Returns whether the cached template is still up to date with the latest template. - * - * @param string $name The template name - * @param int $time The last modification time of the cached template - * @return bool - * @throws TemplateLoaderException if the template doesn’t exist - */ - public function isFresh(string $name, int $time): bool - { - // If this is a control panel request and a DB update is needed, force a recompile. - $request = Craft::$app->getRequest(); - - if ($request->getIsCpRequest() && app(Updates::class)->isCraftUpdatePending()) { - return false; - } - - $sourceModifiedTime = filemtime($this->_resolveTemplate($name)); - return $sourceModifiedTime <= $time; - } - - /** - * Returns the path to a given template, or throws a TemplateLoaderException. - * - * @param string $name - * @return string - * @throws TemplateLoaderException if the template doesn’t exist - */ - private function _resolveTemplate(string $name): string - { - $template = $this->view->resolveTemplate($name); - - if ($template !== false) { - return $template; - } - - throw new TemplateLoaderException($name, t('Unable to find the template “{template}”.', ['template' => $name])); } } + +class_alias(\CraftCms\Cms\Twig\TemplateLoader::class, TemplateLoader::class); diff --git a/yii2-adapter/legacy/web/twig/TemplateLoaderException.php b/yii2-adapter/legacy/web/twig/TemplateLoaderException.php index 45f18e3df2a..d84b11401e9 100644 --- a/yii2-adapter/legacy/web/twig/TemplateLoaderException.php +++ b/yii2-adapter/legacy/web/twig/TemplateLoaderException.php @@ -1,34 +1,18 @@ - * @since 3.0.0 - */ -class TemplateLoaderException extends LoaderError -{ +/** @phpstan-ignore-next-line */ +if (false) { /** - * @var string|null + * @since 3.0.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException} instead. */ - public ?string $template = null; - - /** - * @param string $template The requested template - * @param string $message The exception message - */ - public function __construct(string $template, string $message) + class TemplateLoaderException extends LoaderError { - $this->template = $template; - parent::__construct($message); } } + +class_alias(\CraftCms\Cms\Twig\Exceptions\TemplateLoaderException::class, TemplateLoaderException::class); diff --git a/yii2-adapter/legacy/web/twig/variables/Cp.php b/yii2-adapter/legacy/web/twig/variables/Cp.php index 05ce33950a9..10729695fce 100644 --- a/yii2-adapter/legacy/web/twig/variables/Cp.php +++ b/yii2-adapter/legacy/web/twig/variables/Cp.php @@ -9,11 +9,9 @@ use Craft; use craft\events\FormActionsEvent; -use craft\events\RegisterCpNavItemsEvent; use craft\events\RegisterCpSettingsEvent; use craft\helpers\Cp as CpHelper; use craft\helpers\UrlHelper; -use craft\web\twig\TemplateLoaderException; use CraftCms\Cms\Cms; use CraftCms\Cms\Cp\Events\RegisterCpNavItems; use CraftCms\Cms\Cp\Events\RegisterCpSettings; @@ -747,8 +745,9 @@ public function prepFormActions(?array $formActions): ?array * * @param string $input The input HTML or template path. If passing a template path, it must begin with `template:`. * @param array $config + * * @return string - * @throws TemplateLoaderException if $input begins with `template:` and is followed by an invalid template path + * @throws \CraftCms\Cms\Twig\Exceptions\TemplateLoaderException if $input begins with `template:` and is followed by an invalid template path * @throws InvalidArgumentException if `$config['siteId']` is invalid * @since 3.7.24 */ diff --git a/yii2-adapter/tests/unit/helpers/CpHelperTest.php b/yii2-adapter/tests/unit/helpers/CpHelperTest.php index 28f64ede487..b13a744ac07 100644 --- a/yii2-adapter/tests/unit/helpers/CpHelperTest.php +++ b/yii2-adapter/tests/unit/helpers/CpHelperTest.php @@ -7,10 +7,9 @@ namespace crafttests\unit\helpers; -use Codeception\Test\Unit; use craft\helpers\Cp; use craft\test\TestCase; -use craft\web\twig\TemplateLoaderException; +use CraftCms\Cms\Twig\Exceptions\TemplateLoaderException; use CraftCms\Cms\User\Elements\User; use crafttests\fixtures\SitesFixture; use InvalidArgumentException; diff --git a/yii2-adapter/tests/unit/web/ViewTest.php b/yii2-adapter/tests/unit/web/ViewTest.php index cd067ce2e8b..f339658ac4c 100644 --- a/yii2-adapter/tests/unit/web/ViewTest.php +++ b/yii2-adapter/tests/unit/web/ViewTest.php @@ -18,6 +18,8 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Json; +use CraftCms\Cms\Twig\TemplateResolver; +use CraftCms\Cms\View\AssetRegistry; use CraftCms\Cms\View\Events\RegisterSiteTemplateRoots; use CraftCms\Cms\View\TemplateMode; use crafttests\fixtures\SitesFixture; @@ -648,7 +650,7 @@ protected function _before(): void parent::_before(); // Clear the asset registry to prevent state leaking between tests - app(\CraftCms\Cms\View\AssetRegistry::class)->clear(); + app(AssetRegistry::class)->clear(); $this->view = Craft::createObject(View::class); @@ -712,7 +714,7 @@ private function _getTemplateRoots(string $which): array */ private function _resolveTemplate(string $basePath, string $name, bool $publicOnly = false): ?string { - $path = $this->invokeMethod($this->view, '_resolveTemplate', [$basePath, $name, $publicOnly]); + $path = $this->invokeMethod(new TemplateResolver(), 'resolveFromPath', [$basePath, $name, $publicOnly]); if ($path !== null) { $path = CraftTest::normalizePathSeparators($path); }