From c3c760fdb811f768183f3fb00939e19f5e59f902 Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 14 Nov 2026 18:24:29 -0700 Subject: [PATCH 1/5] Invalidate static cache under every domain a site was cached under --- src/StaticCaching/Cachers/AbstractCacher.php | 116 ++++++++++++++---- .../Cachers/ApplicationCacher.php | 43 ++++--- src/StaticCaching/Cachers/FileCacher.php | 16 +-- tests/StaticCaching/ApplicationCacherTest.php | 93 ++++++++++++++ tests/StaticCaching/CacherTest.php | 4 + .../FullMeasureStaticCachingTest.php | 32 +++++ .../HalfMeasureStaticCachingTest.php | 22 ++++ 7 files changed, 279 insertions(+), 47 deletions(-) diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index fb9698f41dc..dc68fdec29f 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -155,6 +155,16 @@ public function getGeneratedAt(string $url): ?int return null; } + /** + * Get the domains each site has been cached under, keyed by site handle. + * + * @return \Illuminate\Support\Collection + */ + protected function getSiteDomains() + { + return collect($this->cache->get($this->normalizeKey('site-domains'), [])); + } + /** * Flush all the cached URLs. * @@ -167,6 +177,7 @@ public function flushUrls() }); $this->cache->forget($this->normalizeKey('domains')); + $this->cache->forget($this->normalizeKey('site-domains')); } protected function flushDedicatedStore(): void @@ -195,6 +206,7 @@ public function cacheUrl($key, $url, $domain = null) $this->withUrlsLock(function () use ($key, $url, $domain) { $this->cacheDomain($domain); + $this->cacheSiteDomain($domain, $url); $urls = $this->getUrls($domain)->put($key, $url); @@ -233,6 +245,19 @@ public function forgetUrls($keys, $domain = null) }); } + private function cacheSiteDomain($domain, $url) + { + $site = (Site::findByUrl($domain.$url) ?? Site::default())->handle(); + + $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 +283,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 ($url) use ($domain) { $this->invalidateUrl($url, $domain); }); } @@ -308,28 +331,28 @@ 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); - - return; - } + $this->getInvalidationDomains($url, $domain)->each(function ($domain) use ($url) { + $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); - $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 +379,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 ($url) use ($domain) { $this->refreshUrl($url, $domain); }); } @@ -390,6 +411,55 @@ 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. + * + * @param string $url + * @param string|null $domain + * @param \Illuminate\Support\Collection|null $siteDomains + * @return \Illuminate\Support\Collection + */ + protected function getInvalidationDomains($url, $domain = null, $siteDomains = null) + { + $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. + * + * @param string $wildcard + * @param string|null $domain + * @param \Illuminate\Support\Collection|null $siteDomains + * @return \Illuminate\Support\Collection + */ + protected function getWildcardMatches($wildcard, $domain = null, ?callable $urls = null, $siteDomains = null) + { + $urls ??= fn ($domain) => $this->getUrls($domain); + + return $this->getInvalidationDomains($wildcard, $domain, $siteDomains)->reduce(function ($matches, $domain, $index) use ($urls, $wildcard) { + return $matches->concat(collect($urls($domain)) + ->filter(fn ($url) => Str::startsWith($url, $wildcard)) + ->reject(fn ($url) => $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..ff903c06b5c 100644 --- a/src/StaticCaching/Cachers/ApplicationCacher.php +++ b/src/StaticCaching/Cachers/ApplicationCacher.php @@ -165,14 +165,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 ($domain) use ($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); + }); + }); UrlInvalidated::dispatch($url, $domain); } @@ -199,9 +201,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 ($domain) use (&$indexes) { + return $indexes[$domain] ??= $this->getUrlIndex($domain); + }; + + $invalidate = function ($path, $domain) use ($index, $siteDomains, &$removed, &$events) { + foreach ($this->getInvalidationDomains($path, $domain, $siteDomains) as $target) { + foreach ($index($target)['paths'][$path] ?? [] as $key) { + $removed[$target][$key] = true; + } } $events[] = [$path, $domain]; @@ -212,19 +223,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 ($target) use ($index, &$removed) { + return collect($index($target)['urls'])->reject(fn ($value, $key) => 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..c924369ad24 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 ($domain) use ($site, $url) { + $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); + }); + }); $filePath = $this->getFilePath($domain.$url, $site); diff --git a/tests/StaticCaching/ApplicationCacherTest.php b/tests/StaticCaching/ApplicationCacherTest.php index af7505107df..eb3f72d2695 100644 --- a/tests/StaticCaching/ApplicationCacherTest.php +++ b/tests/StaticCaching/ApplicationCacherTest.php @@ -581,6 +581,88 @@ 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_warms_it_under_every_domain_its_site_was_cached_under() + { + Queue::fake(); + + $this->setSites(['en' => ['name' => 'English', 'url' => '/', 'locale' => 'en_US']]); + $cacher = new ApplicationCacher(app(Repository::class), []); + $this->cacheResponse($cacher, 'https://www.example.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(), 'https://www.example.test/about?'); + }); + } + + #[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 +787,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..e3a9f9ed2d1 100644 --- a/tests/StaticCaching/CacherTest.php +++ b/tests/StaticCaching/CacherTest.php @@ -315,6 +315,10 @@ public function caches_a_url_inside_a_lock() $cache->shouldReceive('forever')->with('static-cache:domains', ['http://example.com'])->once()->andReturnUsing(function () use ($locked) { $this->assertTrue($locked->value, 'Domains were written outside the lock.'); }); + $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, 'Site domains were written outside the lock.'); + }); $cache->shouldReceive('forever')->with($urlsKey, ['one' => '/one'])->once()->andReturnUsing(function () use ($locked) { $this->assertTrue($locked->value, 'URLs were written outside the lock.'); }); 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() { From 105921a9dea2aff189303a6076cc8d536869597a Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 14 Nov 2026 18:31:40 -0700 Subject: [PATCH 2/5] Derive cached domains from site domains --- src/StaticCaching/Cachers/AbstractCacher.php | 38 +++++++++----------- tests/StaticCaching/CacherTest.php | 23 +++++++----- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index dc68fdec29f..bbd71e8e6ac 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -118,23 +118,31 @@ 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(); } /** - * Cache the current domain. + * Cache the current domain under the site the given URL belongs to. * * @return void */ - public function cacheDomain($domain = null) + public function cacheDomain($domain = null, $url = '/') { - $domains = $this->getDomains(); + $domain ??= $this->getBaseUrl(); - if (! $domains->contains($domain = $domain ?? $this->getBaseUrl())) { - $domains->push($domain); + $site = (Site::findByUrl($domain.$url) ?? Site::default())->handle(); + + $siteDomains = $this->getSiteDomains(); + + if (in_array($domain, $domains = $siteDomains->get($site, []))) { + return; } - $this->cache->forever($this->normalizeKey('domains'), $domains->all()); + $this->cache->forever($this->normalizeKey('site-domains'), $siteDomains->put($site, [...$domains, $domain])->all()); } /** @@ -205,8 +213,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, $url); + $this->cacheDomain($domain, $url); $urls = $this->getUrls($domain)->put($key, $url); @@ -245,19 +252,6 @@ public function forgetUrls($keys, $domain = null) }); } - private function cacheSiteDomain($domain, $url) - { - $site = (Site::findByUrl($domain.$url) ?? Site::default())->handle(); - - $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) { diff --git a/tests/StaticCaching/CacherTest.php b/tests/StaticCaching/CacherTest.php index e3a9f9ed2d1..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,14 +321,10 @@ 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) { - $this->assertTrue($locked->value, 'Domains were written outside the lock.'); - }); $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, 'Site domains were written outside the lock.'); + $this->assertTrue($locked->value, 'Domains were written outside the lock.'); }); $cache->shouldReceive('forever')->with($urlsKey, ['one' => '/one'])->once()->andReturnUsing(function () use ($locked) { $this->assertTrue($locked->value, 'URLs were written outside the lock.'); From dc1d4eef09fa1fb12f12a79b966fa31de3786fbe Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 14 Nov 2026 18:59:19 -0700 Subject: [PATCH 3/5] tidy --- src/StaticCaching/Cachers/AbstractCacher.php | 72 ++++++++----------- .../Cachers/ApplicationCacher.php | 15 ++-- src/StaticCaching/Cachers/FileCacher.php | 6 +- 3 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index bbd71e8e6ac..11bd182d6fd 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; @@ -126,23 +127,13 @@ public function getDomains() } /** - * Cache the current domain under the site the given URL belongs to. + * Cache the current domain. * * @return void */ - public function cacheDomain($domain = null, $url = '/') + public function cacheDomain($domain = null) { - $domain ??= $this->getBaseUrl(); - - $site = (Site::findByUrl($domain.$url) ?? Site::default())->handle(); - - $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()); + $this->cacheSiteDomain($domain ?? $this->getBaseUrl(), Site::current()->handle()); } /** @@ -165,10 +156,8 @@ public function getGeneratedAt(string $url): ?int /** * Get the domains each site has been cached under, keyed by site handle. - * - * @return \Illuminate\Support\Collection */ - protected function getSiteDomains() + protected function getSiteDomains(): Collection { return collect($this->cache->get($this->normalizeKey('site-domains'), [])); } @@ -202,8 +191,6 @@ protected function flushDedicatedStore(): void /** * Save a URL to the cache. * - * @param string $key - * @param string $url * @return void */ public function cacheUrl($key, $url, $domain = null) @@ -213,7 +200,7 @@ public function cacheUrl($key, $url, $domain = null) $url = Str::removeLeft($url, $domain); $this->withUrlsLock(function () use ($key, $url, $domain) { - $this->cacheDomain($domain, $url); + $this->cacheSiteDomain($domain, (Site::findByUrl($domain.$url) ?? Site::default())->handle()); $urls = $this->getUrls($domain)->put($key, $url); @@ -252,6 +239,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) { @@ -267,8 +265,6 @@ private function withUrlsLock(callable $callback) /** * Invalidate a wildcard URL. - * - * @param string $wildcard */ protected function invalidateWildcardUrl($wildcard) { @@ -277,7 +273,7 @@ protected function invalidateWildcardUrl($wildcard) [$wildcard, $domain] = $this->getPathAndDomain($wildcard); - $this->getWildcardMatches($wildcard, $domain)->each(function ($url) use ($domain) { + $this->getWildcardMatches($wildcard, $domain)->each(function (string $url) use ($domain) { $this->invalidateUrl($url, $domain); }); } @@ -325,10 +321,10 @@ public function refreshUrls($urls) */ public function refreshUrl($url, $domain = null) { - $this->getInvalidationDomains($url, $domain)->each(function ($domain) use ($url) { - $this->getUrls($domain)->filter(function ($value) use ($url) { + $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($url) { + $this->getUrls($domain)->filter(function (string $value) use ($url): bool { return $value === $url || Str::startsWith($value, $url.'?'); - })->each(function ($url, $key) use ($domain) { + })->each(function (string $url, string $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)) { @@ -363,8 +359,6 @@ protected function hasCachedErrorResponse($key) /** * Refresh a wildcard URL. - * - * @param string $wildcard */ protected function refreshWildcardUrl($wildcard) { @@ -373,7 +367,7 @@ protected function refreshWildcardUrl($wildcard) [$wildcard, $domain] = $this->getPathAndDomain($wildcard); - $this->getWildcardMatches($wildcard, $domain)->each(function ($url) use ($domain) { + $this->getWildcardMatches($wildcard, $domain)->each(function (string $url) use ($domain) { $this->refreshUrl($url, $domain); }); } @@ -412,13 +406,8 @@ public function hasCachedPage(Request $request) * 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. - * - * @param string $url - * @param string|null $domain - * @param \Illuminate\Support\Collection|null $siteDomains - * @return \Illuminate\Support\Collection */ - protected function getInvalidationDomains($url, $domain = null, $siteDomains = null) + protected function getInvalidationDomains(string $url, ?string $domain = null, ?Collection $siteDomains = null): Collection { $domain = $domain ?: $this->getBaseUrl(); @@ -436,20 +425,15 @@ protected function getInvalidationDomains($url, $domain = null, $siteDomains = n * * The domain's own matches are returned as-is, followed by any other * matches the remaining domains have that aren't already included. - * - * @param string $wildcard - * @param string|null $domain - * @param \Illuminate\Support\Collection|null $siteDomains - * @return \Illuminate\Support\Collection */ - protected function getWildcardMatches($wildcard, $domain = null, ?callable $urls = null, $siteDomains = null) + protected function getWildcardMatches(string $wildcard, ?string $domain = null, ?callable $urls = null, ?Collection $siteDomains = null): Collection { - $urls ??= fn ($domain) => $this->getUrls($domain); + $urls ??= fn (string $domain): Collection => $this->getUrls($domain); - return $this->getInvalidationDomains($wildcard, $domain, $siteDomains)->reduce(function ($matches, $domain, $index) use ($urls, $wildcard) { + 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 ($url) => Str::startsWith($url, $wildcard)) - ->reject(fn ($url) => $index > 0 && $matches->contains($url)) + ->filter(fn (string $url): bool => Str::startsWith($url, $wildcard)) + ->reject(fn (string $url): bool => $index > 0 && $matches->contains($url)) ->values()); }, collect()); } diff --git a/src/StaticCaching/Cachers/ApplicationCacher.php b/src/StaticCaching/Cachers/ApplicationCacher.php index ff903c06b5c..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,11 +166,11 @@ public function invalidateUrl($url, $domain = null) [$url, $domain] = $this->getPathAndDomain($url); } - $this->getInvalidationDomains($url, $domain)->each(function ($domain) use ($url) { + $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($url) { $this ->getUrls($domain) - ->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?')) - ->each(function ($value, $key) use ($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); @@ -204,11 +205,11 @@ public function invalidateUrls($urls) // Read once, as every URL and wildcard match looks up the domains to invalidate it under. $siteDomains = $this->getSiteDomains(); - $index = function ($domain) use (&$indexes) { + $index = function (string $domain) use (&$indexes): array { return $indexes[$domain] ??= $this->getUrlIndex($domain); }; - $invalidate = function ($path, $domain) use ($index, $siteDomains, &$removed, &$events) { + $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; @@ -231,8 +232,8 @@ public function invalidateUrls($urls) // Match against the indexes as they stand before this wildcard, like // invalidateWildcardUrl() does, then invalidate each match in turn. - $matches = $this->getWildcardMatches($path, $domain, function ($target) use ($index, &$removed) { - return collect($index($target)['urls'])->reject(fn ($value, $key) => isset($removed[$target][$key])); + $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) { diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index c924369ad24..70332be687b 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -147,11 +147,11 @@ public function invalidateUrl($url, $domain = null) { $site = optional(Site::findByUrl($domain.$url))->handle(); - $this->getInvalidationDomains($url, $domain)->each(function ($domain) use ($site, $url) { + $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($site, $url) { $this ->getUrls($domain) - ->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?')) - ->each(function ($value, $key) use ($site, $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); }); From 11e56bcb5dcbbf8e322ae0c8b42900bfb76b320b Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 14 Nov 2026 19:17:48 -0700 Subject: [PATCH 4/5] Only warm the domain a refreshed URL resolves to --- src/StaticCaching/Cachers/AbstractCacher.php | 17 +++++++------ tests/StaticCaching/ApplicationCacherTest.php | 25 ++++++++++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index 11bd182d6fd..a9934dac646 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -321,14 +321,17 @@ public function refreshUrls($urls) */ public function refreshUrl($url, $domain = null) { - $this->getInvalidationDomains($url, $domain)->each(function (string $domain) use ($url) { - $this->getUrls($domain)->filter(function (string $value) use ($url): bool { + $domain = $domain ?: $this->getBaseUrl(); + + $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 ($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); + })->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); return; } diff --git a/tests/StaticCaching/ApplicationCacherTest.php b/tests/StaticCaching/ApplicationCacherTest.php index eb3f72d2695..e52fce54c55 100644 --- a/tests/StaticCaching/ApplicationCacherTest.php +++ b/tests/StaticCaching/ApplicationCacherTest.php @@ -634,20 +634,39 @@ public function invalidating_a_url_leaves_other_sites_domains_alone() } #[Test] - public function refreshing_a_url_warms_it_under_every_domain_its_site_was_cached_under() + 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), []); - $this->cacheResponse($cacher, 'https://www.example.test/about'); + $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(), 'https://www.example.test/about?'); + return Str::startsWith((string) $job->request->getUri(), 'http://localhost/about?'); }); + $this->assertFalse($cacher->hasCachedPage($spoofed)); } #[Test] From 9f745563e4304684fd89a9e15450e1e07772dbc4 Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 14 Nov 2026 19:17:48 -0700 Subject: [PATCH 5/5] Restore param docblocks --- src/StaticCaching/Cachers/AbstractCacher.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php index a9934dac646..850c8625431 100644 --- a/src/StaticCaching/Cachers/AbstractCacher.php +++ b/src/StaticCaching/Cachers/AbstractCacher.php @@ -191,6 +191,8 @@ protected function flushDedicatedStore(): void /** * Save a URL to the cache. * + * @param string $key + * @param string $url * @return void */ public function cacheUrl($key, $url, $domain = null) @@ -265,6 +267,8 @@ private function withUrlsLock(callable $callback) /** * Invalidate a wildcard URL. + * + * @param string $wildcard */ protected function invalidateWildcardUrl($wildcard) { @@ -362,6 +366,8 @@ protected function hasCachedErrorResponse($key) /** * Refresh a wildcard URL. + * + * @param string $wildcard */ protected function refreshWildcardUrl($wildcard) {