Api
7 months ago
Integrations
1 month 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
1 month ago
NoConfigException.php
1 year ago
Pagecache.php
5 months 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
Pagecache.php
530 lines
| 1 | <?php |
| 2 | |
| 3 | namespace NitroPack\SDK; |
| 4 | |
| 5 | use \NitroPack\Url\Url; |
| 6 | |
| 7 | class Pagecache |
| 8 | { |
| 9 | protected $url; |
| 10 | protected $cookies; |
| 11 | protected $supportedCookies; |
| 12 | protected $dataDir; |
| 13 | protected $isAjax; |
| 14 | protected $referer; |
| 15 | protected $parent; |
| 16 | protected $Device; |
| 17 | protected $useCompression; |
| 18 | protected $useInvalidated; |
| 19 | private $urlPathVersion; |
| 20 | private $cookiesProvider; |
| 21 | private $geotVariations; |
| 22 | private $NotModifiedHeaderEnabled = false; |
| 23 | |
| 24 | public static function getUrlDir($dataDir, $url, $useInvalidated = false, $pathVersion = 1) |
| 25 | { |
| 26 | $safeUrl = str_replace(array('/', '?', ':', ';', '=', '&', '.', '--', '%', '~'), '-', $url); |
| 27 | if (defined('NITRO_DEBUG_MODE') && NITRO_DEBUG_MODE) { |
| 28 | $urlDir = $dataDir . "/" . $safeUrl; |
| 29 | } else { |
| 30 | switch ($pathVersion) { |
| 31 | case 2: |
| 32 | $urlDir = $dataDir . "/" . md5($url); |
| 33 | break; |
| 34 | default: |
| 35 | $urlDir = $dataDir . "/" . md5($safeUrl); |
| 36 | break; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | if ($useInvalidated) { |
| 41 | return $urlDir . "_i"; |
| 42 | } else { |
| 43 | return $urlDir; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | public function __construct($url, $userAgent, $cookies = array(), $supportedCookies = array(), $isAjax = false, $referer = NULL) |
| 48 | { |
| 49 | $this->url = new Url($url); |
| 50 | $this->cookies = $cookies; |
| 51 | $this->supportedCookies = $supportedCookies; |
| 52 | $this->dataDir = NULL; |
| 53 | $this->isAjax = $isAjax; |
| 54 | $this->parent = NULL; |
| 55 | $this->Device = new Device($userAgent); |
| 56 | $this->useCompression = false; |
| 57 | $this->useInvalidated = false; |
| 58 | $this->cookiesProvider = NULL; |
| 59 | $this->geotVariations = new \stdClass; |
| 60 | $this->setUrlPathVersion(1); |
| 61 | $this->setReferer($referer); |
| 62 | } |
| 63 | |
| 64 | public function enableCompression() |
| 65 | { |
| 66 | $this->useCompression = true; |
| 67 | if ($this->parent) { |
| 68 | $this->parent->enableCompression(); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | public function disableCompression() |
| 73 | { |
| 74 | $this->useCompression = false; |
| 75 | if ($this->parent) { |
| 76 | $this->parent->disableCompression(); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | public function useInvalidated($useInvalidated) |
| 81 | { |
| 82 | $this->useInvalidated = $useInvalidated; |
| 83 | if ($this->parent) { |
| 84 | $this->parent->useInvalidated($useInvalidated); |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | public function getUseInvalidated() |
| 89 | { |
| 90 | return $this->useInvalidated; |
| 91 | } |
| 92 | |
| 93 | public function setReferer($referer) |
| 94 | { |
| 95 | $this->referer = $referer ? new Url($referer) : NULL; |
| 96 | if ($this->referer) { |
| 97 | $this->parent = new Pagecache($this->referer->getNormalized(), $this->Device->getUserAgent(), $this->cookies, $this->supportedCookies); |
| 98 | $this->parent->setUrlPathVersion($this->urlPathVersion); |
| 99 | //$this->parent->setCookiesProvider($this->cookiesProvider); |
| 100 | |
| 101 | if (count(get_object_vars($this->geotVariations)) > 0) { |
| 102 | $this->parent->setGeotVariations($this->geotVariations); |
| 103 | } |
| 104 | |
| 105 | if ($this->dataDir) { |
| 106 | $this->parent->setDataDir(dirname($this->dataDir)); |
| 107 | } |
| 108 | } else { |
| 109 | $this->parent = NULL; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | public function setUrlPathVersion($version = 1) |
| 114 | { |
| 115 | $this->urlPathVersion = $version; |
| 116 | if ($this->parent) { |
| 117 | $this->parent->setUrlPathVersion($version); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | public function getReferer() |
| 122 | { |
| 123 | return $this->referer; |
| 124 | } |
| 125 | |
| 126 | public function getParent() |
| 127 | { |
| 128 | return $this->parent; |
| 129 | } |
| 130 | |
| 131 | public function setDataDir($dir) |
| 132 | { |
| 133 | if ($dir === null) { |
| 134 | $this->dataDir = null; |
| 135 | } else { |
| 136 | $this->dataDir = $dir . "/" . $this->Device->getType(); |
| 137 | } |
| 138 | |
| 139 | if ($this->parent) { |
| 140 | $this->parent->setDataDir($dir); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | public function setCookiesProvider($provider) |
| 145 | { |
| 146 | $this->cookiesProvider = $provider; |
| 147 | } |
| 148 | |
| 149 | public function setGeotVariations($geotVariations) |
| 150 | { |
| 151 | $this->geotVariations = $geotVariations; |
| 152 | } |
| 153 | |
| 154 | public function enableNotModifiedHeader() |
| 155 | { |
| 156 | $this->NotModifiedHeaderEnabled = true; |
| 157 | } |
| 158 | |
| 159 | public function hasCache() |
| 160 | { |
| 161 | // If there is no cache for the parent we do not need to check cache existance for the current object, because we only serve AJAX cache for pages already cached by NitroPack |
| 162 | if ($this->parent && !$this->parent->hasCache()) return false; |
| 163 | if ($this->useInvalidated) { |
| 164 | $this->convertToStaleCache(); |
| 165 | return Filesystem::fileExists($this->getCachefilePath("stale")); |
| 166 | } else { |
| 167 | return Filesystem::fileExists($this->getCachefilePath()); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | public function hasExpired($ttl = 86400, $cacheRevision = NULL) |
| 172 | { |
| 173 | // If the cache for the parent is expired we do not need to check cache for the current object, because we only serve AJAX cache for pages already cached by NitroPack |
| 174 | if ($this->parent && $this->parent->hasExpired($ttl, $cacheRevision)) return true; |
| 175 | |
| 176 | if ($this->useInvalidated) { |
| 177 | $this->convertToStaleCache(); |
| 178 | $cachefilePath = $this->getCachefilePath("stale"); |
| 179 | $mtime = Filesystem::fileMtime(dirname($cachefilePath)); |
| 180 | } else { |
| 181 | $cachefilePath = $this->getCachefilePath(); |
| 182 | $mtime = Filesystem::fileMtime($cachefilePath); |
| 183 | } |
| 184 | |
| 185 | $now = time(); |
| 186 | |
| 187 | if ($now - $mtime >= $ttl) { |
| 188 | return true; |
| 189 | } else { |
| 190 | try { |
| 191 | $headers = Filesystem::fileGetHeaders($cachefilePath); |
| 192 | if (!empty($headers["x-nitro-expires"]) && $now > (int)$headers["x-nitro-expires"]) { |
| 193 | return true; |
| 194 | } |
| 195 | |
| 196 | if ( |
| 197 | !$this->useInvalidated && |
| 198 | !empty($headers["x-cache-ctime"]) && |
| 199 | $now - (int)$headers["x-cache-ctime"] > $ttl |
| 200 | ) { |
| 201 | return true; |
| 202 | } |
| 203 | |
| 204 | $expectedRev = !empty($cacheRevision) ? $cacheRevision : NULL; |
| 205 | $cachedRev = !empty($headers["x-nitro-rev"]) ? $headers["x-nitro-rev"] : NULL; |
| 206 | // Check if the revision has changed which makes cache file obsolete |
| 207 | if ($expectedRev && $expectedRev !== $cachedRev) { |
| 208 | return true; |
| 209 | } |
| 210 | } catch (\Exception $e) { |
| 211 | return true; |
| 212 | } |
| 213 | } |
| 214 | return false; |
| 215 | } |
| 216 | |
| 217 | public function getRemainingTtl($ttl = 86400) |
| 218 | { |
| 219 | // If there is a parent cache file return its remaining TTL |
| 220 | if ($this->parent) return $this->parent->getRemainingTtl(); |
| 221 | |
| 222 | if ($this->useInvalidated) { |
| 223 | return 0; |
| 224 | } else { |
| 225 | $cachefilePath = $this->getCachefilePath(); |
| 226 | } |
| 227 | |
| 228 | $now = time(); |
| 229 | try { |
| 230 | $headers = Filesystem::fileGetHeaders($cachefilePath); |
| 231 | if (!empty($headers["x-nitro-expires"])) { |
| 232 | $expireTime = (int)$headers["x-nitro-expires"]; |
| 233 | } else if (!empty($headers["x-cache-ctime"])) { |
| 234 | $expireTime = (int)$headers["x-cache-ctime"] + $ttl; |
| 235 | } else { |
| 236 | $mtime = Filesystem::fileMtime($cachefilePath); |
| 237 | $expireTime = $mtime + $ttl; |
| 238 | } |
| 239 | |
| 240 | return max($expireTime - $now, 0); |
| 241 | } catch (\Exception $e) { |
| 242 | return 0; |
| 243 | } |
| 244 | |
| 245 | return 0; |
| 246 | } |
| 247 | |
| 248 | public function setContent($content, $headers = NULL) |
| 249 | { |
| 250 | if (!$this->dataDir) return; |
| 251 | |
| 252 | $filePath = $this->getCachefilePath(); |
| 253 | if (Filesystem::createDir(dirname($filePath))) { |
| 254 | if (!Filesystem::filePutContents($filePath, $content, $this->headersFlatten($headers))) { |
| 255 | return false; |
| 256 | } else { |
| 257 | if ($headers && !empty($headers["x-cache-ctime"])) { |
| 258 | Filesystem::touch($filePath, (int)$headers["x-cache-ctime"]); |
| 259 | } |
| 260 | return $this->compress($filePath); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | private function headersFlatten($headers) |
| 266 | { |
| 267 | // The headers from fileGetAll come as associative array |
| 268 | // They must be converted to an array of strings before being passed to filePutContents, which is what this function does |
| 269 | $headersFlat = array(); |
| 270 | foreach ($headers as $name => $value) { |
| 271 | if (is_array($value)) { |
| 272 | foreach ($value as $subValue) { |
| 273 | $headersFlat[] = $name . ":" . $subValue; |
| 274 | } |
| 275 | } else { |
| 276 | $headersFlat[] = $name . ":" . $value; |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | return $headersFlat; |
| 281 | } |
| 282 | |
| 283 | private function compress($filePath) |
| 284 | { |
| 285 | $fileInfo = Filesystem::fileGetAll($filePath); |
| 286 | $fileInfo->headers["Content-Encoding"] = "gzip"; |
| 287 | $gzPath = $filePath . ".gz"; |
| 288 | $res = Filesystem::filePutContents($gzPath, gzencode($fileInfo->content, 4), $this->headersFlatten($fileInfo->headers)); // save compressed version of the cache file |
| 289 | |
| 290 | if ($res && $fileInfo->headers && !empty($fileInfo->headers["x-cache-ctime"])) { |
| 291 | Filesystem::touch($gzPath, (int)$fileInfo->headers["x-cache-ctime"]); |
| 292 | } |
| 293 | |
| 294 | return $res; |
| 295 | } |
| 296 | |
| 297 | public function readfile() |
| 298 | { |
| 299 | $setOwnLastModified = false; |
| 300 | |
| 301 | // 304 Not Modified improvement - only when enabled via config |
| 302 | if ($this->NotModifiedHeaderEnabled) { |
| 303 | $lastModified = $this->getCacheCreationTime(); |
| 304 | |
| 305 | // Set Last-Modified header based on x-cache-ctime |
| 306 | if ($lastModified > 0) { |
| 307 | $lastModifiedHeader = gmdate('D, d M Y H:i:s', $lastModified) . ' GMT'; |
| 308 | header('Last-Modified: ' . $lastModifiedHeader); |
| 309 | $setOwnLastModified = true; |
| 310 | |
| 311 | // Calculate current variation hash |
| 312 | $currentVariationPrefix = $this->getVariationCookiePrefix(); |
| 313 | $currentVariationHash = md5($currentVariationPrefix); |
| 314 | |
| 315 | // Get last served variation from tracking cookie |
| 316 | $lastVariationHash = isset($_COOKIE['np_cache_variation']) |
| 317 | ? $_COOKIE['np_cache_variation'] |
| 318 | : ''; |
| 319 | |
| 320 | // If variation is different now - bypass 304 mechanism. |
| 321 | $variationChanged = ($currentVariationHash !== $lastVariationHash); |
| 322 | |
| 323 | // Update tracking cookie to current variation |
| 324 | if ($variationChanged) { |
| 325 | setcookie('np_cache_variation', $currentVariationHash, 0, '/'); |
| 326 | } |
| 327 | |
| 328 | if (!$variationChanged) { |
| 329 | $httpModifiedSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) |
| 330 | ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) |
| 331 | : 0; |
| 332 | |
| 333 | if ($httpModifiedSince && $httpModifiedSince >= $lastModified) { |
| 334 | http_response_code(304); |
| 335 | return; |
| 336 | } |
| 337 | } |
| 338 | // If variation changed, skip 304 - always serve full response |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | // Normal flow - serve cache file |
| 343 | $cacheFileContent = $this->returnCacheFileContent(); |
| 344 | $headers = $cacheFileContent[0]; |
| 345 | $contents = $cacheFileContent[1]; |
| 346 | |
| 347 | foreach ($headers as $header) { |
| 348 | // Skip Last-Modified from cache file only if we set our own from x-cache-ctime |
| 349 | if ($setOwnLastModified && strtolower($header['name']) === 'last-modified') { |
| 350 | continue; |
| 351 | } |
| 352 | header($header['name'] . ': ' . $header['value'], false); |
| 353 | } |
| 354 | |
| 355 | echo $contents; |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * Get cache creation time from x-cache-ctime header. |
| 360 | */ |
| 361 | private function getCacheCreationTime() |
| 362 | { |
| 363 | if ($this->useInvalidated) { |
| 364 | $filePath = $this->getCachefilePath("stale"); |
| 365 | } else { |
| 366 | $filePath = $this->getCachefilePath(); |
| 367 | } |
| 368 | |
| 369 | try { |
| 370 | $headers = Filesystem::fileGetHeaders($filePath); |
| 371 | return !empty($headers["x-cache-ctime"]) ? (int)$headers["x-cache-ctime"] : 0; |
| 372 | } catch (\Exception $e) { |
| 373 | return 0; |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * Get variation cookie prefix (only cookies that create cache variations). |
| 379 | * Excludes the tracking cookie to prevent circular dependency. |
| 380 | * Returns empty string if no variation cookies are present. |
| 381 | */ |
| 382 | private function getVariationCookiePrefix() |
| 383 | { |
| 384 | $prefix = ''; |
| 385 | |
| 386 | $cookies = $this->cookiesProvider ? call_user_func($this->cookiesProvider) : $this->cookies; |
| 387 | ksort($cookies); |
| 388 | |
| 389 | foreach ($cookies as $cookieName => $cookieValue) { |
| 390 | // Exclude our tracking cookie to prevent circular dependency |
| 391 | // (tracking cookie value depends on this prefix) |
| 392 | if ($cookieName === 'np_cache_variation') { |
| 393 | continue; |
| 394 | } |
| 395 | |
| 396 | foreach ($this->supportedCookies as $cookie) { |
| 397 | if (preg_match('/' . NitroPack::wildcardToRegex($cookie) . '/', $cookieName)) { |
| 398 | $prefix .= $cookieName . '=' . $cookieValue . ';'; |
| 399 | break; |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | return $prefix; |
| 405 | } |
| 406 | |
| 407 | public function returnCacheFileContent() |
| 408 | { |
| 409 | if ($this->useInvalidated) { |
| 410 | $this->convertToStaleCache(); |
| 411 | $filePath = $this->getCachefilePath("stale"); |
| 412 | } else { |
| 413 | $filePath = $this->getCachefilePath(); |
| 414 | } |
| 415 | if ($this->canUseCompression() && (ini_get("zlib.output_compression") === "0" || ini_set("zlib.output_compression", "0") !== false)) { |
| 416 | $filePath .= ".gz"; |
| 417 | } |
| 418 | $file = Filesystem::fileGetAll($filePath); |
| 419 | $returnHeaders = []; |
| 420 | |
| 421 | foreach ($file->headers as $name => $value) { |
| 422 | if (is_array($value)) { |
| 423 | foreach ($value as $subVal) { |
| 424 | $returnHeaders[] = [ |
| 425 | 'name' => $name, |
| 426 | 'value' => $subVal |
| 427 | ]; |
| 428 | } |
| 429 | } else { |
| 430 | $returnHeaders[] = [ |
| 431 | 'name' => $name, |
| 432 | 'value' => $value |
| 433 | ]; |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | return [ $returnHeaders, $file->content ]; |
| 438 | } |
| 439 | |
| 440 | public function getFileContents() |
| 441 | { |
| 442 | if ($this->useInvalidated) { |
| 443 | $this->convertToStaleCache(); |
| 444 | return Filesystem::fileGetContents($this->getCachefilePath("stale")); |
| 445 | } |
| 446 | return Filesystem::fileGetContents($this->getCachefilePath()); |
| 447 | } |
| 448 | |
| 449 | private function convertToStaleCache() |
| 450 | { |
| 451 | $staleFile = $this->getCachefilePath("stale"); |
| 452 | if (!Filesystem::fileExists($staleFile)) { |
| 453 | $freshFile = $this->getCachefilePath(); |
| 454 | if (Filesystem::fileExists($freshFile)) { |
| 455 | $fileInfo = Filesystem::fileGetAll($freshFile); |
| 456 | $newContent = str_replace("NITROPACK_STATE='FRESH'", "NITROPACK_STATE='STALE'", $fileInfo->content); |
| 457 | Filesystem::filePutContents($freshFile, $newContent, $this->headersFlatten($fileInfo->headers)); |
| 458 | Filesystem::rename($freshFile, $staleFile); |
| 459 | Filesystem::deleteFile($freshFile . ".gz"); |
| 460 | $this->compress($staleFile); |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | private function cookiePrefix() |
| 466 | { |
| 467 | $prefix = ''; |
| 468 | |
| 469 | $cookies = $this->cookiesProvider ? call_user_func($this->cookiesProvider) : $this->cookies; |
| 470 | |
| 471 | ksort($cookies); |
| 472 | |
| 473 | foreach ($cookies as $cookieName => $cookieValue) { |
| 474 | foreach ($this->supportedCookies as $cookie) { |
| 475 | if (preg_match('/' . NitroPack::wildcardToRegex($cookie) . '/', $cookieName)) { |
| 476 | if (preg_match("/^nitro_geot_(.*)/", $cookieName, $matches)) { |
| 477 | $geotComponent = $matches[1]; |
| 478 | if (property_exists($this->geotVariations, $geotComponent) && !empty($this->geotVariations->$geotComponent) && !in_array($cookieValue, $this->geotVariations->$geotComponent)) { |
| 479 | $prefix .= $cookieName . '=' . $this->geotVariations->{$geotComponent}[0] . ';'; // Use the first variation as default |
| 480 | continue; |
| 481 | } |
| 482 | } |
| 483 | $prefix .= $cookieName . '=' . $cookieValue . ';'; |
| 484 | } |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | return substr(md5($prefix), 0, 16); |
| 489 | } |
| 490 | |
| 491 | private function sslPrefix() |
| 492 | { |
| 493 | return $this->url->getScheme() == "https" ? "ssl-" : ""; |
| 494 | } |
| 495 | |
| 496 | private function ajaxPrefix() |
| 497 | { |
| 498 | return $this->isAjax && $this->parent ? "ajax-" . md5($this->url->getNormalized()) . "-" : ""; |
| 499 | } |
| 500 | |
| 501 | private function customCachePrefix() |
| 502 | { |
| 503 | $customCachePrefix = NitroPack::getCustomCachePrefix(); |
| 504 | return $customCachePrefix ? $customCachePrefix . "-" : ""; |
| 505 | } |
| 506 | |
| 507 | private function isCompressionAllowed() |
| 508 | { |
| 509 | return isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false; |
| 510 | } |
| 511 | |
| 512 | private function canUseCompression() |
| 513 | { |
| 514 | return $this->useCompression && $this->isCompressionAllowed() && !headers_sent(); |
| 515 | } |
| 516 | |
| 517 | public function nameOfCachefile() |
| 518 | { |
| 519 | return $this->customCachePrefix() . $this->ajaxPrefix() . $this->sslPrefix() . $this->cookiePrefix() . ".html"; |
| 520 | } |
| 521 | |
| 522 | public function getCachefilePath($suffix = "") |
| 523 | { |
| 524 | if ($suffix) $suffix = "." . $suffix; |
| 525 | if ($this->isAjax && $this->referer) { |
| 526 | return self::getUrlDir($this->dataDir, $this->referer->getNormalized(), $this->useInvalidated, $this->urlPathVersion) . "/" . $this->nameOfCachefile() . $suffix; |
| 527 | } |
| 528 | return self::getUrlDir($this->dataDir, $this->url->getNormalized(), $this->useInvalidated, $this->urlPathVersion) . "/" . $this->nameOfCachefile() . $suffix; |
| 529 | } |
| 530 | } |