diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index fb9698f41dc..850c8625431 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Statamic\Console\Commands\StaticWarmJob; use Statamic\Facades\Site; @@ -118,7 +119,11 @@ protected function makeHash($url) */ public function getDomains() { - return collect($this->cache->get($this->normalizeKey('domains'), [])); + // Caches written before domains were tracked per site only have the flat list. + return collect($this->cache->get($this->normalizeKey('domains'), [])) + ->merge($this->getSiteDomains()->flatten()) + ->unique() + ->values(); } /** @@ -128,13 +133,7 @@ public function getDomains() */ public function cacheDomain($domain = null) { - $domains = $this->getDomains(); - - if (! $domains->contains($domain = $domain ?? $this->getBaseUrl())) { - $domains->push($domain); - } - - $this->cache->forever($this->normalizeKey('domains'), $domains->all()); + $this->cacheSiteDomain($domain ?? $this->getBaseUrl(), Site::current()->handle()); } /** @@ -155,6 +154,14 @@ public function getGeneratedAt(string $url): ?int return null; } + /** + * Get the domains each site has been cached under, keyed by site handle. + */ + protected function getSiteDomains(): Collection + { + return collect($this->cache->get($this->normalizeKey('site-domains'), [])); + } + /** * Flush all the cached URLs. * @@ -167,6 +174,7 @@ public function flushUrls() }); $this->cache->forget($this->normalizeKey('domains')); + $this->cache->forget($this->normalizeKey('site-domains')); } protected function flushDedicatedStore(): void @@ -194,7 +202,7 @@ public function cacheUrl($key, $url, $domain = null) $url = Str::removeLeft($url, $domain); $this->withUrlsLock(function () use ($key, $url, $domain) { - $this->cacheDomain($domain); + $this->cacheSiteDomain($domain, (Site::findByUrl($domain.$url) ?? Site::default())->handle()); $urls = $this->getUrls($domain)->put($key, $url); @@ -233,6 +241,17 @@ public function forgetUrls($keys, $domain = null) }); } + private function cacheSiteDomain(string $domain, string $site): void + { + $siteDomains = $this->getSiteDomains(); + + if (in_array($domain, $domains = $siteDomains->get($site, []))) { + return; + } + + $this->cache->forever($this->normalizeKey('site-domains'), $siteDomains->put($site, [...$domains, $domain])->all()); + } + private function withUrlsLock(callable $callback) { if (! ($store = $this->cache->getStore()) instanceof LockProvider) { @@ -258,9 +277,7 @@ protected function invalidateWildcardUrl($wildcard) [$wildcard, $domain] = $this->getPathAndDomain($wildcard); - $this->getUrls($domain)->filter(function ($url) use ($wildcard) { - return Str::startsWith($url, $wildcard); - })->each(function ($url) use ($domain) { + $this->getWildcardMatches($wildcard, $domain)->each(function (string $url) use ($domain) { $this->invalidateUrl($url, $domain); }); } @@ -308,28 +325,31 @@ public function refreshUrls($urls) */ public function refreshUrl($url, $domain = null) { - $this->getUrls($domain)->filter(function ($value) use ($url) { - return $value === $url || Str::startsWith($value, $url.'?'); - })->each(function ($url, $key) use ($domain) { - // Warming an error response would just fail with the same error, - // so invalidate it and let the next request cache a fresh copy. - if ($this->hasCachedErrorResponse($key)) { - $this->invalidateUrl($url, $domain); + $domain = $domain ?: $this->getBaseUrl(); - return; - } + $this->getInvalidationDomains($url, $domain)->each(function (string $target) use ($url, $domain) { + $this->getUrls($target)->filter(function (string $value) use ($url): bool { + return $value === $url || Str::startsWith($value, $url.'?'); + })->each(function (string $url, string $key) use ($target, $domain) { + // Other domains were recorded from request hosts, which can be spoofed, so they're never + // requested. Warming an error response would just fail with the same error. In both + // cases, invalidate it instead and let the next request cache a fresh copy. + if ($target !== $domain || $this->hasCachedErrorResponse($key)) { + $this->invalidateUrl($url, $target); - $url = ($domain ?: $this->getBaseUrl()).$url; + return; + } - $url = RecacheToken::addToUrl($url); + $url = RecacheToken::addToUrl($domain.$url); - $request = new GuzzleRequest('GET', $url); + $request = new GuzzleRequest('GET', $url); - $job = config('statamic.static_caching.warm_job') ?? StaticWarmJob::class; + $job = config('statamic.static_caching.warm_job') ?? StaticWarmJob::class; - $job::dispatch($request, []) - ->onConnection(config('statamic.static_caching.warm_queue_connection') ?? config('queue.default')) - ->onQueue(config('statamic.static_caching.warm_queue')); + $job::dispatch($request, []) + ->onConnection(config('statamic.static_caching.warm_queue_connection') ?? config('queue.default')) + ->onQueue(config('statamic.static_caching.warm_queue')); + }); }); } @@ -356,9 +376,7 @@ protected function refreshWildcardUrl($wildcard) [$wildcard, $domain] = $this->getPathAndDomain($wildcard); - $this->getUrls($domain)->filter(function ($url) use ($wildcard) { - return Str::startsWith($url, $wildcard); - })->each(function ($url) use ($domain) { + $this->getWildcardMatches($wildcard, $domain)->each(function (string $url) use ($domain) { $this->refreshUrl($url, $domain); }); } @@ -390,6 +408,45 @@ public function hasCachedPage(Request $request) return $this->getCachedPage($request) !== null; } + /** + * Get the domains a URL should be invalidated under. + * + * Pages are tracked under the domain they were requested from, which can differ + * from the domain the URL resolves to in this process. e.g. a queue worker has + * no request, so a relative site URL resolves against the app url instead. So + * the domains the URL's site has actually been cached under are included too. + */ + protected function getInvalidationDomains(string $url, ?string $domain = null, ?Collection $siteDomains = null): Collection + { + $domain = $domain ?: $this->getBaseUrl(); + + if (is_null($site = Site::findByUrl($domain.$url))) { + return collect([$domain]); + } + + $siteDomains ??= $this->getSiteDomains(); + + return collect([$domain, ...$siteDomains->get($site->handle(), [])])->unique()->values(); + } + + /** + * Get the cached URLs matching a wildcard across its invalidation domains. + * + * The domain's own matches are returned as-is, followed by any other + * matches the remaining domains have that aren't already included. + */ + protected function getWildcardMatches(string $wildcard, ?string $domain = null, ?callable $urls = null, ?Collection $siteDomains = null): Collection + { + $urls ??= fn (string $domain): Collection => $this->getUrls($domain); + + return $this->getInvalidationDomains($wildcard, $domain, $siteDomains)->reduce(function (Collection $matches, string $domain, int $index) use ($urls, $wildcard): Collection { + return $matches->concat(collect($urls($domain)) + ->filter(fn (string $url): bool => Str::startsWith($url, $wildcard)) + ->reject(fn (string $url): bool => $index > 0 && $matches->contains($url)) + ->values()); + }, collect()); + } + protected function getPathAndDomain($url) { $parsed = parse_url($url); diff --git a/src/StaticCaching/Cachers/ApplicationCacher.php b/src/StaticCaching/Cachers/ApplicationCacher.php index 28cd8ed90b1..b709abc91f8 100644 --- a/src/StaticCaching/Cachers/ApplicationCacher.php +++ b/src/StaticCaching/Cachers/ApplicationCacher.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Events\ResponsePrepared; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; use Statamic\Events\UrlInvalidated; use Statamic\StaticCaching\Page; @@ -165,14 +166,16 @@ public function invalidateUrl($url, $domain = null) [$url, $domain] = $this->getPathAndDomain($url); } - $this - ->getUrls($domain) - ->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?')) - ->each(function ($value, $key) use ($domain) { - $this->cache->forget($this->normalizeKey('responses:'.$key)); - $this->cache->forget($this->normalizeKey('generated-at:'.$key)); - $this->forgetUrl($key, $domain); - }); + $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($url) { + $this + ->getUrls($domain) + ->filter(fn (string $value): bool => $value === $url || str_starts_with($value, $url.'?')) + ->each(function (string $value, string $key) use ($domain) { + $this->cache->forget($this->normalizeKey('responses:'.$key)); + $this->cache->forget($this->normalizeKey('generated-at:'.$key)); + $this->forgetUrl($key, $domain); + }); + }); UrlInvalidated::dispatch($url, $domain); } @@ -199,9 +202,18 @@ public function invalidateUrls($urls) $removed = []; $events = []; - $invalidate = function ($path, $domain) use (&$indexes, &$removed, &$events) { - foreach ($indexes[$domain]['paths'][$path] ?? [] as $key) { - $removed[$domain][$key] = true; + // Read once, as every URL and wildcard match looks up the domains to invalidate it under. + $siteDomains = $this->getSiteDomains(); + + $index = function (string $domain) use (&$indexes): array { + return $indexes[$domain] ??= $this->getUrlIndex($domain); + }; + + $invalidate = function (string $path, string $domain) use ($index, $siteDomains, &$removed, &$events): void { + foreach ($this->getInvalidationDomains($path, $domain, $siteDomains) as $target) { + foreach ($index($target)['paths'][$path] ?? [] as $key) { + $removed[$target][$key] = true; + } } $events[] = [$path, $domain]; @@ -212,19 +224,17 @@ public function invalidateUrls($urls) [$path, $domain] = $this->getPathAndDomain($wildcard ? substr($url, 0, -1) : $url); - $indexes[$domain] ??= $this->getUrlIndex($domain); - if (! $wildcard) { $invalidate($path, $domain); continue; } - // Match against the index as it stands before this wildcard, like + // Match against the indexes as they stand before this wildcard, like // invalidateWildcardUrl() does, then invalidate each match in turn. - $matches = collect($indexes[$domain]['urls']) - ->reject(fn ($value, $key) => isset($removed[$domain][$key])) - ->filter(fn ($value) => Str::startsWith($value, $path)); + $matches = $this->getWildcardMatches($path, $domain, function (string $target) use ($index, &$removed): Collection { + return collect($index($target)['urls'])->reject(fn (string $value, string $key): bool => isset($removed[$target][$key])); + }, $siteDomains); foreach ($matches as $match) { $invalidate($match, $domain); diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index 66630c2ebf1..70332be687b 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -147,13 +147,15 @@ public function invalidateUrl($url, $domain = null) { $site = optional(Site::findByUrl($domain.$url))->handle(); - $this - ->getUrls($domain) - ->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?')) - ->each(function ($value, $key) use ($site, $domain) { - $this->writer->delete($this->getFilePath($domain.$value, $site)); - $this->forgetUrl($key, $domain); - }); + $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($site, $url) { + $this + ->getUrls($domain) + ->filter(fn (string $value): bool => $value === $url || str_starts_with($value, $url.'?')) + ->each(function (string $value, string $key) use ($site, $domain) { + $this->writer->delete($this->getFilePath($domain.$value, $site)); + $this->forgetUrl($key, $domain); + }); + }); $filePath = $this->getFilePath($domain.$url, $site); diff --git a/tests/StaticCaching/ApplicationCacherTest.php b/tests/StaticCaching/ApplicationCacherTest.php index af7505107df..e52fce54c55 100644 --- a/tests/StaticCaching/ApplicationCacherTest.php +++ b/tests/StaticCaching/ApplicationCacherTest.php @@ -581,6 +581,107 @@ public static function invalidateMultipleUrlsProvider() ]; } + #[Test] + public function invalidating_a_url_invalidates_it_under_every_domain_its_site_was_cached_under() + { + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $about = $this->cacheResponse($cacher, 'https://www.example.test/about'); + $contact = $this->cacheResponse($cacher, 'https://www.example.test/contact'); + + // Outside of a request, the site resolves to the app url instead of the requested domain. + $cacher->invalidateUrls(['http://localhost/about']); + + $this->assertEquals(['/contact'], $cacher->getUrls('https://www.example.test')->values()->all()); + $this->assertFalse($cacher->hasCachedPage($about)); + $this->assertTrue($cacher->hasCachedPage($contact)); + } + + #[Test] + public function invalidating_a_wildcard_invalidates_it_under_every_domain_its_site_was_cached_under() + { + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $one = $this->cacheResponse($cacher, 'https://www.example.test/blog/one'); + $two = $this->cacheResponse($cacher, 'https://www.example.test/blog/two'); + $about = $this->cacheResponse($cacher, 'https://www.example.test/about'); + + $cacher->invalidateUrls(['http://localhost/blog/*']); + + $this->assertEquals(['/about'], $cacher->getUrls('https://www.example.test')->values()->all()); + $this->assertFalse($cacher->hasCachedPage($one)); + $this->assertFalse($cacher->hasCachedPage($two)); + $this->assertTrue($cacher->hasCachedPage($about)); + } + + #[Test] + public function invalidating_a_url_leaves_other_sites_domains_alone() + { + $this->setSites([ + 'a' => ['name' => 'A', 'url' => 'http://a.test/', 'locale' => 'en_US'], + 'b' => ['name' => 'B', 'url' => 'http://b.test/', 'locale' => 'en_US'], + ]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $a = $this->cacheResponse($cacher, 'http://a.test/about'); + $secureA = $this->cacheResponse($cacher, 'https://a.test/about'); + $b = $this->cacheResponse($cacher, 'http://b.test/about'); + + $cacher->invalidateUrls(['http://a.test/about']); + + $this->assertFalse($cacher->hasCachedPage($a)); + $this->assertFalse($cacher->hasCachedPage($secureA)); + $this->assertTrue($cacher->hasCachedPage($b)); + } + + #[Test] + public function refreshing_a_url_invalidates_it_under_other_domains_its_site_was_cached_under() + { + Queue::fake(); + + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $about = $this->cacheResponse($cacher, 'https://www.example.test/about'); + + $cacher->refreshUrls(['http://localhost/about']); + + Queue::assertNothingPushed(); + $this->assertFalse($cacher->hasCachedPage($about)); + } + + #[Test] + public function refreshing_a_url_only_warms_the_domain_it_resolves_to() + { + Queue::fake(); + + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $this->cacheResponse($cacher, 'http://localhost/about'); + + // Domains are recorded from the request's host, which can be spoofed. + $spoofed = $this->cacheResponse($cacher, 'http://attacker.test/about'); + + $cacher->refreshUrls(['http://localhost/about']); + + Queue::assertPushed(StaticWarmJob::class, 1); + Queue::assertPushed(StaticWarmJob::class, function ($job) { + return Str::startsWith((string) $job->request->getUri(), 'http://localhost/about?'); + }); + $this->assertFalse($cacher->hasCachedPage($spoofed)); + } + + #[Test] + public function flushing_forgets_the_domains_each_site_was_cached_under() + { + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cache = app(Repository::class); + $cacher = new ApplicationCacher($cache, []); + $this->cacheResponse($cacher, 'https://www.example.test/about'); + + $cacher->flush(); + + $this->assertNull($cache->get('static-cache:site-domains')); + } + #[Test] public function it_flushes() { @@ -705,6 +806,17 @@ public static function currentUrlProvider() ], ]; } + + private function cacheResponse(ApplicationCacher $cacher, string $url): Request + { + $request = Request::create($url); + $response = response('content', 200); + + $cacher->cachePage($request, $response); + event(new ResponsePrepared($request, $response)); + + return $request; + } } class CustomStaticWarmJob extends StaticWarmJob diff --git a/tests/StaticCaching/CacherTest.php b/tests/StaticCaching/CacherTest.php index bf437a67cfe..cfbc8911ec1 100644 --- a/tests/StaticCaching/CacherTest.php +++ b/tests/StaticCaching/CacherTest.php @@ -135,6 +135,18 @@ public function gets_domains() $this->assertEquals(['http://example.com'], $cacher->getDomains()->all()); } + #[Test] + public function gets_domains_from_every_site_and_the_legacy_list() + { + $cache = app(Repository::class); + $cacher = $this->cacher(); + + $cache->forever('static-cache:domains', ['http://example.com', 'http://legacy.com']); + $cache->forever('static-cache:site-domains', ['en' => ['http://example.com'], 'fr' => ['http://example.fr']]); + + $this->assertEquals(['http://example.com', 'http://legacy.com', 'http://example.fr'], $cacher->getDomains()->all()); + } + #[Test] public function caches_a_url() { @@ -142,16 +154,15 @@ public function caches_a_url() $cacher = $this->cacher(['base_url' => 'http://example.com']); $this->assertNull($cache->get('static-cache:urls')); - $this->assertNull($cache->get('static-cache:domains')); + $this->assertEquals([], $cacher->getDomains()->all()); $cacher->cacheUrl('one', 'http://example.com/one'); $cacher->cacheUrl('two', '/two'); $cacher->cacheUrl('three', 'http://example.org/three', 'http://example.org'); - $domains = $cache->get('static-cache:domains'); $urls = $cache->get('static-cache:'.md5('http://example.com').'.urls'); - $this->assertEquals(['http://example.com', 'http://example.org'], $domains); + $this->assertEquals(['http://example.com', 'http://example.org'], $cacher->getDomains()->all()); $this->assertEquals(['one' => '/one', 'two' => '/two'], $urls); } @@ -310,9 +321,9 @@ public function caches_a_url_inside_a_lock() [$cache, $locked] = $this->lockingCache(); $cacher = Mockery::mock(AbstractCacher::class, [$cache, ['base_url' => 'http://example.com']])->makePartial(); - $cache->shouldReceive('get')->with('static-cache:domains', [])->andReturn([]); $cache->shouldReceive('get')->with($urlsKey = 'static-cache:'.md5('http://example.com').'.urls', [])->andReturn([]); - $cache->shouldReceive('forever')->with('static-cache:domains', ['http://example.com'])->once()->andReturnUsing(function () use ($locked) { + $cache->shouldReceive('get')->with('static-cache:site-domains', [])->andReturn([]); + $cache->shouldReceive('forever')->with('static-cache:site-domains', ['en' => ['http://example.com']])->once()->andReturnUsing(function () use ($locked) { $this->assertTrue($locked->value, 'Domains were written outside the lock.'); }); $cache->shouldReceive('forever')->with($urlsKey, ['one' => '/one'])->once()->andReturnUsing(function () use ($locked) { diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index 243586f4c14..771a8919696 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -2,7 +2,10 @@ namespace Tests\StaticCaching; +use Facades\Tests\Factories\EntryFactory; +use Illuminate\Http\Request; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Collection; use Statamic\Facades\File; use Statamic\Facades\StaticCache; use Statamic\StaticCaching\Cacher; @@ -36,6 +39,35 @@ public function tearDown(): void parent::tearDown(); } + #[Test] + public function saving_an_entry_outside_of_a_request_invalidates_wildcard_rules_cached_under_the_requested_domain() + { + config(['statamic.static_caching.invalidation.rules' => [ + 'collections' => ['pages' => ['urls' => ['/blog/*']]], + ]]); + + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ title }}'); + + $page = $this->createPage('about', ['with' => ['title' => 'About']]); + $this->createPage('blog', ['with' => ['title' => 'Blog']]); + Collection::make('blog')->routes('blog/{slug}')->template('default')->save(); + EntryFactory::collection('blog')->id('post')->slug('post')->data(['title' => 'Post'])->create(); + + $this->get('https://www.example.test/blog')->assertOk(); + $this->get('https://www.example.test/blog/post')->assertOk(); + $this->assertFileExists($this->dir.'/blog/post_.html'); + + // Queue workers have no request, so URLs are resolved against the app url instead. + $this->app->instance('request', Request::create(config('app.url'))); + + $page->set('title', 'Updated')->save(); + + $this->assertFileDoesNotExist($this->dir.'/blog/post_.html'); + $this->assertFileExists($this->dir.'/blog_.html'); + } + #[Test] public function it_can_keep_parts_dynamic_using_nocache_tags() { diff --git a/tests/StaticCaching/HalfMeasureStaticCachingTest.php b/tests/StaticCaching/HalfMeasureStaticCachingTest.php index 48fa771f88a..20432396d5c 100644 --- a/tests/StaticCaching/HalfMeasureStaticCachingTest.php +++ b/tests/StaticCaching/HalfMeasureStaticCachingTest.php @@ -2,6 +2,7 @@ namespace Tests\StaticCaching; +use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; use Orchestra\Testbench\Attributes\DefineEnvironment; @@ -356,6 +357,27 @@ public function wildcard_refresh_invalidates_cached_404s_instead_of_warming_them $this->assertEquals(['/about'], app(Cacher::class)->getUrls()->values()->all()); } + #[Test] + public function saving_an_entry_outside_of_a_request_invalidates_pages_cached_under_the_requested_domain() + { + \Illuminate\Support\Facades\Cache::flush(); + + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $this->withStandardFakeViews(); + $this->viewShouldReturnRaw('default', '{{ title }}'); + + $page = $this->createPage('about', ['with' => ['title' => 'Original']]); + + $this->get('https://www.example.test/about')->assertSee('Original'); + + // Queue workers have no request, so URLs are resolved against the app url instead. + $this->app->instance('request', Request::create(config('app.url'))); + + $page->set('title', 'Updated')->save(); + + $this->get('https://www.example.test/about')->assertSee('Updated'); + } + #[Test] public function it_can_keep_parts_dynamic_using_nocache_tags_in_loops() {