Skip to content
Open
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
39 changes: 39 additions & 0 deletions resources/views/static-caching/csrf-js.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{{--
Swaps the placeholder CSRF token in full-measure statically cached pages for a
real one. Injected inline by default, or served from a dedicated route when
static_caching.script_delivery is "external". Blade data: $csrfPlaceholder
--}}
(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.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 }));
});
})();
51 changes: 51 additions & 0 deletions resources/views/static-caching/nocache-js.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{--
Hydrates `nocache` regions in full-measure statically cached pages by fetching
their rendered contents. Injected inline by default, or served from a dedicated
route when static_caching.script_delivery is "external". Blade data: $nocacheUrl
--}}
(function() {
function createMap() {
var map = {};
var els = document.getElementsByClassName('nocache');
for (var i = 0; i < els.length; i++) {
var section = els[i].getAttribute('data-nocache');
map[section] = els[i];
}
return map;
}

function replaceElement(el, html) {
const tmp = document.createElement('div');
const fragment = document.createDocumentFragment();

tmp.setHTMLUnsafe(html);

while (tmp.firstChild) {
fragment.appendChild(tmp.firstChild);
}

el.replaceWith(fragment);
}

var map = createMap();

fetch('{{ $nocacheUrl }}', {
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) => {
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 }));
});
})();
13 changes: 13 additions & 0 deletions resources/views/static-caching/script.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{{--
The <script> tag injected into full-measure statically cached pages for the
CSRF and nocache helpers. Publish this view to customise the tag - for
example, to add a `nonce` attribute for a strict Content Security Policy:

<script nonce="{{ request()->attributes->get('csp_nonce') }}">...

Available data:
$inline - whether the script body is embedded (true) or loaded via src
$src - URL of the external script (when not inline)
$contents - the script body (when inline)
--}}
@if ($inline)<script>{!! $contents !!}</script>@else<script src="{{ $src }}"></script>@endif
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
4 changes: 4 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
98 changes: 6 additions & 92 deletions src/StaticCaching/Cachers/FileCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,102 +248,16 @@ public function setNocacheJs(string $js)

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.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 = <<<EOT
(function() {
function createMap() {
var map = {};
var els = document.getElementsByClassName('nocache');
for (var i = 0; i < els.length; i++) {
var section = els[i].getAttribute('data-nocache');
map[section] = els[i];
}
return map;
}

function replaceElement(el, html) {
const tmp = document.createElement('div');
const fragment = document.createDocumentFragment();

tmp.setHTMLUnsafe(html);

while (tmp.firstChild) {
fragment.appendChild(tmp.firstChild);
}

el.replaceWith(fragment);
}

var map = createMap();

fetch('{$nocacheUrl}', {
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) => {
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
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));
}
}
16 changes: 13 additions & 3 deletions src/StaticCaching/Replacers/CsrfTokenReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,10 +82,19 @@ 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, $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());
}
}
15 changes: 13 additions & 2 deletions src/StaticCaching/Replacers/NoCacheReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,12 +95,22 @@ 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>', $this->scriptTag($cacher).'</body>', $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());
}
}
6 changes: 6 additions & 0 deletions src/Testing/Concerns/FakesViews.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading