diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c11d571..10ff8cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.0...develop) +### Fixed +- A plugin that answers from `template_redirect` and includes the theme's query template itself (`include get_query_template('404')`, WooCommerce's Review Order page) printed the Blade source of the view: the `{type}_template` filters now hand back a loader that renders it while `template_redirect` runs (#419) +- `template_redirect` ran before the application's providers had booted — WordPress is loaded from a provider's boot, the theme's providers boot after it — so a view rendered from it lacked the theme's view composers and shared data. It now runs once every provider has booted, still before routing, and `pollora_loaded` after it (#419) +- Trackbacks answered 500: `wp-trackback.php` was required from a method, where it found no `$wp` and loaded WordPress a second time (#419) + ## [v13.35.0](https://github.com/Pollora/framework/compare/v13.34.6...v13.35.0) - 2026-10-06 ### Added diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3eaa163e..f75c76db 100755 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -222,6 +222,12 @@ parameters: count: 1 path: src/ThirdParty/WooCommerce/Infrastructure/Services/WooCommerce.php + - + message: '#^Call to an undefined method Illuminate\\Contracts\\View\\View\:\:makeLoader\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/View/Application/UseCases/MakeTemplateIncludableUseCase.php + - message: '#^Constant WP_CLI_VERSION not found\.$#' identifier: constant.notFound diff --git a/rector.php b/rector.php index 1170f499..bfb229f9 100644 --- a/rector.php +++ b/rector.php @@ -32,9 +32,10 @@ // $_SERVER directly is the point rather than an oversight. ServerVariableToRequestFacadeRector::class => [ __DIR__.'/src/WordPress/QueryTrait.php', - // Its test sets $_SERVER['SCRIPT_FILENAME'] to drive that method; + // Its tests set $_SERVER['SCRIPT_FILENAME'] to drive that method; // going through the facade would stop it reaching the code at all. __DIR__.'/tests/Unit/WordPress/LaravelServingRequestTest.php', + __DIR__.'/tests/Unit/WordPress/DeferredTemplateRedirectTest.php', ], __DIR__.'/tests/Unit/helpers.php', // Binding sources each broken on purpose (an untyped field, a private diff --git a/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php b/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php new file mode 100644 index 00000000..c000b861 --- /dev/null +++ b/src/View/Application/UseCases/MakeTemplateIncludableUseCase.php @@ -0,0 +1,48 @@ +templateFinder->getViewNameFromPath($realPath), '\\/.'); + + if ($viewName === '' || ! $this->viewFactory->exists($viewName)) { + return $templatePath; + } + + return $this->viewFactory->make($viewName)->makeLoader(); + } +} diff --git a/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php b/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php index d8b93edf..cdc5c76f 100644 --- a/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php +++ b/src/View/Application/UseCases/RegisterTemplateHierarchyFiltersUseCase.php @@ -64,6 +64,13 @@ public function execute(): void $this->hierarchyFilter->extendTemplateHierarchy(...), 10 ); + + // `404_template`… : for plugins that include get_query_template() themselves + $this->filter->add( + str_replace('_hierarchy', '', $filterName), + $this->hierarchyFilter->includableTemplate(...), + PHP_INT_MAX + ); } // Theme templates filter diff --git a/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php b/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php index 9ba6af79..3a189a5e 100644 --- a/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php +++ b/src/View/Domain/Contracts/TemplateHierarchyFilterInterface.php @@ -28,6 +28,16 @@ public function extendTemplateHierarchy(array $files): array; */ public function resolveTemplateInclude(string $template): string; + /** + * The template a `{type}_template` filter hands back: a file PHP can + * include while `template_redirect` runs, when a plugin includes the + * query template itself instead of going through `template_include`. + * + * @param string $template Template path WordPress resolved + * @return string Template path to use (original or a loader) + */ + public function includableTemplate(string $template): string; + /** * Add Blade compatibility for theme templates. * diff --git a/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php b/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php index e6250011..7782e443 100644 --- a/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php +++ b/src/View/Infrastructure/Providers/TemplateHierarchyServiceProvider.php @@ -10,6 +10,7 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; use Pollora\View\Application\Services\TemplateHierarchyService; +use Pollora\View\Application\UseCases\MakeTemplateIncludableUseCase; use Pollora\View\Application\UseCases\RegisterTemplateHierarchyFiltersUseCase; use Pollora\View\Application\UseCases\ResolveBladeTemplateUseCase; use Pollora\View\Domain\Contracts\TemplateFinderInterface; @@ -63,7 +64,8 @@ private function registerDomainContracts(): void $this->app->bind(TemplateHierarchyFilterInterface::class, fn (Application $app): WordPressTemplateHierarchyFilter => new WordPressTemplateHierarchyFilter( $app->make(TemplateFinderInterface::class), $app->make(ResolveBladeTemplateUseCase::class), - $app->get('view')->getFinder() + $app->get('view')->getFinder(), + $app->make(MakeTemplateIncludableUseCase::class) )); } @@ -78,6 +80,12 @@ private function registerUseCases(): void $app->get('view') )); + // Make Template Includable Use Case + $this->app->bind(MakeTemplateIncludableUseCase::class, fn (Application $app): MakeTemplateIncludableUseCase => new MakeTemplateIncludableUseCase( + $app->make(TemplateFinderInterface::class), + $app->get('view') + )); + // Register Template Hierarchy Filters Use Case $this->app->bind(RegisterTemplateHierarchyFiltersUseCase::class, fn (Application $app): RegisterTemplateHierarchyFiltersUseCase => new RegisterTemplateHierarchyFiltersUseCase( $app->make(Filter::class), diff --git a/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php b/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php index 4892da8e..6be88187 100644 --- a/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php +++ b/src/View/Infrastructure/Services/WordPressTemplateHierarchyFilter.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Illuminate\View\FileViewFinder; use Illuminate\View\ViewFinderInterface; +use Pollora\View\Application\UseCases\MakeTemplateIncludableUseCase; use Pollora\View\Application\UseCases\ResolveBladeTemplateUseCase; use Pollora\View\Domain\Contracts\TemplateFinderInterface; use Pollora\View\Domain\Contracts\TemplateHierarchyFilterInterface; @@ -23,7 +24,8 @@ class WordPressTemplateHierarchyFilter implements TemplateHierarchyFilterInterfa public function __construct( private readonly TemplateFinderInterface $templateFinder, private readonly ResolveBladeTemplateUseCase $resolveBladeTemplateUseCase, - private readonly ViewFinderInterface $viewFinder + private readonly ViewFinderInterface $viewFinder, + private readonly MakeTemplateIncludableUseCase $makeTemplateIncludable ) {} /** @@ -56,6 +58,23 @@ public function resolveTemplateInclude(string $template): string return $this->resolveBladeTemplateUseCase->execute($template); } + /** + * A plugin that answers from `template_redirect` may include the query + * template itself (`include get_query_template('404')`, WooCommerce's + * Review Order page): it gets a loader that renders the view. Outside + * `template_redirect` the path is left alone — the FrontendController + * resolves the hierarchy later and needs the Blade path, and WordPress's + * own template loader must still meet the raw-output guard. + */ + public function includableTemplate(string $template): string + { + if (! function_exists('doing_action') || ! doing_action('template_redirect')) { + return $template; + } + + return $this->makeTemplateIncludable->execute($template); + } + /** * Add Blade compatibility for theme templates. */ diff --git a/src/WordPress/QueryTrait.php b/src/WordPress/QueryTrait.php index 37758924..abcd9030 100644 --- a/src/WordPress/QueryTrait.php +++ b/src/WordPress/QueryTrait.php @@ -5,6 +5,7 @@ namespace Pollora\WordPress; use Exception; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -75,11 +76,15 @@ private function isWordPressInstalled(): bool * Run the WordPress bootstrap process. * * This method initializes WordPress and handles special request types - * like robots.txt, favicon, feeds, trackbacks, and AJAX requests. AJAX requests - * are handled early and bypass the normal WordPress query processing to prevent - * them from reaching the Laravel routing system. Normal template resolution + * like robots.txt, favicon, feeds and trackbacks. Normal template resolution * is delegated to the Laravel routing system. * + * `template_redirect` runs once every provider has booted, still before + * routing: WordPress is loaded from this provider's boot, and the theme's + * providers, registered by functions.php, boot after it. A plugin that + * renders a page from `template_redirect` (WooCommerce's Review Order 404) + * then gets the theme's view composers and shared data. + * * @throws \RuntimeException If WordPress core functions are not available */ protected function runWp(): void @@ -88,41 +93,66 @@ protected function runWp(): void throw new \RuntimeException('The WordPress core functions are not available. Ensure WordPress is loaded.'); } - if ($this->laravelIsServingTheRequest()) { - // Initialize WordPress for the current request - wp(); - - if (wp_using_themes()) { - $this->action->do('template_redirect'); - } - - // Handle special request types that should bypass Laravel routing - if (is_robots()) { - $this->action->do('do_robots'); - exit; - } - - if (is_favicon()) { - $this->action->do('do_favicon'); - exit; - } - - if (is_feed()) { - do_feed(); - exit; - } - - if (is_trackback()) { - require_once ABSPATH.'wp-trackback.php'; - exit; - } + if (! $this->laravelIsServingTheRequest()) { + $this->action->do('pollora_loaded'); + + return; + } + + // Initialize WordPress for the current request + wp(); + + App::booted(function (): void { + $this->withWordPressErrorHandling(function (): void { + $this->handleTemplateRedirect(); + + // For normal requests, let Laravel routing handle template resolution + // Do not load WordPress template-loader.php as we use FrontendController instead + $this->action->do('pollora_loaded'); + }); + }); + } + + /** + * Fire `template_redirect`, then answer the requests WordPress serves itself. + */ + private function handleTemplateRedirect(): void + { + if (wp_using_themes()) { + $this->action->do('template_redirect'); + } + + // Handle special request types that should bypass Laravel routing + if (is_robots()) { + $this->action->do('do_robots'); + exit; + } + + if (is_favicon()) { + $this->action->do('do_favicon'); + exit; + } + + if (is_feed()) { + do_feed(); + exit; } - // For normal requests, let Laravel routing handle template resolution - // Do not load WordPress template-loader.php as we use FrontendController instead - $this->action->do('pollora_loaded'); + if (is_trackback()) { + // wp-trackback.php is written for the global scope: without $wp it + // loads WordPress a second time, and it reads $posts and $wpdb. + global $wp, $wpdb, $posts; + + require_once ABSPATH.'wp-trackback.php'; + exit; + } } + /** + * Run a callback with WordPress-safe error handling. + */ + abstract private function withWordPressErrorHandling(callable $callback): void; + /** * Whether this request is Laravel's to serve. * diff --git a/tests/Unit/View/Application/MakeTemplateIncludableTest.php b/tests/Unit/View/Application/MakeTemplateIncludableTest.php new file mode 100644 index 00000000..4719e0a2 --- /dev/null +++ b/tests/Unit/View/Application/MakeTemplateIncludableTest.php @@ -0,0 +1,90 @@ +dir = sys_get_temp_dir().'/pollora-includable-'.bin2hex(random_bytes(6)); + mkdir($this->dir, 0777, true); + touch($this->dir.'/404.blade.php'); + touch($this->dir.'/404.php'); + + $this->finder = Mockery::mock(TemplateFinderInterface::class); + $this->finder->allows('getViewNameFromPath')->andReturn('404'); + + $view = Mockery::mock(View::class); + $view->allows('makeLoader')->andReturn('/compiled/abc-loader.php'); + + $this->views = Mockery::mock(ViewFactory::class); + $this->views->allows('exists')->with('404')->andReturnTrue(); + $this->views->allows('make')->with('404')->andReturn($view); + + $this->useCase = new MakeTemplateIncludableUseCase($this->finder, $this->views); +}); + +afterEach(function (): void { + exec('rm -rf '.escapeshellarg($this->dir)); +}); + +it('hands back the loader of a Blade view', function (): void { + expect($this->useCase->execute($this->dir.'/404.blade.php'))->toBe('/compiled/abc-loader.php'); +}); + +it('leaves a PHP template, a missing file and an unknown view alone', function (): void { + expect($this->useCase->execute($this->dir.'/404.php'))->toBe($this->dir.'/404.php') + ->and($this->useCase->execute($this->dir.'/gone.blade.php'))->toBe($this->dir.'/gone.blade.php'); + + $views = Mockery::mock(ViewFactory::class); + $views->allows('exists')->andReturnFalse(); + + expect((new MakeTemplateIncludableUseCase($this->finder, $views))->execute($this->dir.'/404.blade.php'))->toBe($this->dir.'/404.blade.php'); +}); + +it('makes a template includable only while template_redirect runs', function (bool $redirecting, string $expected): void { + Functions\when('doing_action')->alias(fn (string $hook): bool => $redirecting && $hook === 'template_redirect'); + + $filter = new WordPressTemplateHierarchyFilter( + $this->finder, + Mockery::mock(ResolveBladeTemplateUseCase::class), + Mockery::mock(ViewFinderInterface::class), + $this->useCase, + ); + + expect($filter->includableTemplate($this->dir.'/404.blade.php'))->toBe($expected === 'loader' ? '/compiled/abc-loader.php' : $this->dir.'/404.blade.php'); +})->with([ + 'during template_redirect' => [true, 'loader'], + // The FrontendController resolves the hierarchy later and needs the Blade path. + 'afterwards' => [false, 'path'], +]); + +it('filters every query template last', function (): void { + $added = []; + $filter = Mockery::mock(Filter::class); + $filter->allows('add')->andReturnUsing(function (string $hook, callable $callback, int $priority = 10) use (&$added, $filter): Filter { + $added[$hook] = $priority; + + return $filter; + }); + + (new RegisterTemplateHierarchyFiltersUseCase($filter, Mockery::mock(TemplateHierarchyFilterInterface::class)->shouldIgnoreMissing()))->execute(); + + expect($added)->toHaveKey('404_template', PHP_INT_MAX) + ->and($added)->toHaveKey('single_template', PHP_INT_MAX) + ->and($added)->toHaveKey('404_template_hierarchy', 10); +}); diff --git a/tests/Unit/WordPress/DeferredTemplateRedirectTest.php b/tests/Unit/WordPress/DeferredTemplateRedirectTest.php new file mode 100644 index 00000000..9360767f --- /dev/null +++ b/tests/Unit/WordPress/DeferredTemplateRedirectTest.php @@ -0,0 +1,130 @@ +originalScript = $_SERVER['SCRIPT_FILENAME'] ?? null; + $this->originalFacadeApp = Facade::getFacadeApplication(); + + // No script to compare against: the request is Laravel's. + unset($_SERVER['SCRIPT_FILENAME']); + + $this->bootedCallbacks = []; + $callbacks = &$this->bootedCallbacks; + $app = new Container; + $app->instance('app', new class($callbacks) + { + public function __construct(private array &$callbacks) {} + + public function booted(callable $callback): void + { + $this->callbacks[] = $callback; + } + }); + Facade::clearResolvedInstance('app'); + Facade::setFacadeApplication($app); + + $this->fired = []; + $fired = &$this->fired; + $action = Mockery::mock(Action::class); + $this->action = $action; + $this->action->allows('do')->andReturnUsing(function (string $hook) use (&$fired, $action): Action { + $fired[] = $hook; + + return $action; + }); + + Functions\when('wp')->alias(function () use (&$fired): void { + $fired[] = 'wp'; + }); + Functions\stubs(['wp_using_themes' => true, 'is_robots' => false, 'is_favicon' => false, 'is_feed' => false, 'is_trackback' => false]); +}); + +afterEach(function (): void { + Facade::clearResolvedInstance('app'); + Facade::setFacadeApplication($this->originalFacadeApp); + + if ($this->originalScript !== null) { + $_SERVER['SCRIPT_FILENAME'] = $this->originalScript; + } +}); + +function wordPressRunner(Action $action): object +{ + return new class($action) + { + use QueryTrait; + + public int $guarded = 0; + + public function __construct(protected Action $action) {} + + public function run(): void + { + $this->runWp(); + } + + private function withWordPressErrorHandling(callable $callback): void + { + $this->guarded++; + $callback(); + } + }; +} + +it('runs the query at once and template_redirect once every provider has booted', function (): void { + $runner = wordPressRunner($this->action); + + $runner->run(); + + expect($this->fired)->toBe(['wp']) + ->and($this->bootedCallbacks)->toHaveCount(1); + + ($this->bootedCallbacks[0])(); + + expect($this->fired)->toBe(['wp', 'template_redirect', 'pollora_loaded']) + ->and($runner->guarded)->toBe(1); +}); + +it('fires only pollora_loaded when WordPress runs one of its own entry points', function (): void { + $dir = sys_get_temp_dir().'/pollora-entry-'.bin2hex(random_bytes(6)); + mkdir($dir.'/public/cms', 0777, true); + touch($dir.'/public/index.php'); + touch($dir.'/public/cms/wp-login.php'); + $_SERVER['SCRIPT_FILENAME'] = $dir.'/public/cms/wp-login.php'; + + // public_path() asks the application for the front controller, absent here. + $original = Container::getInstance(); + $laravel = new class extends Container + { + public string $base = ''; + + public function publicPath(string $path = ''): string + { + return $this->base.'/'.$path; + } + }; + $laravel->base = $dir.'/public'; + Container::setInstance($laravel); + + try { + wordPressRunner($this->action)->run(); + } finally { + Container::setInstance($original); + exec('rm -rf '.escapeshellarg($dir)); + } + + expect($this->fired)->toBe(['pollora_loaded']) + ->and($this->bootedCallbacks)->toBe([]); +}); diff --git a/tests/Unit/WordPress/LaravelServingRequestTest.php b/tests/Unit/WordPress/LaravelServingRequestTest.php index ad9a84fb..bd58f639 100644 --- a/tests/Unit/WordPress/LaravelServingRequestTest.php +++ b/tests/Unit/WordPress/LaravelServingRequestTest.php @@ -26,6 +26,11 @@ public function serving(): bool return $method->invoke($this); } + + private function withWordPressErrorHandling(callable $callback): void + { + $callback(); + } }; } diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php b/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php new file mode 100644 index 00000000..5bec21b5 --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/TemplateRedirectPage.php @@ -0,0 +1,35 @@ + { await context.close(); }); }); + +test.describe('Requests answered from template_redirect', () => { + // A plugin that includes the theme's 404 template itself, as WooCommerce's Review + // Order page does (#419): the Blade view renders, with the theme's layout. + test("a plugin including the theme's 404 template gets the rendered view", async ({ page }) => { + const response = await page.goto(homeUrl('/?e2e-template-redirect=1')); + + expect(response?.status()).toBe(404); + expect(await page.content()).not.toMatch(/@extends|@section|\{\{--/); + // wp_footer ran, so the layout did. + await expect(page.locator('script#e2e-features')).toHaveCount(1); + }); + + test('a trackback is answered and stored', async () => { + const post = wp('post', 'create', '--post_status=publish', '--ping_status=open', '--porcelain', `--post_title=Trackback ${runId}`); + const context = await visitor(); + + try { + const response = await context.post(homeUrl(`/?p=${post}&tb=1`), { + form: { url: `https://example.test/${runId}`, title: 'Trackback', excerpt: 'Excerpt', blog_name: 'E2E' }, + }); + + expect(response.status()).toBe(200); + expect(await response.text()).toContain('0'); + expect(wp('comment', 'list', `--post_id=${post}`, '--type=trackback', '--format=count')).toBe('1'); + } finally { + await context.dispose(); + wp('post', 'delete', post, '--force'); + } + }); +});