cloudflare/Cloudflare-WordPress

Public

mirrored fromhttps://github.com/cloudflare/Cloudflare-WordPress

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dependabot/composer/phpunit/phpunit-9.6.33

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/WordPress/Hooks.php

577lines · modecode

1<?php
2
3namespace Cloudflare\APO\WordPress;
4
5use Cloudflare\APO\API\APIInterface;
6use Cloudflare\APO\Integration;
7use Psr\Log\LoggerInterface;
8use WP_Taxonomy;
9
10class Hooks
11{
12 protected $api;
13 protected $config;
14 protected $dataStore;
15 protected $integrationContext;
16 protected $integrationAPI;
17 protected $logger;
18 protected $proxy;
19
20 const CLOUDFLARE_JSON = 'CLOUDFLARE_JSON';
21 const WP_AJAX_ACTION = 'cloudflare_proxy';
22
23 // See https://developers.cloudflare.com/cache/about/default-cache-behavior/
24 const CLOUDFLARE_CACHABLE_EXTENSIONS = [
25 "7z", "csv", "gif", "midi", "png", "tif", "zip", "avi", "doc", "gz",
26 "mkv", "ppt", "tiff", "zst", "avif", "docx", "ico", "mp3", "pptx",
27 "ttf", "apk", "dmg", "iso", "mp4", "ps", "webm", "bin", "ejs", "jar",
28 "ogg", "rar", "webp", "bmp", "eot", "jpg", "otf", "svg", "woff", "bz2",
29 "eps", "jpeg", "pdf", "svgz", "woff2", "class", "exe", "js", "pict",
30 "swf", "xls", "css", "flac", "mid", "pls", "tar", "xlsx"
31 ];
32
33 public function __construct()
34 {
35 $this->config = new Integration\DefaultConfig(file_get_contents(CLOUDFLARE_PLUGIN_DIR . 'config.json', true));
36 $this->logger = new Integration\DefaultLogger($this->config->getValue('debug'));
37 $this->dataStore = new DataStore($this->logger);
38 $this->integrationAPI = new WordPressAPI($this->dataStore);
39 $this->integrationContext = new Integration\DefaultIntegration($this->config, $this->integrationAPI, $this->dataStore, $this->logger);
40 $this->api = new WordPressClientAPI($this->integrationContext);
41 $this->proxy = new Proxy($this->integrationContext);
42 }
43
44 /**
45 * @param \Cloudflare\APO\API\APIInterface $api
46 */
47 public function setAPI(APIInterface $api)
48 {
49 $this->api = $api;
50 }
51
52 public function setConfig(Integration\ConfigInterface $config)
53 {
54 $this->config = $config;
55 }
56
57 public function setDataStore(Integration\DataStoreInterface $dataStore)
58 {
59 $this->dataStore = $dataStore;
60 }
61
62 public function setIntegrationContext(Integration\IntegrationInterface $integrationContext)
63 {
64 $this->integrationContext = $integrationContext;
65 }
66
67 public function setIntegrationAPI(Integration\IntegrationAPIInterface $integrationAPI)
68 {
69 $this->integrationAPI = $integrationAPI;
70 }
71
72 public function setLogger(LoggerInterface $logger)
73 {
74 $this->logger = $logger;
75 }
76
77 public function setProxy(Proxy $proxy)
78 {
79 $this->proxy = $proxy;
80 }
81
82 public function cloudflareConfigPage()
83 {
84 if (function_exists('add_options_page')) {
85 add_options_page(__('Cloudflare Configuration'), __('Cloudflare'), 'manage_options', 'cloudflare', array($this, 'cloudflareIndexPage'));
86 }
87 }
88
89 public function cloudflareIndexPage()
90 {
91 include CLOUDFLARE_PLUGIN_DIR . 'index.php';
92 }
93
94 public function pluginActionLinks($links)
95 {
96 $links[] = '<a href="' . get_admin_url(null, 'options-general.php?page=cloudflare') . '">Settings</a>';
97
98 return $links;
99 }
100
101 public function initProxy()
102 {
103 $this->proxy->run();
104 }
105
106 public function activate()
107 {
108 if (version_compare($GLOBALS['wp_version'], CLOUDFLARE_MIN_WP_VERSION, '<')) {
109 deactivate_plugins(basename(CLOUDFLARE_PLUGIN_DIR));
110 wp_die('<p><strong>Cloudflare</strong> plugin requires WordPress version ' . CLOUDFLARE_MIN_WP_VERSION . ' or greater.</p>', 'Plugin Activation Error', array('response' => 200, 'back_link' => true));
111 }
112
113 return true;
114 }
115
116 public function deactivate()
117 {
118 $this->dataStore->clearDataStore();
119 }
120
121 public function purgeCacheEverything()
122 {
123 if ($this->isPluginSpecificCacheEnabled() || $this->isAutomaticPlatformOptimizationEnabled()) {
124 $wpDomainList = $this->integrationAPI->getDomainList();
125 if (count($wpDomainList) > 0) {
126 $wpDomain = $wpDomainList[0];
127
128 $zoneTag = $this->api->getZoneTag($wpDomain);
129
130 if (isset($zoneTag)) {
131 $isOK = $this->api->zonePurgeCache($zoneTag);
132
133 $isOK = ($isOK) ? 'succeeded' : 'failed';
134 $this->logger->debug("purgeCacheEverything " . $isOK);
135 }
136 }
137 }
138 }
139
140 public function purgeCacheByRelevantURLs($postIds)
141 {
142 if ($this->isPluginSpecificCacheEnabled() || $this->isAutomaticPlatformOptimizationEnabled()) {
143 $wpDomainList = $this->integrationAPI->getDomainList();
144 if (!count($wpDomainList)) {
145 return;
146 }
147 $wpDomain = $wpDomainList[0];
148 $zoneTag = $this->api->getZoneTag($wpDomain);
149 if (!isset($zoneTag)) {
150 return;
151 }
152
153 $postIds = (array) $postIds;
154 $urls = [];
155 foreach ($postIds as $postId) {
156 // Do not purge for autosaves or updates to post revisions.
157 if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
158 continue;
159 }
160
161 $postType = get_post_type_object(get_post_type($postId));
162 if (!is_post_type_viewable($postType)) {
163 continue;
164 }
165
166 $savedPost = get_post($postId);
167 if (!is_a($savedPost, 'WP_Post')) {
168 continue;
169 }
170
171 $relatedUrls = apply_filters('cloudflare_purge_by_url', $this->getPostRelatedLinks($postId), $postId);
172 $urls = array_merge($urls, $relatedUrls);
173 }
174
175 // Don't attempt to purge anything outside of the provided zone.
176 foreach ($urls as $key => $url) {
177 $url_to_test = $url;
178 if (is_array($url) && !!$url['url']) {
179 $url_to_test = $url['url'];
180 }
181
182 if (!Utils::strEndsWith(parse_url($url_to_test, PHP_URL_HOST), $wpDomain)) {
183 unset($urls[$key]);
184 }
185 }
186
187 if (empty($urls)) {
188 return;
189 }
190
191 // Filter by unique urls
192 $urls = array_values(array_filter(array_unique($urls)));
193
194 $activePageRules = $this->api->getPageRules($zoneTag, "active");
195 $hasCacheOverride = $this->pageRuleContains($activePageRules, "cache_level", "cache_everything");
196
197 // Should we not have a 'cache_everything' page rule override, feeds
198 // shouldn't be attempted to be purged as they are not cachable by
199 // default.
200 if (!$hasCacheOverride) {
201 $this->logger->debug("cache everything behaviour found, filtering out feeds URLs");
202 $urls = array_filter($urls, array($this, "pathIsNotForFeeds"));
203 }
204
205 // Fetch the page rules and should we not have any hints of cache
206 // all behaviour or APO, filter out the non-cacheable URLs.
207 if (!$hasCacheOverride && !$this->isAutomaticPlatformOptimizationEnabled()) {
208 $this->logger->debug("cache everything behaviour and APO not found, filtering URLs to only be those that are cacheable by default");
209 $urls = array_filter($urls, array($this, "pathHasCachableFileExtension"));
210 }
211
212 if ($this->zoneSettingAlwaysUseHTTPSEnabled($zoneTag)) {
213 $this->logger->debug("zone level always_use_https is enabled, removing HTTP based URLs");
214 $urls = array_filter($urls, array($this, "urlIsHTTPS"));
215 }
216
217 if (!empty($urls)) {
218 do_action('cloudflare_purged_urls', $urls, $postIds);
219 $chunks = array_chunk($urls, 30);
220
221 foreach ($chunks as $chunk) {
222 $isOK = $this->api->zonePurgeFiles($zoneTag, $chunk);
223
224 $isOK = ($isOK) ? 'succeeded' : 'failed';
225 $this->logger->debug("List of URLs purged are: " . print_r($chunk, true));
226 $this->logger->debug("purgeCacheByRelevantURLs " . $isOK);
227 }
228
229 // Purge cache on mobile if APO Cache By Device Type
230 if ($this->isAutomaticPlatformOptimizationCacheByDeviceTypeEnabled()) {
231 foreach ($chunks as $chunk) {
232 $isOK = $this->api->zonePurgeFiles($zoneTag, array_map(array($this, 'toPurgeCacheOnMobile'), $chunk));
233
234 $isOK = ($isOK) ? 'succeeded' : 'failed';
235 $this->logger->debug("List of URLs purged on mobile are: " . print_r($chunk, true));
236 $this->logger->debug("purgeCacheByRelevantURLs " . $isOK);
237 }
238 }
239 }
240 }
241 }
242
243 protected function toPurgeCacheOnMobile($url)
244 {
245 //Purge cache on mobile
246 $headers = array("CF-Device-Type" => "mobile");
247 $purge_object = array("url" => $url, "headers" => $headers);
248 $json = json_decode(json_encode($purge_object, JSON_FORCE_OBJECT));
249 return $json;
250 }
251
252 public function getPostRelatedLinks($postId)
253 {
254 $listofurls = array();
255 $postType = get_post_type($postId);
256
257 //Purge taxonomies terms and feeds URLs
258 $postTypeTaxonomies = get_object_taxonomies($postType);
259
260 foreach ($postTypeTaxonomies as $taxonomy) {
261 // Only if taxonomy is public
262 $taxonomy_data = get_taxonomy($taxonomy);
263 if ($taxonomy_data instanceof WP_Taxonomy && false === $taxonomy_data->public) {
264 continue;
265 }
266
267 $terms = get_the_terms($postId, $taxonomy);
268
269 if (empty($terms) || is_wp_error($terms)) {
270 continue;
271 }
272
273 foreach ($terms as $term) {
274 $termLink = get_term_link($term);
275 $termFeedLink = get_term_feed_link($term->term_id, $term->taxonomy);
276 if (!is_wp_error($termLink) && !is_wp_error($termFeedLink)) {
277 array_push($listofurls, $termLink);
278 array_push($listofurls, $termFeedLink);
279 }
280 }
281 }
282
283 // Author URL
284 array_push(
285 $listofurls,
286 get_author_posts_url(get_post_field('post_author', $postId)),
287 get_author_feed_link(get_post_field('post_author', $postId))
288 );
289
290 // Archives and their feeds
291 if (get_post_type_archive_link($postType) == true) {
292 array_push(
293 $listofurls,
294 get_post_type_archive_link($postType),
295 get_post_type_archive_feed_link($postType)
296 );
297 }
298
299 // Post URL
300 array_push($listofurls, get_permalink($postId));
301
302 // Also clean URL for trashed post.
303 if (get_post_status($postId) == 'trash') {
304 $trashPost = get_permalink($postId);
305 $trashPost = str_replace('__trashed', '', $trashPost);
306 array_push($listofurls, $trashPost, $trashPost . 'feed/');
307 }
308
309 // Feeds
310 array_push(
311 $listofurls,
312 get_bloginfo_rss('rdf_url'),
313 get_bloginfo_rss('rss_url'),
314 get_bloginfo_rss('rss2_url'),
315 get_bloginfo_rss('atom_url'),
316 get_bloginfo_rss('comments_rss2_url'),
317 get_post_comments_feed_link($postId)
318 );
319
320 // Home Page and (if used) posts page
321 array_push($listofurls, home_url('/'));
322 $pageLink = get_permalink(get_option('page_for_posts'));
323 if (is_string($pageLink) && !empty($pageLink) && get_option('show_on_front') == 'page') {
324 array_push($listofurls, $pageLink);
325 }
326
327 // Refresh pagination
328 $total_posts_count = wp_count_posts()->publish;
329 $posts_per_page = get_option('posts_per_page');
330 // Limit to up to 3 pages
331 $page_number_max = min(3, ceil($total_posts_count / $posts_per_page));
332
333 $this->logger->debug("total_posts_count $total_posts_count");
334 $this->logger->debug("posts_per_page $posts_per_page");
335 $this->logger->debug("page_number_max $page_number_max");
336
337 foreach (range(1, $page_number_max) as $page_number) {
338 array_push($listofurls, home_url(sprintf('/page/%s/', $page_number)));
339 }
340
341 // Attachments
342 if ('attachment' == $postType) {
343 $attachmentUrls = array();
344 foreach (get_intermediate_image_sizes() as $size) {
345 $attachmentSrc = wp_get_attachment_image_src($postId, $size);
346 if (is_array($attachmentSrc) && !empty($attachmentSrc)) {
347 $attachmentUrls[] = $attachmentSrc[0];
348 }
349 }
350 $listofurls = array_merge(
351 $listofurls,
352 $attachmentUrls
353 );
354 }
355
356 // Clean array and get unique values
357 $listofurls = array_values(array_filter(array_unique($listofurls)));
358
359 // Purge https and http URLs
360 if (function_exists('force_ssl_admin') && force_ssl_admin()) {
361 $listofurls = array_merge($listofurls, str_replace('https://', 'http://', $listofurls));
362 } elseif (!is_ssl() && function_exists('force_ssl_content') && force_ssl_content()) {
363 $listofurls = array_merge($listofurls, str_replace('http://', 'https://', $listofurls));
364 }
365
366 return $listofurls;
367 }
368
369 protected function isPluginSpecificCacheEnabled()
370 {
371 $cacheSettingObject = $this->dataStore->getPluginSetting(\Cloudflare\APO\API\Plugin::SETTING_PLUGIN_SPECIFIC_CACHE);
372
373 if (!$cacheSettingObject) {
374 return false;
375 }
376
377 $cacheSettingValue = $cacheSettingObject[\Cloudflare\APO\API\Plugin::SETTING_VALUE_KEY];
378
379 return $cacheSettingValue !== false
380 && $cacheSettingValue !== 'off';
381 }
382
383 protected function isAutomaticPlatformOptimizationEnabled()
384 {
385 $cacheSettingObject = $this->dataStore->getPluginSetting(\Cloudflare\APO\API\Plugin::SETTING_AUTOMATIC_PLATFORM_OPTIMIZATION);
386
387 if (!$cacheSettingObject) {
388 return false;
389 }
390
391 $cacheSettingValue = $cacheSettingObject[\Cloudflare\APO\API\Plugin::SETTING_VALUE_KEY];
392
393 return $cacheSettingValue !== false
394 && $cacheSettingValue !== 'off';
395 }
396
397 protected function isAutomaticPlatformOptimizationCacheByDeviceTypeEnabled()
398 {
399 $cacheSettingObject = $this->dataStore->getPluginSetting(\Cloudflare\APO\API\Plugin::SETTING_AUTOMATIC_PLATFORM_OPTIMIZATION_CACHE_BY_DEVICE_TYPE);
400
401 if (!$cacheSettingObject) {
402 return false;
403 }
404
405 $cacheSettingValue = $cacheSettingObject[\Cloudflare\APO\API\Plugin::SETTING_VALUE_KEY];
406
407 return $cacheSettingValue !== false
408 && $cacheSettingValue !== 'off';
409 }
410
411 public function http2ServerPushInit()
412 {
413 HTTP2ServerPush::init();
414 }
415
416 /*
417 * php://input can only be read once before PHP 5.6, try to grab it ONLY if the request
418 * is coming from the cloudflare proxy. We store it in a global so \Cloudflare\APO\WordPress\Proxy
419 * can act on the request body later on in the script execution.
420 */
421 public function getCloudflareRequestJSON()
422 {
423 if (isset($_GET['action']) && $_GET['action'] === self::WP_AJAX_ACTION) {
424 $GLOBALS[self::CLOUDFLARE_JSON] = file_get_contents('php://input');
425 }
426 }
427
428 public function initAutomaticPlatformOptimization()
429 {
430 // it could be too late to set the headers,
431 // return early without triggering a warning in logs
432 if (headers_sent()) {
433 return;
434 }
435
436 // add header unconditionally so we can detect plugin is activated
437 $cache = apply_filters('cloudflare_use_cache', !is_user_logged_in());
438 if ($cache) {
439 header('cf-edge-cache: cache,platform=wordpress');
440 } else {
441 header('cf-edge-cache: no-cache');
442 }
443 }
444
445 public function purgeCacheOnPostStatusChange($new_status, $old_status, $post)
446 {
447 if ('publish' === $new_status || 'publish' === $old_status) {
448 $this->purgeCacheByRelevantURLs($post->ID);
449 }
450 }
451
452 public function purgeCacheOnCommentStatusChange($new_status, $old_status, $comment)
453 {
454 if (!isset($comment->comment_post_ID) || empty($comment->comment_post_ID)) {
455 return; // nothing to do
456 }
457
458 // in case the comment status changed, and either old or new status is "approved", we need to purge cache for the corresponding post
459 if (($old_status != $new_status) && (($old_status === 'approved') || ($new_status === 'approved'))) {
460 $this->purgeCacheByRelevantURLs($comment->comment_post_ID);
461 return;
462 }
463 }
464
465 public function purgeCacheOnNewComment($comment_id, $comment_status, $comment_data)
466 {
467 if ($comment_status != 1) {
468 return; // if comment is not approved, stop
469 }
470 if (!is_array($comment_data)) {
471 return; // nothing to do
472 }
473 if (!array_key_exists('comment_post_ID', $comment_data)) {
474 return; // nothing to do
475 }
476
477 // all clear, we ne need to purge cache related to this post id
478 $this->purgeCacheByRelevantURLs($comment_data['comment_post_ID']);
479 }
480
481 /**
482 * Accepts a page rule key and value to check if it exists in the page rules
483 * provided.
484 *
485 * @param mixed $pagerules
486 * @param mixed $key
487 * @param mixed $value
488 * @return bool
489 */
490 private function pageRuleContains($pagerules, $key, $value)
491 {
492 if (!is_array($pagerules)) {
493 return false;
494 }
495
496 foreach ($pagerules as $pagerule) {
497 foreach ($pagerule["actions"] as $action) {
498 // always_use_https can only be toggled on for a URL but doesn't
499 // have a value so we merely check the presence of the key
500 // instead.
501 if ($action["id"] == "always_use_https" && $key == "always_use_https") {
502 return true;
503 }
504
505 if (!array_key_exists("value", $action)) {
506 continue;
507 }
508
509 if ($action["id"] == $key && $action["value"] == $value) {
510 return true;
511 }
512 }
513 }
514
515
516 return false;
517 }
518
519 private function zoneSettingAlwaysUseHTTPSEnabled($zoneTag)
520 {
521 $settings = $this->api->getZoneSetting($zoneTag, "always_use_https");
522 return !empty($settings["value"]) && $settings["value"] == "on";
523 }
524
525
526 /**
527 * pathHasCachableFileExtension takes a string of a URL and evaluates if it
528 * has a file extension that Cloudflare caches by default.
529 *
530 * @param mixed $value
531 * @return bool
532 */
533 private function pathHasCachableFileExtension($value)
534 {
535 $parsed_url = parse_url($value, PHP_URL_PATH);
536
537 foreach (self::CLOUDFLARE_CACHABLE_EXTENSIONS as $ext) {
538 if (Utils::strEndsWith($parsed_url, "." . $ext)) {
539 return true;
540 }
541 }
542
543 return false;
544 }
545
546 /**
547 * pathIsNotForFeeds accepts a string URL and checks if the path doesn't matches any
548 * known feed paths such as "/feed", "/feed/", "/feed/rdf/", "/feed/rss/",
549 * "/feed/atom/", "/author/foo/feed", "/comments/feed", "/shop/feed",
550 * "/tag/.../feed/", etc.
551 *
552 * @param mixed $value
553 * @return bool
554 */
555 private function pathIsNotForFeeds($value)
556 {
557 $parsed_url = parse_url($value, PHP_URL_PATH);
558 return (bool) !preg_match('/\/feed(?:\/(?:atom\/?|r(?:df|ss)\/?)?)?$/', $parsed_url);
559 }
560
561 /**
562 * urlIsHTTPS determines if a scheme used for a URL is HTTPS.
563 *
564 * @param mixed $value
565 * @return bool
566 */
567 private function urlIsHTTPS($value)
568 {
569 $parsed_scheme = parse_url($value, PHP_URL_SCHEME);
570
571 if ($parsed_scheme == "https") {
572 return true;
573 }
574
575 return false;
576 }
577}