diff --git a/config/static_caching.php b/config/static_caching.php index 46766739505..2af89331ea4 100644 --- a/config/static_caching.php +++ b/config/static_caching.php @@ -142,6 +142,23 @@ \Statamic\StaticCaching\Replacers\NoCacheReplacer::class, ], + /* + |-------------------------------------------------------------------------- + | Script Delivery + |-------------------------------------------------------------------------- + | + | Full measure static caching injects small @else@endif diff --git a/routes/web.php b/routes/web.php index cb63cae04bd..8a947c7c485 100755 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ use Statamic\StaticCaching\NoCache\CsrfTokenController; use Statamic\StaticCaching\NoCache\NoCacheController; use Statamic\StaticCaching\NoCache\NoCacheLocalize; +use Statamic\StaticCaching\NoCache\ScriptController; Route::name('statamic.')->group(function () { Route::group(['prefix' => config('statamic.routes.action')], function () { @@ -109,6 +110,11 @@ Route::post('csrf', CsrfTokenController::class) ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\PreventRequestForgery']); + if (config('statamic.static_caching.script_delivery') === 'external') { + Route::get('nocache.js', [ScriptController::class, 'nocache'])->name('nocache.js'); + Route::get('csrf.js', [ScriptController::class, 'csrf'])->name('csrf.js'); + } + Statamic::additionalActionRoutes(); }); diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 05bb28b501f..f9be0a7e58e 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -105,6 +105,10 @@ public function boot() "{$this->root}/resources/views/extend/scaffolding" => resource_path('views/vendor/statamic/scaffolding'), ], 'statamic-scaffolding'); + $this->publishes([ + "{$this->root}/resources/views/static-caching" => resource_path('views/vendor/statamic/static-caching'), + ], 'statamic-static-caching'); + $this->app['redirect']->macro('cpRoute', function ($route, $parameters = []) { /** @var \Illuminate\Routing\Redirector $this */ return $this->to(cp_route($route, $parameters)); diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php index 2b339e40120..66d56809b18 100644 --- a/src/StaticCaching/Cachers/FileCacher.php +++ b/src/StaticCaching/Cachers/FileCacher.php @@ -248,102 +248,16 @@ public function setNocacheJs(string $js) 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.livewireScriptConfig) { - // Replaces token if Livewire is already available. Usually on fast networks. - window.livewireScriptConfig.csrf = data.csrf; - } else { - // Delays replacing the token until Livewire is initialized. Usually on slow networks. - document.addEventListener('livewire:init', () => window.livewireScriptConfig.csrf = data.csrf); - } - - document.dispatchEvent(new CustomEvent('statamic:csrf.replaced', { detail: data })); - }); -})(); -EOT; - - return $this->csrfTokenJs ?? $default; + return $this->csrfTokenJs ?? trim(view('statamic::static-caching.csrf-js', [ + 'csrfPlaceholder' => CsrfTokenReplacer::REPLACEMENT, + ])->render()); } public function getNocacheJs(): string { - $nocacheUrl = URL::makeRelative(route('statamic.nocache')); - - $default = << response.json()) - .then((data) => { - map = createMap(); - - const regions = data.regions; - for (var key in regions) { - if (map[key]) replaceElement(map[key], regions[key]); - } - - document.dispatchEvent(new CustomEvent('statamic:nocache.replaced', { detail: data })); - }); -})(); -EOT; - - return $this->nocacheJs ?? $default; + return $this->nocacheJs ?? trim(view('statamic::static-caching.nocache-js', [ + 'nocacheUrl' => URL::makeRelative(route('statamic.nocache')), + ])->render()); } public function shouldOutputJs(): bool diff --git a/src/StaticCaching/NoCache/ScriptController.php b/src/StaticCaching/NoCache/ScriptController.php new file mode 100644 index 00000000000..e3ec0a111b7 --- /dev/null +++ b/src/StaticCaching/NoCache/ScriptController.php @@ -0,0 +1,37 @@ +response($this->cacher()->getNocacheJs()); + } + + public function csrf(): Response + { + return $this->response($this->cacher()->getCsrfTokenJs()); + } + + private function cacher(): FileCacher + { + $cacher = app(Cacher::class); + + abort_unless($cacher instanceof FileCacher, 404); + + return $cacher; + } + + private function response(string $js): Response + { + return response($js) + ->header('Content-Type', 'application/javascript') + ->header('Cache-Control', 'public, max-age=3600') + ->setEtag(md5($js)); + } +} diff --git a/src/StaticCaching/Replacers/CsrfTokenReplacer.php b/src/StaticCaching/Replacers/CsrfTokenReplacer.php index 485b14564f1..054676ed6d3 100644 --- a/src/StaticCaching/Replacers/CsrfTokenReplacer.php +++ b/src/StaticCaching/Replacers/CsrfTokenReplacer.php @@ -4,6 +4,7 @@ use Illuminate\Http\Response; use Statamic\Facades\StaticCache; +use Statamic\Facades\URL; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\Replacer; @@ -81,10 +82,19 @@ private function modifyFullMeasureResponse(Response $response) Str::position($contents, ''), ])->filter()->min(); - $js = ""; - - $contents = Str::substrReplace($contents, $js, $insertBefore, 0); + $contents = Str::substrReplace($contents, $this->scriptTag($cacher), $insertBefore, 0); $response->setContent($contents); } + + private function scriptTag(FileCacher $cacher): string + { + $external = config('statamic.static_caching.script_delivery') === 'external'; + + return trim(view('statamic::static-caching.script', [ + 'inline' => ! $external, + 'src' => $external ? URL::makeRelative(route('statamic.csrf.js')) : null, + 'contents' => $external ? null : $cacher->getCsrfTokenJs(), + ])->render()); + } } diff --git a/src/StaticCaching/Replacers/NoCacheReplacer.php b/src/StaticCaching/Replacers/NoCacheReplacer.php index f7ca32fd97f..bae6743cc35 100644 --- a/src/StaticCaching/Replacers/NoCacheReplacer.php +++ b/src/StaticCaching/Replacers/NoCacheReplacer.php @@ -4,6 +4,7 @@ use Illuminate\Http\Response; use Statamic\Facades\StaticCache; +use Statamic\Facades\URL; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\Cachers\FileCacher; use Statamic\StaticCaching\NoCache\Session; @@ -94,12 +95,22 @@ private function modifyFullMeasureResponse(Response $response) $contents = $response->getContent(); if ($cacher->shouldOutputJs()) { - $js = $cacher->getNocacheJs(); - $contents = str_replace('', '', $contents); + $contents = str_replace('', $this->scriptTag($cacher).'', $contents); } $contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents); $response->setContent($contents); } + + private function scriptTag(FileCacher $cacher): string + { + $external = config('statamic.static_caching.script_delivery') === 'external'; + + return trim(view('statamic::static-caching.script', [ + 'inline' => ! $external, + 'src' => $external ? URL::makeRelative(route('statamic.nocache.js')) : null, + 'contents' => $external ? null : $cacher->getNocacheJs(), + ])->render()); + } } diff --git a/src/Testing/Concerns/FakesViews.php b/src/Testing/Concerns/FakesViews.php index a37766a11dc..38cd72597d4 100644 --- a/src/Testing/Concerns/FakesViews.php +++ b/src/Testing/Concerns/FakesViews.php @@ -19,6 +19,12 @@ public function withFakeViews() $this->fakeView = app(FakeViewEngine::class); $this->fakeViewFinder = new FakeViewFinder($this->app['files'], config('view.paths')); + // Keep real namespace hints (e.g. `statamic::`) resolving so that faking + // the frontend views doesn't break package views rendered as a side effect. + foreach ($originalFactory->getFinder()->getHints() as $namespace => $paths) { + $this->fakeViewFinder->addNamespace($namespace, $paths); + } + $this->fakeViewFactory = new FakeViewFactory($this->app['view.engine.resolver'], $this->fakeViewFinder, $this->app['events']); $this->fakeViewFactory->setFakeEngine($this->fakeView); foreach (array_reverse($originalFactory->getExtensions()) as $ext => $engine) { diff --git a/tests/StaticCaching/ExternalScriptDeliveryTest.php b/tests/StaticCaching/ExternalScriptDeliveryTest.php new file mode 100644 index 00000000000..6884c2205ae --- /dev/null +++ b/tests/StaticCaching/ExternalScriptDeliveryTest.php @@ -0,0 +1,76 @@ +set('statamic.static_caching.strategy', 'full'); + $app['config']->set('statamic.static_caching.strategies.full.path', $this->dir = __DIR__.'/static'); + $app['config']->set('statamic.static_caching.script_delivery', 'external'); + + File::delete($this->dir); + } + + public function tearDown(): void + { + File::delete($this->dir); + parent::tearDown(); + } + + #[Test] + public function it_references_the_csrf_and_nocache_scripts_instead_of_inlining_them() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ csrf_token }}'); + + $this->createPage('about'); + + $expected = 'STATAMIC_CSRF_TOKEN'; + + $response = $this->get('/about')->assertOk(); + + $this->assertEquals($expected, $response->getContent()); + $this->assertStringNotContainsString('(function()', $response->getContent()); + $this->assertEquals($expected, file_get_contents($this->dir.'/about_.html')); + } + + #[Test] + public function the_scripts_are_served_from_routes() + { + $nocache = $this->get('/!/nocache.js')->assertOk(); + $this->assertStringContainsString('application/javascript', $nocache->headers->get('content-type')); + $this->assertEquals(app(Cacher::class)->getNocacheJs(), $nocache->getContent()); + $this->assertStringContainsString("fetch('/!/nocache'", $nocache->getContent()); + + $csrf = $this->get('/!/csrf.js')->assertOk(); + $this->assertStringContainsString('application/javascript', $csrf->headers->get('content-type')); + $this->assertEquals(app(Cacher::class)->getCsrfTokenJs(), $csrf->getContent()); + } + + #[Test] + public function the_routes_are_registered_only_in_external_mode() + { + $this->assertTrue(Route::has('statamic.nocache.js')); + $this->assertTrue(Route::has('statamic.csrf.js')); + } +} diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index 243586f4c14..641e1913cc1 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -176,6 +176,22 @@ public function it_can_override_the_csrf_and_nocache_scripts() $this->assertEquals(app(Cacher::class)->getCsrfTokenJs(), 'csrf'); } + #[Test] + public function the_injected_script_tag_comes_from_a_publishable_view() + { + // Inline: the body is embedded. + $this->assertEquals( + '', + trim(view('statamic::static-caching.script', ['inline' => true, 'src' => null, 'contents' => 'alert(1)'])->render()) + ); + + // External: the body is referenced. + $this->assertEquals( + '', + trim(view('statamic::static-caching.script', ['inline' => false, 'src' => '/foo.js', 'contents' => null])->render()) + ); + } + #[Test] public function excluded_pages_should_have_real_csrf_token() { diff --git a/tests/StaticCaching/NocacheRouteTest.php b/tests/StaticCaching/NocacheRouteTest.php index f9ae48ecd34..f87de014120 100644 --- a/tests/StaticCaching/NocacheRouteTest.php +++ b/tests/StaticCaching/NocacheRouteTest.php @@ -61,4 +61,15 @@ public function url_is_required() ->postJson('/!/nocache') ->assertJsonValidationErrorFor('url'); } + + #[Test] + public function the_script_routes_are_not_registered_unless_script_delivery_is_external() + { + // Defaults to "inline", so the routes shouldn't exist. + $this->assertFalse(\Illuminate\Support\Facades\Route::has('statamic.nocache.js')); + $this->assertFalse(\Illuminate\Support\Facades\Route::has('statamic.csrf.js')); + + $this->get('/!/nocache.js')->assertNotFound(); + $this->get('/!/csrf.js')->assertNotFound(); + } }