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
2 changes: 0 additions & 2 deletions config/static_caching.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,6 @@

'nocache_db_connection' => env('STATAMIC_NOCACHE_DB_CONNECTION'),

'nocache_js_position' => 'body',

/*
|--------------------------------------------------------------------------
| Replacers
Expand Down
15 changes: 9 additions & 6 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
use Statamic\Http\Middleware\CP\HandleInertiaRequests;
use Statamic\Http\Middleware\RedirectIfTwoFactorSetupIncomplete;
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 () {
Expand Down Expand Up @@ -67,14 +68,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'])
Expand Down
1 change: 1 addition & 0 deletions src/Facades/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
70 changes: 49 additions & 21 deletions src/StaticCaching/Cachers/FileCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ class FileCacher extends AbstractCacher
*/
private $shouldOutputJs = false;

/**
* @var string
*/
private $csrfTokenJs;

/**
* @var string
*/
Expand Down Expand Up @@ -230,16 +235,59 @@ 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 = <<<EOT
(function() {
fetch('/!/csrf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
.then((response) => 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 getNocacheJs(): string
{
$default = <<<'EOT'
(function() {
function createMap() {
var map = {};
Expand Down Expand Up @@ -270,26 +318,6 @@ function createMap() {
if (map[key]) map[key].outerHTML = regions[key];
}

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:nocache.replaced', { detail: data }));
});
})();
Expand Down
13 changes: 13 additions & 0 deletions src/StaticCaching/NoCache/CsrfTokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Statamic\StaticCaching\NoCache;

class CsrfTokenController
{
public function __invoke()
{
return [
'csrf' => csrf_token(),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Statamic\StaticCaching\Replacers\NoCacheReplacer;
use Statamic\Support\Str;

class Controller
class NoCacheController
{
public function __invoke(Request $request, Session $session)
{
Expand Down
50 changes: 43 additions & 7 deletions src/StaticCaching/Replacers/CsrfTokenReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -34,16 +56,30 @@ 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;
}

$contents = $response->getContent();

$insertBefore = collect([
Str::position($contents, '<link'),
Str::position($contents, '<script'),
Str::position($contents, '</head>'),
])->filter()->min();

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

$contents = Str::substrReplace($contents, $js, $insertBefore, 0);

$response->setContent($contents);
}
}
33 changes: 2 additions & 31 deletions src/StaticCaching/Replacers/NoCacheReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('</body>', '<script>'.$js.'</script></body>', $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, '<link'),
Str::position($contents, '<script'),
Str::position($contents, '</head>'),
])->filter()->min();

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

return Str::substrReplace($contents, $js, $insertBefore, 0);
}

private function insertJsInBody($contents, $cacher)
{
$js = $cacher->getNocacheJs();

return str_replace('</body>', '<script>'.$js.'</script></body>', $contents);
}
}
5 changes: 5 additions & 0 deletions src/StaticCaching/StaticCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 18 additions & 6 deletions tests/StaticCaching/FullMeasureStaticCachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,15 +133,16 @@ public function index()
}

#[Test]
public function it_should_add_the_javascript_if_there_is_a_csrf_token()
public function it_adds_the_csrf_and_nocache_scripts()
{
$this->withFakeViews();
$this->viewShouldReturnRaw('layout', '<html><body>{{ template_content }}</body></html>');
$this->viewShouldReturnRaw('layout', '<html><head></head><body>{{ template_content }}</body></html>');
$this->viewShouldReturnRaw('default', '{{ csrf_token }}');

$this->createPage('about');

StaticCache::nocacheJs('js here');
$csrfTokenScript = '<script>'.app(Cacher::class)->getCsrfTokenJs().'</script>';
$nocacheScript = '<script>'.app(Cacher::class)->getNocacheJs().'</script>';

$this->assertFalse(file_exists($this->dir.'/about_.html'));

Expand All @@ -149,12 +151,22 @@ public function it_should_add_the_javascript_if_there_is_a_csrf_token()
->assertOk();

// Initial response should be dynamic and not contain javascript.
$this->assertEquals('<html><body>'.csrf_token().'</body></html>', $response->getContent());
$this->assertEquals('<html><head></head><body>'.csrf_token().'</body></html>', $response->getContent());

// The cached response should have the token placeholder, and the javascript.
$this->assertTrue(file_exists($this->dir.'/about_.html'));
$this->assertEquals(vsprintf('<html><body>STATAMIC_CSRF_TOKEN%s</body></html>', [
'<script>js here</script>',
$this->assertEquals(vsprintf("<html><head>{$csrfTokenScript}</head><body>STATAMIC_CSRF_TOKEN%s</body></html>", [
$nocacheScript,
]), file_get_contents($this->dir.'/about_.html'));
}

#[Test]
public function it_can_override_the_csrf_and_nocache_scripts()
{
StaticCache::nocacheJs('nocache');
StaticCache::csrfTokenJs('csrf');

$this->assertEquals(app(Cacher::class)->getNocacheJs(), 'nocache');
$this->assertEquals(app(Cacher::class)->getCsrfTokenJs(), 'csrf');
}
}