diff --git a/config/static_caching.php b/config/static_caching.php index e190b9729d..8b11db0978 100644 --- a/config/static_caching.php +++ b/config/static_caching.php @@ -125,7 +125,7 @@ 'nocache' => 'cache', - 'nocache_js_position' => 'body', + 'decouple_nocache_scripts' => false, /* |-------------------------------------------------------------------------- diff --git a/routes/web.php b/routes/web.php index 5f2755063f..5b0231d81f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -19,7 +19,8 @@ use Statamic\Http\Middleware\AuthGuard; use Statamic\Http\Middleware\CP\AuthGuard as CPAuthGuard; use Statamic\Statamic; -use Statamic\StaticCaching\NoCache\Controller as NoCacheController; +use Statamic\StaticCaching\NoCache\CsrfTokenController; +use Statamic\StaticCaching\NoCache\NoCacheController; use Statamic\StaticCaching\NoCache\NoCacheLocalize; Route::name('statamic.')->group(function () { @@ -49,14 +50,16 @@ Route::post('activate', [ActivateAccountController::class, 'reset'])->name('account.activate.action'); }); + Route::post('nocache', NoCacheController::class) + ->middleware(NoCacheLocalize::class) + ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']); + + Route::post('csrf', CsrfTokenController::class) + ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']); + Statamic::additionalActionRoutes(); }); - Route::prefix(config('statamic.routes.action')) - ->post('nocache', NoCacheController::class) - ->middleware(NoCacheLocalize::class) - ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']); - if (OAuth::enabled()) { Route::get(config('statamic.oauth.routes.login'), [OAuthController::class, 'redirectToProvider'])->name('oauth.login'); Route::match(['get', 'post'], config('statamic.oauth.routes.callback'), [OAuthController::class, 'handleProviderCallback']) diff --git a/src/Facades/StaticCache.php b/src/Facades/StaticCache.php index df7aadc73a..47055c044e 100644 --- a/src/Facades/StaticCache.php +++ b/src/Facades/StaticCache.php @@ -16,6 +16,7 @@ * @method static ApplicationCacher createApplicationDriver(array $config) * @method static \Illuminate\Cache\Repository cacheStore() * @method static void flush() + * @method static void csrfTokenJs(string $js) * @method static void nocacheJs(string $js) * @method static void nocachePlaceholder(string $placeholder) * @method static void includeJs() diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index 0a0a594b81..f0bf9329bd 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -26,6 +26,11 @@ class FileCacher extends AbstractCacher */ private $shouldOutputJs = false; + /** + * @var string + */ + private $csrfTokenJs; + /** * @var string */ @@ -196,16 +201,107 @@ private function isLongQueryStringPath($path) return Str::contains($path, '_lqs_'); } + public function setCsrfTokenJs(string $js) + { + $this->csrfTokenJs = $js; + } + public function setNocacheJs(string $js) { $this->nocacheJs = $js; } - public function getNocacheJs(): string + public function getCsrfTokenJs(): string { $csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT; $default = << response.json()) + .then((data) => { + for (const input of document.querySelectorAll('input[value="$csrfPlaceholder"]')) { + input.value = data.csrf; + } + + for (const meta of document.querySelectorAll('meta[content="$csrfPlaceholder"]')) { + meta.content = data.csrf; + } + + for (const input of document.querySelectorAll('script[data-csrf="$csrfPlaceholder"]')) { + input.setAttribute('data-csrf', data.csrf); + } + + if (window.hasOwnProperty('livewire_token')) { + window.livewire_token = data.csrf + } + + if (window.hasOwnProperty('livewireScriptConfig')) { + window.livewireScriptConfig.csrf = data.csrf + } + + document.dispatchEvent(new CustomEvent('statamic:csrf.replaced', { detail: data })); + }); +})(); +EOT; + + return $this->csrfTokenJs ?? $default; + } + + public function shouldOutputDecoupledScripts(): bool + { + return config('statamic.static_caching.decouple_nocache_scripts', false); + } + + public function getNocacheJs(): string + { + $default = $this->shouldOutputDecoupledScripts() + ? $this->getDecoupledNocacheJs() + : $this->getLegacyNocacheJs(); + + return $this->nocacheJs ?? $default; + } + + protected function getDecoupledNocacheJs(): string + { + return <<<'EOT' +(function() { + var els = document.getElementsByClassName('nocache'); + var map = {}; + for (var i = 0; i < els.length; i++) { + var section = els[i].getAttribute('data-nocache'); + map[section] = els[i]; + } + + fetch('/!/nocache', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: window.location.href.split('#')[0], + sections: Object.keys(map) + }) + }) + .then((response) => response.json()) + .then((data) => { + const regions = data.regions; + for (var key in regions) { + if (map[key]) map[key].outerHTML = regions[key]; + } + + document.dispatchEvent(new CustomEvent('statamic:nocache.replaced', { detail: data })); + }); +})(); +EOT; + } + + protected function getLegacyNocacheJs(): string + { + $csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT; + + return <<nocacheJs ?? $default; } public function shouldOutputJs(): bool diff --git a/src/StaticCaching/NoCache/CsrfTokenController.php b/src/StaticCaching/NoCache/CsrfTokenController.php new file mode 100644 index 0000000000..b42933cedb --- /dev/null +++ b/src/StaticCaching/NoCache/CsrfTokenController.php @@ -0,0 +1,13 @@ + csrf_token(), + ]; + } +} diff --git a/src/StaticCaching/NoCache/Controller.php b/src/StaticCaching/NoCache/NoCacheController.php similarity index 97% rename from src/StaticCaching/NoCache/Controller.php rename to src/StaticCaching/NoCache/NoCacheController.php index f3e4e51a95..4d3e58f8d8 100644 --- a/src/StaticCaching/NoCache/Controller.php +++ b/src/StaticCaching/NoCache/NoCacheController.php @@ -6,7 +6,7 @@ use Statamic\StaticCaching\Replacers\NoCacheReplacer; use Statamic\Support\Str; -class Controller +class NoCacheController { public function __invoke(Request $request, Session $session) { diff --git a/src/StaticCaching/Replacers/CsrfTokenReplacer.php b/src/StaticCaching/Replacers/CsrfTokenReplacer.php index e7c0d562ef..4d6f6abdd4 100644 --- a/src/StaticCaching/Replacers/CsrfTokenReplacer.php +++ b/src/StaticCaching/Replacers/CsrfTokenReplacer.php @@ -4,6 +4,8 @@ use Illuminate\Http\Response; use Statamic\Facades\StaticCache; +use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\Replacer; use Statamic\Support\Str; @@ -12,6 +14,26 @@ class CsrfTokenReplacer implements Replacer const REPLACEMENT = 'STATAMIC_CSRF_TOKEN'; public function prepareResponseToCache(Response $response, Response $initial) + { + $this->replaceInResponse($response); + + $this->modifyFullMeasureResponse($response); + } + + public function replaceInCachedResponse(Response $response) + { + if (! $response->getContent()) { + return; + } + + $response->setContent(str_replace( + self::REPLACEMENT, + csrf_token(), + $response->getContent() + )); + } + + private function replaceInResponse(Response $response) { if (! $content = $response->getContent()) { return; @@ -34,16 +56,34 @@ public function prepareResponseToCache(Response $response, Response $initial) )); } - public function replaceInCachedResponse(Response $response) + private function modifyFullMeasureResponse(Response $response) { - if (! $response->getContent()) { + $cacher = app(Cacher::class); + + if (! $cacher instanceof FileCacher) { return; } - $response->setContent(str_replace( - self::REPLACEMENT, - csrf_token(), - $response->getContent() - )); + if (! $cacher->shouldOutputJs()) { + return; + } + + if (! $cacher->shouldOutputDecoupledScripts()) { + return; + } + + $contents = $response->getContent(); + + $insertBefore = collect([ + Str::position($contents, ''), + ])->filter()->min(); + + $js = ""; + + $contents = Str::substrReplace($contents, $js, $insertBefore, 0); + + $response->setContent($contents); } } diff --git a/src/StaticCaching/Replacers/NoCacheReplacer.php b/src/StaticCaching/Replacers/NoCacheReplacer.php index 5faaecc127..d685c965a4 100644 --- a/src/StaticCaching/Replacers/NoCacheReplacer.php +++ b/src/StaticCaching/Replacers/NoCacheReplacer.php @@ -3,7 +3,6 @@ namespace Statamic\StaticCaching\Replacers; use Illuminate\Http\Response; -use Illuminate\Support\Str; use Statamic\Facades\StaticCache; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\Cachers\FileCacher; @@ -79,40 +78,12 @@ private function modifyFullMeasureResponse(Response $response) $contents = $response->getContent(); if ($cacher->shouldOutputJs()) { - $contents = match ($pos = $this->insertPosition()) { - 'head' => $this->insertJsInHead($contents, $cacher), - 'body' => $this->insertJsInBody($contents, $cacher), - default => throw new \Exception('Invalid nocache js insert position ['.$pos.']'), - }; + $js = $cacher->getNocacheJs(); + $contents = str_replace('', '', $contents); } $contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents); $response->setContent($contents); } - - private function insertPosition() - { - return config('statamic.static_caching.nocache_js_position', 'body'); - } - - private function insertJsInHead($contents, $cacher) - { - $insertBefore = collect([ - Str::position($contents, ''), - ])->filter()->min(); - - $js = ""; - - return Str::substrReplace($contents, $js, $insertBefore, 0); - } - - private function insertJsInBody($contents, $cacher) - { - $js = $cacher->getNocacheJs(); - - return str_replace('', '', $contents); - } } diff --git a/src/StaticCaching/StaticCacheManager.php b/src/StaticCaching/StaticCacheManager.php index 9af77ab541..4fb2716de0 100644 --- a/src/StaticCaching/StaticCacheManager.php +++ b/src/StaticCaching/StaticCacheManager.php @@ -101,6 +101,11 @@ private function flushNocache() $this->cacheStore()->forget('nocache::urls'); } + public function csrfTokenJs(string $js) + { + $this->fileDriver()->setCsrfTokenJs($js); + } + public function nocacheJs(string $js) { $this->fileDriver()->setNocacheJs($js); diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index ed31ae1425..01f7c67550 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\File; use Statamic\Facades\StaticCache; +use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\NoCache\Session; use Tests\FakesContent; use Tests\FakesViews; @@ -157,4 +158,36 @@ public function it_should_add_the_javascript_if_there_is_a_csrf_token() '', ]), file_get_contents($this->dir.'/about_.html')); } + + #[Test] + public function it_decouples_csrf_and_nocache_scripts_if_option_is_enabled() + { + $this->app['config']->set('statamic.static_caching.decouple_nocache_scripts', true); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ csrf_token }}'); + + $this->createPage('about'); + + StaticCache::nocacheJs('js here'); + + $csrfTokenScript = ''; + $nocacheScript = ''; + + $this->assertFalse(file_exists($this->dir.'/about_.html')); + + $response = $this + ->get('/about') + ->assertOk(); + + // Initial response should be dynamic and not contain javascript. + $this->assertEquals(''.csrf_token().'', $response->getContent()); + + // The cached response should have the token placeholder, and the javascript. + $this->assertTrue(file_exists($this->dir.'/about_.html')); + $this->assertEquals(vsprintf("{$csrfTokenScript}STATAMIC_CSRF_TOKEN%s", [ + $nocacheScript, + ]), file_get_contents($this->dir.'/about_.html')); + } }