diff --git a/README.md b/README.md index b8ad305..83ea173 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ If you are looking additional integrations (Cache Drivers), feel free to contrib * [KeyCDN](https://www.keycdn.com) (CDN/SaaS) * [Fastly](https://www.fastly.com) (CDN/SaaS) * [Cloudflare](https://www.cloudflare.com) (CDN/SaaS) +* [Akamai](https://www.akamai.com) (CDN/SaaS) * Varnish with XKEY support (your own proxy) * Dummy (does nothing) @@ -80,6 +81,17 @@ UPPER_DRIVER=varnish VARNISH_URL= ``` +### Akamai Setup + +``` +UPPER_DRIVER=akamai +AKAMAI_HOST= +AKAMAI_CLIENT_TOKEN= +AKAMAI_CLIENT_SECRET= +AKAMAI_ACCESS_TOKEN= +AKAMAI_MAX_SIZE=2048 +``` + ### Tuning With `Cache-Control` headers you can disabled caching for certain templates: diff --git a/composer.json b/composer.json index 913f2a1..228b260 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "cache", "cloudflare", "fastly", - "keycdn" + "keycdn", + "akamai" ], "support": { "docs": "https://github.com/ostark/upper/blob/master/README.md", @@ -25,7 +26,8 @@ ], "require": { "craftcms/cms": "^3.2.0", - "guzzlehttp/guzzle": "^6.5.5|^7.2.0" + "guzzlehttp/guzzle": "^6.5.5|^7.2.0", + "akamai-open/edgegrid-auth": "^1.0" }, "require-dev": { "vimeo/psalm": "^4.4" diff --git a/src/EventRegistrar.php b/src/EventRegistrar.php index a83dc57..0909ec1 100644 --- a/src/EventRegistrar.php +++ b/src/EventRegistrar.php @@ -1,4 +1,6 @@ -getRequest(); // Don't cache CP, LivePreview, Action, Non-GET requests - if ($request->getIsCpRequest() || + if ( + $request->getIsCpRequest() || $request->getIsLivePreview() || $request->getIsActionRequest() || !$request->getIsGet() @@ -87,7 +89,6 @@ public static function registerFrontendEvents() // Add to collection Plugin::getInstance()->getTagCollection()->addTagsFromElement($event->row); - }); // Add the tags to the response header @@ -118,7 +119,8 @@ public static function registerFrontendEvents() $response->setSharedMaxAge($maxAge); $headers->set(Plugin::INFO_HEADER_NAME, "CACHED: " . date(\DateTime::ISO8601)); - $plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent([ + $plugin->trigger($plugin::EVENT_AFTER_SET_TAG_HEADER, new CacheResponseEvent( + [ 'tags' => $tags, 'maxAge' => $maxAge, 'requestUrl' => \Craft::$app->getRequest()->getUrl(), @@ -126,7 +128,6 @@ public static function registerFrontendEvents() ] )); }); - } @@ -172,7 +173,7 @@ public static function registerFallback() // Insert item \Craft::$app->getDb()->createCommand() ->upsert( - // Table + // Table Plugin::CACHE_TABLE, // Identifier @@ -191,9 +192,7 @@ public static function registerFallback() } catch (\Exception $e) { \Craft::warning("Failed to register fallback.", "upper"); } - }); - } @@ -257,14 +256,13 @@ protected static function handleUpdateEvent(Event $event) Plugin::getInstance()->trigger(Plugin::EVENT_BEFORE_PURGE, $purgeEvent); // Push to queue - \Craft::$app->getQueue()->push(new PurgeCacheJob([ + \Craft::$app->getQueue()->ttr(1200)->push(new PurgeCacheJob( + [ 'tag' => $purgeEvent->tag ] )); Plugin::getInstance()->trigger(Plugin::EVENT_AFTER_PURGE, $purgeEvent); } - } - } diff --git a/src/config.example.php b/src/config.example.php index c1724e2..a4e51f4 100644 --- a/src/config.example.php +++ b/src/config.example.php @@ -1,4 +1,5 @@ getenv('CLOUDFLARE_API_EMAIL'), ], + // Akamai config + 'akamai' => [ + 'tagHeaderName' => 'Edge-Cache-Tag', + 'host' => getenv('AKAMAI_HOST'), + 'clientToken' => getenv('AKAMAI_CLIENT_TOKEN'), + 'clientSecret' => getenv('AKAMAI_CLIENT_SECRET'), + 'accessToken' => getenv('AKAMAI_ACCESS_TOKEN'), + 'maxSize' => getenv('AKAMAI_MAX_SIZE'), + ], + // Dummy driver (default) 'dummy' => [ 'tagHeaderName' => 'X-CacheTag', diff --git a/src/drivers/Akamai.php b/src/drivers/Akamai.php new file mode 100644 index 0000000..6fbcf11 --- /dev/null +++ b/src/drivers/Akamai.php @@ -0,0 +1,150 @@ +useLocalTags) { + return $this->purgeUrlsByTag($tag); + } + + $this->sendRequest('production', 'POST', 'tag', $tag); + $this->sendRequest('staging', 'POST', 'tag', $tag); + + return true; + } + + /** + * Purge cache by urls + * + * @param array $urls + * + * @return bool + */ + public function purgeUrls(array $urls) + { + foreach ($urls as $url) { + if (!$this->sendRequest('production', 'POST', 'url', getenv('DEFAULT_SITE_URL') . $url)) { + return false; + } + if (!$this->sendRequest('staging', 'POST', 'url', getenv('DEFAULT_SITE_URL') . $url)) { + return false; + } + } + + return true; + } + + + /** + * Purge entire cache + * + * @return bool + */ + public function purgeAll() + { + // TODO: Purge all in Akamai + return true; + // return $this->sendRequest('POST', 'purge_all'); + } + + /** + * Send API call + * + * @param string $method HTTP verb + * @param string $type of purge + * @param string $uri + * @param array $headers + * + * @return bool + * @throws \ostark\upper\exceptions\AkamaiApiException + */ + protected function sendRequest(string $environment = 'production', string $method = 'POST', string $type = "url", string $uri = "", array $headers = []) + { + // Akamai Open Edgegrid reads $_ENV which doesn't get populated by Craft, so filling in the blanks + $_ENV['AKAMAI_HOST'] = getenv('AKAMAI_HOST'); + $_ENV['AKAMAI_CLIENT_TOKEN'] = getenv('AKAMAI_CLIENT_TOKEN'); + $_ENV['AKAMAI_CLIENT_SECRET'] = getenv('AKAMAI_CLIENT_SECRET'); + $_ENV['AKAMAI_ACCESS_TOKEN'] = getenv('AKAMAI_ACCESS_TOKEN'); + $_ENV['AKAMAI_MAX_SIZE'] = getenv('AKAMAI_MAX_SIZE'); + + $auth = \Akamai\Open\EdgeGrid\Authentication::createFromEnv(); + + $auth->setHttpMethod('POST'); + $auth->setPath('/ccu/v3/invalidate/' . $type . '/' . $environment); + + $body = json_encode(array( + 'objects' => array($uri) + )); + + $auth->setBody($body); + + $context = array( + 'http' => array( + 'header' => array( + 'Authorization: ' . $auth->createAuthHeader(), + 'Content-Type: application/json', + 'Content-Length: ' . strlen($body), + ), + 'method' => 'POST', + 'content' => $body + ) + ); + + $context = stream_context_create($context); + + try { + json_decode(file_get_contents('https://' . $auth->getHost() . $auth->getPath(), false, $context)); + } catch (BadResponseException $e) { + throw AkamaiApiException::create( + $e->getRequest(), + $e->getResponse() + ); + } + + return true; + } +} diff --git a/src/exceptions/AkamaiApiException.php b/src/exceptions/AkamaiApiException.php new file mode 100644 index 0000000..d49023b --- /dev/null +++ b/src/exceptions/AkamaiApiException.php @@ -0,0 +1,44 @@ +getUri(); + + if (is_null($response)) { + return new static("Akamai no response error, uri: '$uri'"); + } + + // Extract error message from body + $status = $response->getStatusCode(); + $json = json_decode($response->getBody()); + if (json_last_error() !== JSON_ERROR_NONE) { + return new static("Akamai API error ($status) on: '$uri'", $status); + } + + // Error message + if (isset($json->msg)) { + return new static($json->msg . ", uri: '$uri'", $response->getStatusCode()); + } + + // Unknown + return new static("Unknown error, uri: '$uri'"); + } +}