Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
1 change: 1 addition & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
93 changes: 62 additions & 31 deletions src/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Comment thread
timkelty marked this conversation as resolved.

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';
}
Comment thread
timkelty marked this conversation as resolved.

return Craft::createGuzzleClient([
'base_uri' => sprintf(
'%s/api/%s/',
rtrim($config->gatewayBaseUrl, '/'),
rawurlencode($config->environmentId),
),
RequestOptions::HEADERS => $headers,
]);
}
}
15 changes: 9 additions & 6 deletions src/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
],
]);
}

Expand 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(),
],
]);
}

Expand Down
89 changes: 89 additions & 0 deletions tests/unit/HelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace craft\cloud\tests\unit;

use Codeception\Test\Unit;
use craft\cloud\Helper;
use craft\cloud\HeaderEnum;
use craft\cloud\Module;
use GuzzleHttp\RequestOptions;
use ReflectionMethod;

class HelperTest extends Unit
{
private string|false $craftCloudDevMode = false;
private ?Module $previousModule = null;

protected function _before(): void
{
parent::_before();

$this->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());
}
}
Loading