diff --git a/ai.symfony.com/assets/app.js b/ai.symfony.com/assets/app.js index 96a9e206e..19a0ed585 100644 --- a/ai.symfony.com/assets/app.js +++ b/ai.symfony.com/assets/app.js @@ -1,3 +1,4 @@ +import './stimulus_bootstrap.js'; import 'bootstrap'; import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/app.css'; diff --git a/ai.symfony.com/assets/controllers.json b/ai.symfony.com/assets/controllers.json new file mode 100644 index 000000000..a1c6e90cf --- /dev/null +++ b/ai.symfony.com/assets/controllers.json @@ -0,0 +1,4 @@ +{ + "controllers": [], + "entrypoints": [] +} diff --git a/ai.symfony.com/assets/controllers/csrf_protection_controller.js b/ai.symfony.com/assets/controllers/csrf_protection_controller.js new file mode 100644 index 000000000..511fffa5c --- /dev/null +++ b/ai.symfony.com/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,81 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event +// and thus this event-listener will not be executed. +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const h = generateCsrfHeaders(event.detail.formSubmission.formElement); + Object.keys(h).map(function (k) { + event.detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + removeCsrfToken(event.detail.formSubmission.formElement); +}); + +export function generateCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); + csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); + } + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +export function generateCsrfHeaders (formElement) { + const headers = {}; + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + headers[csrfCookie] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/ai.symfony.com/assets/controllers/typed_controller.js b/ai.symfony.com/assets/controllers/typed_controller.js new file mode 100644 index 000000000..780dcefc1 --- /dev/null +++ b/ai.symfony.com/assets/controllers/typed_controller.js @@ -0,0 +1,56 @@ +import { Controller } from '@hotwired/stimulus'; +import Typed from 'typed.js'; + +export default class extends Controller { + static values = { + strings: Array, + typeSpeed: { type: Number, default: 30 }, + smartBackspace: { type: Boolean, default: true }, + startDelay: Number, + backSpeed: Number, + shuffle: Boolean, + backDelay: { type: Number, default: 700 }, + fadeOut: Boolean, + fadeOutClass: { type: String, default: 'typed-fade-out' }, + fadeOutDelay: { type: Number, default: 500 }, + loop: Boolean, + loopCount: { type: Number, default: Number.POSITIVE_INFINITY }, + showCursor: { type: Boolean, default: true }, + cursorChar: { type: String, default: '.' }, + autoInsertCss: { type: Boolean, default: true }, + attr: String, + bindInputFocusEvents: Boolean, + contentType: { type: String, default: 'html' }, + }; + + connect() { + const options = { + strings: this.stringsValue, + typeSpeed: this.typeSpeedValue, + smartBackspace: this.smartBackspaceValue, + startDelay: this.startDelayValue, + backSpeed: this.backSpeedValue, + shuffle: this.shuffleValue, + backDelay: this.backDelayValue, + fadeOut: this.fadeOutValue, + fadeOutClass: this.fadeOutClassValue, + fadeOutDelay: this.fadeOutDelayValue, + loop: this.loopValue, + loopCount: this.loopCountValue, + showCursor: this.showCursorValue, + cursorChar: this.cursorCharValue, + autoInsertCss: this.autoInsertCssValue, + attr: this.attrValue, + bindInputFocusEvents: this.bindInputFocusEventsValue, + contentType: this.contentTypeValue, + }; + + this.dispatchEvent('pre-connect', { options }); + const typed = new Typed(this.element, options); + this.dispatchEvent('connect', { typed, options }); + } + + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'typed' }); + } +} diff --git a/ai.symfony.com/assets/stimulus_bootstrap.js b/ai.symfony.com/assets/stimulus_bootstrap.js new file mode 100644 index 000000000..d4e50c919 --- /dev/null +++ b/ai.symfony.com/assets/stimulus_bootstrap.js @@ -0,0 +1,5 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); +// register any custom, 3rd party controllers here +// app.register('some_controller_name', SomeImportedController); diff --git a/ai.symfony.com/composer.json b/ai.symfony.com/composer.json index b0666bb67..0be0941a8 100644 --- a/ai.symfony.com/composer.json +++ b/ai.symfony.com/composer.json @@ -14,6 +14,7 @@ "symfony/flex": "^2", "symfony/framework-bundle": "*", "symfony/runtime": "*", + "symfony/stimulus-bundle": "^2.31", "symfony/twig-bundle": "*", "symfony/ux-icons": "^2.31", "symfony/yaml": "*", diff --git a/ai.symfony.com/config/bundles.php b/ai.symfony.com/config/bundles.php index 05c1fe876..3437b4810 100644 --- a/ai.symfony.com/config/bundles.php +++ b/ai.symfony.com/config/bundles.php @@ -6,4 +6,5 @@ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], + Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], ]; diff --git a/ai.symfony.com/importmap.php b/ai.symfony.com/importmap.php index 41d7c7118..0a62c8fbf 100644 --- a/ai.symfony.com/importmap.php +++ b/ai.symfony.com/importmap.php @@ -26,4 +26,13 @@ 'version' => '5.3.8', 'type' => 'css', ], + 'typed.js' => [ + 'version' => '2.1.0', + ], + '@hotwired/stimulus' => [ + 'version' => '3.2.2', + ], + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', + ], ]; diff --git a/ai.symfony.com/symfony.lock b/ai.symfony.com/symfony.lock index 95cc6c0e4..aebb01f85 100644 --- a/ai.symfony.com/symfony.lock +++ b/ai.symfony.com/symfony.lock @@ -81,6 +81,21 @@ "config/routes.yaml" ] }, + "symfony/stimulus-bundle": { + "version": "2.31", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.24", + "ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f" + }, + "files": [ + "assets/controllers.json", + "assets/controllers/csrf_protection_controller.js", + "assets/controllers/typed_controller.js", + "assets/stimulus_bootstrap.js" + ] + }, "symfony/twig-bundle": { "version": "7.3", "recipe": { diff --git a/ai.symfony.com/templates/_header.html.twig b/ai.symfony.com/templates/_header.html.twig index be39aa71b..5a24ada23 100644 --- a/ai.symfony.com/templates/_header.html.twig +++ b/ai.symfony.com/templates/_header.html.twig @@ -2,7 +2,7 @@