Skip to content

Commit

Permalink
feat: add cloudfront page cache invalidation
Browse files Browse the repository at this point in the history
  • Loading branch information
carlalexander committed Dec 5, 2021
1 parent dd9ad1c commit e8a2b07
Show file tree
Hide file tree
Showing 22 changed files with 1,796 additions and 19 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"require": {
"php": ">=7.2.5",
"ext-curl": "*",
"ext-json": "*"
"ext-json": "*",
"ext-simplexml": "*"
},
"require-dev": {
"dg/bypass-finals": "^1.2",
Expand Down
1 change: 1 addition & 0 deletions grumphp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ grumphp:
- 'src/CloudStorage/CloudStorageStreamWrapper.php'
- 'src/Email/Email.php'
- 'src/ObjectCache/AbstractPersistentObjectCache.php'
- 'src/Subscriber/ContentDeliveryNetworkPageCachingSubscriber.php'
- 'src/Support/Collection.php'
- 'tests'
phpstan:
Expand Down
18 changes: 15 additions & 3 deletions phpmd.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
<exclude name="UnusedLocalVariable" />
</rule>
<rule ref="rulesets/naming.xml">
<exclude name="LongVariable"/>
<exclude name="ShortVariable"/>
<exclude name="LongClassName" />
<exclude name="LongVariable" />
<exclude name="ShortVariable" />
<exclude name="ShortMethodName" />
</rule>

Expand Down Expand Up @@ -68,6 +69,17 @@
<property name="ignorepattern" description="Ignore methods matching this regex" value="(^(add|set|get|is|has|with|test))i"/>
</properties>
</rule>
<rule name="rulesets/naming.xml/LongClassName"
since="2.9"
message="Avoid excessively long class names like {0}. Keep class name length under {1}."
class="PHPMD\Rule\Naming\LongClassName"
externalInfoUrl="https://phpmd.org/rules/naming.html#longclassname">
<priority>3</priority>
<properties>
<property name="maximum" description="The class name length reporting threshold" value="40"/>
<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"/>
</properties>
</rule>
<rule rf="rulesets/naming.xml/LongVariable"
since="0.2"
message="Avoid excessively long variable names like {0}. Keep variable name length under {1}."
Expand All @@ -89,7 +101,7 @@
<property name="exceptions" value="id,x,y" />
</properties>
</rule>
<rule name="ShortMethodName"
<rule name="rulesets/naming.xml/ShortMethodName"
since="0.2"
message="Avoid using short method names like {0}::{1}(). The configured minimum method name length is {2}."
class="PHPMD\Rule\Naming\ShortMethodName"
Expand Down
16 changes: 8 additions & 8 deletions src/CloudProvider/Aws/AbstractClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ protected function getEndpointName(): string
return $this->getService();
}

/**
* Get the hostname for the AWS request.
*/
protected function getHostname(): string
{
return "{$this->getEndpointName()}.{$this->region}.amazonaws.com";
}

/**
* Parse the status code from the given response.
*/
Expand Down Expand Up @@ -241,14 +249,6 @@ private function getDate(): string
return gmdate('Ymd');
}

/**
* Get the hostname for the AWS request.
*/
private function getHostname(): string
{
return "{$this->getEndpointName()}.{$this->region}.amazonaws.com";
}

/**
* The scope of the AWS request.
*/
Expand Down
219 changes: 219 additions & 0 deletions src/CloudProvider/Aws/CloudFrontClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

declare(strict_types=1);

/*
* This file is part of Ymir WordPress plugin.
*
* (c) Carl Alexander <support@ymirapp.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Ymir\Plugin\CloudProvider\Aws;

use Ymir\Plugin\Http\Client;
use Ymir\Plugin\PageCache\ContentDeliveryNetworkPageCacheClientInterface;
use Ymir\Plugin\Support\Collection;

/**
* The client for AWS CloudFront API.
*/
class CloudFrontClient extends AbstractClient implements ContentDeliveryNetworkPageCacheClientInterface
{
/**
* The ID of the CloudFront distribution.
*
* @var string
*/
private $distributionId;

/**
* All the paths that we want to invalidate.
*
* @var array
*/
private $invalidationPaths;

/**
* {@inheritdoc}
*/
public function __construct(Client $client, string $distributionId, string $key, string $secret)
{
parent::__construct($client, $key, 'us-east-1', $secret);

$this->distributionId = $distributionId;
$this->invalidationPaths = [];
}

/**
* {@inheritdoc}
*/
public function clearAll()
{
$this->addPath('/*');
}

/**
* {@inheritdoc}
*/
public function clearUrl(string $url)
{
$path = parse_url($url, PHP_URL_PATH);

if (false === $path) {
throw new \RuntimeException(sprintf('Unable to parse URL: %s', $url));
}

$this->addPath('/'.ltrim((string) $path, '/'));
}

/**
* {@inheritdoc}
*/
public function sendClearRequest()
{
if (empty($this->invalidationPaths)) {
return;
}

$this->createInvalidation($this->invalidationPaths);

$this->invalidationPaths = [];
}

/**
* {@inheritdoc}
*/
protected function getHostname(): string
{
return 'cloudfront.amazonaws.com';
}

/**
* {@inheritdoc}
*/
protected function getService(): string
{
return 'cloudfront';
}

/**
* Add the given path to the list.
*/
private function addPath(string $path)
{
if (in_array($path, ['*', '/*'])) {
$this->invalidationPaths = ['/*'];
}

if (['/*'] === $this->invalidationPaths) {
return;
}

$this->invalidationPaths[] = $path;
}

/**
* Create an invalidation request.
*/
private function createInvalidation($paths)
{
if (is_string($paths)) {
$paths = [$paths];
} elseif (!is_array($paths)) {
throw new \InvalidArgumentException('"paths" argument must be an array or a string');
}

if (count($paths) > 1) {
$paths = $this->filterUniquePaths($paths);
}

$response = $this->request('post', "/2020-05-31/distribution/{$this->distributionId}/invalidation", $this->generateInvalidationPayload($paths));

if (201 !== $this->parseResponseStatusCode($response)) {
throw new \RuntimeException('Invalidation request failed');
}
}

/**
* Filter all paths and only keep unique ones.
*/
private function filterUniquePaths(array $paths): array
{
$paths = (new Collection($paths))->unique();

$filteredPaths = $paths->filter(function (string $path) {
return '*' !== substr($path, -1);
})->all();
$wildcardPaths = $paths->filter(function (string $path) {
return '*' === substr($path, -1);
});

$wildcardPaths = $wildcardPaths->map(function (string $path) use ($wildcardPaths) {
$filteredWildcardPaths = preg_grep(sprintf('/%s/', str_replace('\*', '.*', preg_quote($path, '/'))), $wildcardPaths->all(), PREG_GREP_INVERT);
$filteredWildcardPaths[] = $path;

return $filteredWildcardPaths;
});

$wildcardPaths = new Collection(array_intersect(...$wildcardPaths->all()));

if ($wildcardPaths->count() > 15) {
throw new \RuntimeException('CloudFront only allows for a maximum of 15 wildcard invalidations');
}

$wildcardPaths->each(function (string $path) use (&$filteredPaths) {
$filteredPaths = preg_grep(sprintf('/%s/', str_replace('\*', '.*', preg_quote($path, '/'))), $filteredPaths, PREG_GREP_INVERT);
});

return array_merge($wildcardPaths->all(), $filteredPaths);
}

/**
* Generate a unique caller reference.
*/
private function generateCallerReference(): string
{
$length = 16;
$reference = '';

while (strlen($reference) < $length) {
$size = $length - strlen($reference);

$bytes = random_bytes($size);

$reference .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
}

return $reference.'-'.time();
}

/**
* Generate the XML payload for an invalidation request.
*/
private function generateInvalidationPayload(array $paths): string
{
$xmlDocument = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/"></InvalidationBatch>');

$xmlDocument->addChild('CallerReference', $this->generateCallerReference());

$pathsNode = $xmlDocument->addChild('Paths');
$itemsNode = $pathsNode->addChild('Items');

foreach ($paths as $path) {
$itemsNode->addChild('Path', $path);
}

$pathsNode->addChild('Quantity', (string) count($paths));

$xml = $xmlDocument->asXML();

if (!is_string($xml)) {
throw new \RuntimeException('Unable to generate invalidation XML payload');
}

return $xml;
}
}
1 change: 1 addition & 0 deletions src/Configuration/EventManagementConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public function modify(Container $container)
$container['subscribers'] = $container->service(function (Container $container) {
$subscribers = [
new Subscriber\AssetsSubscriber($container['content_directory'], $container['site_url'], $container['assets_url'], $container['ymir_project_type'], $container['uploads_baseurl']),
new Subscriber\ContentDeliveryNetworkPageCachingSubscriber($container['cloudfront_client'], $container['rest_url'], $container['is_page_caching_disabled']),
new Subscriber\DisallowIndexingSubscriber($container['ymir_using_vanity_domain']),
new Subscriber\ImageEditorSubscriber($container['console_client'], $container['file_manager']),
new Subscriber\PluploadSubscriber($container['plugin_relative_path'], $container['rest_namespace'], $container['assets_url'], $container['plupload_error_messages']),
Expand Down
43 changes: 43 additions & 0 deletions src/Configuration/PageCacheConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/*
* This file is part of Ymir WordPress plugin.
*
* (c) Carl Alexander <support@ymirapp.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Ymir\Plugin\Configuration;

use Ymir\Plugin\CloudProvider\Aws\CloudFrontClient;
use Ymir\Plugin\DependencyInjection\Container;
use Ymir\Plugin\DependencyInjection\ContainerConfigurationInterface;

/**
* Configures the dependency injection container with page cache and services.
*/
class PageCacheConfiguration implements ContainerConfigurationInterface
{
/**
* {@inheritdoc}
*/
public function modify(Container $container)
{
$container['cloudfront_client'] = $container->service(function (Container $container) {
return new CloudFrontClient($container['ymir_http_client'], getenv('YMIR_DISTRIBUTION_ID'), $container['cloud_provider_key'], $container['cloud_provider_secret']);
});
$container['is_page_caching_disabled'] = $container->service(function (Container $container) {
if (false !== getenv('YMIR_DISABLE_PAGE_CACHING')) {
return (bool) getenv('YMIR_DISABLE_PAGE_CACHING');
} elseif (defined('YMIR_DISABLE_PAGE_CACHING')) {
return (bool) YMIR_DISABLE_PAGE_CACHING;
}

return parse_url($container['upload_url'], PHP_URL_HOST) !== parse_url(WP_HOME, PHP_URL_HOST);
});
}
}
3 changes: 3 additions & 0 deletions src/Configuration/WordPressConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ public function modify(Container $container)
'error_uploading' => __('&#8220;%s&#8221; has failed to upload.'),
];
});
$container['rest_url'] = $container->service(function () {
return get_rest_url();
});
$container['site_icon'] = $container->service(function () {
if (!class_exists(\WP_Site_Icon::class)) {
require_once ABSPATH.'wp-admin/includes/class-wp-site-icon.php';
Expand Down
Loading

0 comments on commit e8a2b07

Please sign in to comment.