Skip to content

Commit e8a2b07

Browse files
committed
feat: add cloudfront page cache invalidation
1 parent dd9ad1c commit e8a2b07

22 files changed

+1796
-19
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"require": {
1515
"php": ">=7.2.5",
1616
"ext-curl": "*",
17-
"ext-json": "*"
17+
"ext-json": "*",
18+
"ext-simplexml": "*"
1819
},
1920
"require-dev": {
2021
"dg/bypass-finals": "^1.2",

grumphp.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ grumphp:
3636
- 'src/CloudStorage/CloudStorageStreamWrapper.php'
3737
- 'src/Email/Email.php'
3838
- 'src/ObjectCache/AbstractPersistentObjectCache.php'
39+
- 'src/Subscriber/ContentDeliveryNetworkPageCachingSubscriber.php'
3940
- 'src/Support/Collection.php'
4041
- 'tests'
4142
phpstan:

phpmd.xml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
<exclude name="UnusedLocalVariable" />
2727
</rule>
2828
<rule ref="rulesets/naming.xml">
29-
<exclude name="LongVariable"/>
30-
<exclude name="ShortVariable"/>
29+
<exclude name="LongClassName" />
30+
<exclude name="LongVariable" />
31+
<exclude name="ShortVariable" />
3132
<exclude name="ShortMethodName" />
3233
</rule>
3334

@@ -68,6 +69,17 @@
6869
<property name="ignorepattern" description="Ignore methods matching this regex" value="(^(add|set|get|is|has|with|test))i"/>
6970
</properties>
7071
</rule>
72+
<rule name="rulesets/naming.xml/LongClassName"
73+
since="2.9"
74+
message="Avoid excessively long class names like {0}. Keep class name length under {1}."
75+
class="PHPMD\Rule\Naming\LongClassName"
76+
externalInfoUrl="https://phpmd.org/rules/naming.html#longclassname">
77+
<priority>3</priority>
78+
<properties>
79+
<property name="maximum" description="The class name length reporting threshold" value="40"/>
80+
<property name="subtract-suffixes" description="Comma-separated list of suffixes that will not count in the length of the class name. Only the first matching suffix will be subtracted." value="Interface, Subscriber"/>
81+
</properties>
82+
</rule>
7183
<rule rf="rulesets/naming.xml/LongVariable"
7284
since="0.2"
7385
message="Avoid excessively long variable names like {0}. Keep variable name length under {1}."
@@ -89,7 +101,7 @@
89101
<property name="exceptions" value="id,x,y" />
90102
</properties>
91103
</rule>
92-
<rule name="ShortMethodName"
104+
<rule name="rulesets/naming.xml/ShortMethodName"
93105
since="0.2"
94106
message="Avoid using short method names like {0}::{1}(). The configured minimum method name length is {2}."
95107
class="PHPMD\Rule\Naming\ShortMethodName"

src/CloudProvider/Aws/AbstractClient.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ protected function getEndpointName(): string
111111
return $this->getService();
112112
}
113113

114+
/**
115+
* Get the hostname for the AWS request.
116+
*/
117+
protected function getHostname(): string
118+
{
119+
return "{$this->getEndpointName()}.{$this->region}.amazonaws.com";
120+
}
121+
114122
/**
115123
* Parse the status code from the given response.
116124
*/
@@ -241,14 +249,6 @@ private function getDate(): string
241249
return gmdate('Ymd');
242250
}
243251

244-
/**
245-
* Get the hostname for the AWS request.
246-
*/
247-
private function getHostname(): string
248-
{
249-
return "{$this->getEndpointName()}.{$this->region}.amazonaws.com";
250-
}
251-
252252
/**
253253
* The scope of the AWS request.
254254
*/
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir WordPress plugin.
7+
*
8+
* (c) Carl Alexander <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Plugin\CloudProvider\Aws;
15+
16+
use Ymir\Plugin\Http\Client;
17+
use Ymir\Plugin\PageCache\ContentDeliveryNetworkPageCacheClientInterface;
18+
use Ymir\Plugin\Support\Collection;
19+
20+
/**
21+
* The client for AWS CloudFront API.
22+
*/
23+
class CloudFrontClient extends AbstractClient implements ContentDeliveryNetworkPageCacheClientInterface
24+
{
25+
/**
26+
* The ID of the CloudFront distribution.
27+
*
28+
* @var string
29+
*/
30+
private $distributionId;
31+
32+
/**
33+
* All the paths that we want to invalidate.
34+
*
35+
* @var array
36+
*/
37+
private $invalidationPaths;
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function __construct(Client $client, string $distributionId, string $key, string $secret)
43+
{
44+
parent::__construct($client, $key, 'us-east-1', $secret);
45+
46+
$this->distributionId = $distributionId;
47+
$this->invalidationPaths = [];
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function clearAll()
54+
{
55+
$this->addPath('/*');
56+
}
57+
58+
/**
59+
* {@inheritdoc}
60+
*/
61+
public function clearUrl(string $url)
62+
{
63+
$path = parse_url($url, PHP_URL_PATH);
64+
65+
if (false === $path) {
66+
throw new \RuntimeException(sprintf('Unable to parse URL: %s', $url));
67+
}
68+
69+
$this->addPath('/'.ltrim((string) $path, '/'));
70+
}
71+
72+
/**
73+
* {@inheritdoc}
74+
*/
75+
public function sendClearRequest()
76+
{
77+
if (empty($this->invalidationPaths)) {
78+
return;
79+
}
80+
81+
$this->createInvalidation($this->invalidationPaths);
82+
83+
$this->invalidationPaths = [];
84+
}
85+
86+
/**
87+
* {@inheritdoc}
88+
*/
89+
protected function getHostname(): string
90+
{
91+
return 'cloudfront.amazonaws.com';
92+
}
93+
94+
/**
95+
* {@inheritdoc}
96+
*/
97+
protected function getService(): string
98+
{
99+
return 'cloudfront';
100+
}
101+
102+
/**
103+
* Add the given path to the list.
104+
*/
105+
private function addPath(string $path)
106+
{
107+
if (in_array($path, ['*', '/*'])) {
108+
$this->invalidationPaths = ['/*'];
109+
}
110+
111+
if (['/*'] === $this->invalidationPaths) {
112+
return;
113+
}
114+
115+
$this->invalidationPaths[] = $path;
116+
}
117+
118+
/**
119+
* Create an invalidation request.
120+
*/
121+
private function createInvalidation($paths)
122+
{
123+
if (is_string($paths)) {
124+
$paths = [$paths];
125+
} elseif (!is_array($paths)) {
126+
throw new \InvalidArgumentException('"paths" argument must be an array or a string');
127+
}
128+
129+
if (count($paths) > 1) {
130+
$paths = $this->filterUniquePaths($paths);
131+
}
132+
133+
$response = $this->request('post', "/2020-05-31/distribution/{$this->distributionId}/invalidation", $this->generateInvalidationPayload($paths));
134+
135+
if (201 !== $this->parseResponseStatusCode($response)) {
136+
throw new \RuntimeException('Invalidation request failed');
137+
}
138+
}
139+
140+
/**
141+
* Filter all paths and only keep unique ones.
142+
*/
143+
private function filterUniquePaths(array $paths): array
144+
{
145+
$paths = (new Collection($paths))->unique();
146+
147+
$filteredPaths = $paths->filter(function (string $path) {
148+
return '*' !== substr($path, -1);
149+
})->all();
150+
$wildcardPaths = $paths->filter(function (string $path) {
151+
return '*' === substr($path, -1);
152+
});
153+
154+
$wildcardPaths = $wildcardPaths->map(function (string $path) use ($wildcardPaths) {
155+
$filteredWildcardPaths = preg_grep(sprintf('/%s/', str_replace('\*', '.*', preg_quote($path, '/'))), $wildcardPaths->all(), PREG_GREP_INVERT);
156+
$filteredWildcardPaths[] = $path;
157+
158+
return $filteredWildcardPaths;
159+
});
160+
161+
$wildcardPaths = new Collection(array_intersect(...$wildcardPaths->all()));
162+
163+
if ($wildcardPaths->count() > 15) {
164+
throw new \RuntimeException('CloudFront only allows for a maximum of 15 wildcard invalidations');
165+
}
166+
167+
$wildcardPaths->each(function (string $path) use (&$filteredPaths) {
168+
$filteredPaths = preg_grep(sprintf('/%s/', str_replace('\*', '.*', preg_quote($path, '/'))), $filteredPaths, PREG_GREP_INVERT);
169+
});
170+
171+
return array_merge($wildcardPaths->all(), $filteredPaths);
172+
}
173+
174+
/**
175+
* Generate a unique caller reference.
176+
*/
177+
private function generateCallerReference(): string
178+
{
179+
$length = 16;
180+
$reference = '';
181+
182+
while (strlen($reference) < $length) {
183+
$size = $length - strlen($reference);
184+
185+
$bytes = random_bytes($size);
186+
187+
$reference .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
188+
}
189+
190+
return $reference.'-'.time();
191+
}
192+
193+
/**
194+
* Generate the XML payload for an invalidation request.
195+
*/
196+
private function generateInvalidationPayload(array $paths): string
197+
{
198+
$xmlDocument = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/"></InvalidationBatch>');
199+
200+
$xmlDocument->addChild('CallerReference', $this->generateCallerReference());
201+
202+
$pathsNode = $xmlDocument->addChild('Paths');
203+
$itemsNode = $pathsNode->addChild('Items');
204+
205+
foreach ($paths as $path) {
206+
$itemsNode->addChild('Path', $path);
207+
}
208+
209+
$pathsNode->addChild('Quantity', (string) count($paths));
210+
211+
$xml = $xmlDocument->asXML();
212+
213+
if (!is_string($xml)) {
214+
throw new \RuntimeException('Unable to generate invalidation XML payload');
215+
}
216+
217+
return $xml;
218+
}
219+
}

src/Configuration/EventManagementConfiguration.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function modify(Container $container)
4444
$container['subscribers'] = $container->service(function (Container $container) {
4545
$subscribers = [
4646
new Subscriber\AssetsSubscriber($container['content_directory'], $container['site_url'], $container['assets_url'], $container['ymir_project_type'], $container['uploads_baseurl']),
47+
new Subscriber\ContentDeliveryNetworkPageCachingSubscriber($container['cloudfront_client'], $container['rest_url'], $container['is_page_caching_disabled']),
4748
new Subscriber\DisallowIndexingSubscriber($container['ymir_using_vanity_domain']),
4849
new Subscriber\ImageEditorSubscriber($container['console_client'], $container['file_manager']),
4950
new Subscriber\PluploadSubscriber($container['plugin_relative_path'], $container['rest_namespace'], $container['assets_url'], $container['plupload_error_messages']),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir WordPress plugin.
7+
*
8+
* (c) Carl Alexander <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Plugin\Configuration;
15+
16+
use Ymir\Plugin\CloudProvider\Aws\CloudFrontClient;
17+
use Ymir\Plugin\DependencyInjection\Container;
18+
use Ymir\Plugin\DependencyInjection\ContainerConfigurationInterface;
19+
20+
/**
21+
* Configures the dependency injection container with page cache and services.
22+
*/
23+
class PageCacheConfiguration implements ContainerConfigurationInterface
24+
{
25+
/**
26+
* {@inheritdoc}
27+
*/
28+
public function modify(Container $container)
29+
{
30+
$container['cloudfront_client'] = $container->service(function (Container $container) {
31+
return new CloudFrontClient($container['ymir_http_client'], getenv('YMIR_DISTRIBUTION_ID'), $container['cloud_provider_key'], $container['cloud_provider_secret']);
32+
});
33+
$container['is_page_caching_disabled'] = $container->service(function (Container $container) {
34+
if (false !== getenv('YMIR_DISABLE_PAGE_CACHING')) {
35+
return (bool) getenv('YMIR_DISABLE_PAGE_CACHING');
36+
} elseif (defined('YMIR_DISABLE_PAGE_CACHING')) {
37+
return (bool) YMIR_DISABLE_PAGE_CACHING;
38+
}
39+
40+
return parse_url($container['upload_url'], PHP_URL_HOST) !== parse_url(WP_HOME, PHP_URL_HOST);
41+
});
42+
}
43+
}

src/Configuration/WordPressConfiguration.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public function modify(Container $container)
8686
'error_uploading' => __('&#8220;%s&#8221; has failed to upload.'),
8787
];
8888
});
89+
$container['rest_url'] = $container->service(function () {
90+
return get_rest_url();
91+
});
8992
$container['site_icon'] = $container->service(function () {
9093
if (!class_exists(\WP_Site_Icon::class)) {
9194
require_once ABSPATH.'wp-admin/includes/class-wp-site-icon.php';

0 commit comments

Comments
 (0)