PluginProbe ʕ •ᴥ•ʔ
NitroPack – Performance, Page Speed & Cache Plugin for Core Web Vitals, CDN & Image Optimization / 1.18.6
NitroPack – Performance, Page Speed & Cache Plugin for Core Web Vitals, CDN & Image Optimization v1.18.6
1.19.8 1.19.7 1.19.6 1.19.5 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.11.0 1.12.0 1.13.0 1.14.0 1.15.0 1.15.1 1.15.2 1.15.3 1.16.0 1.16.1 1.16.2 1.16.3 1.16.4 1.16.5 1.16.6 1.16.7 1.16.8 1.17.0 1.17.6 1.17.7 1.17.8 1.17.9 1.18.0 1.18.1 1.18.2 1.18.3 1.18.4 1.18.5 1.18.6 1.18.7 1.18.8 1.18.9 1.19.0 1.19.1 1.19.2 1.19.3 1.19.4 1.3.19 1.3.20 1.4.0 1.4.1 1.5.0 1.5.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.16 1.5.17 1.5.18 1.5.19 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.7.0 1.7.1 1.8.0 1.8.1 1.8.3 1.9.0 1.9.1 1.9.2
nitropack / nitropack-sdk / NitroPack / SDK / NitroPack.php
nitropack / nitropack-sdk / NitroPack / SDK Last commit date
Api 8 months ago Integrations 1 year ago StorageDriver 1 year ago Url 1 year ago Utils 1 year ago data 1 year ago Api.php 1 year ago Backlog.php 1 year ago BacklogReplayTimeoutException.php 1 year ago ChallengeProcessingException.php 1 year ago ChallengeVerificationException.php 1 year ago ConfigFetcherException.php 1 year ago Crypto.php 1 year ago Device.php 1 year ago DeviceType.php 1 year ago ElementRevision.php 1 year ago EmptyConfigException.php 1 year ago ExcludeEntry.php 1 year ago ExcludeOperationType.php 1 year ago FileHandle.php 1 year ago Filesystem.php 1 year ago HealthStatus.php 1 year ago IntegrationUrl.php 1 year ago NitroPack.php 11 months ago NoConfigException.php 1 year ago Pagecache.php 1 year ago PurgeType.php 1 year ago ServiceDownException.php 1 year ago StorageException.php 1 year ago VariationCookieException.php 1 year ago WebhookException.php 1 year ago Website.php 1 year ago
NitroPack.php
1240 lines
1 <?php
2 namespace NitroPack\SDK;
3
4 use \NitroPack\Url\Url;
5 use \NitroPack\SDK\Url\Embedjs;
6
7 class NitroPack {
8 const VERSION = '0.56.1';
9 const PAGECACHE_LOCK_EXPIRATION_TIME = 300; // in seconds
10 private $dataDir;
11 private $cachePath = array('data', 'pagecache');
12 private $configFile = array('data', 'config.json');
13 private $healthStatusFile = array('data', 'service-health');
14 private $timestampFile = array('data', 'time.mark');
15 private $pageCacheLockFile = array('data', 'get_cache.lock');
16 private $statefulCacheRevisionsFile = array('data', 'element-revision.json');
17 private $cachePathSuffix = NULL;
18 private $configTTL; // In seconds
19
20 private $siteId;
21 private $siteSecret;
22 private $userAgent; // Defaults to desktop Chrome
23
24 private $url;
25 private $config;
26 private $device;
27 private $api;
28 private $varnishProxyCacheHeaders = [];
29 private $referer;
30
31 public $backlog;
32 public $elementRevision;
33 public $healthStatus;
34 public $pageCache; // TODO: consider better ways of protecting/providing this outside the class
35 public $useCompression;
36
37 private static $cachePrefixes = array();
38 private static $cookieFilters = array();
39
40 public static function getRemoteAddr() {
41 // IP check order is: CloudFlare, Proxy, Client IP
42 $ipKeys = ["HTTP_X_FORWARDED_FOR", "HTTP_CF_CONNECTING_IP", "REMOTE_ADDR"];
43 foreach ($ipKeys as $key) {
44 if (!empty($_SERVER[$key])) {
45 return $_SERVER[$key];
46 }
47 }
48
49 return NULL;
50 }
51
52 public static function getCookies() {
53 $cookies = array();
54
55 foreach ($_COOKIE as $name=>$value) {
56 if (is_array($value)) {
57 foreach ($value as $k=>$v) {
58 $key = $name . "[$k]";
59 $cookies[$key] = $v;
60 }
61 } else {
62 $cookies[$name] = $value;
63 }
64 }
65
66 foreach (self::$cookieFilters as $cookieFilter) {
67 call_user_func_array($cookieFilter, array(&$cookies));
68 }
69
70 return $cookies;
71 }
72
73 public static function addCookieFilter($callback) {
74 if (is_callable($callback)) {
75 if (!in_array($callback, self::$cookieFilters, true)) {
76 self::$cookieFilters[] = $callback;
77 }
78 } else {
79 throw new \RuntimeException("Non-callable callback passed to " . __FUNCTION__);
80 }
81 }
82
83 public static function getExternalCustomPrefix() {
84 return !empty($_SERVER["HTTP_X_NITRO_CACHE_PREFIX"]) ? $_SERVER["HTTP_X_NITRO_CACHE_PREFIX"] : NULL;
85 }
86
87 public static function addCustomCachePrefix($prefix = "") {
88 if (!in_array($prefix, self::$cachePrefixes)) {
89 self::$cachePrefixes[] = $prefix;
90 }
91 }
92
93 public static function getCustomCachePrefix() {
94 return implode("-", self::$cachePrefixes);
95 }
96
97 public static function wildcardToRegex($str, $delim = "/") {
98 return implode(".*?", array_map(function($input) use ($delim) { return preg_quote($input, $delim); }, explode("*", $str)));
99 }
100
101 private function nitro_parse_str($qStr, &$resArr) {
102 if (strpos($qStr, '?') !== false) {
103 $tmpArr = explode('?', $qStr, 2);
104 parse_str($tmpArr[0], $resArr);
105 $completeValue = end($resArr) . $tmpArr[1];
106 $resArr[key($resArr)] = $completeValue;
107 reset($resArr);
108 } else {
109 parse_str($qStr, $resArr);
110 }
111 }
112
113 public function __construct($siteId, $siteSecret, $userAgent = NULL, $url = NULL, $dataDir = __DIR__, $referer = NULL) {
114 $this->configTTL = 3600;
115 $this->siteId = $siteId;
116 $this->siteSecret = $siteSecret;
117 $this->dataDir = $dataDir;
118 $this->referer = $referer;
119 $this->backlog = new Backlog($dataDir, $this);
120 $this->elementRevision = new ElementRevision($siteId, $this->getStatefulCacheRevisionFile());
121 $this->healthStatus = HealthStatus::HEALTHY;
122 $this->loadHealthStatus();
123
124 if (empty($userAgent)) {
125 $this->userAgent = !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36';
126 } else {
127 $this->userAgent = $userAgent;
128 }
129
130 $this->loadConfig($siteId, $siteSecret);
131 $this->device = new Device($this->userAgent);
132
133 if(empty($url)) {
134 $host = !empty($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : "example.com";
135 $uri = !empty($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : "/";
136 $url = $this->getScheme() . $host . $uri;
137 }
138
139 $queryStr = parse_url($url, PHP_URL_QUERY);
140
141 if ($queryStr) {
142 $this->nitro_parse_str($queryStr, $queryParams);
143
144 if ($queryParams) {
145 if ($this->config->IgnoredParams) {
146 foreach ($this->config->IgnoredParams as $ignorePattern) {
147 $regex = "/^" . self::wildcardToRegex($ignorePattern) . "$/";
148 foreach($queryParams as $paramName => $paramValue) {
149 if (preg_match($regex, $paramName)) {
150 unset($queryParams[$paramName]);
151 }
152 }
153 }
154 }
155
156 ksort($queryParams);
157 $url = str_replace($queryStr, http_build_query($queryParams), $url);
158 }
159 }
160
161 $urlInfo = new Url($url);
162 $this->url = $urlInfo->getNormalized();
163
164 $this->pageCache = new Pagecache($this->url, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->config->PageCache->SupportedCookies, $this->isAJAXRequest());
165 $this->pageCache->setCookiesProvider([$this, "getPagecacheCookies"]);
166
167 if ($this->config->PageCache->Geot->Status) {
168 $this->pageCache->setGeotVariations($this->config->PageCache->Geot->Variations);
169 }
170
171 if ($this->isAJAXRequest() && $this->isAllowedAJAXUrl($this->url) && !empty($_SERVER["HTTP_REFERER"]) && !$this->isAllowedStandaloneAJAXUrl($url)) {
172 $refererInfo = new Url($_SERVER["HTTP_REFERER"]);
173 $this->pageCache->setReferer($refererInfo->getNormalized());
174 }
175
176 if ($this->isServiceRequest()) {
177 $customPrefix = self::getExternalCustomPrefix();
178 if ($customPrefix) {
179 self::addCustomCachePrefix($customPrefix);
180 }
181 } else {
182 if (!!$this->config->LanguageVary->Status) {
183 $lang = $this->config->LanguageVary->DefaultLanguage;
184 $clientLanguagePreference = !empty($_SERVER["HTTP_ACCEPT_LANGUAGE"]) ? $_SERVER["HTTP_ACCEPT_LANGUAGE"] : "";
185 $languagePreferences = $this->parseLanguagePreferences($clientLanguagePreference);
186 $possibilities = [];
187 foreach ($languagePreferences as $pref) {
188 list($langCode, $langVariant, $weight) = $pref;
189
190 if ($langCode === "*") {
191 $langTries = [$this->config->LanguageVary->DefaultLanguage];
192 } else {
193 $langTries = ["$langCode-$langVariant", $langCode];
194 }
195
196 foreach ($langTries as $langTry) {
197 if (in_array($langTry, $this->config->LanguageVary->AvailableLanguages)) {
198 $possibilities[] = [$langTry, $weight];
199 }
200 }
201 }
202
203 if (!empty($possibilities)) {
204 usort($possibilities, function($a, $b) {
205 return $a[1] < $b[1] ? 1 : -1;
206 });
207 $lang = $possibilities[0][0];
208 }
209
210 self::addCustomCachePrefix("lang_$lang");
211 }
212 }
213
214 if (!empty($this->config->URLPathVersion)) {
215 $this->pageCache->setUrlPathVersion($this->config->URLPathVersion);
216 }
217
218
219 $this->api = new Api($this->siteId, $siteSecret);
220 $this->api->setBacklog($this->backlog);
221 $this->api->setNitroPack($this);
222
223 $this->pageCache->setDataDir($this->getCacheDir());
224
225 $this->useCompression = false;
226 }
227
228 private function parseLanguagePreferences($languagePreferences) {
229 $preferenceParts = array_filter(array_map("trim", explode(",", $languagePreferences)));
230 $preferences = [];
231 foreach ($preferenceParts as $lang) {
232 // Parse variants of the form en-US;q=0.8, en-US, en;q=0.8, en, *;q=0.8, *
233 if (preg_match("/^(?<code>[a-z\*]+)(-(?<variant>[A-Z]+))?(;q=(?<weight>[\d\.]+))?$/", $lang, $matches)) {
234 $langCode = $matches["code"];
235 $langVariant = !empty($matches["variant"]) ? $matches["variant"] : "";
236 $weight = !empty($matches["weight"]) ? (float)$matches["weight"] : 1;
237 $preferences[] = [$langCode, $langVariant, $weight];
238 }
239 }
240
241 return $preferences;
242 }
243
244 public function setReferer($referer) {
245 $refererInfo = new Url($referer);
246 $this->referer = $refererInfo->getNormalized();
247 $this->pageCache->setReferer($this->referer);
248 }
249
250 public function getPagecacheCookies() {
251 return $this->supportedCookiesFilter(self::getCookies());
252 }
253
254 public function supportedCookiesFilter($cookies) {
255 $supportedCookies = array();
256 foreach ($cookies as $cookieName=>$cookieValue) {
257 foreach ($this->config->PageCache->SupportedCookies as $cookie) {
258 if (preg_match('/^' . self::wildcardToRegex($cookie) . '$/', $cookieName)) {
259 $supportedCookies[$cookieName] = $cookieValue;
260 }
261 }
262 }
263 return $supportedCookies;
264 }
265
266 public function tagUrl($url, $tag) {
267 if ($this->isAllowedUrl($url)) {
268 return $this->api->tagUrl($url, $tag);
269 } else {
270 return false;
271 }
272 }
273
274 public function setCachePathSuffix($suffix) {
275 $this->cachePathSuffix = $suffix;
276 $this->pageCache->setDataDir($this->getCacheDir());
277 }
278
279 public function enableCompression() {
280 $this->pageCache->enableCompression();
281 }
282
283 public function disableCompression() {
284 $this->pageCache->disableCompression();
285 }
286
287 public function getUrl() {
288 return $this->url;
289 }
290
291 public function getApi() {
292 return $this->api;
293 }
294
295 public function getSiteId() {
296 return $this->siteId;
297 }
298
299 public function getConfig() {
300 return $this->config;
301 }
302
303 public function getCacheDir() {
304 $cachePath = $this->cachePath;
305 array_unshift($cachePath, $this->dataDir);
306 if ($this->cachePathSuffix) {
307 $cachePath[] = $this->cachePathSuffix;
308 }
309 return Filesystem::getOsPath($cachePath);
310 }
311
312 public function getStatefulCacheRevisionFile() {
313 $revisionFile = $this->statefulCacheRevisionsFile;
314 array_unshift($revisionFile, $this->dataDir);
315 return Filesystem::getOsPath($revisionFile);
316 }
317
318 public function getHealthStatus() {
319 return $this->healthStatus;
320 }
321
322 public function getHealthStatusFile() {
323 $healthStatusFile = $this->healthStatusFile;
324 array_unshift($healthStatusFile, $this->dataDir);
325 return Filesystem::getOsPath($healthStatusFile);
326 }
327
328 public function setHealthStatus($status) {
329 $this->healthStatus = $status;
330 Filesystem::filePutContents($this->getHealthStatusFile(), $status);
331 }
332
333 public function loadHealthStatus() {
334 if (defined("NITROPACK_DISABLE_BACKLOG")) return;
335 if (Filesystem::fileExists($this->getHealthStatusFile())) {
336 $this->healthStatus = Filesystem::fileGetContents($this->getHealthStatusFile());
337 } else {
338 $this->healthStatus = HealthStatus::HEALTHY;
339 }
340 }
341
342 public function checkHealthStatus() {
343 try {
344 // TODO: Potentially replace this with a dedicated method in the API
345 $this->fetchConfig(true);
346 $this->setHealthStatus(HealthStatus::HEALTHY);
347 return HealthStatus::HEALTHY;
348 } catch (\Exception $e) {
349 $this->setHealthStatus(HealthStatus::SICK);
350 return HealthStatus::SICK;
351 }
352 }
353
354 public function getTimestampFile() {
355 $timestampFile = $this->timestampFile;
356 array_unshift($timestampFile, $this->dataDir);
357 return Filesystem::getOsPath($timestampFile);
358 }
359
360 public function loadTimeMarks() {
361 if (Filesystem::fileExists($this->getTimestampFile())) {
362 try {
363 $marks = @json_decode(Filesystem::fileGetContents($this->getTimestampFile()), true);
364 if (!$marks) {
365 $marks = [];
366 }
367 } catch (\Exception $e) {
368 $marks = [];
369 }
370 } else {
371 $marks = [];
372 }
373
374 return $marks;
375 }
376
377 public function setTimeMark($name, $time = NULL) {
378 if ($time === NULL) $time = microtime(true);
379 $marks = $this->loadTimeMarks();
380 $marks[$name] = $time;
381 Filesystem::filePutContents($this->getTimestampFile(), json_encode($marks));
382 }
383
384 public function unsetTimeMark($name) {
385 $marks = $this->loadTimeMarks();
386 if (isset($marks[$name])) {
387 unset($marks[$name]);
388 }
389 Filesystem::filePutContents($this->getTimestampFile(), json_encode($marks));
390 }
391
392 public function getTimeMark($name) {
393 $marks = $this->loadTimeMarks();
394 if (!empty($marks[$name])) {
395 return $marks[$name];
396 } else {
397 return NULL;
398 }
399 }
400
401 public function getRemainingCacheTtl() {
402 return $this->pageCache->getRemainingTtl($this->config->PageCache->ExpireTime);
403 }
404
405 public function hasCache($layout = 'default') {
406 if ($this->hasLocalCache()) {
407 return true;
408 } else {
409 return $this->hasRemoteCache($layout);
410 }
411 }
412
413 public function hasLocalCache($checkIfRequestIsAllowed = true) {
414 if ($this->backlog->exists()) return false;
415 if (!$this->isAllowedUrl($this->url) || ($checkIfRequestIsAllowed && !$this->isAllowedRequest())) return false;
416 $cacheRevision = !empty($this->config->RevisionHash) ? $this->config->RevisionHash : NULL;
417 if ($this->getHealthStatus() !== HealthStatus::HEALTHY) {
418 return false;
419 }
420
421 if (!$this->pageCache->getUseInvalidated()) {
422 $ttl = $this->config->PageCache->ExpireTime;
423 } else {
424 $ttl = $this->config->PageCache->StaleExpireTime;
425 }
426
427 return $this->pageCache->hasCache() && !$this->pageCache->hasExpired($ttl, $cacheRevision);
428 }
429
430 public function hasRemoteCache($layout, $checkIfRequestIsAllowed = true) {
431 if ($this->backlog->exists()) return false;
432
433 if (
434 !$this->isAllowedUrl($this->url) ||
435 ($checkIfRequestIsAllowed && !$this->isAllowedRequest()) ||
436 ($this->pageCache->getParent() && !$this->pageCache->getParent()->hasCache())
437 ) return false;
438
439 $isWarmupRequest = !empty($_SERVER["HTTP_X_NITRO_WARMUP"]);
440 $isPageCacheLocked = $this->isPageCacheLocked();
441
442 if ($isPageCacheLocked && !$isWarmupRequest) {
443 return false;
444 }
445
446 $resp = $this->api->getCache($this->url, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->isAJAXRequest(), $layout, $this->referer);
447
448 if ($isPageCacheLocked) {
449 return false;
450 }
451
452 if ($resp->getStatus() == Api\ResponseStatus::OK) {// We have cache response
453
454 // Check for invalidated cache and delete it if such is found
455 $this->pageCache->useInvalidated(true);
456 if ($this->pageCache->hasCache()) {
457 $path = $this->pageCache->getCachefilePath();
458 Filesystem::deleteFile($path);
459 Filesystem::deleteFile($path . ".gz");
460 Filesystem::deleteFile($path . ".stale");
461 Filesystem::deleteFile($path . ".stale.gz");
462 if (Filesystem::isDirEmpty(dirname($path))) {
463 Filesystem::deleteDir(dirname($path));
464 }
465 }
466 $this->pageCache->useInvalidated(false);
467 // End of check
468
469 list($headers, $content) = Filesystem::explodeByHeaders($resp->getBody());
470 $this->pageCache->setContent($content, $headers);
471 return true;
472 } else {
473 // The goal is to serve cache at all times even when it is slightly outdated. This approach should be ok because new cache has been requested and it should be ready soon
474 if ($this->pageCache->hasCache()) {
475 return true;
476 } else {
477 // Check for invalidated cache
478 $this->pageCache->useInvalidated(true);
479 if ($this->hasLocalCache(false)) {
480 return true;
481 } else {
482 $this->pageCache->useInvalidated(false);
483 }
484 }
485
486 return false;
487 }
488 }
489
490 public function hasRemoteCacheMulti($urls, $layout, $checkIfRequestIsAllowed = true) {
491 if ($this->backlog->exists()) return false;
492
493 foreach ($urls as $url) {
494 if (
495 !$this->isAllowedUrl($url) ||
496 ($checkIfRequestIsAllowed && !$this->isAllowedRequest()) ||
497 ($this->pageCache->getParent() && !$this->pageCache->getParent()->hasCache()) || // This check is works correctly despite the fact that it doesn't check the current iteration's url because the check is based on headers and all of the headers for the URLs are the same for batched webhooks
498 $this->isPageCacheLocked()
499 ) return false;
500 }
501
502 $hasCache = true; // hasCache is false if even one of the URLs doesn't have a cache
503 $responses = $this->api->getCacheMulti($urls, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->isAJAXRequest(), $layout, $this->referer);
504
505 $originalURL = $this->url;
506 $originalPageCache = $this->pageCache;
507
508 foreach ($responses as $url => $resp) {
509 // Overwrite the url
510 $urlInfo = new Url($url);
511 $this->url = $urlInfo->getNormalized();
512
513 // Overwrite the page cache
514 $this->pageCache = new Pagecache($this->url, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->config->PageCache->SupportedCookies, $this->isAJAXRequest());
515 $this->pageCache->setCookiesProvider([$this, "getPagecacheCookies"]);
516
517 if ($this->config->PageCache->Geot->Status) {
518 $this->pageCache->setGeotVariations($this->config->PageCache->Geot->Variations);
519 }
520
521 if ($this->isAJAXRequest() && $this->isAllowedAJAXUrl($this->url) && !empty($_SERVER["HTTP_REFERER"]) && !$this->isAllowedStandaloneAJAXUrl($url)) {
522 $refererInfo = new Url($_SERVER["HTTP_REFERER"]);
523 $this->pageCache->setReferer($refererInfo->getNormalized());
524 }
525 if (!empty($this->config->URLPathVersion)) {
526 $this->pageCache->setUrlPathVersion($this->config->URLPathVersion);
527 }
528 $this->pageCache->setDataDir($this->getCacheDir());
529
530 // Process the response
531 if ($resp->getStatus() == Api\ResponseStatus::OK) {// We have cache response
532
533 // Check for invalidated cache and delete it if such is found
534 $this->pageCache->useInvalidated(true);
535 if ($this->pageCache->hasCache()) {
536 $path = $this->pageCache->getCachefilePath();
537 Filesystem::deleteFile($path);
538 Filesystem::deleteFile($path . ".gz");
539 Filesystem::deleteFile($path . ".stale");
540 Filesystem::deleteFile($path . ".stale.gz");
541 if (Filesystem::isDirEmpty(dirname($path))) {
542 Filesystem::deleteDir(dirname($path));
543 }
544 }
545 $this->pageCache->useInvalidated(false);
546 // End of check
547
548 list($headers, $content) = Filesystem::explodeByHeaders($resp->getBody());
549 $this->pageCache->setContent($content, $headers);
550 continue;
551 } else {
552 // The goal is to serve cache at all times even when it is slightly outdated. This approach should be ok because new cache has been requested and it should be ready soon
553 if ($this->pageCache->hasCache()) {
554 continue;
555 } else {
556 // Check for invalidated cache
557 $this->pageCache->useInvalidated(true);
558 if ($this->hasLocalCache(false)) {
559 continue;
560 } else {
561 $this->pageCache->useInvalidated(false);
562 }
563 }
564
565 $hasCache = false;
566 continue;
567 }
568 }
569
570 // Restore the url and page cache
571 $this->url = $originalURL;
572 $this->pageCache = $originalPageCache;
573
574 return $hasCache;
575 }
576
577 public function invalidateCache($url = NULL, $tag = NULL, $reason = NULL) {
578 return $this->purgeCache($url, $tag, PurgeType::INVALIDATE | PurgeType::PAGECACHE_ONLY, $reason);
579 }
580
581 public function clearPageCache($reason = NULL) {
582 return $this->purgeCache(NULL, NULL, PurgeType::PAGECACHE_ONLY, $reason);
583 }
584
585 public function purgeCache($url = NULL, $tag = NULL, $purgeType = PurgeType::COMPLETE, $reason = NULL) {
586 @set_time_limit(0);
587 $this->lockPageCache(); // Set the page cache lock, expires after self::PAGECACHE_LOCK_EXPIRATION_TIME seconds
588
589 try {
590 $invalidate = !!($purgeType & PurgeType::INVALIDATE);
591 $pageCacheOnly = !!($purgeType & PurgeType::PAGECACHE_ONLY);
592 $lightPurge = !!($purgeType & PurgeType::LIGHT_PURGE);
593
594 if ($url || $tag) {
595 $localResult = true;
596 $apiResult = true;
597 if ($url) {
598 if (is_array($url)) {
599 foreach ($url as &$urlLink) {
600 $urlLink = $this->normalizeUrl($urlLink);
601 if ($invalidate) {
602 $localResult &= $this->invalidateLocalUrlCache($urlLink);
603 } else {
604 $localResult &= $this->purgeLocalUrlCache($urlLink);
605 }
606 }
607 } else {
608 $url = $this->normalizeUrl($url);
609 if ($invalidate) {
610 $localResult &= $this->invalidateLocalUrlCache($url);
611 } else {
612 $localResult &= $this->purgeLocalUrlCache($url);
613 }
614 }
615
616 try {
617 $apiResult &= $this->api->purgeCache($url, false, $reason, $lightPurge);
618 } catch (ServiceDownException $e) {
619 $apiResult = false;
620 // TODO: Potentially log this
621 }
622 }
623
624 if ($tag) {
625 $attemptsLeft = 10;
626 $purgedUrls = array();
627 do {
628 $hadError = false;
629
630 try {
631 $purgedUrls = $this->api->purgeCacheByTag($tag, $reason);
632
633 foreach ($purgedUrls as $url) {
634 if ($invalidate) {
635 $localResult &= $this->invalidateLocalUrlCache($url);
636 } else {
637 $localResult &= $this->purgeLocalUrlCache($url);
638 }
639 }
640 } catch (ServiceDownException $e) {
641 $this->purgeLocalCache(true); // TODO: This will leave stale cache files. Think of a way to delete them on systems that do not have a heartbeat (i.e custom integrations).
642 $apiResult = false;
643 // TODO: Log this
644 break;
645 } catch (\Exception $e) {
646 $hadError = true;
647 $attemptsLeft--;
648 sleep(3);
649 }
650 } while ($attemptsLeft > 0 && count($purgedUrls) > 0);
651 }
652 } else {
653 if ($invalidate) {
654 $localResult = $this->invalidateLocalCache();
655 $apiResult = $this->api->purgeCache(NULL, $pageCacheOnly, $reason, $lightPurge); // delete only page cache
656 } else {
657 $staleCacheDir = $this->purgeLocalCache(true);
658
659 // Call the cache purge method
660 $apiResult = $this->api->purgeCache(NULL, $pageCacheOnly, $reason, $lightPurge);
661
662 // Finally, delete the files of the stale directory
663 Filesystem::deleteDir($staleCacheDir);
664
665 $localResult = true; // We do not care if $staleCacheDir was not deleted successfully
666 }
667
668 $this->elementRevision->refresh();
669 }
670
671 $this->unlockPageCache(); // Purge cache is done, we can now unlock
672 } catch (\Exception $e) {
673 $this->unlockPageCache(); // Purge cache had an error, so just unlock
674
675 throw $e;
676 }
677
678 return $apiResult && $localResult;
679 }
680
681 public function purgeLocalCache($quick = false) {
682 $staleCacheDir = $this->getCacheDir() . '.stale.' . md5(microtime(true));
683 $staleCacheDirSuffix = "";
684 $counter = 0;
685 while (Filesystem::fileExists($staleCacheDir . $staleCacheDirSuffix)) {
686 $counter++;
687 $staleCacheDirSuffix = "_" . $counter;
688 }
689 $staleCacheDir .= $staleCacheDirSuffix;
690 $this->purgeProxyCache();
691 $this->config->LastFetch = 0;
692 $this->setConfig($this->config);
693
694 // Rename cache files directory
695 if (Filesystem::fileExists($this->getCacheDir()) && !Filesystem::rename($this->getCacheDir(), $staleCacheDir)) {
696 throw new \Exception("No write permissions to rename the directory: " . $this->getCacheDir());
697 }
698
699 // Create a new empty directory
700 if (!Filesystem::createDir($this->getCacheDir())) {
701 throw new \Exception("No write permissions to create the directory: " . $this->getCacheDir());
702 }
703
704 if (!$quick) {
705 // Finally, delete the files of the stale directory
706 Filesystem::deleteDir($staleCacheDir);
707 }
708
709 $this->elementRevision->refresh();
710
711 return $staleCacheDir;
712 }
713
714 public function fetchConfig($ignoreHealthStatus = false) {
715 // TODO: Record failures and repeat with a delay
716 $fetcher = new Api\RemoteConfigFetcher($this->siteId, $this->siteSecret);
717
718 if (!$ignoreHealthStatus) {
719 $fetcher->setBacklog($this->backlog);
720 $fetcher->setNitroPack($this);
721 }
722
723 try {
724 $configContents = $fetcher->get(); // this can throw in case of http errors or validation failures
725 } catch (\Exception $e) {
726 // TODO: Record this failure, possibly in the backlog
727 throw $e;
728 }
729
730 $config = json_decode($configContents);
731 if ($config) {
732 $config->SDKVersion = NitroPack::VERSION;
733 $config->LastFetch = time();
734
735 $this->setConfig($config);
736 return true;
737 } else {
738 throw new EmptyConfigException("Config response was empty");
739 }
740 }
741
742 public function setConfig($config) {
743 $file = $this->getConfigFile();
744 if (Filesystem::createDir(dirname($file))) {
745 if (Filesystem::filePutContents($file, json_encode($config))) {
746 return true;
747 } else {
748 throw new StorageException(sprintf("Config file %s cannot be saved to disk", $file));
749 }
750 } else {
751 throw new StorageException(sprintf("Storage directory %s cannot be created", dirname($file)));
752 }
753 }
754
755 public function setVarnishProxyCacheHeaders($newHeaders) {
756 $this->varnishProxyCacheHeaders = $newHeaders;
757 }
758
759 public function purgeProxyCache($url = NULL) {
760 if (empty($this->config->CacheIntegrations)) {
761 return;
762 }
763
764 if (!empty($this->config->CacheIntegrations->Varnish)) {
765 if (empty($this->config->CacheIntegrations->Varnish->PurgeConfigSet)) {
766 if ($url) {
767 $url = $this->normalizeUrl($url);
768 $varnish = new Integrations\Varnish(
769 $this->config->CacheIntegrations->Varnish->Servers,
770 $this->config->CacheIntegrations->Varnish->PurgeSingleMethod,
771 $this->varnishProxyCacheHeaders
772 );
773 $varnish->purge($url);
774 } else {
775 $varnish = new Integrations\Varnish(
776 $this->config->CacheIntegrations->Varnish->Servers,
777 $this->config->CacheIntegrations->Varnish->PurgeAllMethod,
778 $this->varnishProxyCacheHeaders
779 );
780 $varnish->purge($this->config->CacheIntegrations->Varnish->PurgeAllUrl);
781 }
782
783 return;
784 }
785
786 foreach ($this->config->CacheIntegrations->Varnish->PurgeConfigSet as $purgeSet) {
787 if ($url) {
788 foreach ($purgeSet->PurgeSingleHeadersTemplates as $headerTemplateTuple) {
789 $this->varnishProxyCacheHeaders[$headerTemplateTuple->HeaderName] = $this->valueFromTemplate($url, $headerTemplateTuple->HeaderTemplate);
790 }
791
792 $varnish = new Integrations\Varnish(
793 null,
794 $purgeSet->PurgeSingleMethod,
795 $this->varnishProxyCacheHeaders
796 );
797
798 $purgeUrl = $this->valueFromTemplate($url, $purgeSet->PurgeSingleTemplate);
799 $varnish->purge($purgeUrl, true);
800 } else {
801 $varnish = new Integrations\Varnish(
802 null,
803 $purgeSet->PurgeAllMethod,
804 $this->varnishProxyCacheHeaders
805 );
806 $varnish->purge($purgeSet->PurgeAllUrl, true);
807 }
808 }
809 }
810
811 //if (!empty($this->config->CacheIntegrations->LiteSpeed) && php_sapi_name() !== "cli") {
812 // if ($url) {
813 // $urlObj = new Url($url);
814 // $liteSpeedPath = $urlObj->getPath();
815 // if ($urlObj->getQuery()) {
816 // $liteSpeedPath .= "?" . $urlObj->getQuery();
817 // }
818 // header("X-LiteSpeed-Purge: $liteSpeedPath", false);
819 // } else {
820 // header("X-LiteSpeed-Purge: *", false);
821 // }
822 //}
823 }
824
825 public function isAllowedUrl($url) {
826 if (strpos($url, 'sucurianticache=') !== false) return false;
827
828 if ($this->config->EnabledURLs->Status) {
829 if (!empty($this->config->EnabledURLs->URLs)) {
830 foreach ($this->config->EnabledURLs->URLs as $enabledUrl) {
831 $enabledUrlModified = preg_replace("/^(https?:)?\/\//", "*", $enabledUrl);
832
833 if (preg_match('/^' . self::wildcardToRegex($enabledUrlModified) . '$/', $url)) {
834 return true;
835 }
836 }
837
838 return false;
839 }
840 } else if ($this->config->DisabledURLs->Status) {
841 if (!empty($this->config->DisabledURLs->URLs)) {
842 foreach ($this->config->DisabledURLs->URLs as $disabledUrl) {
843 $disabledUrlModified = preg_replace("/^(https?:)?\/\//", "*", $disabledUrl);
844
845 if (preg_match('/^' . self::wildcardToRegex($disabledUrlModified) . '$/', $url)) {
846 return false; // don't cache disabled URLs
847 }
848 }
849 }
850 }
851
852 return true;
853 }
854
855 public function isServiceRequest() {
856 return isset($_SERVER["HTTP_X_NITROPACK_REQUEST"]);
857 }
858
859 public function isAllowedRequest($allowServiceRequests = false) {
860 if (($this->isAJAXRequest() && !$this->isAllowedAJAX()) || !($this->isRequestMethod("GET") || $this->isRequestMethod("HEAD"))) {// TODO: Allow URLs which match a pattern in the AJAX URL whitelist
861 return false; // don't cache ajax or not GET requests
862 }
863
864 if (!$allowServiceRequests && $this->isServiceRequest()) { // Skip requests coming from NitroPack
865 return false;
866 }
867
868 if (isset($_GET["nonitro"])) { // Skip requests having ?nonitro
869 return false;
870 }
871
872 if (!$this->isAllowedBrowser()) {
873 return false;
874 }
875
876 if (isset($this->config->ExcludedCookies) && $this->config->ExcludedCookies->Status) {
877 foreach ($this->config->ExcludedCookies->Cookies as $cookieExclude) {
878 foreach (self::getCookies() as $cookieName => $cookieValue) {
879 if (preg_match('/^' . self::wildcardToRegex($cookieExclude->name) . '$/', $cookieName)) {
880 if (count($cookieExclude->values) == 0) {
881 return false; // no excluded cookie values entered, reject all values
882 } else {
883 foreach ($cookieExclude->values as $val) {
884 if (preg_match('/^' . self::wildcardToRegex($val) . '$/', $cookieValue)) {
885 return false;
886 }
887 }
888 }
889 }
890 }
891 }
892 }
893
894 // Deactivate NitroPack for a desired % of journeys
895 if ($this->config->TestImpact->Status) {
896 $impactGroup = $this->getImpactGroupValue();
897 if ($impactGroup <= $this->config->TestImpact->ThresholdPercentage) {
898 return false;
899 }
900 }
901
902 return true;
903 }
904
905 private function getImpactGroupValue() {
906 if (!empty($_COOKIE['nitroImpactGroup'])) {
907 return $_COOKIE['nitroImpactGroup'];
908 } else {
909 $impactGroup = mt_rand(1,100);
910 setcookie('nitroImpactGroup', $impactGroup, time() + 86400, '/', $_SERVER['HTTP_HOST']);
911 return $impactGroup;
912 }
913 }
914
915 public function isAllowedBrowser() {
916 // Right now this function always returns true but is kept in case of possible future exclusions
917
918 if (empty($_SERVER["HTTP_USER_AGENT"])) {
919 return true;
920 }
921
922 return true;
923 }
924
925 public function getScheme() {
926 return $this->isSecure() ? 'https://' : 'http://';
927 }
928
929 public function isSecure() {
930 $result = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && in_array("https", array_map("strtolower", array_map("trim", explode(",", $_SERVER['HTTP_X_FORWARDED_PROTO']))))) ||
931 (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
932 (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
933 (!empty($_SERVER['HTTP_SSL_FLAG']) && $_SERVER['HTTP_SSL_FLAG'] == 'SSL');
934
935 if (!$result && !empty($_SERVER['HTTP_CF_VISITOR'])) {
936 $visitor = json_decode($_SERVER['HTTP_CF_VISITOR']);
937 $result = $visitor && property_exists($visitor, "scheme") && strtolower($visitor->scheme) == "https";
938 }
939
940 return $result;
941 }
942
943 public function isAJAXRequest() {
944 return
945 (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') ||
946 $this->isAllowedAJAXUrl($this->url) ||
947 $this->isAllowedStandaloneAJAXUrl($this->url);
948 }
949
950 public function isRequestMethod($method) {
951 return empty($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] == $method;
952 }
953
954 public function isAllowedAJAX() {
955 if (!$this->isAllowedStandaloneAJAXUrl($this->url)) {
956 if (!$this->pageCache->getParent()) return false;
957 if (!$this->pageCache->getParent()->hasCache() || $this->pageCache->getParent()->hasExpired($this->config->PageCache->ExpireTime)) return false;
958 }
959 return true;
960 }
961
962 public function isAllowedAJAXUrl($url) {
963 if ($this->config->AjaxURLs->Status) {
964 if (!empty($this->config->AjaxURLs->URLs)) {
965 foreach ($this->config->AjaxURLs->URLs as $ajaxUrl) {
966 $ajaxUrlModified = preg_replace("/^(https?:)?\/\//", "*", $ajaxUrl);
967 if (preg_match('/^' . self::wildcardToRegex($ajaxUrlModified) . '$/', $url)) {
968 return true;
969 }
970 }
971 return false;
972 }
973 }
974 return false;
975 }
976
977 public function isAllowedStandaloneAJAXUrl($url)
978 {
979 if ($this->config->AjaxURLs->Status) {
980 if (!empty($this->config->AjaxURLs->StandaloneURLs)) {
981 foreach ($this->config->AjaxURLs->StandaloneURLs as $ajaxUrl) {
982 $ajaxUrlModified = preg_replace("/^(https?:)?\/\//", "*", $ajaxUrl);
983 if (preg_match('/^' . self::wildcardToRegex($ajaxUrlModified) . '$/', $url)) {
984 return true;
985 }
986 }
987 }
988 }
989 return false;
990 }
991
992 public function isCacheAllowed() {
993 return $this->isAllowedRequest() && $this->isAllowedUrl($this->url);
994 }
995
996 public function isStatefulCacheSatisfied($type = NULL) {
997 if ($this->config->StatefulCache->Status) {
998 $foundTypeSelectors = false;
999 foreach ($this->config->StatefulCache->Selectors as $selector) {
1000 if ($type !== NULL) {
1001 if ($selector->type === $type) {
1002 $foundTypeSelectors = true;
1003 } else {
1004 continue;
1005 }
1006 }
1007
1008 $selectorEncoded = str_replace("=", "", base64_encode($selector->string)); // base64 can produce == at the end which breaks is invalid for a cookie name, hence why we need to remove it
1009 $cookieKey = 'np-' . $selector->type . '-' . $selectorEncoded . '-override';
1010 if (empty($_COOKIE[$cookieKey]) || $_COOKIE[$cookieKey] != $this->elementRevision->get()) {
1011 return false;
1012 }
1013 }
1014
1015 return $type !== NULL ? $foundTypeSelectors : true;
1016 }
1017
1018 return false;
1019 }
1020
1021 public function purgeLocalUrlCache($url) {
1022 $url = $this->normalizeUrl($url);
1023 $this->purgeProxyCache($url);
1024 $localResult = true;
1025 $cacheDir = $this->getCacheDir();
1026 $knownDeviceTypes = Device::getKnownTypes();
1027 foreach ($knownDeviceTypes as $deviceType) {
1028 $urlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, false, $this->config->URLPathVersion);
1029 $invalidatedUrlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, true, $this->config->URLPathVersion);
1030 $localResult &= Filesystem::deleteDir($urlDir);
1031 $localResult &= Filesystem::deleteDir($invalidatedUrlDir);
1032 }
1033 return $localResult;
1034 }
1035
1036 public function invalidateLocalUrlCache($url) {
1037 $url = $this->normalizeUrl($url);
1038 $this->purgeProxyCache($url);
1039 $localResult = true;
1040 $cacheDir = $this->getCacheDir();
1041 $knownDeviceTypes = Device::getKnownTypes();
1042 foreach ($knownDeviceTypes as $deviceType) {
1043 $urlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, false, $this->config->URLPathVersion);
1044 $urlDirInvalid = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, true, $this->config->URLPathVersion);
1045
1046 $this->invalidateDir($urlDir, $urlDirInvalid);
1047 }
1048 return $localResult;
1049 }
1050
1051 public function invalidateLocalCache() {
1052 $this->purgeProxyCache();
1053 $this->config->LastFetch = 0;
1054 $this->setConfig($this->config);
1055
1056 $cacheDir = $this->getCacheDir();
1057 $knownDeviceTypes = Device::getKnownTypes();
1058 foreach ($knownDeviceTypes as $deviceType) {
1059 $deviceTypeDir = $cacheDir . "/" . $deviceType;
1060 Filesystem::dirForeach($deviceTypeDir, function($urlDir) {
1061 if (substr($urlDir, -2) !== "_i") {
1062 $this->invalidateDir($urlDir, $urlDir . "_i");
1063 }
1064 });
1065 }
1066 return true;
1067 }
1068
1069 private function invalidateDir($urlDir, $urlDirInvalid) {
1070 if (Filesystem::fileExists($urlDirInvalid)) {
1071 Filesystem::dirForeach($urlDir, function($file) use ($urlDirInvalid) {
1072 Filesystem::rename($file, $urlDirInvalid . "/" . basename($file));
1073 });
1074
1075 Filesystem::deleteDir($urlDir);
1076 } else {
1077 if (Filesystem::rename($urlDir, $urlDirInvalid)) {
1078 Filesystem::touch($urlDirInvalid);
1079 }
1080 }
1081 }
1082
1083 public function integrationUrl($widget, $version = null) {
1084 $integration = new IntegrationUrl($widget, $this->siteId, $this->siteSecret, $version);
1085
1086 return $integration->getUrl();
1087 }
1088
1089 public function embedJsUrl() {
1090 $embedjs = new Embedjs();
1091
1092 return $embedjs->getUrl();
1093 }
1094
1095 public function enableSafeMode() {
1096 $this->api->safe_mode->enable();
1097 $this->fetchConfig();
1098 }
1099
1100 public function disableSafeMode() {
1101 $this->api->safe_mode->disable();
1102 $this->fetchConfig();
1103 }
1104
1105 public function enableCartCache() {
1106 $this->api->enableCartCache();
1107 }
1108
1109 public function disableCartCache() {
1110 $this->api->disableCartCache();
1111 }
1112
1113 public function getStatefulCacheHandlerScript() {
1114 if ($this->config->StatefulCache->Status && $this->config->StatefulCache->HandlerScript) {
1115 $keyRevision = $this->elementRevision->get();
1116 return '<script id="nitro-stateful-cache" nitro-exclude>' . str_replace("KEY_REVISION", $keyRevision, $this->config->StatefulCache->HandlerScript) . '</script>';
1117 }
1118
1119 return "";
1120 }
1121
1122 private function loadConfig() {
1123 $file = $this->getConfigFile();
1124
1125 $config = array();
1126 if (Filesystem::fileExists($file) || $this->fetchConfig(true)) {
1127 $config = json_decode(Filesystem::fileGetContents($file));
1128 if (empty($config->SDKVersion) || $config->SDKVersion !== NitroPack::VERSION || empty($config->LastFetch) || time() - $config->LastFetch >= $this->configTTL) {
1129 if ($this->getHealthStatus() === HealthStatus::HEALTHY) {
1130 if ($this->fetchConfig()) {
1131 $config = json_decode(Filesystem::fileGetContents($file));
1132 } else {
1133 throw new NoConfigException("Can't load config file");
1134 }
1135 }
1136 }
1137 $this->config = $config;
1138 } else {
1139 throw new NoConfigException("Can't load config file");
1140 }
1141 }
1142
1143 private function getConfigFile() {
1144 $configFile = $this->configFile;
1145
1146 $filename = array_pop($configFile);
1147
1148 $filename = $this->siteId . '-' . $filename;
1149
1150 array_push($configFile, $filename);
1151 array_unshift($configFile, $this->dataDir);
1152
1153 return Filesystem::getOsPath($configFile);
1154 }
1155
1156 private function lockPageCache() {
1157 $filename = $this->getPageCacheLockFilename();
1158
1159 if (Filesystem::fileExists($filename)) {
1160 $sem = 1 + (int)Filesystem::fileGetContents($filename);
1161 } else {
1162 $sem = 1;
1163 }
1164
1165 return !!Filesystem::filePutContents($filename, $sem);
1166 }
1167
1168 private function unlockPageCache() {
1169 $filename = $this->getPageCacheLockFilename();
1170
1171 if (Filesystem::fileExists($filename)) {
1172 $sem = (int)Filesystem::fileGetContents($filename);
1173
1174 $sem--;
1175
1176 if ($sem <= 0) {
1177 return !!Filesystem::deleteFile($filename);
1178 } else {
1179 return !!Filesystem::filePutContents($filename, $sem);
1180 }
1181 }
1182
1183 return false;
1184 }
1185
1186 private function isPageCacheLocked() {
1187 $filename = $this->getPageCacheLockFilename();
1188
1189 if (!Filesystem::fileExists($filename)) {
1190 return false;
1191 } else {
1192 if (time() - Filesystem::fileMTime($filename) <= self::PAGECACHE_LOCK_EXPIRATION_TIME) {
1193 return true;
1194 } else {
1195 Filesystem::deleteFile($filename);
1196
1197 return false;
1198 }
1199 }
1200
1201 // We should never get here, so consider this a default return value in case of future changes
1202 return false;
1203 }
1204
1205 private function getPageCacheLockFilename() {
1206 $pageCacheLockFile = $this->pageCacheLockFile;
1207 array_unshift($pageCacheLockFile, $this->dataDir);
1208 return Filesystem::getOsPath($pageCacheLockFile);
1209 }
1210
1211 private function normalizeUrl($url) {
1212 $urlObj = new Url($url);
1213 return $urlObj->getNormalized();
1214 }
1215
1216 /**
1217 * Substitutes values from a template based on URL components
1218 * @param string $url The URL, from which to extract substitution values
1219 * @param string $template The template to use
1220 * @return string
1221 */
1222 private function valueFromTemplate($url, $template) {
1223 $urlObj = new Url($url);
1224
1225 $proxyPurgeTemplate = array
1226 (
1227 '{{targetUrl}}' => $urlObj->getNormalized(),
1228 '{{targetProtocol}}' => $urlObj->getScheme(),
1229 '{{targetHost}}' => $urlObj->getHost(),
1230 '{{targetPath}}' => $urlObj->getPath(),
1231 '{{targetQuery}}' => $urlObj->getQuery(),
1232 );
1233
1234 $templateKeys = array_keys($proxyPurgeTemplate);
1235 $templateVals = array_values($proxyPurgeTemplate);
1236 $constructed = str_replace($templateKeys, $templateVals, $template);
1237
1238 return $constructed;
1239 }
1240 }