PluginProbe ʕ •ᴥ•ʔ
NitroPack – Performance, Page Speed & Cache Plugin for Core Web Vitals, CDN & Image Optimization / 1.15.2
NitroPack – Performance, Page Speed & Cache Plugin for Core Web Vitals, CDN & Image Optimization v1.15.2
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 2 years ago Integrations 2 years ago StorageDriver 4 years ago Url 2 years ago Utils 2 years ago data 6 years ago Api.php 2 years ago Backlog.php 4 years ago BacklogReplayTimeoutException.php 4 years ago ChallengeProcessingException.php 4 years ago ChallengeVerificationException.php 4 years ago ConfigFetcherException.php 4 years ago Crypto.php 3 years ago Device.php 6 years ago DeviceType.php 6 years ago ElementRevision.php 3 years ago EmptyConfigException.php 6 years ago ExcludeEntry.php 2 years ago ExcludeOperationType.php 2 years ago FileHandle.php 5 years ago Filesystem.php 4 years ago HealthStatus.php 5 years ago IntegrationUrl.php 2 years ago NitroPack.php 2 years ago NoConfigException.php 5 years ago Pagecache.php 2 years ago PurgeType.php 3 years ago ServiceDownException.php 5 years ago StorageException.php 6 years ago VariationCookieException.php 5 years ago WebhookException.php 5 years ago Website.php 6 years ago
NitroPack.php
1228 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.0';
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 [$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 }
238
239 $preferences[] = [$langCode, $langVariant, $weight];
240 }
241
242 return $preferences;
243 }
244
245 public function setReferer($referer) {
246 $refererInfo = new Url($referer);
247 $this->referer = $refererInfo->getNormalized();
248 $this->pageCache->setReferer($this->referer);
249 }
250
251 public function getPagecacheCookies() {
252 return $this->supportedCookiesFilter(self::getCookies());
253 }
254
255 public function supportedCookiesFilter($cookies) {
256 $supportedCookies = array();
257 foreach ($cookies as $cookieName=>$cookieValue) {
258 foreach ($this->config->PageCache->SupportedCookies as $cookie) {
259 if (preg_match('/^' . self::wildcardToRegex($cookie) . '$/', $cookieName)) {
260 $supportedCookies[$cookieName] = $cookieValue;
261 }
262 }
263 }
264 return $supportedCookies;
265 }
266
267 public function tagUrl($url, $tag) {
268 if ($this->isAllowedUrl($url)) {
269 return $this->api->tagUrl($url, $tag);
270 } else {
271 return false;
272 }
273 }
274
275 public function setCachePathSuffix($suffix) {
276 $this->cachePathSuffix = $suffix;
277 $this->pageCache->setDataDir($this->getCacheDir());
278 }
279
280 public function enableCompression() {
281 $this->pageCache->enableCompression();
282 }
283
284 public function disableCompression() {
285 $this->pageCache->disableCompression();
286 }
287
288 public function getUrl() {
289 return $this->url;
290 }
291
292 public function getApi() {
293 return $this->api;
294 }
295
296 public function getSiteId() {
297 return $this->siteId;
298 }
299
300 public function getConfig() {
301 return $this->config;
302 }
303
304 public function getCacheDir() {
305 $cachePath = $this->cachePath;
306 array_unshift($cachePath, $this->dataDir);
307 if ($this->cachePathSuffix) {
308 $cachePath[] = $this->cachePathSuffix;
309 }
310 return Filesystem::getOsPath($cachePath);
311 }
312
313 public function getStatefulCacheRevisionFile() {
314 $revisionFile = $this->statefulCacheRevisionsFile;
315 array_unshift($revisionFile, $this->dataDir);
316 return Filesystem::getOsPath($revisionFile);
317 }
318
319 public function getHealthStatus() {
320 return $this->healthStatus;
321 }
322
323 public function getHealthStatusFile() {
324 $healthStatusFile = $this->healthStatusFile;
325 array_unshift($healthStatusFile, $this->dataDir);
326 return Filesystem::getOsPath($healthStatusFile);
327 }
328
329 public function setHealthStatus($status) {
330 $this->healthStatus = $status;
331 Filesystem::filePutContents($this->getHealthStatusFile(), $status);
332 }
333
334 public function loadHealthStatus() {
335 if (defined("NITROPACK_DISABLE_BACKLOG")) return;
336 if (Filesystem::fileExists($this->getHealthStatusFile())) {
337 $this->healthStatus = Filesystem::fileGetContents($this->getHealthStatusFile());
338 } else {
339 $this->healthStatus = HealthStatus::HEALTHY;
340 }
341 }
342
343 public function checkHealthStatus() {
344 try {
345 // TODO: Potentially replace this with a dedicated method in the API
346 $this->fetchConfig(true);
347 $this->setHealthStatus(HealthStatus::HEALTHY);
348 return HealthStatus::HEALTHY;
349 } catch (\Exception $e) {
350 $this->setHealthStatus(HealthStatus::SICK);
351 return HealthStatus::SICK;
352 }
353 }
354
355 public function getTimestampFile() {
356 $timestampFile = $this->timestampFile;
357 array_unshift($timestampFile, $this->dataDir);
358 return Filesystem::getOsPath($timestampFile);
359 }
360
361 public function loadTimeMarks() {
362 if (Filesystem::fileExists($this->getTimestampFile())) {
363 try {
364 $marks = @json_decode(Filesystem::fileGetContents($this->getTimestampFile()), true);
365 if (!$marks) {
366 $marks = [];
367 }
368 } catch (\Exception $e) {
369 $marks = [];
370 }
371 } else {
372 $marks = [];
373 }
374
375 return $marks;
376 }
377
378 public function setTimeMark($name, $time = NULL) {
379 if ($time === NULL) $time = microtime(true);
380 $marks = $this->loadTimeMarks();
381 $marks[$name] = $time;
382 Filesystem::filePutContents($this->getTimestampFile(), json_encode($marks));
383 }
384
385 public function unsetTimeMark($name) {
386 $marks = $this->loadTimeMarks();
387 if (isset($marks[$name])) {
388 unset($marks[$name]);
389 }
390 Filesystem::filePutContents($this->getTimestampFile(), json_encode($marks));
391 }
392
393 public function getTimeMark($name) {
394 $marks = $this->loadTimeMarks();
395 if (!empty($marks[$name])) {
396 return $marks[$name];
397 } else {
398 return NULL;
399 }
400 }
401
402 public function getRemainingCacheTtl() {
403 return $this->pageCache->getRemainingTtl($this->config->PageCache->ExpireTime);
404 }
405
406 public function hasCache($layout = 'default') {
407 if ($this->hasLocalCache()) {
408 return true;
409 } else {
410 return $this->hasRemoteCache($layout);
411 }
412 }
413
414 public function hasLocalCache($checkIfRequestIsAllowed = true) {
415 if ($this->backlog->exists()) return false;
416 if (!$this->isAllowedUrl($this->url) || ($checkIfRequestIsAllowed && !$this->isAllowedRequest())) return false;
417 $cacheRevision = !empty($this->config->RevisionHash) ? $this->config->RevisionHash : NULL;
418 if ($this->getHealthStatus() !== HealthStatus::HEALTHY) {
419 return false;
420 }
421
422 if (!$this->pageCache->getUseInvalidated()) {
423 $ttl = $this->config->PageCache->ExpireTime;
424 } else {
425 $ttl = $this->config->PageCache->StaleExpireTime;
426 }
427
428 return $this->pageCache->hasCache() && !$this->pageCache->hasExpired($ttl, $cacheRevision);
429 }
430
431 public function hasRemoteCache($layout, $checkIfRequestIsAllowed = true) {
432 if ($this->backlog->exists()) return false;
433 if (
434 !$this->isAllowedUrl($this->url) ||
435 ($checkIfRequestIsAllowed && !$this->isAllowedRequest()) ||
436 ($this->pageCache->getParent() && !$this->pageCache->getParent()->hasCache()) ||
437 $this->isPageCacheLocked()
438 ) return false;
439
440 $resp = $this->api->getCache($this->url, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->isAJAXRequest(), $layout, $this->referer);
441 if ($resp->getStatus() == Api\ResponseStatus::OK) {// We have cache response
442
443 // Check for invalidated cache and delete it if such is found
444 $this->pageCache->useInvalidated(true);
445 if ($this->pageCache->hasCache()) {
446 $path = $this->pageCache->getCachefilePath();
447 Filesystem::deleteFile($path);
448 Filesystem::deleteFile($path . ".gz");
449 Filesystem::deleteFile($path . ".stale");
450 Filesystem::deleteFile($path . ".stale.gz");
451 if (Filesystem::isDirEmpty(dirname($path))) {
452 Filesystem::deleteDir(dirname($path));
453 }
454 }
455 $this->pageCache->useInvalidated(false);
456 // End of check
457
458 list($headers, $content) = Filesystem::explodeByHeaders($resp->getBody());
459 $this->pageCache->setContent($content, $headers);
460 return true;
461 } else {
462 // 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
463 if ($this->pageCache->hasCache()) {
464 return true;
465 } else {
466 // Check for invalidated cache
467 $this->pageCache->useInvalidated(true);
468 if ($this->hasLocalCache(false)) {
469 return true;
470 } else {
471 $this->pageCache->useInvalidated(false);
472 }
473 }
474
475 return false;
476 }
477 }
478
479 public function hasRemoteCacheMulti($urls, $layout, $checkIfRequestIsAllowed = true) {
480 if ($this->backlog->exists()) return false;
481
482 foreach ($urls as $url) {
483 if (
484 !$this->isAllowedUrl($url) ||
485 ($checkIfRequestIsAllowed && !$this->isAllowedRequest()) ||
486 ($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
487 $this->isPageCacheLocked()
488 ) return false;
489 }
490
491 $hasCache = true; // hasCache is false if even one of the URLs doesn't have a cache
492 $responses = $this->api->getCacheMulti($urls, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->isAJAXRequest(), $layout, $this->referer);
493
494 $originalURL = $this->url;
495 $originalPageCache = $this->pageCache;
496
497 foreach ($responses as $url => $resp) {
498 // Overwrite the url
499 $urlInfo = new Url($url);
500 $this->url = $urlInfo->getNormalized();
501
502 // Overwrite the page cache
503 $this->pageCache = new Pagecache($this->url, $this->userAgent, $this->supportedCookiesFilter(self::getCookies()), $this->config->PageCache->SupportedCookies, $this->isAJAXRequest());
504 $this->pageCache->setCookiesProvider([$this, "getPagecacheCookies"]);
505
506 if ($this->config->PageCache->Geot->Status) {
507 $this->pageCache->setGeotVariations($this->config->PageCache->Geot->Variations);
508 }
509
510 if ($this->isAJAXRequest() && $this->isAllowedAJAXUrl($this->url) && !empty($_SERVER["HTTP_REFERER"]) && !$this->isAllowedStandaloneAJAXUrl($url)) {
511 $refererInfo = new Url($_SERVER["HTTP_REFERER"]);
512 $this->pageCache->setReferer($refererInfo->getNormalized());
513 }
514 if (!empty($this->config->URLPathVersion)) {
515 $this->pageCache->setUrlPathVersion($this->config->URLPathVersion);
516 }
517 $this->pageCache->setDataDir($this->getCacheDir());
518
519 // Process the response
520 if ($resp->getStatus() == Api\ResponseStatus::OK) {// We have cache response
521
522 // Check for invalidated cache and delete it if such is found
523 $this->pageCache->useInvalidated(true);
524 if ($this->pageCache->hasCache()) {
525 $path = $this->pageCache->getCachefilePath();
526 Filesystem::deleteFile($path);
527 Filesystem::deleteFile($path . ".gz");
528 Filesystem::deleteFile($path . ".stale");
529 Filesystem::deleteFile($path . ".stale.gz");
530 if (Filesystem::isDirEmpty(dirname($path))) {
531 Filesystem::deleteDir(dirname($path));
532 }
533 }
534 $this->pageCache->useInvalidated(false);
535 // End of check
536
537 list($headers, $content) = Filesystem::explodeByHeaders($resp->getBody());
538 $this->pageCache->setContent($content, $headers);
539 continue;
540 } else {
541 // 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
542 if ($this->pageCache->hasCache()) {
543 continue;
544 } else {
545 // Check for invalidated cache
546 $this->pageCache->useInvalidated(true);
547 if ($this->hasLocalCache(false)) {
548 continue;
549 } else {
550 $this->pageCache->useInvalidated(false);
551 }
552 }
553
554 $hasCache = false;
555 continue;
556 }
557 }
558
559 // Restore the url and page cache
560 $this->url = $originalURL;
561 $this->pageCache = $originalPageCache;
562
563 return $hasCache;
564 }
565
566 public function invalidateCache($url = NULL, $tag = NULL, $reason = NULL) {
567 return $this->purgeCache($url, $tag, PurgeType::INVALIDATE | PurgeType::PAGECACHE_ONLY, $reason);
568 }
569
570 public function clearPageCache($reason = NULL) {
571 return $this->purgeCache(NULL, NULL, PurgeType::PAGECACHE_ONLY, $reason);
572 }
573
574 public function purgeCache($url = NULL, $tag = NULL, $purgeType = PurgeType::COMPLETE, $reason = NULL) {
575 @set_time_limit(0);
576 $this->lockPageCache(); // Set the page cache lock, expires after self::PAGECACHE_LOCK_EXPIRATION_TIME seconds
577
578 try {
579 $invalidate = !!($purgeType & PurgeType::INVALIDATE);
580 $pageCacheOnly = !!($purgeType & PurgeType::PAGECACHE_ONLY);
581 $lightPurge = !!($purgeType & PurgeType::LIGHT_PURGE);
582
583 if ($url || $tag) {
584 $localResult = true;
585 $apiResult = true;
586 if ($url) {
587 if (is_array($url)) {
588 foreach ($url as &$urlLink) {
589 $urlLink = $this->normalizeUrl($urlLink);
590 if ($invalidate) {
591 $localResult &= $this->invalidateLocalUrlCache($urlLink);
592 } else {
593 $localResult &= $this->purgeLocalUrlCache($urlLink);
594 }
595 }
596 } else {
597 $url = $this->normalizeUrl($url);
598 if ($invalidate) {
599 $localResult &= $this->invalidateLocalUrlCache($url);
600 } else {
601 $localResult &= $this->purgeLocalUrlCache($url);
602 }
603 }
604
605 try {
606 $apiResult &= $this->api->purgeCache($url, false, $reason, $lightPurge);
607 } catch (ServiceDownException $e) {
608 $apiResult = false;
609 // TODO: Potentially log this
610 }
611 }
612
613 if ($tag) {
614 $attemptsLeft = 10;
615 $purgedUrls = array();
616 do {
617 $hadError = false;
618
619 try {
620 $purgedUrls = $this->api->purgeCacheByTag($tag, $reason);
621
622 foreach ($purgedUrls as $url) {
623 if ($invalidate) {
624 $localResult &= $this->invalidateLocalUrlCache($url);
625 } else {
626 $localResult &= $this->purgeLocalUrlCache($url);
627 }
628 }
629 } catch (ServiceDownException $e) {
630 $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).
631 $apiResult = false;
632 // TODO: Log this
633 break;
634 } catch (\Exception $e) {
635 $hadError = true;
636 $attemptsLeft--;
637 sleep(3);
638 }
639 } while ($attemptsLeft > 0 && count($purgedUrls) > 0);
640 }
641 } else {
642 if ($invalidate) {
643 $localResult = $this->invalidateLocalCache();
644 $apiResult = $this->api->purgeCache(NULL, $pageCacheOnly, $reason, $lightPurge); // delete only page cache
645 } else {
646 $staleCacheDir = $this->purgeLocalCache(true);
647
648 // Call the cache purge method
649 $apiResult = $this->api->purgeCache(NULL, $pageCacheOnly, $reason, $lightPurge);
650
651 // Finally, delete the files of the stale directory
652 Filesystem::deleteDir($staleCacheDir);
653
654 $localResult = true; // We do not care if $staleCacheDir was not deleted successfully
655 }
656
657 $this->elementRevision->refresh();
658 }
659
660 $this->unlockPageCache(); // Purge cache is done, we can now unlock
661 } catch (\Exception $e) {
662 $this->unlockPageCache(); // Purge cache had an error, so just unlock
663
664 throw $e;
665 }
666
667 return $apiResult && $localResult;
668 }
669
670 public function purgeLocalCache($quick = false) {
671 $staleCacheDir = $this->getCacheDir() . '.stale.' . md5(microtime(true));
672 $staleCacheDirSuffix = "";
673 $counter = 0;
674 while (Filesystem::fileExists($staleCacheDir . $staleCacheDirSuffix)) {
675 $counter++;
676 $staleCacheDirSuffix = "_" . $counter;
677 }
678 $staleCacheDir .= $staleCacheDirSuffix;
679 $this->purgeProxyCache();
680 $this->config->LastFetch = 0;
681 $this->setConfig($this->config);
682
683 // Rename cache files directory
684 if (Filesystem::fileExists($this->getCacheDir()) && !Filesystem::rename($this->getCacheDir(), $staleCacheDir)) {
685 throw new \Exception("No write permissions to rename the directory: " . $this->getCacheDir());
686 }
687
688 // Create a new empty directory
689 if (!Filesystem::createDir($this->getCacheDir())) {
690 throw new \Exception("No write permissions to create the directory: " . $this->getCacheDir());
691 }
692
693 if (!$quick) {
694 // Finally, delete the files of the stale directory
695 Filesystem::deleteDir($staleCacheDir);
696 }
697
698 $this->elementRevision->refresh();
699
700 return $staleCacheDir;
701 }
702
703 public function fetchConfig($ignoreHealthStatus = false) {
704 // TODO: Record failures and repeat with a delay
705 $fetcher = new Api\RemoteConfigFetcher($this->siteId, $this->siteSecret);
706
707 if (!$ignoreHealthStatus) {
708 $fetcher->setBacklog($this->backlog);
709 $fetcher->setNitroPack($this);
710 }
711
712 try {
713 $configContents = $fetcher->get(); // this can throw in case of http errors or validation failures
714 } catch (\Exception $e) {
715 // TODO: Record this failure, possibly in the backlog
716 throw $e;
717 }
718
719 $config = json_decode($configContents);
720 if ($config) {
721 $config->SDKVersion = NitroPack::VERSION;
722 $config->LastFetch = time();
723
724 $this->setConfig($config);
725 return true;
726 } else {
727 throw new EmptyConfigException("Config response was empty");
728 }
729 }
730
731 public function setConfig($config) {
732 $file = $this->getConfigFile();
733 if (Filesystem::createDir(dirname($file))) {
734 if (Filesystem::filePutContents($file, json_encode($config))) {
735 return true;
736 } else {
737 throw new StorageException(sprintf("Config file %s cannot be saved to disk", $file));
738 }
739 } else {
740 throw new StorageException(sprintf("Storage directory %s cannot be created", dirname($file)));
741 }
742 }
743
744 public function setVarnishProxyCacheHeaders($newHeaders) {
745 $this->varnishProxyCacheHeaders = $newHeaders;
746 }
747
748 public function purgeProxyCache($url = NULL) {
749 if (empty($this->config->CacheIntegrations)) {
750 return;
751 }
752
753 if (!empty($this->config->CacheIntegrations->Varnish)) {
754 if (empty($this->config->CacheIntegrations->Varnish->PurgeConfigSet)) {
755 if ($url) {
756 $url = $this->normalizeUrl($url);
757 $varnish = new Integrations\Varnish(
758 $this->config->CacheIntegrations->Varnish->Servers,
759 $this->config->CacheIntegrations->Varnish->PurgeSingleMethod,
760 $this->varnishProxyCacheHeaders
761 );
762 $varnish->purge($url);
763 } else {
764 $varnish = new Integrations\Varnish(
765 $this->config->CacheIntegrations->Varnish->Servers,
766 $this->config->CacheIntegrations->Varnish->PurgeAllMethod,
767 $this->varnishProxyCacheHeaders
768 );
769 $varnish->purge($this->config->CacheIntegrations->Varnish->PurgeAllUrl);
770 }
771
772 return;
773 }
774
775 foreach ($this->config->CacheIntegrations->Varnish->PurgeConfigSet as $purgeSet) {
776 if ($url) {
777 foreach ($purgeSet->PurgeSingleHeadersTemplates as $headerTemplateTuple) {
778 $this->varnishProxyCacheHeaders[$headerTemplateTuple->HeaderName] = $this->valueFromTemplate($url, $headerTemplateTuple->HeaderTemplate);
779 }
780
781 $varnish = new Integrations\Varnish(
782 null,
783 $purgeSet->PurgeSingleMethod,
784 $this->varnishProxyCacheHeaders
785 );
786
787 $purgeUrl = $this->valueFromTemplate($url, $purgeSet->PurgeSingleTemplate);
788 $varnish->purge($purgeUrl, true);
789 } else {
790 $varnish = new Integrations\Varnish(
791 null,
792 $purgeSet->PurgeAllMethod,
793 $this->varnishProxyCacheHeaders
794 );
795 $varnish->purge($purgeSet->PurgeAllUrl, true);
796 }
797 }
798 }
799
800 //if (!empty($this->config->CacheIntegrations->LiteSpeed) && php_sapi_name() !== "cli") {
801 // if ($url) {
802 // $urlObj = new Url($url);
803 // $liteSpeedPath = $urlObj->getPath();
804 // if ($urlObj->getQuery()) {
805 // $liteSpeedPath .= "?" . $urlObj->getQuery();
806 // }
807 // header("X-LiteSpeed-Purge: $liteSpeedPath", false);
808 // } else {
809 // header("X-LiteSpeed-Purge: *", false);
810 // }
811 //}
812 }
813
814 public function isAllowedUrl($url) {
815 if (strpos($url, 'sucurianticache=') !== false) return false;
816
817 if ($this->config->EnabledURLs->Status) {
818 if (!empty($this->config->EnabledURLs->URLs)) {
819 foreach ($this->config->EnabledURLs->URLs as $enabledUrl) {
820 $enabledUrlModified = preg_replace("/^(https?:)?\/\//", "*", $enabledUrl);
821
822 if (preg_match('/^' . self::wildcardToRegex($enabledUrlModified) . '$/', $url)) {
823 return true;
824 }
825 }
826
827 return false;
828 }
829 } else if ($this->config->DisabledURLs->Status) {
830 if (!empty($this->config->DisabledURLs->URLs)) {
831 foreach ($this->config->DisabledURLs->URLs as $disabledUrl) {
832 $disabledUrlModified = preg_replace("/^(https?:)?\/\//", "*", $disabledUrl);
833
834 if (preg_match('/^' . self::wildcardToRegex($disabledUrlModified) . '$/', $url)) {
835 return false; // don't cache disabled URLs
836 }
837 }
838 }
839 }
840
841 return true;
842 }
843
844 public function isServiceRequest() {
845 return isset($_SERVER["HTTP_X_NITROPACK_REQUEST"]);
846 }
847
848 public function isAllowedRequest($allowServiceRequests = false) {
849 if (($this->isAJAXRequest() && !$this->isAllowedAJAX()) || !($this->isRequestMethod("GET") || $this->isRequestMethod("HEAD"))) {// TODO: Allow URLs which match a pattern in the AJAX URL whitelist
850 return false; // don't cache ajax or not GET requests
851 }
852
853 if (!$allowServiceRequests && $this->isServiceRequest()) { // Skip requests coming from NitroPack
854 return false;
855 }
856
857 if (isset($_GET["nonitro"])) { // Skip requests having ?nonitro
858 return false;
859 }
860
861 if (!$this->isAllowedBrowser()) {
862 return false;
863 }
864
865 if (isset($this->config->ExcludedCookies) && $this->config->ExcludedCookies->Status) {
866 foreach ($this->config->ExcludedCookies->Cookies as $cookieExclude) {
867 foreach (self::getCookies() as $cookieName => $cookieValue) {
868 if (preg_match('/^' . self::wildcardToRegex($cookieExclude->name) . '$/', $cookieName)) {
869 if (count($cookieExclude->values) == 0) {
870 return false; // no excluded cookie values entered, reject all values
871 } else {
872 foreach ($cookieExclude->values as $val) {
873 if (preg_match('/^' . self::wildcardToRegex($val) . '$/', $cookieValue)) {
874 return false;
875 }
876 }
877 }
878 }
879 }
880 }
881 }
882
883 // Deactivate NitroPack for a desired % of journeys
884 if ($this->config->TestImpact->Status) {
885 $impactGroup = $this->getImpactGroupValue();
886 if ($impactGroup <= $this->config->TestImpact->ThresholdPercentage) {
887 return false;
888 }
889 }
890
891 return true;
892 }
893
894 private function getImpactGroupValue() {
895 if (!empty($_COOKIE['nitroImpactGroup'])) {
896 return $_COOKIE['nitroImpactGroup'];
897 } else {
898 $impactGroup = mt_rand(1,100);
899 setcookie('nitroImpactGroup', $impactGroup, time() + 86400, '/', $_SERVER['HTTP_HOST']);
900 return $impactGroup;
901 }
902 }
903
904 public function isAllowedBrowser() {
905 if (empty($_SERVER["HTTP_USER_AGENT"])) return true;
906
907 if (preg_match("~MSIE|Internet Explorer~i", $_SERVER["HTTP_USER_AGENT"]) || strpos($_SERVER["HTTP_USER_AGENT"], "Trident/7.0; rv:11.0") !== false) { // Skip IE
908 return false;
909 }
910
911 return true;
912 }
913
914 public function getScheme() {
915 return $this->isSecure() ? 'https://' : 'http://';
916 }
917
918 public function isSecure() {
919 $result = (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && in_array("https", array_map("strtolower", array_map("trim", explode(",", $_SERVER['HTTP_X_FORWARDED_PROTO']))))) ||
920 (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
921 (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) ||
922 (!empty($_SERVER['HTTP_SSL_FLAG']) && $_SERVER['HTTP_SSL_FLAG'] == 'SSL');
923
924 if (!$result && !empty($_SERVER['HTTP_CF_VISITOR'])) {
925 $visitor = json_decode($_SERVER['HTTP_CF_VISITOR']);
926 $result = $visitor && property_exists($visitor, "scheme") && strtolower($visitor->scheme) == "https";
927 }
928
929 return $result;
930 }
931
932 public function isAJAXRequest() {
933 return
934 (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') ||
935 $this->isAllowedAJAXUrl($this->url) ||
936 $this->isAllowedStandaloneAJAXUrl($this->url);
937 }
938
939 public function isRequestMethod($method) {
940 return empty($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] == $method;
941 }
942
943 public function isAllowedAJAX() {
944 if (!$this->isAllowedStandaloneAJAXUrl($this->url)) {
945 if (!$this->pageCache->getParent()) return false;
946 if (!$this->pageCache->getParent()->hasCache() || $this->pageCache->getParent()->hasExpired($this->config->PageCache->ExpireTime)) return false;
947 }
948 return true;
949 }
950
951 public function isAllowedAJAXUrl($url) {
952 if ($this->config->AjaxURLs->Status) {
953 if (!empty($this->config->AjaxURLs->URLs)) {
954 foreach ($this->config->AjaxURLs->URLs as $ajaxUrl) {
955 $ajaxUrlModified = preg_replace("/^(https?:)?\/\//", "*", $ajaxUrl);
956 if (preg_match('/^' . self::wildcardToRegex($ajaxUrlModified) . '$/', $url)) {
957 return true;
958 }
959 }
960 return false;
961 }
962 }
963 return false;
964 }
965
966 public function isAllowedStandaloneAJAXUrl($url)
967 {
968 if ($this->config->AjaxURLs->Status) {
969 if (!empty($this->config->AjaxURLs->StandaloneURLs)) {
970 foreach ($this->config->AjaxURLs->StandaloneURLs as $ajaxUrl) {
971 $ajaxUrlModified = preg_replace("/^(https?:)?\/\//", "*", $ajaxUrl);
972 if (preg_match('/^' . self::wildcardToRegex($ajaxUrlModified) . '$/', $url)) {
973 return true;
974 }
975 }
976 }
977 }
978 return false;
979 }
980
981 public function isCacheAllowed() {
982 return $this->isAllowedRequest() && $this->isAllowedUrl($this->url);
983 }
984
985 public function isStatefulCacheSatisfied($type = NULL) {
986 if ($this->config->StatefulCache->Status) {
987 $foundTypeSelectors = false;
988 foreach ($this->config->StatefulCache->Selectors as $selector) {
989 if ($type !== NULL) {
990 if ($selector->type === $type) {
991 $foundTypeSelectors = true;
992 } else {
993 continue;
994 }
995 }
996
997 $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
998 $cookieKey = 'np-' . $selector->type . '-' . $selectorEncoded . '-override';
999 if (empty($_COOKIE[$cookieKey]) || $_COOKIE[$cookieKey] != $this->elementRevision->get()) {
1000 return false;
1001 }
1002 }
1003
1004 return $type !== NULL ? $foundTypeSelectors : true;
1005 }
1006
1007 return false;
1008 }
1009
1010 public function purgeLocalUrlCache($url) {
1011 $url = $this->normalizeUrl($url);
1012 $this->purgeProxyCache($url);
1013 $localResult = true;
1014 $cacheDir = $this->getCacheDir();
1015 $knownDeviceTypes = Device::getKnownTypes();
1016 foreach ($knownDeviceTypes as $deviceType) {
1017 $urlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, false, $this->config->URLPathVersion);
1018 $invalidatedUrlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, true, $this->config->URLPathVersion);
1019 $localResult &= Filesystem::deleteDir($urlDir);
1020 $localResult &= Filesystem::deleteDir($invalidatedUrlDir);
1021 }
1022 return $localResult;
1023 }
1024
1025 public function invalidateLocalUrlCache($url) {
1026 $url = $this->normalizeUrl($url);
1027 $this->purgeProxyCache($url);
1028 $localResult = true;
1029 $cacheDir = $this->getCacheDir();
1030 $knownDeviceTypes = Device::getKnownTypes();
1031 foreach ($knownDeviceTypes as $deviceType) {
1032 $urlDir = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, false, $this->config->URLPathVersion);
1033 $urlDirInvalid = PageCache::getUrlDir($cacheDir . "/" . $deviceType, $url, true, $this->config->URLPathVersion);
1034
1035 $this->invalidateDir($urlDir, $urlDirInvalid);
1036 }
1037 return $localResult;
1038 }
1039
1040 public function invalidateLocalCache() {
1041 $this->purgeProxyCache();
1042 $this->config->LastFetch = 0;
1043 $this->setConfig($this->config);
1044
1045 $cacheDir = $this->getCacheDir();
1046 $knownDeviceTypes = Device::getKnownTypes();
1047 foreach ($knownDeviceTypes as $deviceType) {
1048 $deviceTypeDir = $cacheDir . "/" . $deviceType;
1049 Filesystem::dirForeach($deviceTypeDir, function($urlDir) {
1050 if (substr($urlDir, -2) !== "_i") {
1051 $this->invalidateDir($urlDir, $urlDir . "_i");
1052 }
1053 });
1054 }
1055 return true;
1056 }
1057
1058 private function invalidateDir($urlDir, $urlDirInvalid) {
1059 if (Filesystem::fileExists($urlDirInvalid)) {
1060 Filesystem::dirForeach($urlDir, function($file) use ($urlDirInvalid) {
1061 Filesystem::rename($file, $urlDirInvalid . "/" . basename($file));
1062 });
1063
1064 Filesystem::deleteDir($urlDir);
1065 } else {
1066 Filesystem::rename($urlDir, $urlDirInvalid);
1067 }
1068 Filesystem::touch($urlDirInvalid);
1069 }
1070
1071 public function integrationUrl($widget, $version = null) {
1072 $integration = new IntegrationUrl($widget, $this->siteId, $this->siteSecret, $version);
1073
1074 return $integration->getUrl();
1075 }
1076
1077 public function embedJsUrl() {
1078 $embedjs = new Embedjs();
1079
1080 return $embedjs->getUrl();
1081 }
1082
1083 public function enableSafeMode() {
1084 $this->api->safe_mode->enable();
1085 $this->fetchConfig();
1086 }
1087
1088 public function disableSafeMode() {
1089 $this->api->safe_mode->disable();
1090 $this->fetchConfig();
1091 }
1092
1093 public function enableCartCache() {
1094 $this->api->enableCartCache();
1095 }
1096
1097 public function disableCartCache() {
1098 $this->api->disableCartCache();
1099 }
1100
1101 public function getStatefulCacheHandlerScript() {
1102 if ($this->config->StatefulCache->Status && $this->config->StatefulCache->HandlerScript) {
1103 $keyRevision = $this->elementRevision->get();
1104 return '<script id="nitro-stateful-cache" nitro-exclude>' . str_replace("KEY_REVISION", $keyRevision, $this->config->StatefulCache->HandlerScript) . '</script>';
1105 }
1106
1107 return "";
1108 }
1109
1110 private function loadConfig() {
1111 $file = $this->getConfigFile();
1112
1113 $config = array();
1114 if (Filesystem::fileExists($file) || $this->fetchConfig(true)) {
1115 $config = json_decode(Filesystem::fileGetContents($file));
1116 if (empty($config->SDKVersion) || $config->SDKVersion !== NitroPack::VERSION || empty($config->LastFetch) || time() - $config->LastFetch >= $this->configTTL) {
1117 if ($this->getHealthStatus() === HealthStatus::HEALTHY) {
1118 if ($this->fetchConfig()) {
1119 $config = json_decode(Filesystem::fileGetContents($file));
1120 } else {
1121 throw new NoConfigException("Can't load config file");
1122 }
1123 }
1124 }
1125 $this->config = $config;
1126 } else {
1127 throw new NoConfigException("Can't load config file");
1128 }
1129 }
1130
1131 private function getConfigFile() {
1132 $configFile = $this->configFile;
1133
1134 $filename = array_pop($configFile);
1135
1136 $filename = $this->siteId . '-' . $filename;
1137
1138 array_push($configFile, $filename);
1139 array_unshift($configFile, $this->dataDir);
1140
1141 return Filesystem::getOsPath($configFile);
1142 }
1143
1144 private function lockPageCache() {
1145 $filename = $this->getPageCacheLockFilename();
1146
1147 if (Filesystem::fileExists($filename)) {
1148 $sem = 1 + (int)Filesystem::fileGetContents($filename);
1149 } else {
1150 $sem = 1;
1151 }
1152
1153 return !!Filesystem::filePutContents($filename, $sem);
1154 }
1155
1156 private function unlockPageCache() {
1157 $filename = $this->getPageCacheLockFilename();
1158
1159 if (Filesystem::fileExists($filename)) {
1160 $sem = (int)Filesystem::fileGetContents($filename);
1161
1162 $sem--;
1163
1164 if ($sem <= 0) {
1165 return !!Filesystem::deleteFile($filename);
1166 } else {
1167 return !!Filesystem::filePutContents($filename, $sem);
1168 }
1169 }
1170
1171 return false;
1172 }
1173
1174 private function isPageCacheLocked() {
1175 $filename = $this->getPageCacheLockFilename();
1176
1177 if (!Filesystem::fileExists($filename)) {
1178 return false;
1179 } else {
1180 if (time() - Filesystem::fileMTime($filename) <= self::PAGECACHE_LOCK_EXPIRATION_TIME) {
1181 return true;
1182 } else {
1183 Filesystem::deleteFile($filename);
1184
1185 return false;
1186 }
1187 }
1188
1189 // We should never get here, so consider this a default return value in case of future changes
1190 return false;
1191 }
1192
1193 private function getPageCacheLockFilename() {
1194 $pageCacheLockFile = $this->pageCacheLockFile;
1195 array_unshift($pageCacheLockFile, $this->dataDir);
1196 return Filesystem::getOsPath($pageCacheLockFile);
1197 }
1198
1199 private function normalizeUrl($url) {
1200 $urlObj = new Url($url);
1201 return $urlObj->getNormalized();
1202 }
1203
1204 /**
1205 * Substitutes values from a template based on URL components
1206 * @param string $url The URL, from which to extract substitution values
1207 * @param string $template The template to use
1208 * @return string
1209 */
1210 private function valueFromTemplate($url, $template) {
1211 $urlObj = new Url($url);
1212
1213 $proxyPurgeTemplate = array
1214 (
1215 '{{targetUrl}}' => $urlObj->getNormalized(),
1216 '{{targetProtocol}}' => $urlObj->getScheme(),
1217 '{{targetHost}}' => $urlObj->getHost(),
1218 '{{targetPath}}' => $urlObj->getPath(),
1219 '{{targetQuery}}' => $urlObj->getQuery(),
1220 );
1221
1222 $templateKeys = array_keys($proxyPurgeTemplate);
1223 $templateVals = array_values($proxyPurgeTemplate);
1224 $constructed = str_replace($templateKeys, $templateVals, $template);
1225
1226 return $constructed;
1227 }
1228 }