diff --git a/CHANGELOG.md b/CHANGELOG.md index 750c02a..a28b62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,5 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-2](https://github.com/itk-dev/itk-project-database/pull/2) + Add Symfony UX Turbo and stimulus * [PR-1](https://github.com/itk-dev/itk-project-database/pull/1) Initial Symfony 8 rebuild of the project database. diff --git a/Taskfile.yml b/Taskfile.yml index 4dd5255..c4d341d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -51,6 +51,7 @@ tasks: - "{{.PRETTIER}} 'assets/**/*.js' --write" - "{{.PRETTIER}} 'assets/**/*.{css,scss}' --write" - "{{.PRETTIER}} '**/*.{yml,yaml}' --write" + - "{{.MARKDOWNLINT}} '**/*.md' --fix" static-analysis: desc: 'Run PHPStan static analysis' diff --git a/assets/app.js b/assets/app.js index 27ff6a1..e9e19b0 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,3 +1,4 @@ +import "./stimulus_bootstrap.js"; import "./styles/app.css"; import TomSelect from "tom-select"; import "tom-select/dist/css/tom-select.default.min.css"; @@ -5,29 +6,28 @@ import "tom-select/dist/css/tom-select.default.min.css"; function initUserMenu() { const toggle = document.getElementById("userMenuToggle"); const menu = document.getElementById("userMenu"); - if (!toggle || !menu) { + if (!toggle || !menu || toggle.dataset.bound) { return; } + toggle.dataset.bound = "1"; toggle.addEventListener("click", (event) => { event.stopPropagation(); menu.classList.toggle("is-open"); }); - - document.addEventListener("click", (event) => { - if (!menu.contains(event.target) && event.target !== toggle) { - menu.classList.remove("is-open"); - } - }); } function initCollections() { document.querySelectorAll("[data-collection]").forEach((collection) => { + if (collection.dataset.bound) { + return; + } const list = collection.querySelector("[data-collection-list]"); const addButton = collection.querySelector("[data-collection-add]"); if (!list || !addButton) { return; } + collection.dataset.bound = "1"; let index = list.querySelectorAll("[data-collection-item]").length; @@ -65,6 +65,10 @@ function initCollections() { function initContactSelect() { document.querySelectorAll("[data-contact-select]").forEach((select) => { + if (select.dataset.bound) { + return; + } + select.dataset.bound = "1"; new TomSelect(select, { plugins: ["remove_button"], hideSelected: true, @@ -73,7 +77,19 @@ function initContactSelect() { }); } -document.addEventListener("DOMContentLoaded", () => { +// The document survives Turbo navigations, so the outside-click handler is +// registered once here rather than re-added on every page. +document.addEventListener("click", (event) => { + const menu = document.getElementById("userMenu"); + const toggle = document.getElementById("userMenuToggle"); + if (menu && !menu.contains(event.target) && event.target !== toggle) { + menu.classList.remove("is-open"); + } +}); + +// Turbo Drive swaps on each visit and never fires DOMContentLoaded; +// turbo:load runs on the first load and on every subsequent visit. +document.addEventListener("turbo:load", () => { initUserMenu(); initCollections(); initContactSelect(); diff --git a/assets/controllers.json b/assets/controllers.json new file mode 100644 index 0000000..8d7580b --- /dev/null +++ b/assets/controllers.json @@ -0,0 +1,18 @@ +{ + "controllers": { + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager", + "autoimport": { + "@symfony/ux-turbo/dist/mercure_stream_source_element.js": true + } + }, + "mercure-turbo-stream": { + "enabled": false, + "fetch": "eager" + } + } + }, + "entrypoints": [] +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js new file mode 100644 index 0000000..400afbb --- /dev/null +++ b/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,123 @@ +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/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js new file mode 100644 index 0000000..6fc936c --- /dev/null +++ b/assets/controllers/hello_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * This is an example Stimulus controller! + * + * Any element with a data-controller="hello" attribute will cause + * this controller to be executed. The name "hello" comes from the filename: + * hello_controller.js -> "hello" + * + * Delete this file or adapt it for your use! + */ +export default class extends Controller { + connect() { + this.element.textContent = + "Hello Stimulus! Edit me in assets/controllers/hello_controller.js"; + } +} diff --git a/assets/stimulus_bootstrap.js b/assets/stimulus_bootstrap.js new file mode 100644 index 0000000..3abb4a0 --- /dev/null +++ b/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/composer.json b/composer.json index 991eb8d..1108de4 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "symfony/security-bundle": "~8.1.0", "symfony/translation": "~8.1.0", "symfony/twig-bundle": "~8.1.0", + "symfony/ux-turbo": "^3.2", "symfony/validator": "~8.1.0", "symfony/yaml": "~8.1.0", "twig/extra-bundle": "^2.12 || ^3.0", diff --git a/composer.lock b/composer.lock index 50de4a2..07f058f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ad29920513377f1fcf7144fe62d3ff57", + "content-hash": "e29c853067fa17e3d8aa43befcfdd42a", "packages": [ { "name": "composer/semver", @@ -5246,6 +5246,82 @@ ], "time": "2026-03-28T09:44:51+00:00" }, + { + "name": "symfony/stimulus-bundle", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stimulus-bundle.git", + "reference": "3cf8cd35c400f287def6ccd4574c111c07661054" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/3cf8cd35c400f287def6ccd4574c111c07661054", + "reference": "3cf8cd35c400f287def6ccd4574c111c07661054", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.0|^3.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "twig/twig": "^2.15.3|^3.8" + }, + "conflict": { + "symfony/asset-mapper": "<6.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "zenstruck/browser": "^1.9" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\UX\\StimulusBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Integration with your Symfony app & Stimulus!", + "keywords": [ + "symfony-ux" + ], + "support": { + "source": "https://github.com/symfony/stimulus-bundle/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T09:42:50+00:00" + }, { "name": "symfony/stopwatch", "version": "v8.1.0", @@ -5851,6 +5927,107 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/ux-turbo", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-turbo.git", + "reference": "923c6479da86ebdcbd4f723a80c24d47d50567fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/923c6479da86ebdcbd4f723a80c24d47d50567fa", + "reference": "923c6479da86ebdcbd4f723a80c24d47d50567fa", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/stimulus-bundle": "^2.9.1|^3.0" + }, + "conflict": { + "symfony/flex": "<1.13", + "symfony/mercure": ">=0.7.0 <0.7.2" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.14|^3.0|^4.0", + "doctrine/orm": "^2.8|^3.0", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^11.1|^12.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/debug-bundle": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/mercure-bundle": "^0.3.7|^0.4.1", + "symfony/messenger": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/ux-twig-component": "^2.21|^3.0", + "symfony/web-profiler-bundle": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Turbo\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Hotwire Turbo integration for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "hotwire", + "javascript", + "mercure", + "symfony-ux", + "turbo", + "turbo-stream" + ], + "support": { + "source": "https://github.com/symfony/ux-turbo/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-19T07:14:15+00:00" + }, { "name": "symfony/validator", "version": "v8.1.0", diff --git a/config/bundles.php b/config/bundles.php index 84cc77d..5b14226 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,6 @@ Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], + Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], + Symfony\UX\Turbo\TurboBundle::class => ['all' => true], ]; diff --git a/config/packages/ux_turbo.yaml b/config/packages/ux_turbo.yaml new file mode 100644 index 0000000..c2a6a44 --- /dev/null +++ b/config/packages/ux_turbo.yaml @@ -0,0 +1,4 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + csrf_protection: + check_header: true diff --git a/importmap.php b/importmap.php index 122616b..d322546 100644 --- a/importmap.php +++ b/importmap.php @@ -25,6 +25,8 @@ return [ 'app' => ['path' => './assets/app.js', 'entrypoint' => true], '@hotwired/stimulus' => ['version' => '3.2.2'], + '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], + '@hotwired/turbo' => ['version' => '8.0.23'], 'tom-select' => ['version' => '2.6.1'], '@orchidjs/sifter' => ['version' => '1.1.0'], '@orchidjs/unicode-variants' => ['version' => '1.1.2'], diff --git a/symfony.lock b/symfony.lock index ee5e9ba..9df9c52 100644 --- a/symfony.lock +++ b/symfony.lock @@ -208,6 +208,21 @@ "config/routes/security.yaml" ] }, + "symfony/stimulus-bundle": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.24", + "ref": "d21494ed2ddbde38942e8278299a23ce5cf4a9a1" + }, + "files": [ + "assets/controllers.json", + "assets/controllers/csrf_protection_controller.js", + "assets/controllers/hello_controller.js", + "assets/stimulus_bootstrap.js" + ] + }, "symfony/translation": { "version": "8.1", "recipe": { @@ -234,6 +249,18 @@ "templates/base.html.twig" ] }, + "symfony/ux-turbo": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.20", + "ref": "287f7c6eb6e9b65e422d34c00795b360a787380b" + }, + "files": [ + "config/packages/ux_turbo.yaml" + ] + }, "symfony/validator": { "version": "8.1", "recipe": {