From 306de8a94bfa54de1f0901d826d79d9fa299b037 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 21 Oct 2026 14:25:05 +0200 Subject: [PATCH 1/3] fix: serve the home page and the admin address under Valet and Herd --- CHANGELOG.md | 3 ++ .../Http/Controllers/FrontendController.php | 10 +++- .../PublicDirectoryIndexRedirect.php | 49 +++++++++++++++++++ src/WordPress/QueryTrait.php | 9 +++- .../PublicDirectoryIndexRedirectTest.php | 48 ++++++++++++++++++ .../WordPress/LaravelServingRequestTest.php | 10 ++++ 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php create mode 100644 tests/Unit/Route/PublicDirectoryIndexRedirectTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f09db89..d3221673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - An `APP_URL` without a scheme (`APP_URL=pollora.test`, written by hand in `.env`) no longer breaks the site: every Artisan command failed with `Undefined array key "host"`, and `/wp-admin/` answered a 404 because WordPress built its URLs relative to the current path. The value is read as `http://pollora.test` for `WP_HOME`, `WP_SITEURL`, the cookie path and the console host (#518) +- The home page of a site served by Valet or Herd no longer redirects to itself. Both run the site through their own `server.php`, which includes `public/index.php`, so `SCRIPT_FILENAME` is not the front controller. Pollora took the request for a WordPress entry point and sent it to the home URL, in an endless loop. A front controller PHP has included now counts as Laravel serving the request +- `/cms/wp-admin/`, the address `pollora:install` prints for the admin, answered a 404 under Valet and Herd, which hand every URL that is not a file to the front controller. A request for a `public/` directory that holds an `index.php` is now sent to that file + ### Added - `pollora:doctor` warns when `APP_URL` has no scheme, and gives the value to write diff --git a/src/Route/UI/Http/Controllers/FrontendController.php b/src/Route/UI/Http/Controllers/FrontendController.php index c27cdbaf..1c719ca8 100644 --- a/src/Route/UI/Http/Controllers/FrontendController.php +++ b/src/Route/UI/Http/Controllers/FrontendController.php @@ -5,12 +5,14 @@ namespace Pollora\Route\UI\Http\Controllers; use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\View; use Pollora\Route\Application\Services\AnsweringTemplate; use Pollora\Route\Domain\Enums\TemplateOutcome; use Pollora\Route\Domain\Models\TemplateResolution; +use Pollora\Route\UI\Http\Responses\PublicDirectoryIndexRedirect; use Pollora\Route\UI\Http\Responses\SetupRequiredResponse; use Pollora\View\Domain\Contracts\TemplateFinderInterface; @@ -48,8 +50,14 @@ public function __construct( /** * Handle the request using WordPress template hierarchy. */ - public function handle(Request $request): Response + public function handle(Request $request): Response|RedirectResponse { + // A directory of public/ (the admin at /cms/wp-admin/) has an index.php + // a document-root server runs by itself; Valet and Herd do not + if ($redirect = PublicDirectoryIndexRedirect::for($request, public_path())) { + return $redirect; + } + // WordPress loads once the database is configured: until then there is // no hierarchy to ask (Pollora/pollora#78) if (! $this->wordPressIsLoaded()) { diff --git a/src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php b/src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php new file mode 100644 index 00000000..6a473ce7 --- /dev/null +++ b/src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php @@ -0,0 +1,49 @@ +isMethod('GET') && ! $request->isMethod('HEAD')) { + return null; + } + + $path = rawurldecode($request->getPathInfo()); + + if ($path === '/' || ! str_ends_with($path, '/') || str_contains($path, '..')) { + return null; + } + + $root = realpath($publicPath); + $directory = realpath($publicPath.$path); + + if ($root === false || $directory === false || ! str_starts_with($directory, $root.DIRECTORY_SEPARATOR)) { + return null; + } + + if (! is_file($directory.DIRECTORY_SEPARATOR.'index.php')) { + return null; + } + + $query = $request->getQueryString(); + + return new RedirectResponse($request->getBaseUrl().$request->getPathInfo().'index.php'.($query !== null && $query !== '' ? '?'.$query : '')); + } +} diff --git a/src/WordPress/QueryTrait.php b/src/WordPress/QueryTrait.php index abcd9030..9e9aff98 100644 --- a/src/WordPress/QueryTrait.php +++ b/src/WordPress/QueryTrait.php @@ -168,6 +168,12 @@ abstract private function withWordPressErrorHandling(callable $callback): void; * The test is which script PHP is running, not a list of filenames: any * entry point WordPress grows later is covered without being named, and a * project adding its own is too. + * + * A server may start another script that includes the front controller: + * Valet and Herd run their own `server.php`, which requires + * `public/index.php`, so `SCRIPT_FILENAME` is not the front controller + * there. The request is still Laravel's, and a front controller PHP has + * included proves it — WordPress's entry points never include it. */ private function laravelIsServingTheRequest(): bool { @@ -193,6 +199,7 @@ private function laravelIsServingTheRequest(): bool return true; } - return $running === $frontController; + return $running === $frontController + || in_array($frontController, array_map('realpath', get_included_files()), true); } } diff --git a/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php b/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php new file mode 100644 index 00000000..52a0c113 --- /dev/null +++ b/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php @@ -0,0 +1,48 @@ +public = sys_get_temp_dir().'/pollora-public-'.bin2hex(random_bytes(6)); + mkdir($this->public.'/cms/wp-admin', 0777, true); + mkdir($this->public.'/cms/empty', 0777, true); + touch($this->public.'/index.php'); + touch($this->public.'/cms/wp-admin/index.php'); + touch($this->public.'/cms/wp-login.php'); +}); + +afterEach(function (): void { + exec('rm -rf '.escapeshellarg($this->public)); +}); + +describe('PublicDirectoryIndexRedirect', function (): void { + it('sends a directory URL to the index.php inside it', function (): void { + $response = PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/'), $this->public); + + expect($response?->getTargetUrl())->toBe('/cms/wp-admin/index.php'); + }); + + it('keeps the query string', function (): void { + $response = PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/?page=x'), $this->public); + + expect($response?->getTargetUrl())->toBe('/cms/wp-admin/index.php?page=x'); + }); + + it('leaves alone what is not a directory with an index.php', function (string $uri): void { + expect(PublicDirectoryIndexRedirect::for(Request::create($uri), $this->public))->toBeNull(); + })->with([ + 'directory without index' => '/cms/empty/', + 'no trailing slash' => '/cms/wp-admin', + 'a file' => '/cms/wp-login.php', + 'the root' => '/', + 'a WordPress page' => '/sample-page/', + 'outside public' => '/../', + ]); + + it('only redirects reads', function (): void { + expect(PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/', 'POST'), $this->public))->toBeNull(); + }); +}); diff --git a/tests/Unit/WordPress/LaravelServingRequestTest.php b/tests/Unit/WordPress/LaravelServingRequestTest.php index bd58f639..015dfd17 100644 --- a/tests/Unit/WordPress/LaravelServingRequestTest.php +++ b/tests/Unit/WordPress/LaravelServingRequestTest.php @@ -82,6 +82,16 @@ public function publicPath(string $path = ''): string expect(servingProbe()->serving())->toBeFalse(); }); + it('says yes when a server script such as Valet\'s server.php includes the front controller', function (): void { + $server = $this->dir.'/server.php'; + touch($server); + $_SERVER['SCRIPT_FILENAME'] = $server; + + require $this->dir.'/public/index.php'; + + expect(servingProbe()->serving())->toBeTrue(); + }); + it('says yes when there is nothing to compare against', function (): void { // Better to do the work than to skip something the request needs. unset($_SERVER['SCRIPT_FILENAME']); From 1becbd30f436ecc1e8ccaca6ff05d1753710873a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 21 Oct 2026 14:34:07 +0200 Subject: [PATCH 2/3] feat: report in Site Health a document root that exposes the project folder --- CHANGELOG.md | 1 + .../Infrastructure/Checks/PublicRootCheck.php | 66 +++++++++++++++++++ .../Providers/DoctorServiceProvider.php | 2 + tests/Feature/Doctor/InstallChecksTest.php | 34 ++++++++++ 4 files changed, 103 insertions(+) create mode 100644 src/Doctor/Infrastructure/Checks/PublicRootCheck.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d3221673..b94640c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/cms/wp-admin/`, the address `pollora:install` prints for the admin, answered a 404 under Valet and Herd, which hand every URL that is not a file to the front controller. A request for a `public/` directory that holds an `index.php` is now sent to that file ### Added +- Site Health reports an error when the web server serves `.env`: MAMP, Apache or nginx pointed at the project folder instead of `public/` expose the application key and the database password, and answer the site with a 404. The check requests `/.env` and says to point the document root to `public/` - `pollora:doctor` warns when `APP_URL` has no scheme, and gives the value to write ## [v13.38.0](https://github.com/Pollora/framework/compare/v13.37.1...v13.38.0) - 2026-10-21 diff --git a/src/Doctor/Infrastructure/Checks/PublicRootCheck.php b/src/Doctor/Infrastructure/Checks/PublicRootCheck.php new file mode 100644 index 00000000..06e3a633 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/PublicRootCheck.php @@ -0,0 +1,66 @@ +withoutRedirecting()->get($url.'/.env'); + } catch (Throwable) { + return CheckResult::skipped('The site could not request itself.'); + } + + if ($response->ok() && str_contains($response->body(), 'APP_KEY=')) { + return CheckResult::error( + '.env is served over HTTP: the web server exposes the project folder, not public/.', + [sprintf('GET %s/.env answered 200 with the application settings', $url)], + 'Point the document root to the public/ folder of the project (MAMP: Preferences › Server › Document root), then change APP_KEY and the passwords in .env', + ); + } + + return CheckResult::ok('.env is not served; the document root is public/.'); + } +} diff --git a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php index 39923e55..64143546 100644 --- a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php +++ b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php @@ -17,6 +17,7 @@ use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck; use Pollora\Doctor\Infrastructure\Checks\PatternCacheCheck; use Pollora\Doctor\Infrastructure\Checks\PatternFilesCheck; +use Pollora\Doctor\Infrastructure\Checks\PublicRootCheck; use Pollora\Doctor\Infrastructure\Checks\SymlinkedDirectoryCheck; use Pollora\Doctor\Infrastructure\Checks\TemplatePlaceholdersCheck; use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck; @@ -51,6 +52,7 @@ class DoctorServiceProvider extends ServiceProvider BlockThemeRoutesCheck::class, LegacyBlocksDirectoryCheck::class, BlockRegistrationCheck::class, + PublicRootCheck::class, ]; public function register(): void diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php index c37f3f49..b7b018d8 100644 --- a/tests/Feature/Doctor/InstallChecksTest.php +++ b/tests/Feature/Doctor/InstallChecksTest.php @@ -4,6 +4,7 @@ use Composer\InstalledVersions; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Http; use Pollora\Application\Domain\Contracts\DebugDetectorInterface; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; @@ -15,6 +16,7 @@ use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck; use Pollora\Doctor\Infrastructure\Checks\EnvironmentFileCheck; use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck; +use Pollora\Doctor\Infrastructure\Checks\PublicRootCheck; use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck; use Spatie\StructureDiscoverer\Cache\DiscoverCacheDriver; @@ -179,6 +181,38 @@ function writeLock(string $project, array $patches): void }); }); +describe('Document root', function (): void { + it('is an error when .env is served, the folder exposed being the project', function (): void { + config(['app.url' => 'http://pollora.test']); + Http::fake(['pollora.test/.env' => Http::response("APP_NAME=Pollora\nAPP_KEY=base64:abc\n")]); + + $result = (new PublicRootCheck)->run(RunContext::Http); + + expect($result->status->value)->toBe('error') + ->and($result->fix)->toContain('public/'); + }); + + it('passes when .env is not served, or when a catch-all page answers 200 without it', function (int $status, string $body): void { + config(['app.url' => 'http://pollora.test']); + Http::fake(['pollora.test/.env' => Http::response($body, $status)]); + + expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('ok'); + })->with([[404, 'Not found'], [200, 'home']]); + + it('is skipped when the site cannot request itself', function (): void { + config(['app.url' => 'http://pollora.test']); + Http::fake(fn () => throw new RuntimeException('timeout')); + + expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('skipped'); + }); + + it('is skipped without an APP_URL', function (): void { + config(['app.url' => '']); + + expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('skipped'); + }); +}); + describe('Discovery cache', function (): void { function discoveryCheck(bool $cacheEnabled, array $missing): DiscoveryCacheCheck { From cbe5de4506926889a0dc3dddc8cb17e3a104fa6d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 21 Oct 2026 14:46:53 +0200 Subject: [PATCH 3/3] style: apply Rector --- src/Route/UI/Http/Controllers/FrontendController.php | 2 +- src/WordPress/QueryTrait.php | 2 +- tests/Unit/WordPress/LaravelServingRequestTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Route/UI/Http/Controllers/FrontendController.php b/src/Route/UI/Http/Controllers/FrontendController.php index 1c719ca8..b5af904f 100644 --- a/src/Route/UI/Http/Controllers/FrontendController.php +++ b/src/Route/UI/Http/Controllers/FrontendController.php @@ -54,7 +54,7 @@ public function handle(Request $request): Response|RedirectResponse { // A directory of public/ (the admin at /cms/wp-admin/) has an index.php // a document-root server runs by itself; Valet and Herd do not - if ($redirect = PublicDirectoryIndexRedirect::for($request, public_path())) { + if (($redirect = PublicDirectoryIndexRedirect::for($request, public_path())) instanceof RedirectResponse) { return $redirect; } diff --git a/src/WordPress/QueryTrait.php b/src/WordPress/QueryTrait.php index 9e9aff98..c45db04c 100644 --- a/src/WordPress/QueryTrait.php +++ b/src/WordPress/QueryTrait.php @@ -200,6 +200,6 @@ private function laravelIsServingTheRequest(): bool } return $running === $frontController - || in_array($frontController, array_map('realpath', get_included_files()), true); + || in_array($frontController, array_map(realpath(...), get_included_files()), true); } } diff --git a/tests/Unit/WordPress/LaravelServingRequestTest.php b/tests/Unit/WordPress/LaravelServingRequestTest.php index 015dfd17..509c957c 100644 --- a/tests/Unit/WordPress/LaravelServingRequestTest.php +++ b/tests/Unit/WordPress/LaravelServingRequestTest.php @@ -82,7 +82,7 @@ public function publicPath(string $path = ''): string expect(servingProbe()->serving())->toBeFalse(); }); - it('says yes when a server script such as Valet\'s server.php includes the front controller', function (): void { + it("says yes when a server script such as Valet's server.php includes the front controller", function (): void { $server = $this->dir.'/server.php'; touch($server); $_SERVER['SCRIPT_FILENAME'] = $server;