diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c5ab7b..b94640c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.38.0...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.38.1...develop) + +## [v13.38.1](https://github.com/Pollora/framework/compare/v13.38.0...v13.38.1) - 2026-10-21 + +### 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 +- 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/AppUrlCheck.php b/src/Doctor/Infrastructure/Checks/AppUrlCheck.php new file mode 100644 index 00000000..57389d95 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/AppUrlCheck.php @@ -0,0 +1,54 @@ +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 a30f5d3b..64143546 100644 --- a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php +++ b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\ServiceProvider; use Pollora\Doctor\Application\Services\Doctor; +use Pollora\Doctor\Infrastructure\Checks\AppUrlCheck; use Pollora\Doctor\Infrastructure\Checks\AssetBuildCheck; use Pollora\Doctor\Infrastructure\Checks\BlockRegistrationCheck; use Pollora\Doctor\Infrastructure\Checks\BlockThemeRoutesCheck; @@ -16,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; @@ -39,6 +41,7 @@ class DoctorServiceProvider extends ServiceProvider WordPressCorePatchCheck::class, PatchesLockCheck::class, EnvironmentFileCheck::class, + AppUrlCheck::class, DevelopmentCachesCheck::class, DiscoveryCacheCheck::class, AssetBuildCheck::class, @@ -49,6 +52,7 @@ class DoctorServiceProvider extends ServiceProvider BlockThemeRoutesCheck::class, LegacyBlocksDirectoryCheck::class, BlockRegistrationCheck::class, + PublicRootCheck::class, ]; public function register(): void diff --git a/src/Route/UI/Http/Controllers/FrontendController.php b/src/Route/UI/Http/Controllers/FrontendController.php index c27cdbaf..b5af904f 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())) instanceof RedirectResponse) { + 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..c48aab1d --- /dev/null +++ b/src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php @@ -0,0 +1,55 @@ +isMethod('GET') && ! $request->isMethod('HEAD')) { + return null; + } + + $path = rawurldecode($request->getPathInfo()); + + if ($path === '/' || ! str_ends_with($path, '/') || str_contains($path, '..')) { + return null; + } + + // The content folders answer the site's 404 on purpose (see the + // skeleton's .htaccess): redirecting would tell a visitor they exist + if (preg_match('#^/(cms/wp-content|content)(/|$)#', $path) === 1) { + 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/Services/WordPress/Installation/WordPressInstallLoaderService.php b/src/Services/WordPress/Installation/WordPressInstallLoaderService.php index 3d45217a..785c186c 100755 --- a/src/Services/WordPress/Installation/WordPressInstallLoaderService.php +++ b/src/Services/WordPress/Installation/WordPressInstallLoaderService.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Config; use Pollora\Hook\Domain\Contract\Filter; +use Pollora\WordPress\AppUrl; /** * Service for loading WordPress core files and initializing the environment. @@ -153,10 +154,10 @@ private function loadAdminFiles(): self */ private function defineConstants(): self { - $appUrl = Config::get('app.url'); + $appUrl = AppUrl::normalize((string) Config::get('app.url')); if (! defined('COOKIEHASH')) { - define('COOKIEHASH', md5((string) $appUrl)); + define('COOKIEHASH', md5($appUrl)); } $cookiePath = $this->getCookiePath($appUrl); diff --git a/src/WordPress/AppUrl.php b/src/WordPress/AppUrl.php new file mode 100644 index 00000000..13c62085 --- /dev/null +++ b/src/WordPress/AppUrl.php @@ -0,0 +1,42 @@ + 'pollora.test']); + + $result = (new AppUrlCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details)->toContain('Pollora reads it as http://pollora.test') + ->and($result->fix)->toContain('APP_URL=http://pollora.test'); + }); + + it('passes a URL with a scheme, and a port', function (string $url): void { + config(['app.url' => $url]); + + expect((new AppUrlCheck)->run(RunContext::Console)->status->value)->toBe('ok'); + })->with(['https://pollora.test', 'http://localhost:8000']); + + it('is skipped without an APP_URL', function (): void { + config(['app.url' => '']); + + expect((new AppUrlCheck)->run(RunContext::Console)->status->value)->toBe('skipped'); + }); +}); + +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 { diff --git a/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php b/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php new file mode 100644 index 00000000..1d4e14e9 --- /dev/null +++ b/tests/Unit/Route/PublicDirectoryIndexRedirectTest.php @@ -0,0 +1,54 @@ +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); + mkdir($this->public.'/cms/wp-content/plugins', 0777, true); + mkdir($this->public.'/content/plugins', 0777, true); + touch($this->public.'/cms/wp-content/plugins/index.php'); + touch($this->public.'/content/plugins/index.php'); + 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' => '/../', + 'WordPress content folder' => '/cms/wp-content/plugins/', + 'content folder' => '/content/plugins/', + ]); + + it('only redirects reads', function (): void { + expect(PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/', 'POST'), $this->public))->toBeNull(); + }); +}); diff --git a/tests/Unit/WordPress/AppUrlTest.php b/tests/Unit/WordPress/AppUrlTest.php new file mode 100644 index 00000000..34a28137 --- /dev/null +++ b/tests/Unit/WordPress/AppUrlTest.php @@ -0,0 +1,35 @@ +toBe($expected); + })->with([ + 'a host' => ['pollora.test', 'http://pollora.test'], + 'a host and a port' => ['localhost:8888', 'http://localhost:8888'], + 'a host with a path' => ['pollora.test/site', 'http://pollora.test/site'], + 'a protocol-relative URL' => ['//pollora.test', 'http://pollora.test'], + 'spaces around' => [' pollora.test ', 'http://pollora.test'], + ]); + + it('leaves a URL that has a scheme alone', function (string $url): void { + expect(AppUrl::normalize($url))->toBe($url); + })->with(['http://pollora.test', 'https://pollora.test', 'https://pollora.test/', 'HTTPS://pollora.test']); + + it('leaves an empty value alone', function (): void { + expect(AppUrl::normalize(''))->toBe(''); + }); +}); + +describe('host', function (): void { + it('finds the host with and without a scheme', function (string $url): void { + expect(AppUrl::host($url))->toBe('pollora.test'); + })->with(['pollora.test', 'http://pollora.test', 'https://pollora.test:8443/cms', 'pollora.test:8000']); + + it('has no host for an empty value', function (): void { + expect(AppUrl::host(''))->toBeNull(); + }); +}); diff --git a/tests/Unit/WordPress/LaravelServingRequestTest.php b/tests/Unit/WordPress/LaravelServingRequestTest.php index bd58f639..509c957c 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']);