diff --git a/README.md b/README.md index 22e6adf..2916670 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl | `artifactBaseUrl` | `string\|null` | Directly set a fully-qualified URL to build artifacts. | | `s3ClientOptions` | `array` | Additional settings to pass to the `Aws\S3\S3Client` instance when accessing storage APIs. | | `cdnBaseUrl` | `string` | Used when building URLs to [assets](#filesystem) and other build [artifacts](#artifacturl). | +| `gatewayBaseUrl` | `string` | Used when making gateway API requests. | | `sqsUrl` | `string` | Determines how Craft communicates with the underlying queue provider. | | `projectId` | `string` | UUID of the current project. | | `environmentId` | `string` | UUID of the current [environment](https://craftcms.com/knowledge-base/cloud-environments). | diff --git a/src/Config.php b/src/Config.php index 4ce9ac8..97a3345 100644 --- a/src/Config.php +++ b/src/Config.php @@ -19,6 +19,7 @@ class Config extends BaseConfig { public ?string $artifactBaseUrl = null; public string $cdnBaseUrl = 'https://cdn.craft.cloud'; + public string $gatewayBaseUrl = 'https://gateway.craft.cloud'; public bool $gzipResponse = true; public ?string $sqsUrl = null; public ?string $projectId = null; diff --git a/src/Helper.php b/src/Helper.php index 0047f02..08c758a 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -5,8 +5,8 @@ use Craft; use craft\cloud\fs\BuildArtifactsFs; use craft\helpers\App; -use GuzzleHttp\Psr7\Request; -use HttpSignatures\Context; +use GuzzleHttp\Client; +use GuzzleHttp\RequestOptions; use Illuminate\Support\Collection; use Psr\Http\Message\ResponseInterface; use yii\base\Exception; @@ -23,49 +23,80 @@ public static function artifactUrl(string $path = ''): string return (new BuildArtifactsFs())->createUrl($path); } + /** + * @deprecated Use createGatewayApiClient()->request() instead. + */ public static function makeGatewayApiRequest(iterable $headers): ResponseInterface { - if (!Helper::isCraftCloud()) { - throw new Exception('Gateway API requests are only supported in a Craft Cloud environment.'); - } + $normalizeHeaderValue = fn(mixed $value): array => Collection::make(is_iterable($value) ? $value : [$value]) + ->flatMap(fn(mixed $value) => explode(',', (string) $value)) + ->map(fn(string $value) => trim($value)) + ->filter() + ->unique() + ->values() + ->all(); - $headers = Collection::make($headers) - ->put(HeaderEnum::REQUEST_TYPE->value, 'api'); + $tags = []; + $prefixes = []; - if (Module::getInstance()->getConfig()->getDevMode()) { - $headers->put(HeaderEnum::DEV_MODE->value, '1'); - } + foreach ($headers as $name => $value) { + if (HeaderEnum::CACHE_PURGE_TAG->matches((string) $name)) { + $tags = $normalizeHeaderValue($value); - $url = Craft::$app->getRequest()->getIsConsoleRequest() - ? Module::getInstance()->getConfig()->getPreviewDomainUrl() - : Craft::$app->getRequest()->getHostInfo(); + if (!empty($tags)) { + break; + } - if (!$url) { - throw new Exception('Gateway API requests require a URL.'); + continue; + } + + if (HeaderEnum::CACHE_PURGE_PREFIX->matches((string) $name)) { + $prefixes = $normalizeHeaderValue($value); + } } - $context = Helper::createSigningContext($headers->keys()); - $request = new Request( - 'HEAD', - (string) $url, - $headers->all(), - ); + if (empty($tags) && empty($prefixes)) { + throw new Exception('Gateway API requests require a supported purge header.'); + } - return Craft::createGuzzleClient()->send( - $context->signer()->sign($request) + return self::createGatewayApiClient()->request( + 'POST', + 'cache/purge', + [ + RequestOptions::JSON => !empty($tags) + ? ['tags' => $tags] + : ['prefixes' => $prefixes], + ], ); } - private static function createSigningContext(iterable $headers = []): Context + public static function createGatewayApiClient(): Client { - $headers = Collection::make($headers); + $config = Module::getInstance()->getConfig(); - return new Context([ - 'keys' => [ - 'hmac' => Module::getInstance()->getConfig()->signingKey, - ], - 'algorithm' => 'hmac-sha256', - 'headers' => $headers->all(), + if (!$config->environmentId) { + throw new Exception('Gateway API requests require an environment ID.'); + } + + if (!$config->signingKey) { + throw new Exception('Gateway API requests require a signing key.'); + } + + $headers = [ + 'X-Gateway-Authorization' => "Bearer {$config->signingKey}", + ]; + + if ($config->getDevMode()) { + $headers[HeaderEnum::DEV_MODE->value] = '1'; + } + + return Craft::createGuzzleClient([ + 'base_uri' => sprintf( + '%s/api/%s/', + rtrim($config->gatewayBaseUrl, '/'), + rawurlencode($config->environmentId), + ), + RequestOptions::HEADERS => $headers, ]); } } diff --git a/src/StaticCache.php b/src/StaticCache.php index 67468f9..1b60192 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -13,6 +13,7 @@ use craft\utilities\ClearCaches; use craft\web\UrlManager; use craft\web\View; +use GuzzleHttp\RequestOptions; use GuzzleHttp\Utils as GuzzleUtils; use Illuminate\Support\Collection; use League\Uri\Components\Path; @@ -269,10 +270,11 @@ public function purgeTags(string|StaticCacheTag ...$tags): void return; } - // TODO: make sure we don't go over max header size - Helper::makeGatewayApiRequest([ + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ // Mapping to string because: https://github.com/laravel/framework/pull/54630 - HeaderEnum::CACHE_PURGE_TAG->value => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->implode(','), + RequestOptions::JSON => [ + 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), + ], ]); } @@ -288,9 +290,10 @@ public function purgeUrlPrefixes(string ...$urlPrefixes): void 'urlPrefixes' => $urlPrefixes->all(), ]), __METHOD__); - // TODO: make sure we don't go over max header size - Helper::makeGatewayApiRequest([ - HeaderEnum::CACHE_PURGE_PREFIX->value => $urlPrefixes->implode(','), + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => [ + 'prefixes' => $urlPrefixes->values()->all(), + ], ]); } diff --git a/tests/unit/HelperTest.php b/tests/unit/HelperTest.php new file mode 100644 index 0000000..c8110d1 --- /dev/null +++ b/tests/unit/HelperTest.php @@ -0,0 +1,89 @@ +craftCloudDevMode = getenv('CRAFT_CLOUD_DEV_MODE'); + $this->previousModule = Module::getInstance(); + Module::setInstance(new Module('cloud')); + + $config = Module::getInstance()->getConfig(); + + $config->environmentId = '123-environment-id'; + $config->gatewayBaseUrl = 'https://gateway.craft.cloud'; + $config->signingKey = 'test-signing-key'; + } + + protected function _after(): void + { + if ($this->craftCloudDevMode === false) { + putenv('CRAFT_CLOUD_DEV_MODE'); + } else { + putenv("CRAFT_CLOUD_DEV_MODE={$this->craftCloudDevMode}"); + } + + Module::setInstance($this->previousModule); + + parent::_after(); + } + + public function testGatewayApiClientsUseEnvironmentApiRoute(): void + { + $this->assertSame( + 'https://gateway.craft.cloud/api/123-environment-id/', + (string) Helper::createGatewayApiClient()->getConfig('base_uri'), + ); + } + + public function testGatewayApiClientsUseConfiguredGatewayBaseUrl(): void + { + Module::getInstance()->getConfig()->gatewayBaseUrl = 'https://gateway.craftstaging.cloud/'; + + $this->assertSame( + 'https://gateway.craftstaging.cloud/api/123-environment-id/', + (string) Helper::createGatewayApiClient()->getConfig('base_uri'), + ); + } + + public function testGatewayApiClientsAddAuthenticationHeaders(): void + { + $client = Helper::createGatewayApiClient(); + $headers = $client->getConfig(RequestOptions::HEADERS); + + $this->assertSame('Bearer test-signing-key', $headers['X-Gateway-Authorization'] ?? null); + } + + public function testGatewayApiClientsAddDevModeHeader(): void + { + putenv('CRAFT_CLOUD_DEV_MODE=1'); + + $client = Helper::createGatewayApiClient(); + $headers = $client->getConfig(RequestOptions::HEADERS); + + $this->assertSame('1', $headers[HeaderEnum::DEV_MODE->value] ?? null); + } + + public function testGatewayApiRequestHelperKeepsLegacySignature(): void + { + $method = new ReflectionMethod(Helper::class, 'makeGatewayApiRequest'); + $parameters = $method->getParameters(); + + $this->assertCount(1, $parameters); + $this->assertSame('headers', $parameters[0]->getName()); + } +}