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