diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 9861271b12c..af631a07ff3 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -167,36 +167,14 @@ protected function getFormUrls($form) { $rules = collect(Arr::get($this->rules, "forms.{$form->handle()}.urls")); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { - return $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule)); - })->flatten()->all(); - - return [ - ...$absoluteUrls, - ...$prefixedRelativeUrls, - ]; + return $this->resolveRuleUrls($rules, Site::all()); } protected function getAssetUrls($asset) { $rules = collect(Arr::get($this->rules, "assets.{$asset->container()->handle()}.urls", [])); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { - return $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule)); - })->flatten()->all(); - - return [ - ...$absoluteUrls, - ...$prefixedRelativeUrls, - ]; + return $this->resolveRuleUrls($rules, Site::all()); } protected function getEntryUrls($entry) @@ -212,17 +190,9 @@ protected function getEntryUrls($entry) ->map->absoluteUrl() ->all(); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($entry->site()->url().'/'.$rule)) - ->all(); - return [ ...$urls, - ...$absoluteUrls, - ...$prefixedRelativeUrls, + ...$this->resolveRuleUrls($rules, [$entry->site()]), ]; } @@ -241,17 +211,9 @@ protected function getTermUrls($term) ->all(); } - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($term->site()->url().'/'.$rule)) - ->all(); - return [ ...$urls ?? [], - ...$absoluteUrls, - ...$prefixedRelativeUrls, + ...$this->resolveRuleUrls($rules, [$term->site()]), ]; } @@ -262,18 +224,7 @@ protected function getNavUrls($nav) $nav->toAugmentedCollection()->all() ); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $nav->sites()->map(function ($site) use ($rules) { - return $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule)); - })->flatten()->all(); - - return [ - ...$absoluteUrls, - ...$prefixedRelativeUrls, - ]; + return $this->resolveRuleUrls($rules, $nav->sites()->map(fn ($site) => Site::get($site))); } protected function getNavTreeUrls($tree) @@ -283,17 +234,7 @@ protected function getNavTreeUrls($tree) $tree->structure()->toAugmentedCollection()->all() ); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule)) - ->all(); - - return [ - ...$absoluteUrls, - ...$prefixedRelativeUrls, - ]; + return $this->resolveRuleUrls($rules, [$tree->site()]); } protected function getGlobalUrls($variables) @@ -303,17 +244,7 @@ protected function getGlobalUrls($variables) $variables->toAugmentedCollection()->all() ); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($variables->site()->url().'/'.$rule)) - ->all(); - - return [ - ...$absoluteUrls, - ...$prefixedRelativeUrls, - ]; + return $this->resolveRuleUrls($rules, [$variables->site()]); } protected function getCollectionUrls($collection) @@ -322,18 +253,9 @@ protected function getCollectionUrls($collection) $urls = $collection->sites()->map(fn ($site) => $collection->absoluteUrl($site))->filter()->all(); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $collection->sites()->map(function ($site) use ($rules) { - return $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule)); - })->flatten()->all(); - return [ ...$urls, - ...$absoluteUrls, - ...$prefixedRelativeUrls, + ...$this->resolveRuleUrls($rules, $collection->sites()->map(fn ($site) => Site::get($site))), ]; } @@ -343,17 +265,9 @@ protected function getCollectionTreeUrls($tree) $urls = $this->getMovedEntryUrls($tree); - $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule))->all(); - - $prefixedRelativeUrls = $rules - ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule)) - ->all(); - return [ ...$urls, - ...$absoluteUrls, - ...$prefixedRelativeUrls, + ...$this->resolveRuleUrls($rules, [$tree->site()]), ]; } @@ -369,6 +283,24 @@ private function getMovedEntryUrls($tree) ->all(); } + private function resolveRuleUrls(IlluminateCollection $rules, iterable $sites): array + { + $absoluteUrls = $rules->filter(fn (string $rule) => URL::isAbsolute($rule)); + + // Prefix with the absolute site URL so the cacher can resolve the domain. A relative + // site URL (e.g. "/de") would otherwise fall back to the cacher's base URL, which + // includes the current site's path and never matches the host-only cached domains. + $prefixedRelativeUrls = collect($sites)->flatMap(fn ($site) => $rules + ->reject(fn (string $rule) => URL::isAbsolute($rule)) + ->map(fn (string $rule) => $site->absoluteUrl().'/'.$rule)); + + // The cacher removes the final character of wildcard rules, so keep the asterisk last. + return $absoluteUrls->concat($prefixedRelativeUrls) + ->map(fn (string $url) => URL::tidy($url, withTrailingSlash: Str::endsWith($url, '*') ? false : null)) + ->values() + ->all(); + } + private function parseInvalidationRules(array $rules, array $context = []): IlluminateCollection { return collect($rules) diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index e56eb36599f..fab720e4053 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -2,9 +2,15 @@ namespace Tests\StaticCaching; +use Illuminate\Contracts\Cache\Repository; +use Illuminate\Http\Request; +use Illuminate\Routing\Events\ResponsePrepared; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Queue; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Console\Commands\StaticWarmJob; use Statamic\Contracts\Assets\Asset; use Statamic\Contracts\Assets\AssetContainer; use Statamic\Contracts\Entries\Collection; @@ -20,6 +26,7 @@ use Statamic\Globals\Variables; use Statamic\Sites\Site as SiteModel; use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\Cachers\ApplicationCacher; use Statamic\StaticCaching\DefaultInvalidator as Invalidator; use Statamic\Structures\CollectionTree; use Statamic\Structures\CollectionTreeDiff; @@ -965,6 +972,124 @@ public function navigation_urls_can_be_invalidated_in_a_multisite() $this->assertNull($invalidator->invalidate($nav)); } + #[Test] + public function navigation_urls_are_made_absolute_in_a_multisite_with_relative_site_urls() + { + $this->setSites([ + 'de' => ['url' => '/de/', 'locale' => 'de_DE'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR'], + ]); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/de/*', + 'http://localhost/fr/*', + ])->once(); + }); + + $nav = tap(Mockery::mock(Nav::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('sites')->andReturn(collect(['de', 'fr'])); + $m->shouldReceive('toAugmentedCollection')->andReturn(collect()); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => [ + 'links' => [ + 'urls' => ['/*'], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($nav)); + } + + #[Test] + public function absolute_navigation_urls_are_tidied() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/de/*', + 'http://localhost/fr', + ])->once(); + }); + + $nav = tap(Mockery::mock(Nav::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + $m->shouldReceive('toAugmentedCollection')->andReturn(collect()); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => [ + 'links' => [ + 'urls' => [ + 'http://localhost//de/*', + 'http://localhost//fr/', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($nav)); + } + + #[Test] + #[DataProvider('wildcardRulesWithTrailingSlashes')] + public function wildcard_rules_work_with_trailing_slash_enforcement($rule, $path, $method) + { + URL::enforceTrailingSlashes(); + config(['statamic.static_caching.background_recache' => true]); + Queue::fake(); + + $cacher = new ApplicationCacher(app(Repository::class), []); + $request = Request::create('http://localhost'.$path); + $unrelatedRequest = Request::create('http://localhost/other/'); + + foreach ([$request, $unrelatedRequest] as $cachedRequest) { + $response = response('cached content'); + $cacher->cachePage($cachedRequest, $response); + event(new ResponsePrepared($cachedRequest, $response)); + $this->assertTrue($cacher->hasCachedPage($cachedRequest)); + } + + $nav = tap(Mockery::mock(Nav::class), function ($m) { + $m->shouldReceive('handle')->andReturn('links'); + $m->shouldReceive('sites')->andReturn(collect(['en'])); + $m->shouldReceive('toAugmentedCollection')->andReturn(collect()); + }); + + $invalidator = new Invalidator($cacher, [ + 'navigation' => ['links' => ['urls' => [$rule]]], + ]); + + $invalidator->{$method}($nav); + + if ($method === 'refresh') { + Queue::assertPushed(StaticWarmJob::class, 1); + Queue::assertPushed(StaticWarmJob::class, fn ($job) => $job->request->getUri()->getPath() === $path); + } else { + $this->assertFalse($cacher->hasCachedPage($request)); + Queue::assertNothingPushed(); + } + + $this->assertTrue($cacher->hasCachedPage($unrelatedRequest)); + } + + public static function wildcardRulesWithTrailingSlashes() + { + foreach ([ + 'absolute subtree' => ['http://localhost//blog/*', '/blog/one/'], + 'relative subtree' => ['/blog/*', '/blog/one/'], + 'absolute prefix' => ['http://localhost/blog*', '/blogroll/'], + 'relative prefix' => ['/blog*', '/blogroll/'], + ] as $name => [$rule, $path]) { + foreach (['invalidate', 'refresh'] as $method) { + yield "$name $method" => [$rule, $path, $method]; + } + } + } + #[Test] public function navigation_urls_can_be_invalidated_by_a_tree() {