diff --git a/src/StaticCache.php b/src/StaticCache.php index 8104e46b..640fb387 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -27,19 +27,21 @@ * The values are comma-separated and can be in several formats: * * - Added by the gateway: - * - `{environmentId}` - * - `{environmentId}:{uri}` (URI has a leading and no trailing slash) + * - `{environmentId}` (legacy) + * - `{environmentId}:{uri}` (legacy; non-homepage URI has a leading and no trailing slash) + * - `{environmentId}:uri` + * - `{environmentId}:uri:{uri}` (homepage URI is `/`, otherwise with a leading and no trailing slash) * - Added by the CDN: - * - `cdn:{environmentId}` - * - `cdn:{environmentId}:{objectKey}` (object key has no leading slash) + * - `cdn:{environmentId}` (legacy) + * - `cdn:{environmentId}:{objectKey}` (legacy; object key has no leading slash) + * - `{environmentId}:cdn` + * - `{environmentId}:cdn:{objectKey}` (object key has no leading slash) * - Added by Craft: * - `{environmentShortId}{hashed}` - * - `{environmentId}:overflow` (when the response has too many cache tags) + * - `{environmentId}:overflow` (when the response has too many tags or a selector is too long) */ class StaticCache extends \yii\base\Component { - public const CDN_PREFIX = 'cdn:'; - /** * @event PurgeEvent The event that is triggered before static cache tags are purged. */ @@ -51,7 +53,7 @@ class StaticCache extends \yii\base\Component * @see https://developers.cloudflare.com/workers/cache/configuration/ */ private const MAX_TAG_HEADER_VALUE_LENGTH = 16 * 1024; - private const MAX_TAG_VALUE_LENGTH = 1024; + public const MAX_TAG_VALUE_LENGTH = 1024; private const MAX_TAG_COUNT = 1000; private ?int $cacheDuration = null; private Collection $tags; @@ -225,9 +227,10 @@ public function purgeAll(): void public function purgeOrigin(): void { + $environmentId = Module::getInstance()->getConfig()->environmentId; $tags = $this->beforePurge( null, - Module::getInstance()->getConfig()->environmentId, + "$environmentId:uri", ); $this->tagsToPurge->push(...$tags); } @@ -246,9 +249,10 @@ public function purgeGateway(): void public function purgeCdn(): void { + $environmentId = Module::getInstance()->getConfig()->environmentId; $tags = $this->beforePurge( null, - self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId, + "$environmentId:cdn", ); $this->tagsToPurge->push(...$tags); } @@ -266,7 +270,12 @@ private function purgeElementUri(ElementInterface $element): bool : Path::new($uri)->withLeadingSlash()->withoutTrailingSlash(); $environmentId = Module::getInstance()->getConfig()->environmentId; - $tags = $this->beforePurge($element, "$environmentId:$uri"); + $tag = StaticCacheTag::create("$environmentId:uri:$uri"); + $overflowTag = $this->overflowTag(); + $tags = $this->beforePurge( + $element, + strlen($tag->getValue()) > self::MAX_TAG_VALUE_LENGTH ? $overflowTag : $tag, + ); $this->tagsToPurge = $tags->concat($this->tagsToPurge); return $tags->isNotEmpty(); diff --git a/src/fs/Fs.php b/src/fs/Fs.php index 99e75ea0..eb06ac16 100644 --- a/src/fs/Fs.php +++ b/src/fs/Fs.php @@ -202,12 +202,13 @@ protected function createAdapter(): AwsS3V3Adapter protected function invalidateCdnPath(string $path): bool { try { - $prefix = StaticCache::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId . ':'; - $tag = StaticCacheTag::create($this->createBucketPath($path)->toString()) - ->minify(false) - ->withPrefix($prefix); + $environmentId = Module::getInstance()->getConfig()->environmentId; + $objectKey = $this->createBucketPath($path)->toString(); + $cdnTag = StaticCacheTag::create("$environmentId:cdn:$objectKey"); - Module::getInstance()->getStaticCache()->purgeTags($tag); + Module::getInstance()->getStaticCache()->purgeTags( + strlen($cdnTag->getValue()) > StaticCache::MAX_TAG_VALUE_LENGTH ? "$environmentId:overflow" : $cdnTag, + ); return true; } catch (\Throwable $e) { diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index ce87696d..ac0e6148 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -5,6 +5,7 @@ use Codeception\Test\Unit; use Craft; use craft\cloud\events\PurgeEvent; +use craft\cloud\fs\AssetsFs; use craft\cloud\HeaderEnum; use craft\cloud\Module; use craft\cloud\signing\RequestSigner; @@ -218,6 +219,50 @@ public function testPurgeTagsKeepExistingHeaderTags(): void ); } + public function testPurgeAllUsesOriginAndCdnTags(): void + { + $staticCache = new StaticCache(); + + $staticCache->purgeAll(); + + $this->assertSame( + ['123-environment-id:uri', '123-environment-id:cdn'], + $this->collectionProperty($staticCache, 'tagsToPurge') + ->map(fn(StaticCacheTag $tag) => $tag->getValue()) + ->all(), + ); + } + + public function testAssetCdnPurgeUsesEnvironmentFirstTag(): void + { + $staticCache = new StaticCache(); + Module::getInstance()->set('staticCache', $staticCache); + $fs = new AssetsFs(); + $method = new ReflectionMethod($fs, 'invalidateCdnPath'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke($fs, 'image.jpg')); + $this->assertSame( + '123-environment-id:cdn:123-environment-id/assets/image.jpg', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testOverlongAssetCdnPurgeUsesOverflowTag(): void + { + $staticCache = new StaticCache(); + Module::getInstance()->set('staticCache', $staticCache); + $fs = new AssetsFs(); + $method = new ReflectionMethod($fs, 'invalidateCdnPath'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke($fs, str_repeat('x', 1024))); + $this->assertSame( + '123-environment-id:overflow', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + public function testBeforePurgeEventCanCancelPurge(): void { $staticCache = new StaticCache(); @@ -266,7 +311,7 @@ public function testFailedFetchRequestFallsBackToPurgeHeader(): void } $this->assertSame( - '123-environment-id:/news', + '123-environment-id:uri:/news', Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), ); } @@ -313,7 +358,43 @@ public function testBeforePurgeEventReceivesElement(): void $this->saveElement($staticCache, $element); $this->assertSame($element, $purgeEvent->element); - $this->assertSame('123-environment-id:/news', $purgeEvent->tags[0]->getValue()); + $this->assertSame( + ['123-environment-id:uri:/news'], + array_map(fn(StaticCacheTag $tag) => $tag->getValue(), $purgeEvent->tags), + ); + } + + public function testHomepagePurgeUsesUriTag(): void + { + $staticCache = new StaticCache(); + $element = new Entry(['uri' => Entry::HOMEPAGE_URI]); + + $this->saveElement($staticCache, $element); + + $this->assertSame( + ['123-environment-id:uri:/'], + $this->collectionProperty($staticCache, 'tagsToPurge') + ->map(fn(StaticCacheTag $tag) => $tag->getValue()) + ->all(), + ); + } + + public function testOverlongElementUriPurgeUsesOverflowTag(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => str_repeat('x', 1024)]); + $element->fetchUrl = 'https://example.com/overlong'; + + $this->saveElement($staticCache, $element); + $this->sendPendingPurgeTags($staticCache); + + $payload = json_decode( + (string) $this->gatewayRequest?->getBody(), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $this->assertSame(['123-environment-id:overflow'], $payload['tags']); } public function testCancelledElementPurgeDoesNotQueueFetch(): void @@ -369,7 +450,7 @@ public function testSavedElementPurgeRequestIncludesFetchUrls(): void flags: JSON_THROW_ON_ERROR, ); - $this->assertSame(['123-environment-id:/news'], $payload['tags']); + $this->assertSame(['123-environment-id:uri:/news'], $payload['tags']); $this->assertSame([ 'https://example.com/news', 'https://example.com/fr/nouvelles',