Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions config/static_caching.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,23 @@
\Statamic\StaticCaching\Replacers\NoCacheReplacer::class,
],

/*
|--------------------------------------------------------------------------
| Script Delivery
|--------------------------------------------------------------------------
|
| Full measure static caching injects small <script> snippets into cached
| pages to swap CSRF tokens and hydrate nocache regions. By default these
| are inlined. Sites with a Content Security Policy that disallows inline
| scripts may set this to "external" to have the snippets served from
| dedicated routes and referenced with a <script src> tag instead.
|
| Supported: "inline", "external"
|
*/

'script_delivery' => env('STATAMIC_STATIC_CACHING_SCRIPT_DELIVERY', 'inline'),

/*
|--------------------------------------------------------------------------
| Warm Queue
Expand Down
6 changes: 6 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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();
});

Expand Down
18 changes: 18 additions & 0 deletions src/StaticCaching/Cachers/FileCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\StaticCaching\Cachers;

use Closure;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -346,6 +347,23 @@ function replaceElement(el, html) {
return $this->nocacheJs ?? $default;
}

public function getCsrfScript(): string
{
return $this->script('statamic.csrf.js', fn () => $this->getCsrfTokenJs());
}

public function getNocacheScript(): string
{
return $this->script('statamic.nocache.js', fn () => $this->getNocacheJs());
}

private function script(string $route, Closure $js): string
{
return config('statamic.static_caching.script_delivery') === 'external'
? '<script src="'.URL::makeRelative(route($route)).'"></script>'
: '<script>'.$js().'</script>';
}

public function shouldOutputJs(): bool
{
return $this->shouldOutputJs;
Expand Down
37 changes: 37 additions & 0 deletions src/StaticCaching/NoCache/ScriptController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Statamic\StaticCaching\NoCache;

use Illuminate\Http\Response;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\FileCacher;

class ScriptController
{
public function nocache(): Response
{
return $this->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));
}
}
4 changes: 1 addition & 3 deletions src/StaticCaching/Replacers/CsrfTokenReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ private function modifyFullMeasureResponse(Response $response)
Str::position($contents, '</head>'),
])->filter()->min();

$js = "<script>{$cacher->getCsrfTokenJs()}</script>";

$contents = Str::substrReplace($contents, $js, $insertBefore, 0);
$contents = Str::substrReplace($contents, $cacher->getCsrfScript(), $insertBefore, 0);

$response->setContent($contents);
}
Expand Down
3 changes: 1 addition & 2 deletions src/StaticCaching/Replacers/NoCacheReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ private function modifyFullMeasureResponse(Response $response)
$contents = $response->getContent();

if ($cacher->shouldOutputJs()) {
$js = $cacher->getNocacheJs();
$contents = str_replace('</body>', '<script>'.$js.'</script></body>', $contents);
$contents = str_replace('</body>', $cacher->getNocacheScript().'</body>', $contents);
}

$contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents);
Expand Down
76 changes: 76 additions & 0 deletions tests/StaticCaching/ExternalScriptDeliveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Tests\StaticCaching;

use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\File;
use Statamic\StaticCaching\Cacher;
use Tests\FakesContent;
use Tests\FakesViews;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class ExternalScriptDeliveryTest extends TestCase
{
use FakesContent;
use FakesViews;
use PreventSavingStacheItemsToDisk;

private $dir;

protected function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);

$app['config']->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', '<html><head></head><body>{{ template_content }}</body></html>');
$this->viewShouldReturnRaw('default', '{{ csrf_token }}');

$this->createPage('about');

$expected = '<html><head><script src="/!/csrf.js"></script></head><body>STATAMIC_CSRF_TOKEN<script src="/!/nocache.js"></script></body></html>';

$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'));
}
}
11 changes: 11 additions & 0 deletions tests/StaticCaching/NocacheRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading