diff --git a/.codex/framework-version-recap.md b/.codex/framework-version-recap.md index 70081d67..b82d3acb 100644 --- a/.codex/framework-version-recap.md +++ b/.codex/framework-version-recap.md @@ -98,6 +98,7 @@ Versions were checked against the installed Composer packages, `composer.json`, - `@symfony/stimulus-bundle`, `@hotwired/stimulus`, and `@hotwired/turbo` are in `importmap.php`. - `assets/controllers.json` keeps optional UX Stimulus controllers lazy by default. Leave expensive controllers lazy until a template actually references them. - React and Vue use the AssetMapper loader form of `registerReactControllerComponents()` and `registerVueControllerComponents()`; do not copy Webpack-era `require.context()` examples into this project. +- UX Translator dumps JavaScript translations to `var/translations` during `cache:warmup` or `ux:translator:warm-cache`; ensure one of those runs before AssetMapper resolves `assets/translator.js`. - UX Icons has remote Iconify lookup disabled in `config/packages/ux_icons.yaml` so builds and CI stay offline-safe. `bin/init` and `assets:rebuild` run `ux:icons:lock` to import referenced icons into `assets/icons` when Iconify is reachable; failures are non-blocking warnings so offline CI and admin rebuilds do not fail only because remote icon lookup is unavailable. - `bin/lint` validates static Twig icon references locally without network access or writes. It checks `ux_icon('...')` and `` references against `assets/icons`, resolving configured aliases first; use `ux:icons:lock` only as the mutating import step. - Commit locked SVGs under `assets/icons` as reviewable dependency snapshots. Avoid committing complete upstream icon sets by default; let the set grow from real template usage and explicit aliases. diff --git a/.env b/.env index faef650f..a6881976 100755 --- a/.env +++ b/.env @@ -16,7 +16,7 @@ ###> symfony/framework-bundle ### APP_ENV=dev -APP_SECRET='d3f4uLt_$3cR3t' +APP_SECRET='local-default-app-secret-not-secure' APP_SHARE_DIR=var/share APP_DEBUG=false APP_MAINTENANCE=false @@ -42,12 +42,9 @@ MAILER_DSN=null://null LOCK_DSN=flock ###< symfony/lock ### -###> symfony/mercure-notifier ### -MERCURE_DSN=mercure://default -###< symfony/mercure-notifier ### - ###> symfony/mercure-bundle ### -MERCURE_URL=${DEFAULT_URI}/.well-known/mercure +MERCURE_HUB_LISTEN=127.0.0.1:3000 +MERCURE_URL=http://${MERCURE_HUB_LISTEN}/.well-known/mercure MERCURE_PUBLIC_URL=${DEFAULT_URI}/.well-known/mercure MERCURE_JWT_SECRET=${APP_SECRET} ###< symfony/mercure-bundle ### diff --git a/.env.test b/.env.test index 93fbdd06..bd508936 100755 --- a/.env.test +++ b/.env.test @@ -2,7 +2,7 @@ KERNEL_CLASS='App\Kernel' APP_DEBUG=false APP_MAINTENANCE=false -APP_SECRET='t35t1nG_$3cR3t' +APP_SECRET='test-environment-app-secret-not-secure' ###< symfony/framework-bundle ### ###> doctrine/doctrine-bundle ### diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 614c17f8..44d741ed 100755 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,7 +3,9 @@ ## Testing - [ ] `bin/phpunit`: [RESULT] -- [ ] Other (describe): +- [ ] `bin/jstest`: [RESULT] +- [ ] `bin/lint`: [RESULT] +- [ ] Other (if not already covered by the full suites above): ## Documentation - [ ] Updated project readme (`README.md`) @@ -13,10 +15,13 @@ - [ ] Updated dev/user manuals (`dev/manual/*.md` / `docs/*.md`) ## Additional Checks -- [ ] Security/privacy considerations -- [ ] Project rules, architecture/naming drift, and documentation drift reviewed (see #57) for details +- [ ] Security/privacy considerations, public entry points, sessions, secrets, and browser storage reviewed +- [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed +- [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed +- [ ] Project-rules-, architecture-, naming- and documentation-drift reviewed (see #57 for details) - [ ] Follow-up tasks captured in WORKLOG -- [ ] Linked issues / discussions -- [ ] Updated / aligned translations +- [ ] Updated / aligned translations and user-facing copy + +## Linked Issues / Discussions ## Review Notes diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 387285f1..c2d962f5 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -62,5 +62,8 @@ jobs: if: matrix.lint run: php bin/lint - - name: Run test suite + - name: Run PHPUnit test suite run: php bin/phpunit + + - name: Run JavaScript test suite + run: php bin/jstest diff --git a/.manifest b/.manifest index a1fb2002..9039cbdf 100644 --- a/.manifest +++ b/.manifest @@ -3,8 +3,8 @@ # APP_CHANNEL defines the target branch inside the specified repository. ##> aavion/studio manifest ### -APP_VERSION=0.2.0 -APP_DATE=2026-05-24 +APP_VERSION=0.2.4 +APP_DATE=2026-06-14 APP_NAME=Studio APP_AUTHOR=Dominik Letica APP_DESCRIPTION=Symfony 8.1 based content-management system for structured project websites. diff --git a/AGENTS.md b/AGENTS.md index e0abbebf..c1e7aa5f 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,12 +109,13 @@ - `php -l ` checks PHP syntax for a changed file. - `php bin/console lint:container` validates Symfony container wiring after service or configuration changes. - `php bin/console tailwind:build` compiles Tailwind CSS. -- `php bin/console asset-map:compile` refreshes AssetMapper output and importmap pins. +- `php bin/console asset-map:compile` is production/release-only. Do not run it for local development or normal verification; if `public/assets/` is created locally, remove that generated production output from the worktree. - `php bin/console ux:icons:lock` imports referenced Symfony UX/Iconify icons into `assets/icons`; commit the resulting SVGs as versioned UI dependency snapshots, but avoid bulk-locking complete icon sets without a concrete need. - `php bin/console doctrine:migrations:diff` generates schema migrations. - `php bin/console doctrine:migrations:migrate` applies schema migrations. - `php bin/phpunit` runs the full PHPUnit suite. - `php bin/phpunit --coverage-text` runs PHPUnit with quick coverage feedback before PRs. +- `bin/jstest` runs native Node.js JavaScript behavior tests from `tests/assets/**/*.test.mjs` through `node --test`; pass test files or Node test-runner options for focused runs. If Node.js is not available, the command prints a skip notice and exits successfully so only real JavaScript test failures fail CI. - `bin/lint` includes the translation source catalogue file/key comparison for release-safe validation without requiring `.codex/`. - Before committing, use `bin/lint --diff` or the relevant focused `bin/lint ` for Git-aware whitespace checks. Markdown files may contain intentional two-space hard line breaks; preserve those hard breaks when reviewing whitespace output from raw Git commands. - `php bin/console render:route /` renders a route for Twig, translation, and debug user/role review. @@ -123,7 +124,7 @@ - PHP-only logic: run targeted PHPUnit coverage and `php -l` for edited PHP files. - Service, DI, security, or configuration changes: run targeted tests and `php bin/console lint:container`. - Twig, translation, or UX copy changes: run `bin/lint ` and render affected routes with `php bin/console render:route /` when available. -- Asset or Stimulus changes: prefer `bin/lint ` for focused JavaScript, JSON, CSS, YAML, Twig, Markdown, and PHP syntax checks, then run the relevant asset build command and targeted UI/functional checks when build output or rendering can change. +- Asset or Stimulus changes: prefer `bin/lint ` for focused JavaScript, JSON, CSS, YAML, Twig, Markdown, and PHP syntax checks, run `bin/jstest` or focused `bin/jstest ` when DOM-free JavaScript behavior can be covered, then run the relevant development asset build command and targeted UI/functional checks when build output or rendering can change. Do not use production-only `asset-map:compile` for local verification. - Focused CSS checks use the strict CSS parser and may report Tailwind-specific directives or generated modern at-rules such as `@apply`, `@theme`, or `@supports` as unsupported syntax; treat the accompanying linter note as context, and use `php bin/console tailwind:build` for the authoritative full Tailwind validation. - Doctrine mapping or entity changes: generate or update migrations and run tests covering persistence behavior. - Documentation changes: run `bin/lint ` for Markdown parse coverage, then verify style, relative links, and alignment with current behavior. diff --git a/README.md b/README.md index 4d0f60c5..afcffd1a 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Studio -> **Version**: 0.2.0 +> **Version**: 0.2.4 > **Status**: Active development -> **Updated**: 2026-06-01 +> **Updated**: 2026-06-14 > **Owner**: Dominik Letica > **Purpose:** A Symfony-based CMS foundation for structured, extensible project websites. diff --git a/assets/app.js b/assets/app.js index a8432f17..55971566 100755 --- a/assets/app.js +++ b/assets/app.js @@ -11,9 +11,5 @@ import './js/packages/extension.js'; import './js/packages/frontend-theme.js'; import './js/packages/backend-theme.js'; -import alpine from 'alpinejs'; -window.Alpine = alpine; -alpine.start(); - registerReactControllerComponents(); registerVueControllerComponents(); diff --git a/assets/controllers.json b/assets/controllers.json index 262166ac..f4907f38 100755 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -51,12 +51,6 @@ } } }, - "@symfony/ux-notify": { - "notify": { - "enabled": true, - "fetch": "lazy" - } - }, "@symfony/ux-react": { "react": { "enabled": true, diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index b834c150..2da51b94 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -1,63 +1,480 @@ import { Controller } from '@hotwired/stimulus'; +import { createAlertElement, updateAlertElement } from '../js/alerts/alert_element.js'; +import { + actionDetailFromElement, + alertId, + alertIds, + alertMode, + payloadFromAlertElement, + storableAlertPayload, +} from '../js/alerts/alert_payload.js'; export default class extends Controller { - static targets = ['alert']; + static targets = ['alert', 'badge', 'clearAll', 'empty', 'list', 'panel', 'toggle']; static values = { dismissDelay: { type: Number, default: 8000 }, + storageScope: { type: String, default: 'public' }, }; + static memoryAlerts = new Map(); + static memoryClosedAlerts = new Map(); + static storageKey = 'system.alerts.active'; + static closedStorageKey = 'system.alerts.closed'; + + initialize() { + this.alerts = new Map(); + this.closedAlertIds = new Set(); + this.connected = false; + } + connect() { - for (const alert of this.alertTargets) { - this.schedule(alert); - } + this.connected = true; + this.ensureAlertState(); + document.addEventListener('pointerdown', this.hideOnOutsidePointerDown, true); + document.addEventListener('keydown', this.hideOnEscape); + this.hydrateClosedAlerts(); + this.hydrateServerAlerts(); + this.hydrateStoredAlerts(); + this.renderState(); + } + + disconnect() { + this.connected = false; + document.removeEventListener('pointerdown', this.hideOnOutsidePointerDown, true); + document.removeEventListener('keydown', this.hideOnEscape); + window.cancelAnimationFrame(this.panelRevealFrame); + window.clearTimeout(this.panelHideTimer); + window.clearTimeout(this.hideTimer); } alertTargetConnected(alert) { - this.schedule(alert); + this.ensureAlertState(); + this.registerAlert(alert, this.connected); + } + + toggle(event) { + event.preventDefault(); + + if (this.panelTarget.hidden) { + this.showPanel(); + + return; + } + + this.hidePanel(); + } + + hide(event) { + event.preventDefault(); + this.hidePanel(); } close(event) { event.preventDefault(); - this.dismiss(event.currentTarget.closest('[data-alert-stack-target="alert"]'), false); + this.closeAlert(event.currentTarget.closest('[data-alert-stack-target="alert"]')); } - schedule(alert) { - if (!alert || alert.dataset.alertPersistent === 'true' || alert.dataset.alertScheduled === 'true') { + closeAll(event) { + event.preventDefault(); + this.ensureAlertState(); + + const ids = [...this.alerts.keys()]; + for (const id of ids) { + this.closeAlertById(id, false); + } + + this.persist(); + this.persistClosedAlerts(); + this.renderState({ keepPanelOpen: true }); + } + + action(event) { + const action = event.currentTarget; + const alert = action.closest('[data-alert-stack-target="alert"]'); + const eventName = action.dataset.alertActionEvent || ''; + const detail = actionDetailFromElement(action); + + if (eventName) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent(eventName, { + detail, + })); + } + + if (detail.keepAlert === true) { return; } - alert.dataset.alertScheduled = 'true'; - window.setTimeout(() => this.dismiss(alert, true), this.dismissDelayValue); + this.closeAlert(alert); } - dismiss(alert, animated) { - if (!alert || alert.dataset.alertDismissing === 'true') { + append(event) { + this.upsertAlert(event.detail || {}, true); + } + + upsertAlert(payload, store = true, notify = true) { + this.ensureAlertState(); + + for (const id of alertIds(payload.closes)) { + this.closeAlertById(id, false); + } + + if (!String(payload.message || '').trim()) { + if (store) { + this.persist(); + } + + return null; + } + + const id = alertId(payload); + if (this.closedAlertIds.has(id)) { + if (!payload.reopen) { + return null; + } + + this.closedAlertIds.delete(id); + this.persistClosedAlerts(); + } + + const existing = this.alerts.get(id); + const normalizedPayload = { ...payload, id }; + const nextSignature = JSON.stringify(storableAlertPayload(normalizedPayload)); + const existingSignature = existing ? JSON.stringify(storableAlertPayload(existing.payload)) : ''; + + const existingConnected = existing?.element?.isConnected === true; + + if (existingConnected && existingSignature === nextSignature) { + if (alertMode(payload) !== 'hidden') { + this.showPanel(); + } + + if (alertMode(payload) === 'auto') { + this.scheduleHide(); + } + + this.renderState(); + + return existing.element; + } + + const alert = existing?.element?.isConnected + ? updateAlertElement(existing.element, normalizedPayload, this.closeLabel) + : createAlertElement(normalizedPayload, this.closeLabel); + + if (!existing?.element?.isConnected) { + this.listTarget.append(alert); + } + + this.registerAlert(alert, false, true); + + if (store) { + this.persist(); + } + + if (notify && !existingConnected) { + document.dispatchEvent(new CustomEvent('ui-alert:shown', { + detail: storableAlertPayload(normalizedPayload), + })); + } + + if (alertMode(payload) !== 'hidden') { + this.showPanel(); + } + + if (alertMode(payload) === 'auto') { + this.scheduleHide(); + } + + this.renderState(); + + return alert; + } + + registerAlert(alert, store = true, refresh = false) { + this.ensureAlertState(); + + if (!alert || (alert.dataset.alertRegistered === 'true' && !refresh)) { return; } - alert.dataset.alertDismissing = 'true'; + const payload = payloadFromAlertElement(alert); + const id = alertId(payload); + alert.dataset.alertId = id; + alert.dataset.alertRegistered = 'true'; + this.alerts.set(id, { + id, + element: alert, + payload: { ...payload, id }, + }); + + if (store) { + this.persist(); + } + + this.renderState(); + } - if (!animated) { - alert.remove(); + closeAlert(alert, store = true) { + if (!alert) { return; } - if ('function' === typeof alert.animate) { - const animation = alert.animate([ - { opacity: 1, transform: 'translateY(0) scale(1)' }, - { opacity: 0, transform: 'translateY(-0.35rem) scale(0.99)' }, - ], { - duration: 1200, - easing: 'ease', - fill: 'forwards', - }); + this.closeAlertById(alert.dataset.alertId || '', store); + } - animation.finished.finally(() => alert.remove()); + closeAlertById(id, store = true) { + this.ensureAlertState(); + if (!this.removeAlertById(id, true)) { return; } - alert.classList.add('is-leaving'); - window.setTimeout(() => alert.remove(), 1200); + if (store) { + this.persist(); + } + + this.renderState(); + } + + hydrateStoredAlerts() { + for (const payload of this.readStoredAlerts()) { + if (this.alerts.has(alertId(payload))) { + continue; + } + + this.upsertAlert({ ...payload, mode: 'hidden' }, false, false); + } + + this.persist(); + } + + hydrateServerAlerts() { + for (const alert of this.alertTargets) { + const payload = payloadFromAlertElement(alert); + const id = alertId(payload); + if (this.closedAlertIds.has(id)) { + this.alerts.delete(id); + alert.remove(); + + continue; + } + + this.registerAlert(alert, false); + + if (alert.dataset.alertMode !== 'hidden') { + this.showPanel(); + } + + if (alert.dataset.alertMode === 'auto') { + this.scheduleHide(); + } + } + + this.persist(); + } + + scheduleHide() { + window.clearTimeout(this.hideTimer); + this.hideTimer = window.setTimeout(() => this.dismissAutoAlerts(), this.dismissDelayValue); + } + + dismissAutoAlerts() { + this.ensureAlertState(); + + let changed = false; + + for (const [id, entry] of this.alerts.entries()) { + const payload = entry.payload || {}; + if (alertMode(payload) !== 'auto' || payload.persistent) { + continue; + } + + changed = this.removeAlertById(id, true, false) || changed; + } + + if (!changed) { + this.hidePanel(); + + return; + } + + this.persist(); + this.renderState(); + } + + showPanel() { + if (this.activeCount === 0) { + return; + } + + this.revealPanel(); + } + + revealPanel() { + window.cancelAnimationFrame(this.panelRevealFrame); + window.clearTimeout(this.hideTimer); + window.clearTimeout(this.panelHideTimer); + this.panelTarget.hidden = false; + this.panelTarget.classList.remove('is-closing'); + this.panelRevealFrame = window.requestAnimationFrame(() => { + this.panelRevealFrame = null; + this.panelTarget.classList.add('is-open'); + }); + } + + hidePanel() { + if (this.panelTarget.hidden) { + return; + } + + window.cancelAnimationFrame(this.panelRevealFrame); + this.panelRevealFrame = null; + this.panelTarget.classList.remove('is-open'); + this.panelTarget.classList.add('is-closing'); + window.clearTimeout(this.panelHideTimer); + this.panelHideTimer = window.setTimeout(() => { + this.panelTarget.hidden = true; + this.panelTarget.classList.remove('is-closing'); + }, 180); + } + + renderState(options = {}) { + const count = this.activeCount; + this.toggleTarget.hidden = count === 0; + this.badgeTarget.hidden = count === 0; + this.badgeTarget.textContent = String(count); + + if (this.hasClearAllTarget) { + this.clearAllTarget.hidden = count === 0; + } + + if (this.hasEmptyTarget) { + this.emptyTarget.hidden = count !== 0; + } + + if (count === 0 && options.keepPanelOpen) { + this.revealPanel(); + + return; + } + + if (count === 0) { + this.hidePanel(); + } + } + + persist() { + this.ensureAlertState(); + + const payloads = [...this.alerts.values()].map((entry) => storableAlertPayload(entry.payload)); + + try { + window.sessionStorage.setItem(this.storageKey, JSON.stringify(payloads)); + } catch { + this.constructor.memoryAlerts.set(this.storageKey, payloads); + } + } + + readStoredAlerts() { + try { + const raw = window.sessionStorage.getItem(this.storageKey); + const parsed = raw ? JSON.parse(raw) : []; + + return Array.isArray(parsed) ? parsed.filter((payload) => payload && typeof payload === 'object') : []; + } catch { + return this.constructor.memoryAlerts.get(this.storageKey) || []; + } + } + + hydrateClosedAlerts() { + this.closedAlertIds = new Set(this.readClosedAlertIds()); + } + + persistClosedAlerts() { + const ids = [...this.closedAlertIds].slice(-200); + this.closedAlertIds = new Set(ids); + + try { + window.sessionStorage.setItem(this.closedStorageKey, JSON.stringify(ids)); + } catch { + this.constructor.memoryClosedAlerts.set(this.closedStorageKey, ids); + } + } + + readClosedAlertIds() { + try { + const raw = window.sessionStorage.getItem(this.closedStorageKey); + const parsed = raw ? JSON.parse(raw) : []; + + return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : []; + } catch { + return this.constructor.memoryClosedAlerts.get(this.closedStorageKey) || []; + } + } + + get activeCount() { + this.ensureAlertState(); + + return this.alerts.size; + } + + get closeLabel() { + return this.element.dataset.alertCloseLabel || 'Close notification'; + } + + get storageKey() { + return `${this.constructor.storageKey}.${this.normalizedStorageScope}`; + } + + get closedStorageKey() { + return `${this.constructor.closedStorageKey}.${this.normalizedStorageScope}`; + } + + get normalizedStorageScope() { + return String(this.storageScopeValue || 'public').replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 120) || 'public'; + } + + ensureAlertState() { + if (!(this.alerts instanceof Map)) { + this.alerts = new Map(); + } + } + + hideOnOutsidePointerDown = (event) => { + if (this.panelTarget.hidden || this.element.contains(event.target)) { + return; + } + + this.hidePanel(); + }; + + hideOnEscape = (event) => { + if (event.key !== 'Escape' || this.panelTarget.hidden) { + return; + } + + this.hidePanel(); + }; + + removeAlertById(id, rememberClosed = true, dispatchClosedEvent = true) { + if (!id || !this.alerts.has(id)) { + return false; + } + + const entry = this.alerts.get(id); + entry.element?.remove(); + this.alerts.delete(id); + if (rememberClosed) { + this.closedAlertIds.add(id); + this.persistClosedAlerts(); + } + if (dispatchClosedEvent) { + document.dispatchEvent(new CustomEvent('ui-alert:closed', { + detail: { id }, + })); + } + + return true; } } diff --git a/assets/controllers/chart_controller.js b/assets/controllers/chart_controller.js deleted file mode 100644 index 365dc6c3..00000000 --- a/assets/controllers/chart_controller.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import ApexCharts from 'apexcharts'; - -/* stimulusFetch: 'lazy' */ -export default class extends Controller { - static values = { - options: Object, - series: Array, - type: String, - }; - - connect() { - this.chart = new ApexCharts(this.element, this.buildOptions()); - this.chart.render(); - } - - disconnect() { - if (this.chart) { - this.chart.destroy(); - this.chart = null; - } - } - - optionsValueChanged() { - if (this.chart) { - this.chart.updateOptions(this.buildOptions()); - } - } - - seriesValueChanged() { - if (this.chart && this.hasSeriesValue) { - this.chart.updateSeries(this.seriesValue); - } - } - - buildOptions() { - const options = this.hasOptionsValue ? { ...this.optionsValue } : {}; - - if (this.hasTypeValue) { - options.chart = { ...(options.chart || {}), type: this.typeValue }; - } - - if (this.hasSeriesValue) { - options.series = this.seriesValue; - } - - return options; - } -} diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js new file mode 100644 index 00000000..0c8aa239 --- /dev/null +++ b/assets/controllers/clipboard_controller.js @@ -0,0 +1,65 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['source', 'status']; + static values = { + text: String, + success: String, + failure: String, + }; + + async copy(event) { + event.preventDefault(); + + try { + await this.copyText(this.text); + this.status = this.successValue || ''; + } catch (error) { + this.status = this.failureValue || ''; + } + } + + async copyText(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const input = document.createElement('textarea'); + input.value = text; + input.setAttribute('readonly', 'readonly'); + input.style.position = 'fixed'; + input.style.insetBlockStart = '-100vh'; + document.body.append(input); + input.select(); + + try { + if (!document.execCommand('copy')) { + throw new Error('Copy command failed.'); + } + } finally { + input.remove(); + } + } + + get text() { + if (this.hasTextValue) { + return this.textValue; + } + + if (this.hasSourceTarget) { + return 'value' in this.sourceTarget + ? this.sourceTarget.value + : this.sourceTarget.textContent.trim(); + } + + return this.element.textContent.trim(); + } + + set status(message) { + if (this.hasStatusTarget && message) { + this.statusTarget.textContent = message; + this.statusTarget.removeAttribute('hidden'); + } + } +} diff --git a/assets/controllers/cookie_consent_controller.js b/assets/controllers/cookie_consent_controller.js new file mode 100644 index 00000000..4bbf67bf --- /dev/null +++ b/assets/controllers/cookie_consent_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['details', 'option']; + + connect() { + this.openFromTrigger = (event) => { + const trigger = event.target instanceof Element + ? event.target.closest('[data-cookie-consent-open]') + : null; + + if (!trigger) { + return; + } + + event.preventDefault(); + this.open(); + }; + + document.addEventListener('click', this.openFromTrigger); + } + + disconnect() { + document.removeEventListener('click', this.openFromTrigger); + } + + open() { + this.element.hidden = false; + + if (this.hasDetailsTarget) { + this.detailsTarget.hidden = false; + } + + this.element.querySelector('button, input, a')?.focus(); + } + + close(event) { + event?.preventDefault(); + this.element.hidden = true; + } + + toggleDetails(event) { + event.preventDefault(); + this.detailsTarget.hidden = !this.detailsTarget.hidden; + } + + rejectOptional() { + for (const option of this.optionTargets) { + option.checked = false; + } + } +} diff --git a/assets/controllers/dialog_controller.js b/assets/controllers/dialog_controller.js new file mode 100644 index 00000000..95b2721f --- /dev/null +++ b/assets/controllers/dialog_controller.js @@ -0,0 +1,43 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['dialog']; + + open(event) { + event.preventDefault(); + + const dialog = this.dialog(event.params.id); + + if (!dialog || dialog.open) { + return; + } + + dialog.showModal(); + } + + close(event) { + event.preventDefault(); + + const dialog = this.dialog(event.params.id) || event.target.closest('dialog'); + + if (dialog?.open) { + dialog.close(); + } + } + + closeOnBackdrop(event) { + if (event.target === event.currentTarget && event.currentTarget instanceof HTMLDialogElement) { + event.currentTarget.close(); + } + } + + dialog(id) { + if (id) { + const scoped = this.element.querySelector(`#${CSS.escape(id)}`); + + return scoped instanceof HTMLDialogElement ? scoped : null; + } + + return this.hasDialogTarget ? this.dialogTarget : null; + } +} diff --git a/assets/controllers/disclosure_controller.js b/assets/controllers/disclosure_controller.js new file mode 100644 index 00000000..1c4277df --- /dev/null +++ b/assets/controllers/disclosure_controller.js @@ -0,0 +1,38 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['trigger', 'panel']; + static values = { + open: { type: Boolean, default: false }, + }; + + connect() { + this.apply(); + } + + toggle() { + this.openValue = !this.openValue; + } + + open() { + this.openValue = true; + } + + close() { + this.openValue = false; + } + + openValueChanged() { + this.apply(); + } + + apply() { + for (const panel of this.panelTargets) { + panel.hidden = !this.openValue; + } + + for (const trigger of this.triggerTargets) { + trigger.setAttribute('aria-expanded', String(this.openValue)); + } + } +} diff --git a/assets/controllers/filter_form_controller.js b/assets/controllers/filter_form_controller.js new file mode 100644 index 00000000..ee2c48df --- /dev/null +++ b/assets/controllers/filter_form_controller.js @@ -0,0 +1,142 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static fallbackStorage = new Map(); + static storagePrefix = 'system.filter-form.focus.'; + + static values = { + autoSubmit: { type: Boolean, default: true }, + debounce: { type: Number, default: 450 }, + }; + + connect() { + this.restoreFocus(); + } + + queue(event) { + if (!this.autoSubmitValue || this.submitting) { + return; + } + + window.clearTimeout(this.timer); + + const target = event.target; + const delay = target instanceof HTMLInputElement && target.type === 'search' + ? this.debounceValue + : 0; + + this.timer = window.setTimeout(() => this.submitNow(), delay); + } + + submit(event) { + this.resetPage(); + this.rememberFocus(); + this.submitting = true; + this.element.setAttribute('aria-busy', 'true'); + + for (const button of this.element.querySelectorAll('button[type="submit"]')) { + button.disabled = true; + } + } + + submitNow() { + this.resetPage(); + this.rememberFocus(); + this.element.requestSubmit(); + } + + resetPage() { + const page = this.element.querySelector('input[name="page"]'); + + if (page instanceof HTMLInputElement) { + page.value = '1'; + } + } + + disconnect() { + window.clearTimeout(this.timer); + } + + rememberFocus() { + const active = document.activeElement; + + if (!this.element.contains(active) || !(active instanceof HTMLInputElement || active instanceof HTMLSelectElement || active instanceof HTMLTextAreaElement)) { + return; + } + + const name = active.name || active.id; + + if (!name) { + return; + } + + this.storeFocusState(JSON.stringify({ + name, + selectionStart: typeof active.selectionStart === 'number' ? active.selectionStart : null, + selectionEnd: typeof active.selectionEnd === 'number' ? active.selectionEnd : null, + })); + } + + restoreFocus() { + const raw = this.takeFocusState(); + + if (!raw) { + return; + } + + let state; + + try { + state = JSON.parse(raw); + } catch (error) { + return; + } + + if (!state || typeof state.name !== 'string') { + return; + } + + window.requestAnimationFrame(() => { + const field = this.element.querySelector(`[name="${CSS.escape(state.name)}"], #${CSS.escape(state.name)}`); + + if (!(field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement)) { + return; + } + + field.focus({ preventScroll: true }); + + if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && 'setSelectionRange' in field) { + field.setSelectionRange(state.selectionStart, state.selectionEnd); + } + }); + } + + get storageKey() { + const action = this.element.getAttribute('action') || window.location.pathname; + + return `${this.constructor.storagePrefix}${window.location.pathname}.${this.element.method}.${action}`; + } + + storeFocusState(value) { + try { + window.sessionStorage.setItem(this.storageKey, value); + return; + } catch (error) { + this.constructor.fallbackStorage.set(this.storageKey, value); + } + } + + takeFocusState() { + try { + const value = window.sessionStorage.getItem(this.storageKey); + window.sessionStorage.removeItem(this.storageKey); + + return value; + } catch (error) { + const value = this.constructor.fallbackStorage.get(this.storageKey) || null; + this.constructor.fallbackStorage.delete(this.storageKey); + + return value; + } + } +} diff --git a/assets/controllers/live_poll_controller.js b/assets/controllers/live_poll_controller.js new file mode 100644 index 00000000..1f537555 --- /dev/null +++ b/assets/controllers/live_poll_controller.js @@ -0,0 +1,90 @@ +import { Controller } from '@hotwired/stimulus'; +import { LivePoller, liveRouteUrl } from '../js/live/live_poll.js'; + +export default class extends Controller { + static values = { + url: String, + relativeRoute: String, + interval: { type: Number, default: 750 }, + cursor: { type: Number, default: 0 }, + autostart: { type: Boolean, default: true }, + }; + + connect() { + if (this.autostartValue && this.endpoint) { + this.start(); + } + } + + disconnect() { + this.stop(); + } + + start() { + if (!this.endpoint) { + return; + } + + this.poller = new LivePoller({ + interval: this.intervalValue, + onPayload: (payload, cursor) => this.payload(payload, cursor), + onError: (response, error) => this.error(response, error), + onDone: (payload) => this.done(payload), + }); + this.poller.poll(this.endpoint, this.cursorValue); + } + + async pollOnce(event) { + event?.preventDefault(); + + if (!this.endpoint) { + return; + } + + const poller = new LivePoller({ + interval: this.intervalValue, + onPayload: (payload, cursor) => this.payload(payload, cursor), + onError: (response, error) => this.error(response, error), + onDone: (payload) => this.done(payload), + }); + + await poller.pollOnce(this.endpoint, this.cursorValue); + } + + async poll(event) { + await this.pollOnce(event); + } + + async refresh(event) { + await this.pollOnce(event); + } + + stop() { + this.poller?.stop(); + } + + payload(payload, cursor) { + this.cursorValue = cursor; + this.dispatch('payload', { detail: { payload, cursor } }); + } + + error(response, error) { + this.dispatch('error', { detail: { response, error } }); + } + + done(payload) { + this.dispatch('done', { detail: { payload } }); + } + + get endpoint() { + if (this.hasUrlValue && this.urlValue) { + return this.urlValue; + } + + if (this.hasRelativeRouteValue && this.relativeRouteValue) { + return liveRouteUrl(this.relativeRouteValue); + } + + return null; + } +} diff --git a/assets/controllers/operation_overlay_controller.js b/assets/controllers/operation_overlay_controller.js index 528b2dd8..413bae2f 100644 --- a/assets/controllers/operation_overlay_controller.js +++ b/assets/controllers/operation_overlay_controller.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus'; +import { LivePoller } from '../js/live/live_poll.js'; export default class extends Controller { static values = { @@ -9,15 +10,32 @@ export default class extends Controller { static storedOperationMaxAgeMs = 60 * 60 * 1000; connect() { + document.addEventListener('operation-overlay:show', this.showFromAlert); + document.addEventListener('ui-alert:closed', this.alertClosed); + const stored = this.storedOperation(); if (this.enabledValue && stored?.statusUrl) { - this.open(); + this.bindOperationButton(this.primaryOperationButton()); + this.markOperationButtonRunning(); + this.prepareOverlay(); + this.updateOperationAlert({ + status: stored.status || 'queued', + progress: stored.progress || null, + }); this.reset(); this.poll(stored.statusUrl, Number(stored.cursor || 0)); } } + disconnect() { + document.removeEventListener('operation-overlay:show', this.showFromAlert); + document.removeEventListener('ui-alert:closed', this.alertClosed); + this.livePoller?.stop(); + this.stopSuccessfulCompletionTimer(); + this.restoreOperationButton(); + } + async submit(event) { if (!this.enabledValue) { return; @@ -28,7 +46,9 @@ export default class extends Controller { const stored = this.storedOperation(); if (stored?.statusUrl) { - this.open(); + this.bindOperationButton(event.submitter || this.primaryOperationButton()); + this.markOperationButtonRunning(); + this.prepareOverlay(); this.reset(); await this.poll(stored.statusUrl, Number(stored.cursor || 0)); @@ -44,8 +64,15 @@ export default class extends Controller { } this.starting = true; - this.open(); + this.suppressRunningAlert = false; + this.bindOperationButton(submitter || this.primaryOperationButton()); + this.prepareOverlay(); this.reset(); + this.markOperationButtonRunning(); + this.updateOperationAlert({ + status: 'queued', + progress: null, + }); const formData = new FormData(this.element); if (submitter?.name) { @@ -77,7 +104,7 @@ export default class extends Controller { return; } - this.storeOperation(payload.value.status_url, 0, null, 'queued'); + this.storeOperation(payload.value.status_url, 0, null, 'queued', null); await this.poll(payload.value.status_url); } catch (error) { this.clearStoredOperation(); @@ -89,53 +116,45 @@ export default class extends Controller { async poll(statusUrl, cursor = 0) { this.polling = true; - - try { - while (this.polling) { - const url = new URL(statusUrl, window.location.origin); - url.searchParams.set('cursor', String(cursor)); - const response = await fetch(url.toString(), { - headers: { - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - - if (!response.ok) { - if (response.status === 404) { - this.clearStoredOperation(); - this.fail(this.label('statusError')); - this.retryButton.hidden = false; - - return; - } - - this.fail(this.label('statusError'), true); + this.livePoller = new LivePoller({ + interval: 750, + invalidJsonMessage: this.label('statusError'), + onPayload: (payload, nextCursor) => { + this.storeOperation( + statusUrl, + nextCursor, + payload.continue_url || null, + payload.status || null, + payload.progress || null, + payload.label || payload.operation || null, + ); + this.render(payload); + }, + onError: (response, error) => { + if (response?.status === 404) { + this.clearStoredOperation(); + this.fail(this.label('statusError')); + this.retryButton.hidden = false; return; } - const payload = await this.readJson(response); - cursor = Number(payload.cursor || cursor); - this.storeOperation(statusUrl, cursor, payload.continue_url || null, payload.status || null); - this.render(payload); - - if (['success', 'requires_review', 'failed'].includes(payload.status)) { + this.fail(error instanceof Error ? error.message : this.label('statusError'), true); + }, + onDone: (payload) => { + if (payload) { this.finish(payload); - - return; } + }, + }); - await this.sleep(Number(payload.next_poll_ms || 750)); - } - } catch (error) { - this.fail(error instanceof Error ? error.message : this.label('requestError'), true); - } + await this.livePoller.poll(statusUrl, cursor); } render(payload) { if (!['success', 'requires_review', 'failed'].includes(payload.status)) { this.setSummary(this.label('waiting'), 'running'); + this.updateOperationAlert(payload); } this.emptyElement?.remove(); @@ -198,8 +217,22 @@ export default class extends Controller { open() { this.rootElement.hidden = false; + this.wireControls(); + + if (this.finishedStatus === 'success') { + this.stopSuccessfulCompletionTimer(); + this.okButton.hidden = false; + } + } + + prepareOverlay() { this.finishedStatus = null; + this.wireControls(); this.hideButtons(); + this.rootElement.hidden = true; + } + + wireControls() { this.okButton.onclick = this.ok; this.continueButton.onclick = this.continueOperation; this.retryButton.onclick = this.retry; @@ -231,6 +264,7 @@ export default class extends Controller { } this.reset(); + this.markOperationButtonRunning(); try { const response = await fetch(stored.continueUrl, { @@ -248,7 +282,8 @@ export default class extends Controller { return; } - this.storeOperation(payload.value.status_url, 0, null, 'queued'); + this.storeOperation(payload.value.status_url, 0, null, 'queued', null, payload.value.label || payload.value.operation || null); + this.suppressRunningAlert = false; await this.poll(payload.value.status_url); } catch (error) { this.fail(error instanceof Error ? error.message : this.label('requestError')); @@ -275,11 +310,19 @@ export default class extends Controller { cancel = () => { this.clearStoredOperation(); + this.restoreOperationButton(); this.close(); }; close = () => { + if (this.polling || this.starting) { + this.rootElement.hidden = true; + + return; + } + this.polling = false; + this.livePoller?.stop(); this.rootElement.hidden = true; }; @@ -290,6 +333,7 @@ export default class extends Controller { this.resultRendered = false; this.stepElements = new Map(); this.hideButtons(); + this.showCloseControls(); } finish(payload) { @@ -297,6 +341,7 @@ export default class extends Controller { this.finishedStatus = status; this.polling = false; this.spinnerElement.hidden = true; + this.updateOperationAlert(payload); this.setSummary( status === 'success' ? this.label('completed') @@ -307,12 +352,13 @@ export default class extends Controller { if (status === 'success') { this.clearStoredOperation(); - this.okButton.hidden = false; + this.finishSuccessfulOperation(); return; } if (status === 'requires_review') { + this.mapOperationButton('requires_review'); this.continueButton.hidden = !payload.continue_url; this.cancelButton.hidden = false; @@ -320,6 +366,7 @@ export default class extends Controller { } this.clearStoredOperation(); + this.mapOperationButton('failed'); this.retryButton.hidden = false; this.cancelButton.hidden = false; } @@ -327,8 +374,15 @@ export default class extends Controller { fail(message, refreshable = false) { this.polling = false; this.spinnerElement.hidden = true; + this.updateOperationAlert({ + status: 'failed', + result: { + issues: [{ message }], + }, + }); this.setSummary(message, 'error'); this.hideButtons(); + this.mapOperationButton('failed'); if (refreshable) { this.refreshButton.hidden = false; @@ -340,6 +394,29 @@ export default class extends Controller { this.showCloseControls(); } + finishSuccessfulOperation() { + if (!this.rootElement.hidden) { + this.okButton.hidden = false; + + return; + } + + this.stopSuccessfulCompletionTimer(); + this.successfulCompletionTimer = window.setTimeout(() => { + this.successfulCompletionTimer = null; + this.ok(); + }, 2000); + } + + stopSuccessfulCompletionTimer() { + if (!this.successfulCompletionTimer) { + return; + } + + window.clearTimeout(this.successfulCompletionTimer); + this.successfulCompletionTimer = null; + } + hideButtons() { this.okButton.hidden = true; this.continueButton.hidden = true; @@ -355,10 +432,6 @@ export default class extends Controller { this.closeIconButton.hidden = false; } - sleep(ms) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); - } - async readJson(response) { const contentType = response.headers.get('content-type') || ''; @@ -415,13 +488,15 @@ export default class extends Controller { } } - storeOperation(statusUrl, cursor, continueUrl = null, status = null) { + storeOperation(statusUrl, cursor, continueUrl = null, status = null, progress = null, label = null) { try { window.sessionStorage.setItem(this.storageKey(), JSON.stringify({ statusUrl, cursor, continueUrl, status, + progress, + label, updatedAt: new Date().toISOString(), })); } catch { @@ -447,6 +522,192 @@ export default class extends Controller { return ['success', 'failed'].includes(stored.status) || (stored.status === 'requires_review' && !stored.continueUrl); } + showFromAlert = (event) => { + const storageKey = event.detail?.storageKey || ''; + + if (storageKey && storageKey !== this.storageKey()) { + return; + } + + this.suppressRunningAlert = true; + this.open(); + }; + + alertClosed = (event) => { + if (event.detail?.id === this.operationAlertId()) { + this.suppressRunningAlert = true; + } + }; + + updateOperationAlert(payload) { + const status = String(payload.status || 'queued'); + const terminal = ['success', 'requires_review', 'failed'].includes(status); + + if (!terminal && this.suppressRunningAlert) { + return; + } + + if (terminal) { + this.suppressRunningAlert = false; + } + + const issue = payload.result?.issues?.[0] || null; + const title = this.operationTitle(payload); + const message = terminal + ? (status === 'success' + ? this.label('completed') + : (status === 'requires_review' + ? this.label('requiresReview') + : (issue?.message || issue?.translation_key || issue?.code || this.label('failed')))) + : this.runningMessage(payload); + + this.dispatchAlert({ + id: this.operationAlertId(), + reopen: true, + title, + level: status === 'success' ? 'success' : (status === 'requires_review' ? 'warning' : (status === 'failed' ? 'error' : 'info')), + message, + mode: terminal && status === 'success' ? 'auto' : 'persistent', + loading: !terminal, + actions: [{ + label: this.label('showDetails'), + event: 'operation-overlay:show', + detail: { + storageKey: this.storageKey(), + keepAlert: !terminal, + }, + }], + }); + } + + operationTitle(payload) { + const label = String(payload.label || '').trim(); + + if (label) { + return label; + } + + const operation = String(payload.operation || '').trim(); + + return operation ? this.actionLabel(operation) : this.label('operation'); + } + + runningMessage(payload) { + const progress = payload.progress || {}; + const total = Number(progress.total || 0); + const index = Number(progress.index || 0); + + if (total > 0) { + return `${this.label('waiting')} [${Math.max(0, index)}/${total}]`; + } + + return this.label('waiting'); + } + + dispatchAlert(payload) { + const stack = document.querySelector('[data-controller~="alert-stack"]'); + + if (!stack) { + return; + } + + stack.dispatchEvent(new CustomEvent('ui-alert:received', { + bubbles: true, + detail: payload, + })); + } + + operationAlertId() { + return `operation:${this.storageKey()}`; + } + + bindOperationButton(button) { + if (!button || !(button instanceof HTMLButtonElement)) { + return; + } + + if (this.operationButton === button) { + return; + } + + this.restoreOperationButton(); + this.operationButton = button; + this.operationButtonInitial = { + className: button.className, + disabled: button.disabled, + html: button.innerHTML, + type: button.type || 'submit', + }; + } + + primaryOperationButton() { + return this.element.querySelector('button[type="submit"], button:not([type])'); + } + + markOperationButtonRunning() { + if (!this.operationButton) { + return; + } + + this.operationButton.disabled = true; + this.operationButton.type = 'button'; + this.operationButton.dataset.operationButtonState = 'running'; + this.operationButton.setAttribute('aria-busy', 'true'); + this.operationButton.textContent = this.label('waiting'); + } + + mapOperationButton(status) { + if (!this.operationButton) { + return; + } + + this.operationButton.disabled = false; + this.operationButton.type = 'button'; + this.operationButton.dataset.operationButtonState = status; + this.operationButton.removeAttribute('aria-busy'); + this.operationButton.textContent = this.label('showDetails'); + this.setOperationButtonVariant(status === 'requires_review' ? 'warning' : 'danger'); + this.operationButton.removeEventListener('click', this.operationButtonClick); + this.operationButton.addEventListener('click', this.operationButtonClick); + } + + restoreOperationButton() { + if (!this.operationButton || !this.operationButtonInitial) { + return; + } + + this.operationButton.removeEventListener('click', this.operationButtonClick); + this.operationButton.className = this.operationButtonInitial.className; + this.operationButton.disabled = this.operationButtonInitial.disabled; + this.operationButton.innerHTML = this.operationButtonInitial.html; + this.operationButton.type = this.operationButtonInitial.type; + this.operationButton.removeAttribute('aria-busy'); + delete this.operationButton.dataset.operationButtonState; + this.operationButton = null; + this.operationButtonInitial = null; + } + + operationButtonClick = (event) => { + const state = this.operationButton?.dataset.operationButtonState || ''; + + if (!state || state === 'running') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + this.open(); + }; + + setOperationButtonVariant(variant) { + for (const name of ['primary', 'secondary', 'success', 'warning', 'danger', 'ghost']) { + this.operationButton.classList.remove(`system-button-${name}`); + } + + this.operationButton.classList.add(`system-button-${variant}`); + } + get rootElement() { return document.querySelector('[data-operation-overlay-root]'); } diff --git a/assets/controllers/tabs_controller.js b/assets/controllers/tabs_controller.js new file mode 100644 index 00000000..fdba7718 --- /dev/null +++ b/assets/controllers/tabs_controller.js @@ -0,0 +1,37 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['tab', 'panel']; + static values = { + selected: String, + }; + + connect() { + const selected = this.selectedValue || this.tabTargets.find((tab) => tab.getAttribute('aria-selected') === 'true')?.dataset.tabsId; + this.selectedValue = selected || this.tabTargets[0]?.dataset.tabsId || ''; + } + + select(event) { + const id = event.params.id || event.currentTarget?.dataset.tabsId || ''; + + if (id) { + this.selectedValue = id; + } + } + + selectedValueChanged() { + this.apply(); + } + + apply() { + for (const tab of this.tabTargets) { + const active = tab.dataset.tabsId === this.selectedValue; + tab.setAttribute('aria-selected', String(active)); + tab.tabIndex = active ? 0 : -1; + } + + for (const panel of this.panelTargets) { + panel.hidden = panel.dataset.tabsId !== this.selectedValue; + } + } +} diff --git a/assets/controllers/ui_alert_poll_controller.js b/assets/controllers/ui_alert_poll_controller.js new file mode 100644 index 00000000..a575b41b --- /dev/null +++ b/assets/controllers/ui_alert_poll_controller.js @@ -0,0 +1,38 @@ +import { Controller } from '@hotwired/stimulus'; +import { LivePoller } from '../js/live/live_poll.js'; + +export default class extends Controller { + static values = { + url: String, + interval: { type: Number, default: 15000 }, + cursor: { type: Number, default: 0 }, + }; + + connect() { + if (!this.hasUrlValue || !this.urlValue) { + return; + } + + this.poller = new LivePoller({ + interval: this.intervalValue, + onPayload: (payload, cursor) => this.payload(payload, cursor), + retryOnError: true, + }); + this.poller.poll(this.urlValue, this.cursorValue); + } + + disconnect() { + this.poller?.stop(); + } + + payload(payload, cursor) { + this.cursorValue = cursor; + + for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { + this.element.dispatchEvent(new CustomEvent('ui-alert:received', { + bubbles: true, + detail: alert, + })); + } + } +} diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js new file mode 100644 index 00000000..f92977ba --- /dev/null +++ b/assets/controllers/ui_alert_stream_controller.js @@ -0,0 +1,224 @@ +import { Controller } from '@hotwired/stimulus'; +import { LivePoller } from '../js/live/live_poll.js'; + +export default class extends Controller { + static reconnectBaseDelay = 1000; + static reconnectMaxDelay = 30000; + + static values = { + url: String, + catchUpUrl: String, + catchUpCursor: { type: Number, default: 0 }, + credentials: { type: Boolean, default: false }, + fallbackUrl: String, + fallbackInterval: { type: Number, default: 15000 }, + }; + + connect() { + if (!this.hasUrlValue || !this.urlValue) { + return; + } + + this.reconnectAttempts = 0; + this.shouldReconnect = true; + this.streamOpened = false; + document.addEventListener('visibilitychange', this.reconnectWhenActive); + window.addEventListener('online', this.reconnectWhenActive); + + if (typeof window.EventSource !== 'function') { + this.startFallbackPolling(); + + return; + } + + this.catchUp(); + this.openSource(); + } + + disconnect() { + this.shouldReconnect = false; + window.clearTimeout(this.reconnectTimer); + document.removeEventListener('visibilitychange', this.reconnectWhenActive); + window.removeEventListener('online', this.reconnectWhenActive); + this.fallbackPoller?.stop(); + this.closeSource(); + } + + openSource() { + if (this.source || !this.shouldReconnect) { + return; + } + + this.source = new EventSource(this.urlValue, { withCredentials: this.credentialsValue }); + this.source.addEventListener('open', this.open); + this.source.addEventListener('error', this.error); + this.source.addEventListener('message', this.receive); + this.source.addEventListener('ui-alert', this.receive); + } + + open = () => { + this.reconnectAttempts = 0; + this.streamOpened = true; + this.catchUp(); + }; + + error = () => { + if (this.source?.readyState === EventSource.CLOSED) { + this.closeSource(); + if (!this.streamOpened) { + this.startFallbackPolling(); + + return; + } + + this.scheduleReconnect(); + } + }; + + reconnectWhenActive = () => { + if (document.hidden || !this.shouldReconnect) { + return; + } + + if (!this.source || this.source.readyState === EventSource.CLOSED) { + this.closeSource(); + this.scheduleReconnect(0); + } + }; + + receive = (event) => { + try { + const payload = JSON.parse(event.data || '{}'); + this.element.dispatchEvent(new CustomEvent('ui-alert:received', { + bubbles: true, + detail: payload, + })); + } catch { + // Ignore malformed updates; the stream can continue with the next event. + } + }; + + async catchUp() { + if (!this.hasCatchUpUrlValue || !this.catchUpUrlValue || typeof window.fetch !== 'function') { + return; + } + + if (this.catchUpRunning) { + this.catchUpRequested = true; + + return; + } + + this.catchUpRunning = true; + + try { + let previousCursor = -1; + + do { + previousCursor = Math.max(0, this.catchUpCursorValue || 0); + + const payload = await this.fetchCatchUpPage(previousCursor); + if (!payload) { + return; + } + + const cursor = Number(payload.cursor); + if (Number.isFinite(cursor)) { + this.updateCursor(cursor); + } + + for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { + this.dispatchAlert(alert); + } + + if (payload.has_more !== true) { + return; + } + } while (this.catchUpCursorValue > previousCursor); + } catch { + // Stream delivery remains active; the next open/reconnect can catch up again. + } finally { + this.catchUpRunning = false; + if (this.catchUpRequested && this.shouldReconnect) { + this.catchUpRequested = false; + this.catchUp(); + } + } + } + + async fetchCatchUpPage(cursor) { + const url = new URL(this.catchUpUrlValue, window.location.origin); + url.searchParams.set('cursor', String(Math.max(0, cursor || 0))); + const response = await window.fetch(url.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return null; + } + + return response.json(); + } + + startFallbackPolling() { + this.shouldReconnect = false; + if (!this.hasFallbackUrlValue || !this.fallbackUrlValue || this.fallbackPoller) { + return; + } + + this.closeSource(); + this.fallbackPoller = new LivePoller({ + interval: this.fallbackIntervalValue, + onPayload: (payload, cursor) => this.fallbackPayload(payload, cursor), + retryOnError: true, + }); + this.fallbackPoller.poll(this.fallbackUrlValue, this.catchUpCursorValue); + } + + fallbackPayload(payload, cursor) { + this.updateCursor(cursor); + + for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { + this.dispatchAlert(alert); + } + } + + updateCursor(cursor) { + if (Number.isFinite(Number(cursor))) { + this.catchUpCursorValue = Math.max(0, this.catchUpCursorValue || 0, Number(cursor)); + } + } + + dispatchAlert(alert) { + this.element.dispatchEvent(new CustomEvent('ui-alert:received', { + bubbles: true, + detail: alert, + })); + } + + scheduleReconnect(delay = null) { + if (!this.shouldReconnect || this.reconnectTimer) { + return; + } + + const nextDelay = delay ?? Math.min( + this.constructor.reconnectBaseDelay * (2 ** this.reconnectAttempts), + this.constructor.reconnectMaxDelay, + ); + this.reconnectAttempts += 1; + + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.openSource(); + }, nextDelay); + } + + closeSource() { + this.source?.removeEventListener('open', this.open); + this.source?.removeEventListener('error', this.error); + this.source?.removeEventListener('message', this.receive); + this.source?.removeEventListener('ui-alert', this.receive); + this.source?.close(); + this.source = null; + } +} diff --git a/assets/icons/tabler/cookie.svg b/assets/icons/tabler/cookie.svg new file mode 100644 index 00000000..958b24e8 --- /dev/null +++ b/assets/icons/tabler/cookie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/js/alerts/alert_element.js b/assets/js/alerts/alert_element.js new file mode 100644 index 00000000..ff5a0d65 --- /dev/null +++ b/assets/js/alerts/alert_element.js @@ -0,0 +1,216 @@ +import { alertId, alertMode, normalizeAlertLevel } from './alert_payload.js'; + +export function createAlertElement(payload, closeLabel) { + return updateAlertElement(document.createElement('section'), payload, closeLabel); +} + +export function updateAlertElement(alert, payload, closeLabel) { + const level = normalizeAlertLevel(payload.level || 'info'); + const mode = alertMode(payload); + const actions = normalizeActions(Array.isArray(payload.actions) ? payload.actions : []); + alert.className = `system-alert system-alert-${level}`; + alert.setAttribute('role', ['error', 'exception'].includes(level) ? 'alert' : 'status'); + alert.dataset.alertStackTarget = 'alert'; + alert.dataset.alertId = alertId(payload); + alert.dataset.alertMode = mode; + alert.dataset.alertPersistent = mode === 'persistent' || payload.persistent ? 'true' : 'false'; + alert.dataset.alertPayload = JSON.stringify({ + ...payload, + id: alert.dataset.alertId, + level, + mode, + actions, + }); + alert.replaceChildren(); + + const statusIcon = document.createElement('span'); + statusIcon.className = `system-alert-icon ti ${alertIcon(level)}`; + statusIcon.setAttribute('aria-hidden', 'true'); + alert.append(statusIcon); + + const content = document.createElement('div'); + content.className = 'system-alert-content'; + + const title = String(payload.title || '').trim(); + const message = String(payload.message || '').trim(); + + if (payload.loading || title) { + const header = document.createElement('div'); + header.className = 'system-alert-heading'; + + const spinner = document.createElement('span'); + if (payload.loading) { + spinner.className = 'system-alert-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + header.append(spinner); + } + + if (title) { + const titleElement = document.createElement('strong'); + titleElement.className = 'system-alert-title'; + titleElement.textContent = title; + header.append(titleElement); + } + + content.append(header); + } + + if (message) { + const messageElement = document.createElement('span'); + messageElement.className = 'system-alert-message'; + messageElement.textContent = message; + content.append(messageElement); + } + + appendActions(content, actions); + alert.append(content); + alert.append(closeButton(closeLabel)); + + return alert; +} + +function alertIcon(level) { + const icons = { + debug: 'ti-bug', + info: 'ti-info-circle', + success: 'ti-circle-check', + warning: 'ti-alert-triangle', + error: 'ti-alert-circle', + exception: 'ti-alert-circle', + }; + + return icons[level] || icons.info; +} + +function appendActions(content, actions) { + if (actions.length === 0) { + return; + } + + const actionList = document.createElement('div'); + actionList.className = 'system-alert-actions'; + + for (const action of actions) { + actionList.append(actionElement(action)); + } + + content.append(actionList); +} + +function actionElement(action) { + const href = String(action.href || '').trim(); + const element = href ? document.createElement('a') : document.createElement('button'); + element.className = 'system-alert-action'; + element.dataset.action = 'alert-stack#action'; + element.textContent = String(action.label).trim(); + + if (href) { + element.href = href; + if (action.target) { + element.target = String(action.target); + if (element.target === '_blank') { + element.rel = 'noopener noreferrer'; + } + } + } else { + element.type = 'button'; + } + + if (action.event) { + element.dataset.alertActionEvent = String(action.event); + } + + if (action.detail) { + element.dataset.alertActionDetail = JSON.stringify(action.detail); + } + + return element; +} + +function normalizeActions(actions) { + return actions.map(normalizeAction).filter(Boolean); +} + +function normalizeAction(action) { + if (!action || typeof action !== 'object') { + return null; + } + + const label = String(action.label || '').trim(); + if (!label) { + return null; + } + + const href = String(action.href || '').trim(); + if (href) { + if (!hrefAllowed(href)) { + return null; + } + + const normalized = { label, href }; + const target = String(action.target || '').trim(); + if (targetAllowed(target)) { + normalized.target = target; + } + + return normalized; + } + + const event = String(action.event || '').trim(); + if (!event) { + return null; + } + + const normalized = { label, event }; + if (action.detail && typeof action.detail === 'object' && !Array.isArray(action.detail)) { + normalized.detail = action.detail; + } + + return normalized; +} + +function hrefAllowed(href) { + if (!href || href.includes('\\') || /[\x00-\x1F\x7F]/.test(href)) { + return false; + } + + if (href.startsWith('/')) { + return !href.startsWith('//'); + } + + const lowerHref = href.toLowerCase(); + if ((lowerHref.startsWith('http:') && !lowerHref.startsWith('http://')) + || (lowerHref.startsWith('https:') && !lowerHref.startsWith('https://')) + ) { + return false; + } + + let parsed; + try { + parsed = new URL(href); + } catch { + return false; + } + + return ['http:', 'https:', 'mailto:'].includes(parsed.protocol) + && (parsed.protocol === 'mailto:' || parsed.hostname.trim() !== ''); +} + +function targetAllowed(target) { + return ['_blank', '_self', '_parent', '_top'].includes(target); +} + +function closeButton(closeLabel) { + const button = document.createElement('button'); + button.className = 'system-alert-close'; + button.type = 'button'; + button.dataset.action = 'alert-stack#close'; + button.setAttribute('aria-label', closeLabel); + + const icon = document.createElement('span'); + icon.className = 'ti ti-x'; + icon.setAttribute('aria-hidden', 'true'); + button.append(icon); + + return button; +} diff --git a/assets/js/alerts/alert_payload.js b/assets/js/alerts/alert_payload.js new file mode 100644 index 00000000..a7086b11 --- /dev/null +++ b/assets/js/alerts/alert_payload.js @@ -0,0 +1,86 @@ +export function alertId(payload) { + const id = String(payload.id || '').trim(); + + if (id) { + return id; + } + + return `alert:${Date.now()}:${Math.random().toString(16).slice(2)}`; +} + +export function alertIds(value) { + if (Array.isArray(value)) { + return value.map((id) => String(id || '').trim()).filter(Boolean); + } + + const id = String(value || '').trim(); + + return id ? [id] : []; +} + +export function alertMode(payload) { + const mode = String(payload.mode || '').toLowerCase(); + + return ['auto', 'hidden', 'persistent'].includes(mode) ? mode : 'auto'; +} + +export function normalizeAlertLevel(level) { + const normalized = String(level).toLowerCase(); + const aliases = { + danger: 'error', + error: 'error', + exception: 'exception', + notice: 'info', + success: 'success', + warn: 'warning', + warning: 'warning', + debug: 'debug', + }; + + return aliases[normalized] || 'info'; +} + +export function storableAlertPayload(payload) { + return { + id: payload.id, + title: payload.title, + message: payload.message, + level: normalizeAlertLevel(payload.level || 'info'), + mode: alertMode(payload), + persistent: Boolean(payload.persistent), + loading: Boolean(payload.loading), + actions: Array.isArray(payload.actions) ? payload.actions : [], + }; +} + +export function payloadFromAlertElement(alert) { + try { + const payload = JSON.parse(alert.dataset.alertPayload || '{}'); + + return { + ...payload, + id: alert.dataset.alertId || payload.id, + mode: alert.dataset.alertMode || payload.mode || 'auto', + }; + } catch { + return { + id: alert.dataset.alertId || '', + title: alert.querySelector('.system-alert-title')?.textContent || '', + message: alert.querySelector('.system-alert-message')?.textContent + || alert.querySelector('.system-alert-content')?.textContent + || '', + level: [...alert.classList].find((name) => name.startsWith('system-alert-'))?.replace('system-alert-', '') || 'info', + mode: alert.dataset.alertMode || 'auto', + persistent: alert.dataset.alertPersistent === 'true', + actions: [], + }; + } +} + +export function actionDetailFromElement(action) { + try { + return JSON.parse(action.dataset.alertActionDetail || '{}'); + } catch { + return {}; + } +} diff --git a/assets/js/live/live_poll.js b/assets/js/live/live_poll.js new file mode 100644 index 00000000..d58615da --- /dev/null +++ b/assets/js/live/live_poll.js @@ -0,0 +1,150 @@ +export class LivePoller { + constructor({ + interval = 750, + onPayload = () => {}, + onError = () => {}, + onDone = () => {}, + fetcher = window.fetch.bind(window), + invalidJsonMessage = 'The live endpoint returned an invalid response.', + retryOnError = false, + } = {}) { + this.interval = Number(interval || 0); + this.onPayload = onPayload; + this.onError = onError; + this.onDone = onDone; + this.fetcher = fetcher; + this.invalidJsonMessage = invalidJsonMessage; + this.retryOnError = retryOnError; + this.active = false; + } + + async poll(url, cursor = 0) { + this.active = true; + let nextCursor = Number(cursor || 0); + + try { + while (this.active) { + let result = null; + const previousCursor = nextCursor; + + try { + result = await this.fetchPayload(url, nextCursor); + } catch (error) { + this.onError(null, error); + } + + if (!result) { + if (!this.retryOnError || !this.active) { + return null; + } + + await this.sleep(Math.max(this.interval, 1000)); + continue; + } + + const { payload } = result; + nextCursor = result.cursor; + this.onPayload(payload, nextCursor); + + const hasMore = payload.has_more === true && nextCursor > previousCursor; + const nextDelay = hasMore ? 0 : Number(payload.next_poll_ms ?? this.interval); + + if (this.isTerminal(payload) || (!hasMore && nextDelay <= 0)) { + this.active = false; + this.onDone(payload); + + return payload; + } + + if (nextDelay > 0) { + await this.sleep(nextDelay); + } + } + } catch (error) { + this.active = false; + this.onError(null, error); + } + + return null; + } + + async pollOnce(url, cursor = 0) { + this.active = true; + + try { + const result = await this.fetchPayload(url, Number(cursor || 0)); + + if (!result) { + return null; + } + + this.onPayload(result.payload, result.cursor); + this.onDone(result.payload); + + return result.payload; + } catch (error) { + this.onError(null, error); + + return null; + } finally { + this.active = false; + } + } + + stop() { + this.active = false; + } + + async fetchPayload(url, cursor) { + const response = await this.fetcher(this.urlWithCursor(url, cursor), { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!response.ok) { + this.onError(response, null); + + return null; + } + + const payload = await this.readJson(response); + + return { + cursor: Number(payload.cursor || cursor), + payload, + }; + } + + urlWithCursor(url, cursor) { + const liveUrl = new URL(url, window.location.origin); + liveUrl.searchParams.set('cursor', String(Math.max(0, Number(cursor || 0)))); + + return liveUrl.toString(); + } + + async readJson(response) { + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.includes('application/json')) { + throw new Error(this.invalidJsonMessage); + } + + return response.json(); + } + + isTerminal(payload) { + return ['success', 'requires_review', 'failed'].includes(payload?.status); + } + + sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } +} + +export function liveRouteUrl(relativeRoute) { + const route = String(relativeRoute || '').replace(/^\/+/, ''); + + return new URL(`api/live/${route}`, window.location.origin).toString(); +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 173cdd5e..3fc89f70 100755 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -5,6 +5,7 @@ @import "./codemirror.css"; @import "./tokens/base.css"; @import "./system/base.css"; +@import "./system/alerts.css"; @import "./frontend/base.css"; @import "./backend/base.css"; @import "./backend/admin/base.css"; diff --git a/assets/styles/system/alerts.css b/assets/styles/system/alerts.css new file mode 100644 index 00000000..ed971473 --- /dev/null +++ b/assets/styles/system/alerts.css @@ -0,0 +1,405 @@ +.system-alert-stack { + justify-items: end; +} + +.system-alert-bell { + position: relative; + display: inline-grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-gray-300) 78%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-paper) 94%, transparent); + color: var(--color-ink); + box-shadow: 0 0.85rem 2rem color-mix(in srgb, var(--color-brand-canvas) 20%, transparent); + cursor: pointer; + pointer-events: auto; + backdrop-filter: blur(16px); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.system-alert-bell:hover, +.system-alert-bell:focus-visible { + border-color: color-mix(in srgb, var(--color-primary) 46%, transparent); + box-shadow: 0 1rem 2.4rem color-mix(in srgb, var(--color-brand-canvas) 24%, transparent); + transform: translateY(-1px); +} + +.system-alert-badge { + position: absolute; + top: -0.35rem; + right: -0.35rem; + display: inline-grid; + min-width: 1.2rem; + height: 1.2rem; + padding: 0 0.28rem; + place-items: center; + border-radius: 999px; + border: 2px solid var(--color-paper); + background: var(--color-error); + color: var(--color-paper); + font-size: 0.68rem; + font-weight: 800; + line-height: 1; +} + +.system-alert-panel-shell { + display: grid; + width: 100%; + padding: 0.35rem; + border: 1px solid color-mix(in srgb, var(--color-paper) 72%, transparent); + border-radius: calc(var(--system-radius) + 0.35rem); + background: color-mix(in srgb, var(--color-brand-canvas) 10%, transparent); + box-shadow: 0 1.35rem 3.25rem color-mix(in srgb, var(--color-brand-canvas) 28%, transparent); + opacity: 0; + pointer-events: auto; + transform: translateY(-0.35rem) scale(0.98); + transform-origin: top right; + transition: opacity 160ms ease, transform 160ms ease, visibility 160ms ease; + visibility: hidden; + backdrop-filter: blur(18px); +} + +.system-alert-panel-shell.is-open { + opacity: 1; + transform: translateY(0) scale(1); + visibility: visible; +} + +.system-alert-panel-shell.is-closing { + opacity: 0; + transform: translateY(-0.35rem) scale(0.98); + visibility: hidden; +} + +.system-alert-panel { + display: grid; + width: 100%; + max-height: min(30rem, calc(100vh - 5.5rem)); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-gray-300) 74%, transparent); + border-radius: var(--system-radius); + background: color-mix(in srgb, var(--color-paper) 96%, transparent); + box-shadow: 0 0.35rem 1rem color-mix(in srgb, var(--color-brand-canvas) 12%, transparent); +} + +.system-alert-panel-header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + padding: 0.75rem 0.85rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-gray-200) 78%, transparent); + color: var(--color-ink); + font-size: var(--system-text-sm); +} + +.system-alert-panel-actions { + display: flex; + min-width: 0; + flex: 0 0 auto; + align-items: center; + gap: 0.45rem; +} + +.system-alert-clear-all { + padding: 0; + border: 0; + background: transparent; + color: var(--color-muted); + cursor: pointer; + font: inherit; + font-size: var(--system-text-xs); + font-weight: 800; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +.system-alert-clear-all:hover, +.system-alert-clear-all:focus-visible { + color: var(--color-primary); +} + +.system-alert-panel-close { + display: inline-grid; + width: 1.8rem; + height: 1.8rem; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--color-muted); + cursor: pointer; +} + +.system-alert-panel-close:hover, +.system-alert-panel-close:focus-visible { + background: color-mix(in srgb, var(--color-gray-100) 78%, transparent); + color: var(--color-ink); +} + +.system-alert-list { + display: grid; + gap: 0.55rem; + max-height: min(25rem, calc(100vh - 9rem)); + overflow: auto; + padding: 0.65rem; + scrollbar-width: thin; +} + +.system-alert-empty { + display: grid; + justify-items: center; + gap: 0.45rem; + padding: 1.3rem 1rem 1.45rem; + color: var(--color-muted); + text-align: center; + font-size: var(--system-text-sm); +} + +.system-alert-empty > .ti { + display: inline-grid; + width: 2.1rem; + height: 2.1rem; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--color-gray-100) 78%, transparent); + color: var(--color-muted); + font-size: 1.05rem; +} + +.system-alert-empty > strong { + color: var(--color-ink); + font-size: var(--system-text-sm); +} + +.system-alert-stack .system-alert { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.65rem; + padding: 0.8rem; + border-radius: calc(var(--system-radius-sm) + 2px); + box-shadow: none; +} + +.system-alert-icon { + display: inline-grid; + width: 1.45rem; + height: 1.45rem; + place-items: center; + border-radius: 999px; + font-size: 1rem; + line-height: 1; +} + +.system-alert-info .system-alert-icon { + color: var(--color-info); + background: color-mix(in srgb, var(--color-info-soft) 72%, var(--color-paper)); +} + +.system-alert-debug .system-alert-icon { + color: var(--color-debug); + background: color-mix(in srgb, var(--color-debug-soft) 74%, var(--color-paper)); +} + +.system-alert-success .system-alert-icon { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success-soft) 76%, var(--color-paper)); +} + +.system-alert-warning .system-alert-icon { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning-soft) 76%, var(--color-paper)); +} + +.system-alert-error .system-alert-icon, +.system-alert-exception .system-alert-icon { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error-soft) 78%, var(--color-paper)); +} + +.system-alert-heading { + display: flex; + min-width: 0; + align-items: center; + gap: 0.45rem; +} + +.system-alert-spinner { + display: inline-grid; + width: 0.9rem; + height: 0.9rem; + flex: 0 0 auto; + border: 2px solid color-mix(in srgb, currentColor 28%, transparent); + border-top-color: currentColor; + border-radius: 999px; + animation: system-alert-spin 850ms linear infinite; +} + +.system-alert-content { + display: grid; + min-width: 0; + gap: 0.18rem; +} + +.system-alert-title { + min-width: 0; + color: var(--color-ink); + font-size: var(--system-text-sm); + line-height: 1.35; +} + +.system-alert-message { + min-width: 0; +} + +.system-alert-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.45rem; +} + +.system-alert-action { + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + font-size: var(--system-text-xs); + font-weight: 800; + text-decoration: underline; + text-underline-offset: 0.18em; + text-decoration-thickness: 1px; +} + +.system-alert-action:hover, +.system-alert-action:focus-visible { + color: var(--color-primary); +} + +@keyframes system-alert-spin { + to { + transform: rotate(360deg); + } +} + +.system-cookie-consent { + position: fixed; + right: clamp(1rem, 3vw, 2rem); + bottom: clamp(1rem, 3vw, 2rem); + z-index: 70; + width: min(34rem, calc(100vw - 2rem)); + pointer-events: none; +} + +.system-cookie-consent-card { + display: grid; + gap: 1rem; + padding: 1rem; + border: 1px solid color-mix(in srgb, var(--color-gray-300) 78%, transparent); + border-radius: var(--system-radius); + background: color-mix(in srgb, var(--color-paper) 97%, transparent); + box-shadow: 0 1.5rem 3.75rem color-mix(in srgb, var(--color-brand-canvas) 28%, transparent); + color: var(--color-ink); + pointer-events: auto; + backdrop-filter: blur(18px); +} + +.system-cookie-consent-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.75rem; +} + +.system-cookie-consent-icon { + display: inline-grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--color-primary-soft) 70%, var(--color-paper)); + color: var(--color-primary); +} + +.system-cookie-consent-icon-svg { + display: block; + width: 1.2rem; + height: 1.2rem; +} + +.system-cookie-consent-summary p { + margin: 0.25rem 0 0; + color: var(--color-muted); + font-size: var(--system-text-sm); + line-height: 1.5; +} + +.system-cookie-consent-section-title { + color: var(--color-ink); + font-size: var(--system-text-sm); +} + +.system-cookie-consent-empty { + margin: 0; + color: var(--color-muted); + font-size: var(--system-text-sm); + line-height: 1.5; +} + +.system-cookie-consent-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.55rem; +} + +.system-cookie-consent-details { + display: grid; + gap: 0.75rem; + min-height: 0; + padding-top: 0.75rem; + border-top: 1px solid color-mix(in srgb, var(--color-gray-200) 78%, transparent); +} + +.system-cookie-consent-options { + display: grid; + gap: 0.65rem; + max-height: min(18rem, calc(100vh - 18rem)); + overflow: auto; + padding-right: 0.15rem; + scrollbar-width: thin; +} + +.system-cookie-consent-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.65rem; + align-items: start; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--color-gray-200) 78%, transparent); + border-radius: var(--system-radius-sm); + background: color-mix(in srgb, var(--color-gray-50) 72%, transparent); +} + +.system-cookie-consent-option > span { + display: grid; + min-width: 0; + gap: 0.15rem; + color: var(--color-muted); + font-size: var(--system-text-sm); +} + +.system-cookie-consent-option strong { + color: var(--color-ink); +} + +.system-cookie-consent-option a { + justify-self: start; + color: var(--color-primary); + font-weight: 700; +} diff --git a/assets/styles/system/base.css b/assets/styles/system/base.css index 82a394c1..a04b3eef 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -217,6 +217,13 @@ html { color: #ffffff; } +.system-button:disabled, +.system-backend-button:disabled, +.system-backend-setup-nav-button:disabled { + cursor: wait; + opacity: 0.72; +} + .system-icon-button { display: inline-flex; align-items: center; @@ -343,7 +350,7 @@ html { .system-alert-stack { position: fixed; - z-index: 70; + z-index: 90; top: clamp(0.75rem, 2vw, 1.25rem); right: clamp(0.75rem, 2vw, 1.25rem); display: grid; @@ -352,6 +359,22 @@ html { pointer-events: none; } +.system-chart-panel, +.system-map-view { + min-width: 0; +} + +.system-chart-panel-canvas, +.system-map-view-canvas { + min-height: 20rem; +} + +.system-map-view-canvas { + width: 100%; + overflow: hidden; + border-radius: var(--system-radius-sm); +} + .system-alert { padding: 0.875rem 1rem; border: var(--system-border); diff --git a/bin/init b/bin/init index 39b4fba2..a2bdb281 100755 --- a/bin/init +++ b/bin/init @@ -183,7 +183,9 @@ final class InitCommand $this->runRequiredCommand([...$console, 'assets:install', 'public'], 'Bundle assets are installed.'); $this->runRequiredCommand([...$console, 'importmap:install'], 'Importmap packages are installed.'); $this->runOptionalCommand([...$console, 'ux:icons:lock'], 'Symfony UX icons are locked locally.'); + $this->runOptionalCommand([...$console, 'mercure:install'], 'Optional Mercure hub binary is installed when supported.'); $this->runRequiredCommand([...$console, 'tailwind:build'], 'Tailwind CSS is built.'); + $this->runRequiredCommand([...$console, 'cache:warmup'], 'Symfony cache is warmed.'); } private function ensurePackageAssetRegistries(): void diff --git a/bin/jstest b/bin/jstest new file mode 100755 index 00000000..f1d00d14 --- /dev/null +++ b/bin/jstest @@ -0,0 +1,63 @@ +#!/usr/bin/env php + STDIN, + 1 => STDOUT, + 2 => STDERR, + ], + $pipes, + $root, +); + +if (!is_resource($process)) { + fwrite(STDOUT, "Node.js could not be started; JavaScript tests require Node.js and were skipped successfully.\n"); + + exit(0); +} + +exit(proc_close($process)); + +function nodeAvailable(string $node, string $root): bool +{ + $process = @proc_open( + [$node, '--version'], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $root, + ); + + if (!is_resource($process)) { + return false; + } + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + return 0 === proc_close($process); +} diff --git a/bin/lint b/bin/lint index 1d6305c1..79e1f772 100755 --- a/bin/lint +++ b/bin/lint @@ -293,6 +293,57 @@ function lintSourceFiles(string $root, array $files, LinterInterface $linter, st return $failures; } +/** + * @param list $files + */ +function lintCssFiles(string $root, array $files): int +{ + fwrite(STDOUT, "\n==> CSS syntax\n"); + $failures = 0; + $linter = new CssLinter(); + + foreach ($files as $file) { + $relative = relativePath($root, $file); + $contents = (string) file_get_contents($file); + $result = $linter->lint($contents, $relative); + + foreach ($result->issues() as $issue) { + if (isStrictParserUnsupportedCssIssue($contents, $issue->line()) + && $linter->lint(CssLinter::forStrictParser($contents), $relative)->isSuccess() + ) { + fwrite(STDOUT, sprintf( + "%s:%s:%s strict CSS parser skipped a supported build-time or modern CSS construct; tailwind:build remains authoritative for this file.\n", + $relative, + $issue->line() ?? 1, + $issue->column() ?? 1, + )); + + continue; + } + + fwrite(STDERR, sprintf( + "%s:%s:%s %s\n", + $relative, + $issue->line() ?? 1, + $issue->column() ?? 1, + $issue->details()['error'] ?? $issue->message(), + )); + ++$failures; + } + } + + if (0 === $failures) { + fwrite(STDOUT, "CSS syntax OK.\n"); + } + + return $failures; +} + +function isStrictParserUnsupportedCssIssue(string $contents, ?int $line): bool +{ + return CssLinter::hasStrictParserUnsupportedContext($contents, $line); +} + /** * @param list $files */ @@ -532,6 +583,10 @@ function lintFileType(string $path): ?string { $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ('mjs' === $extension) { + return 'js'; + } + if ('' !== $extension) { return $extension; } @@ -964,7 +1019,7 @@ if ($diffMode || $stagedMode || [] !== $targets) { if (isset($filesByExtension['css'])) { fwrite(STDOUT, "\nNote: focused CSS lint uses the strict CSS parser and may report Tailwind-specific directives or generated modern at-rules such as @apply, @theme, or @supports as unsupported syntax.\n"); - $failures += lintSourceFiles($root, $filesByExtension['css'], new CssLinter(), 'CSS syntax'); + $failures += lintCssFiles($root, $filesByExtension['css']); } if (containsTranslationSource($root, $collected['files'])) { diff --git a/bin/mercure b/bin/mercure new file mode 100755 index 00000000..d4133a65 --- /dev/null +++ b/bin/mercure @@ -0,0 +1,18 @@ +#!/usr/bin/env php +setTimeout(null); +$process->run(static function (string $type, string $buffer): void { + fwrite(Process::ERR === $type ? STDERR : STDOUT, $buffer); +}); + +exit($process->getExitCode() ?? 1); diff --git a/composer.json b/composer.json index 93ddba39..08f4ad2f 100755 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "symfony/intl": "8.1.*", "symfony/lock": "8.1.*", "symfony/mailer": "8.1.*", + "symfony/mercure-bundle": "^0.4", "symfony/messenger": "8.1.*", "symfony/mime": "8.1.*", "symfony/monolog-bundle": "^4.0.2", @@ -83,7 +84,6 @@ "symfony/ux-live-component": "^3.1", "symfony/ux-map": "^3.1", "symfony/ux-native": "^3.1", - "symfony/ux-notify": "^3.1", "symfony/ux-react": "^3.1", "symfony/ux-translator": "^3.1", "symfony/ux-turbo": "^3.1", diff --git a/composer.lock b/composer.lock index dfe93773..b6c17c59 100755 --- 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": "7c755a404de3c1407af6a470aaecdf84", + "content-hash": "463d469af7c7ad1f9497a36ec6ac3853", "packages": [ { "name": "composer/ca-bundle", @@ -6136,78 +6136,6 @@ ], "time": "2025-11-25T12:51:49+00:00" }, - { - "name": "symfony/mercure-notifier", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/mercure-notifier.git", - "reference": "0b6682f5903496aa1535b0cc706c14e143991701" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/mercure-notifier/zipball/0b6682f5903496aa1535b0cc706c14e143991701", - "reference": "0b6682f5903496aa1535b0cc706c14e143991701", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/mercure": "^0.5.2|^0.6|^0.7", - "symfony/notifier": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "symfony-notifier-bridge", - "autoload": { - "psr-4": { - "Symfony\\Component\\Notifier\\Bridge\\Mercure\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mathias Arlaud", - "email": "mathias.arlaud@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Mercure Notifier Bridge", - "homepage": "https://symfony.com", - "keywords": [ - "mercure", - "notifier" - ], - "support": { - "source": "https://github.com/symfony/mercure-notifier/tree/v8.1.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-05-29T05:06:50+00:00" - }, { "name": "symfony/messenger", "version": "v8.1.0", @@ -10410,92 +10338,6 @@ ], "time": "2026-05-06T04:34:57+00:00" }, - { - "name": "symfony/ux-notify", - "version": "v3.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/ux-notify.git", - "reference": "1455e2e7dd57013bffc17fd16bd65bdc1977ac3e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-notify/zipball/1455e2e7dd57013bffc17fd16bd65bdc1977ac3e", - "reference": "1455e2e7dd57013bffc17fd16bd65bdc1977ac3e", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mercure-bundle": "^0.3.4|^0.4.1", - "symfony/mercure-notifier": "^7.4|^8.0", - "symfony/stimulus-bundle": "^2.9.1|^3.0", - "symfony/twig-bundle": "^7.4|^8.0" - }, - "conflict": { - "symfony/config": "<6.4" - }, - "require-dev": { - "phpunit/phpunit": "^11.1|^12.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" - }, - "type": "symfony-bundle", - "extra": { - "thanks": { - "url": "https://github.com/symfony/ux", - "name": "symfony/ux" - } - }, - "autoload": { - "psr-4": { - "Symfony\\UX\\Notify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mathias Arlaud", - "email": "mathias.arlaud@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Native notification integration for Symfony", - "homepage": "https://symfony.com", - "keywords": [ - "symfony-ux" - ], - "support": { - "source": "https://github.com/symfony/ux-notify/tree/v3.1.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-05-22T05:04:55+00:00" - }, { "name": "symfony/ux-react", "version": "v3.1.0", diff --git a/config/bundles.php b/config/bundles.php index 48864fd5..499171b3 100755 --- a/config/bundles.php +++ b/config/bundles.php @@ -26,7 +26,6 @@ Symfony\UX\Map\UXMapBundle::class => ['all' => true], Symfony\UX\Native\UXNativeBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], - Symfony\UX\Notify\NotifyBundle::class => ['all' => true], Symfony\UX\React\ReactBundle::class => ['all' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Symfony\UX\Vue\VueBundle::class => ['all' => true], diff --git a/config/packages/mercure_local.yaml b/config/packages/mercure_local.yaml new file mode 100644 index 00000000..36b38f02 --- /dev/null +++ b/config/packages/mercure_local.yaml @@ -0,0 +1,4 @@ +parameters: + # Specify the exact Mercure Hub version to download for local tooling. + # The optional binary is stored below var/mercure/{version}/ and is not committed. + app.mercure_binary_version: '0.24.2' diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 1d9fedfd..d6a20493 100755 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -20,6 +20,7 @@ framework: routing: App\Core\Package\PackageAssetRebuildMessage: async App\Core\Package\PackageDiscoveryMessage: async + App\View\Alert\DispatchUiAlertMessage: async Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async diff --git a/config/packages/notifier.yaml b/config/packages/notifier.yaml index 3f8e2fe8..d02f986a 100755 --- a/config/packages/notifier.yaml +++ b/config/packages/notifier.yaml @@ -1,7 +1,6 @@ framework: notifier: chatter_transports: - mercure: '%env(MERCURE_DSN)%' texter_transports: channel_policy: # use chat/slack, chat/telegram, sms/twilio or sms/nexmo diff --git a/config/reference.php b/config/reference.php index 7705ba5e..633100a5 100755 --- a/config/reference.php +++ b/config/reference.php @@ -1680,9 +1680,6 @@ * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. * } - * @psalm-type NotifyConfig = array{ - * mercure_hub?: scalar|Param|null, // Mercube hub service id // Default: "mercure.hub.default" - * } * @psalm-type ReactConfig = array{ * controllers_path?: scalar|Param|null, // The path to the directory where React controller components are stored - relevant only when using symfony/asset-mapper. // Default: "%kernel.project_dir%/assets/react/controllers" * name_glob?: list, @@ -1721,7 +1718,6 @@ * ux_map?: UxMapConfig, * ux_native?: UxNativeConfig, * mercure?: MercureConfig, - * notify?: NotifyConfig, * react?: ReactConfig, * ux_translator?: UxTranslatorConfig, * vue?: VueConfig, @@ -1749,7 +1745,6 @@ * ux_map?: UxMapConfig, * ux_native?: UxNativeConfig, * mercure?: MercureConfig, - * notify?: NotifyConfig, * react?: ReactConfig, * ux_translator?: UxTranslatorConfig, * vue?: VueConfig, @@ -1775,7 +1770,6 @@ * ux_map?: UxMapConfig, * ux_native?: UxNativeConfig, * mercure?: MercureConfig, - * notify?: NotifyConfig, * react?: ReactConfig, * ux_translator?: UxTranslatorConfig, * vue?: VueConfig, @@ -1802,7 +1796,6 @@ * ux_map?: UxMapConfig, * ux_native?: UxNativeConfig, * mercure?: MercureConfig, - * notify?: NotifyConfig, * react?: ReactConfig, * ux_translator?: UxTranslatorConfig, * vue?: VueConfig, diff --git a/config/routes/ux_autocomplete.yaml b/config/routes/ux_autocomplete.yaml index da6b261a..5c3b1907 100644 --- a/config/routes/ux_autocomplete.yaml +++ b/config/routes/ux_autocomplete.yaml @@ -1,3 +1,3 @@ ux_autocomplete: resource: '@AutocompleteBundle/config/routes.php' - prefix: '/autocomplete' + prefix: '/_autocomplete' diff --git a/config/services.yaml b/config/services.yaml index e1e3d4ee..da508fcb 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -26,6 +26,18 @@ services: App\Api\Endpoint\ApiEndpointHandlerProviderInterface: tags: - { name: 'system.api_endpoint_handler', priority: 0 } + App\Live\LiveEndpointProviderInterface: + tags: + - { name: 'system.live_endpoint_provider', priority: 0 } + App\Live\LiveEndpointHandlerInterface: + tags: + - { name: 'system.live_endpoint_handler', priority: 0 } + App\Live\LiveEndpointHandlerProviderInterface: + tags: + - { name: 'system.live_endpoint_handler', priority: 0 } + App\Privacy\Cookie\CookieConsentProviderInterface: + tags: + - { name: 'system.cookie_consent_provider', priority: 0 } App\Core\Event\EventHookDescriptorProviderInterface: tags: - { name: 'system.event_hook_provider', priority: 0 } @@ -109,6 +121,12 @@ services: App\Core\Config\ConfigDefaultProviderInterface: alias: App\Core\Config\Settings\CoreConfigDefaultProvider + App\View\Alert\UiAlertDispatcherInterface: + alias: App\View\Alert\UiAlertDispatcher + + App\View\Alert\UiAlertUserIdentityResolverInterface: + alias: App\View\Alert\DoctrineUiAlertUserIdentityResolver + App\Api\Security\ApiAvailabilityCheckerInterface: alias: App\Api\Security\DatabaseApiAvailabilityChecker @@ -120,6 +138,26 @@ services: arguments: $handlers: !tagged_iterator { tag: system.api_endpoint_handler } + App\Live\LiveEndpointRegistry: + arguments: + $providers: !tagged_iterator { tag: system.live_endpoint_provider } + + App\Live\LiveEndpointHandlerRegistry: + arguments: + $handlers: !tagged_iterator { tag: system.live_endpoint_handler } + + App\Privacy\Cookie\CookieConsentRegistry: + arguments: + $providers: !tagged_iterator { tag: system.cookie_consent_provider } + + App\Privacy\Cookie\CookieConsentManager: + arguments: + $secret: '%kernel.secret%' + + App\View\Twig\UiAlertTwigExtension: + arguments: + $secret: '%kernel.secret%' + App\Content\Api\ContentApiPath: ~ App\Core\Package\PackageTranslationNamespaceValidator: @@ -441,6 +479,36 @@ services: arguments: $projectDir: '%kernel.project_dir%' + App\View\Alert\UiAlertTopicFactory: + arguments: + $secret: '%kernel.secret%' + + App\View\Alert\MercureAvailability: + arguments: + $projectDir: '%kernel.project_dir%' + + App\View\Alert\UiAlertPublisherInterface: + alias: App\View\Alert\MercureUiAlertPublisher + + App\Core\Mercure\MercureBinaryManager: + arguments: + $projectDir: '%kernel.project_dir%' + $version: '%app.mercure_binary_version%' + + App\Core\Mercure\MercureRuntime: + arguments: + $defaultUri: '%env(DEFAULT_URI)%' + $projectDir: '%kernel.project_dir%' + + App\Command\MercureStartCommand: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Controller\LiveAlertController: + arguments: + $projectDir: '%kernel.project_dir%' + $environment: '%kernel.environment%' + App\View\Http\HttpErrorRenderer: arguments: $debug: '%kernel.debug%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index ec2984b3..8bbacd39 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-14 > **Owner**: Core > **Purpose:** This document tracks callable entry points (services, commands, controllers, Twig components, Stimulus controllers). Keep it up to date as new classes are added or interfaces change. This document is meant to evolve alongside the codebase—treat it as a living index for developers to quickly discover callables without grepping through the project. @@ -49,6 +49,7 @@ | Value object | `App\Core\Operation\OperationExecution` | Value object containing an operation action log and aggregate workflow result. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/OperationExecutorTest.php` | | Service | `App\Core\Operation\OperationExecutor` | Bridges dry-run planning and action execution with ActionLog output, result message aggregation, highest-severity result aggregation, and exception mapping. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/OperationExecutorTest.php` | | Services | `App\Core\Operation\Live\LiveOperationRunStore`, `App\Core\Operation\Live\LiveOperationRunCreator`, `App\Core\Operation\Live\LiveOperationRunStorage`, `App\Core\Operation\Live\LiveOperationRunProgressWriter`, `App\Core\Operation\Live\LiveOperationRunPresenter`, `App\Core\Operation\Live\LiveOperationRunLifecycle`, `App\Core\Operation\Live\LiveOperationRunnerSupervisor`, `App\Core\Operation\Live\LiveOperationRunnerProcessInspector`, `App\Core\Operation\Live\LiveOperationRunLock`, `App\Core\Operation\Live\LiveOperationQueueFactory`, `App\Core\Operation\Live\LiveOperationStarter`, `App\Core\Operation\Live\LiveOperationQueueProviderInterface`, package live-operation providers, `App\Core\Operation\Live\AclGroupApplyLiveOperationProvider` | File-backed live-operation foundation for staging ActionQueue runs under `var/operations/{APP_ENV}`, resolving queue providers including package operations and ACL group applies, starting a platform-aware detached console runner with PID tracking, serializing live runner execution through Symfony Lock plus a separate admin-visible runner-state file, listing transient run summaries for Admin Operations, cleaning expired runs, exposing token-protected ActionLog polling state under `/api/live/operations/**`, and exposing operator continuations for review-required runs. Creation, JSON storage, progress mutation, report shaping, stale-state/retention cleanup, runner lock supervision, and PID/process inspection are split into focused services behind the public run-store facade. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/LiveOperationControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service/contract/controller | `App\Live\LiveEndpointDefinition`, `App\Live\LiveEndpointProviderInterface`, `App\Live\LiveEndpointHandlerInterface`, `App\Live\LiveEndpointRegistry`, `App\Live\LiveEndpointHandlerRegistry`, `App\Live\PackageLiveEndpointPath`, `App\Core\Package\PackageLiveContributionGuard`, `App\Core\Package\PackagePathPatternScope`, `App\Controller\LiveEndpointController` | Provides package-owned GET-only `/api/live/{package_slug}/...` endpoint registration for lightweight polling/manual live interactions, validates non-empty package live resource paths, path patterns, and handler keys below package-owned namespaces without top-level alternation escapes, rechecks the dispatched route slug against the endpoint owner, reserves system live slugs such as `alerts` and `operations`, defaults endpoints to public reads unless a definition declares a higher minimum access level, prefers exact endpoint paths before broad pattern matches, and supports `next_poll_ms: 0` manual poll responses plus explicit `live-poll#poll`/`live-poll#refresh` one-shot triggers through the shared frontend poller. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php`, `tests/Live/LiveEndpointRegistryTest.php` | | Operation action | `App\Core\Operation\Filesystem\CopyFileAction` | Root-scoped operation action for copying files between source and target roots with dry-run diff previews and overwrite protection. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Filesystem\EnsureDirectoryAction` | Root-scoped operation action for creating missing directories with ActionLog context. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Filesystem\RemovePathAction` | Root-scoped operation action for removing files or directories with symlink and Windows directory-link guardrails. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | @@ -56,9 +57,15 @@ | Operation action | `App\Core\Operation\Process\RunCommandAction`, `App\Core\Operation\Process\PhpCliUnavailableAction` | Operation actions for running argument-list commands with dry-run metadata, exit-code mapping, optional warning-only failures, CLI-process environment filtering, and output excerpts, or for failing command queues with a clear Message when no PHP CLI binary can be resolved. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/RunCommandActionTest.php`, `tests/Core/Operation/PhpCliUnavailableActionTest.php` | | Service/value object | `App\Core\Process\PhpCliBinaryManager`, `App\Core\Process\PhpCliBinaryResolver`, `App\Core\Process\PhpCliBinaryValidator`, `App\Core\Process\PhpCliBinaryPreferenceStore`, `App\Core\Process\PhpProjectRequirements`, `App\Core\Process\PhpCliBinaryResolution`, `App\Core\Process\CliProcessEnvironment`, `App\Core\Process\DetachedProcessStarter` | Resolves a real PHP CLI command prefix across web and CLI environments by validating the cached `APP_DEFAULT_PHP_BINARY` preference first, checking safe mode, process support, PHP version, required extensions, project/console readability, and resolver fallbacks, refreshing the preference in controlled setup/operation flows, passing Symfony Dotenv values to child processes while filtering web/CGI request variables, and starting detached background commands through one cross-platform output/PID marker boundary. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/manual/setup-init-snippets.md` | `tests/Core/Process/PhpCliBinaryManagerTest.php`, `tests/Core/Process/PhpCliBinaryResolverTest.php`, `tests/Core/Process/CliProcessEnvironmentTest.php`, `tests/Core/Process/DetachedProcessStarterTest.php`, `tests/Setup/SetupPreflightCheckerTest.php` | | Service | `App\Core\Security\SecretPayloadProtector` | Protects reversible secret payloads with context-labeled, versioned `APP_SECRET`-derived encryption material and optional associated data. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Security/SecretPayloadProtectorTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php` | -| Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic package-aware asset rebuild queue with package asset sync, translation aggregation, Symfony asset commands, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | +| Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic package-aware asset rebuild queue with package asset sync, translation aggregation, Symfony asset commands, UX Translator warm-cache output, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | +| Service/commands | `App\Core\Mercure\MercureBinaryManager`, `App\Core\Mercure\MercureRuntime`, `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Provides optional local Mercure hub tooling with a YAML-configured fixed version, fixed OS/architecture asset names for the Caddy-based prebuilt hub, SHA256-pinned release archive downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, Bolt transport storage at `var/mercure/updates.db`, release-provided Caddyfile startup, JWT secrets passed through a protected `var/mercure/mercure.env` file instead of command arguments, non-secret local Caddy/Mercure directives passed through the detached-process environment, relaxed read-only hub reachability diagnostics for `2xx`, `400`, and `401` responses, strict publish-health probes that require a successful authenticated POST, colon-only local listen and configured hub URL normalization for probe URLs, Mercure-fingerprinted public EventSource subscribe health probes, best-effort macOS quarantine release, a `bin/mercure` wrapper, publish self-healing, public-endpoint failure shutdown, read-only diagnostics, PID-first plus exact-binary process detection, OS-aware stop support that waits for the tracked PID and exact-binary fallback processes to disappear, disabled-integration health no-op success, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | Service | `App\Core\Output\JsonOutputRenderer` | Shared raw JSON response renderer for `/api/live/**` UI flows, captcha seeds, polling, and future small JSON endpoints. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Output/JsonOutputRendererTest.php` | -| Service/contract/controller | `App\Api\ApiFeaturePolicy`, `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel`, `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry` | Provides the versioned `/api/v1` foundation with stateless Bearer API-key authentication when credentials are supplied, config-controlled API availability and CORS handling, explicit `allow_public` anonymous read opt-ins through endpoint definitions, public safe-method enforcement during endpoint registration, request-scoped authenticated or anonymous API context, read-only method gating, endpoint-derived minimum-access gating before handlers, endpoint-defined JSON request content-type enforcement, setup/maintenance/database/disabled availability `503` JSON handling, response trace headers for internal request IDs and validated inbound correlation IDs, central definition-backed endpoint dispatch, consistent JSON data/error responses with localized Message-layer feedback and stable validation details, JSON object request parsing, `page`/`limit` list-parameter definitions and API-boundary normalization from shared backend list metadata to public `limit`/`page_count` pagination, domain-owned endpoint definition/handler registration through service tags, explicit Hypermedia-style parent navigation resources including `/api/v1` root navigation with access metadata, package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, admin-readable endpoint permissions matrix under `/api/v1/admin/permissions`, dynamic OpenAPI 3.2 document generation from registered endpoint definitions with manifest-derived product/API metadata, `$self`, named server entries, native shell/domain-scoped tag hierarchy metadata, neutral `x-access` operation metadata, reusable data/error/message/link/pagination/mutation/operation schemas, shared JSON error responses including 415 unsupported media type, and documented trace headers, navigable admin endpoints under `/api/v1/admin`, settings-section read/update models through the existing settings form handler, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, package lifecycle review/confirmation endpoints that start LiveOperation runs, collision-free API dynamic resources below `items/`, user-facing self-service resources under `/api/v1/user` for profile reads/patches and own API-key list/create/revoke with prefix validation before key material is generated, user detail update resources for one role plus multiple groups, ACL group create/detail/edit/delete resources with impact review and optional LiveOperation execution, user/group membership relationship mutations, registration/invitation token review approval/reissue/denial actions, disputed-account security-review confirm/deny actions, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content item collection pagination/filtering/sorting after ACL filtering with non-published status lists deferred to an editor-visible read surface, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Controller/ApiFoundationControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | +| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling, request-scoped authenticated or anonymous API context, read-only method gating, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | +| Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | +| Content API | `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler` | Provides collision-free content API dynamic resources below `items/`, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content collection pagination/filtering/sorting after ACL filtering, deferred non-published status read surfaces, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php` | +| Package/user API | `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackagePathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, package read/navigation resources, package API path-pattern validation below the owned package namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php` | | Service | `App\Core\Lint\CssLinter` | Reusable string-based CSS syntax linter using the strict Sabberworm CSS parser. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\JavaScriptLinter` | Reusable string-based JavaScript module syntax linter using Peast. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\JsonLinter` | Reusable string-based JSON syntax linter. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | @@ -85,7 +92,7 @@ | Service | `App\Security\AdminUserAccessPolicy`, `App\Security\AclGroupImpactService`, `App\Security\AclGroupReferenceProviderInterface`, ACL group reference providers, `App\Security\AclGroupMemberProvider`, `App\Security\AclGroupApplyService`, `App\Security\AclGroupApplyAction` | Enforces administrative user/ACL hierarchy guardrails, role assignment limits, assignable-group filtering by group minimum role, self-lockout, last-owner protection, targeted ACL group impact dry runs including published content/menu access-opening warnings, provider-owned cleanup of deleted group identifiers and below-floor references across users, account tokens, content items, schema versions, and site menu items, database-backed group member summaries, and actor-rechecked LiveLog-friendly ACL group apply actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Security handler | `App\Security\HttpErrorSecurityHandler` | Symfony authentication entry point and access-denied handler that delegates unauthorized requests to the shared HTTP error renderer. | `dev/draft/0.1.x-ErrorHandlingValidation.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Security checker | `App\Security\UserAccountChecker` | Symfony form-login user checker that rejects inactive or deleted `UserAccount` records before authentication can create a session. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php` | -| Event subscriber | `App\Security\AppSecretRotationGuard` | Stores an environment-specific `APP_SECRET` fingerprint, baselines first-seen secrets, and on detected rotation revokes active API keys while issuing password-reset links to active owners through the account-link delivery boundary. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | +| Event subscriber | `App\Security\AppSecretRotationGuard` | Rejects unsupported short runtime `APP_SECRET` values before recovery handling, stores an environment-specific secret fingerprint, baselines first-seen secrets, and on detected rotation revokes active API keys while issuing password-reset links to active owners through the account-link delivery boundary; local Mercure hubs are stopped/refreshed around rotation when possible and marked unavailable if they cannot be safely stopped. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/AppSecretRotationGuardTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserAccountLifecycle`, `App\Security\AdminUserAssignmentOptions`, `App\Security\AdminUserAccountUpdateService`, `App\Security\AdminUserAccountUpdateResult`, `App\Security\AdminUserPasswordResetService`, `App\Security\UserPasswordChangeService`, `App\Security\UserPasswordChangeResult`, `App\Security\UserAccountClosureService`, `App\Security\UserAccountClosureResult`, `App\Security\PasswordPolicy`, `App\Security\PasswordPolicyErrorMapper` | Applies account status changes, records current/last status state markers, revokes active API keys plus pending password-reset/security-review tokens when accounts become inactive or deleted, keeps admin assignment option filtering, account update mutations, deleted-account status changes, admin password-reset creation, authenticated password-change review-token delivery, and self-service account closure outside controllers, enforces the shared account password policy across setup, registration, reset, and profile changes, and maps policy violations to stable user-facing error keys outside controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php`, `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Security/PasswordPolicyTest.php`, `tests/Security/PasswordPolicyErrorMapperTest.php` | | Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | @@ -123,7 +130,7 @@ | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, and Security audit policy controls. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, separated typed casting, separated option/value validation, centralized translated validation keys, and captcha-provider field support. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | | Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | | Value object | `App\Core\Message\Message` | Universal message value object carrying log level, code, translation key, parameters, and context for logs, output, validation, future localization, and invalid-argument diagnostics. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageTest.php` | | Registry | `App\Core\Message\MessageCode`, domain-owned `*MessageCode` catalogues | Aggregates core-owned machine-readable message code catalogues while keeping constants close to their owning domains and leaving room for validated package-owned catalogues. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageCodeTest.php` | @@ -168,7 +175,7 @@ | Services | `App\Core\Translation\TranslationRuntimePath`, `App\Core\Translation\TranslationCatalogueAggregator`, `App\Core\Translation\TranslationSourceCollector`, `App\Core\Translation\TranslationCatalogueMerger`, `App\Core\Translation\TranslationRuntimeWriter`, `App\Core\Translation\TranslationAggregateAction` | Resolves and aggregates modular core translation sources plus active package language files through separated source collection, deterministic path ordering, YAML merge/collision handling, and staged runtime-directory replacement while preserving runtime metadata with platform-safe cleanup and keeping runtime generation out of Symfony cache warmers. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/TranslationCatalogueAggregatorTest.php` | | Service contract | `App\Core\Package\PackageLifecycleCleanupRunnerInterface`, `App\Core\Package\PackageLifecycleCleanupRunner` | Cleanup boundary used by package purge/removal operations; removes package-scoped settings and leaves package-owned migrations/data cleanup for later. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | | Registry/service | `App\Core\Package\Settings\PackageSettingDefinition`, `App\Core\Package\Settings\PackageSettingProviderInterface`, `App\Core\Package\Settings\PackageSettingRegistry`, `App\Core\Package\Settings\PackageSettings`, `App\Core\Package\Settings\PackageSettingsFormHandler`, `App\Core\Package\Settings\PackageSettingsBackendViewProvider` | Provides typed package setting definitions with shared form input/validation metadata, active-package filtering, package-scoped get/set storage and typed form persistence, plus generic Admin Settings navigation/views for active packages with simple settings. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Package/PackageSettingRegistryTest.php`, `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageSettingsFormHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/API | `App\Core\Package\PackagePhpLoader`, `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackageContributions` | Loads optional `package.php` runtime loaders for active real packages, atomically collects supported view/settings/scheduler contributions, provides a readable package contribution builder for grouped package entry points, retains package scheduler execution providers, validates package-owned scheduler task source/trust boundaries, evaluates contribution providers inside the loader boundary, converts loader failures into lifecycle diagnostics, marks failing packages faulty, and deactivates active dependents with explicit messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageContributionsTest.php`, `tests/Core/Package/PackageActivatorTest.php` | +| Service/API | `App\Core\Package\PackagePhpLoader`, `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackageContributions` | Loads optional `package.php` runtime loaders for active real packages, atomically collects supported view/settings/API/live endpoint/scheduler/cookie-consent contributions, provides a readable package contribution builder for grouped package entry points, retains package scheduler execution providers, validates package-owned scheduler task source/trust boundaries, live endpoint namespaces, and package-scoped host-only same-site necessary cookie definitions, evaluates contribution providers inside the loader boundary, converts loader failures into lifecycle diagnostics, marks failing packages faulty, and deactivates active dependents with explicit messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageContributionsTest.php`, `tests/Core/Package/PackageActivatorTest.php`, `tests/Core/Package/PackageLiveContributionGuardTest.php` | | Service | `App\Core\Package\PackageDependentDeactivator` | Deactivates active reverse dependents when a package becomes unavailable at runtime and emits dependency-aware lifecycle messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | | Service | `App\Core\Package\PackageRemover`, `App\Core\Package\PackageRemovalPlanner`, `App\Core\Package\PackageFilesystemRemover`, `App\Core\Package\PackagePurger` | Plans and executes package removal through separated collaborators for deactivation-aware removal planning, safe package-directory deletion, registry row removal, final asset rebuilds, and removed-package purge cleanup. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | | Service | `App\Core\Package\PackageFaultResetter` | Provides the non-destructive admin recovery path for faulty packages by validating the current package folder and resetting successful repairs to inactive. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | @@ -177,7 +184,7 @@ | Service | `App\Core\Package\PackageOperationPlanner` | Translates selected package files into deterministic ActionQueues without installing or classifying packages. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageOperationPlannerTest.php` | | Enum | `App\Core\Package\PackageSource` | Defines a normalized, project-root-scoped package discovery source and its optional manifest specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageDiscoveryTest.php`, `tests/Core/Package/PackageSourceTest.php` | | Value object | `App\Core\Package\PackageSpec` | Domain-neutral package filesystem and optional preflight linting specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageValidatorTest.php` | -| Service | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageInventoryInspector`, `App\Core\Package\PackageRequiredPathValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy`, `App\Core\Package\PackageFileSyntaxValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageSourceNamespaceValidator`, `App\Core\Package\PackageTranslationNamespaceValidator`, `App\Core\Package\PackageSchedulerCronValidator`, `App\Core\Package\PackageSchedulerCronInspector`, `App\Core\Package\PackageSchedulerDefinitionCallScanner`, `App\Core\Package\PackageSchedulerDefinitionImportResolver`, `App\Core\Package\PackagePhpCallArgumentParser` | Validates discovered package candidates for stable `PACKAGE_SLUG` metadata, required files/directories, feature inventory, package-owned CSS target class namespaces, template-scope references, source namespace boundaries, package translation namespaces, installable package file-policy allow/warn/block decisions, direct PHP capability blocks, literal scheduler cron registrations including aliases, and optional syntax checks before dry-run planning through focused inventory, policy, PHP parser, CSS selector, Twig reference, and cron-call inspection collaborators. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageValidatorTest.php` | +| Service | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageInventoryInspector`, `App\Core\Package\PackageRequiredPathValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy`, `App\Core\Package\PackageFileSyntaxValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageSourceNamespaceValidator`, `App\Core\Package\PackageTranslationNamespaceValidator`, `App\Core\Package\PackageSchedulerCronValidator`, `App\Core\Package\PackageSchedulerCronInspector`, `App\Core\Package\PackageSchedulerDefinitionCallScanner`, `App\Core\Package\PackageSchedulerDefinitionImportResolver`, `App\Core\Package\PackagePhpCallArgumentParser` | Validates discovered package candidates for stable `PACKAGE_SLUG` metadata, required files/directories, feature inventory, package-owned CSS target class namespaces, template-scope references, source namespace boundaries, package translation namespaces, installable package file-policy allow/warn/block decisions, direct PHP capability blocks, literal scheduler cron registrations including aliases, and optional syntax checks with Tailwind directive tolerance before dry-run planning through focused inventory, policy, PHP parser, CSS selector, Twig reference, and cron-call inspection collaborators. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageValidatorTest.php` | | Enum | `App\Core\Package\ExtensionPackageStatus` | Enum for managed extension package lifecycle states including active, inactive, removed, and faulty registry records. | `dev/draft/0.2.x-PluginModules.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Core/Package/PackageRegistryHandlerTest.php` | | Enum | `App\Core\Package\PackageScope` | Enum for allowed package scopes such as frontend theme, backend theme, module, captcha provider, and editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageScopeTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Event payload | `App\Core\Package\Event\PackageAssetSyncStartedEvent` | Public observe hook dispatched before active package assets are synchronized. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | @@ -217,11 +224,11 @@ | Service | `App\Setup\SetupLanguageCatalog` | Discovers available setup languages through the shared language catalogue discovery service while keeping setup-safe configured-default fallback behavior. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupLanguageCatalogTest.php`, `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupLanguageSelector` | Validates selected setup language and returns translation-ready ActionLog messages for CLI or future UI output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupMessageTranslator` | Reads setup-facing translation catalogue entries for CLI prompts and localized setup output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupCliInputFactoryTest.php` | -| Service/model | `App\Setup\SetupPreflightChecker`, `App\Setup\SetupComposerPreflightProbe`, `App\Setup\SetupTailwindPreflightProbe`, `App\Setup\SetupPreflightDetailRowBuilder`, `App\Setup\SetupSiteSettings`, `App\Setup\SetupWebInputFactory`, `App\Setup\SetupWebInputResult`, `App\Setup\SetupWizardFlow`, `App\Setup\SetupWizardStateStore`, `App\Setup\SetupWizardDatabaseTester` | Build DB-free setup wizard checks with detected values, explicit auto-heal support, Composer-derived PHP/extension requirements, media-capability diagnostics, translated PHP CLI subprocess diagnostics, optional Tailwind native-build warning diagnostics, public-webroot validation, modular initial site/settings fields, CSRF-protected web setup input for `/setup`, wizard step transitions, protected session-state persistence without plaintext setup secrets, and driver-aware database connection tests while delegating final execution to the shared setup runner; Composer probing, Tailwind smoke checks, reusable check rows, requirement groups, detail-row aggregation, state persistence, step transitions, and database test execution live in focused collaborators so new setup checks or wizard behavior do not expand the controller facade. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/manual/web-server-configuration.md` | `tests/Controller/BackendControllerTest.php`, `tests/Setup/SetupPreflightCheckerTest.php`, `tests/Setup/SetupWebInputFactoryTest.php` | +| Service/model | `App\Setup\SetupPreflightChecker`, `App\Setup\SetupComposerPreflightProbe`, `App\Setup\SetupTailwindPreflightProbe`, `App\Setup\SetupPreflightDetailRowBuilder`, `App\Setup\SetupSiteSettings`, `App\Setup\SetupWebInputFactory`, `App\Setup\SetupWebInputResult`, `App\Setup\SetupWizardFlow`, `App\Setup\SetupWizardStateStore`, `App\Setup\SetupWizardDatabaseTester` | Build DB-free setup wizard checks with detected values, explicit auto-heal support, Composer-derived PHP/extension requirements, media-capability diagnostics, translated PHP CLI subprocess diagnostics, optional Tailwind native-build warning diagnostics, public-webroot validation, modular initial site/settings fields, CSRF-protected web setup input for `/setup`, setup-secret browser constraints derived from the shared input validator, wizard step transitions, protected session-state persistence without plaintext setup secrets, and driver-aware database connection tests while delegating final execution to the shared setup runner; Composer probing, Tailwind smoke checks, reusable check rows, requirement groups, detail-row aggregation, state persistence, step transitions, and database test execution live in focused collaborators so new setup checks or wizard behavior do not expand the controller facade. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/manual/web-server-configuration.md` | `tests/Controller/BackendControllerTest.php`, `tests/Setup/SetupPreflightCheckerTest.php`, `tests/Setup/SetupWebInputFactoryTest.php` | | Service | `App\Setup\SetupCompletionMarker` | DB-free setup completion marker that writes `APP_SETUP_COMPLETED` into Composer's dumped `.env.local.php` array and checks the already-loaded environment value so `/setup` can be locked after installation without requiring a database connection. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Setup/SetupCompletionMarkerTest.php`, `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupPasswordResetRunner` | Callable setup recovery runner that finds users and resets password hashes with shared password-policy validation and ActionLog output. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Setup/SetupPasswordResetRunnerTest.php` | | Value object | `App\Setup\SetupPasswordResetUser` | Read model for displaying UID, username, email, and status before a setup password reset. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Setup/SetupPasswordResetRunnerTest.php` | -| Service | `App\Setup\SetupRunner`, `App\Setup\SetupRuntimeCommandRunner`, `App\Setup\SetupDatabaseEnvironmentScope`, `App\Setup\SetupRunInputValidator`, `App\Setup\SetupStepAction`, `App\Setup\SetupPasswordPolicy`, `App\Setup\SetupRollbacker`, `App\Setup\SetupDatabaseTableSnapshot`, `App\Setup\SetupLiveOperationPayloadProtector`, `App\Setup\SetupWizardState` | Shared first-run setup runner that validates installer language and setup input policy, writes env overrides, delegates dump-env/migration/cache/package discovery/asset rebuild subprocesses to focused runtime command services, seeds default settings/admin/content data through a database-ready environment scope, exposes each setup step as a LiveOperation action, and marks setup complete with ActionLog output and dry-run planning; failed setup runs restore pre-existing env files and clean up only tables that were not present before this setup run across SQLite, MySQL/MariaDB, and PostgreSQL with a visible rollback summary message on the failed ActionLog step, while dry-run preparation avoids database table snapshots and file creation; web live-operation setup payloads protect setup secrets through the central context-labeled secret payload protector before they are stored on disk, and the wizard session key is shared centrally for controller cleanup after completion. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service | `App\Setup\SetupRunner`, `App\Setup\SetupRuntimeCommandRunner`, `App\Setup\SetupDatabaseEnvironmentScope`, `App\Setup\SetupRunInputValidator`, `App\Setup\SetupStepAction`, `App\Setup\SetupPasswordPolicy`, `App\Setup\SetupRollbacker`, `App\Setup\SetupDatabaseTableSnapshot`, `App\Setup\SetupLiveOperationPayloadProtector`, `App\Setup\SetupWizardState` | Shared first-run setup runner that validates installer language and setup input policy, writes env overrides, delegates dump-env/migration/cache/package discovery/asset rebuild subprocesses to focused runtime command services, seeds default settings/admin/content data through a database-ready environment scope, stops any stale local Mercure hub before the post-seed health check can restart it with persisted setup secrets, exposes each setup step as a LiveOperation action, and marks setup complete with ActionLog output and dry-run planning; failed setup runs restore pre-existing env files and clean up only tables that were not present before this setup run across SQLite, MySQL/MariaDB, and PostgreSQL with a visible rollback summary message on the failed ActionLog step, while dry-run preparation avoids database table snapshots and file creation; web live-operation setup payloads protect setup secrets through the central context-labeled secret payload protector before they are stored on disk, and the wizard session key is shared centrally for controller cleanup after completion. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service | `App\Setup\SetupSensitiveValueMasker` | Masks setup secrets in ActionLog contexts, especially database URL passwords in dry-run output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Event subscriber | `App\Setup\SetupRedirectSubscriber` | Redirects main requests to `/setup` before setup completion using only the loaded environment marker, while allowing setup, profiler, toolbar, and static asset paths without touching Doctrine/DBAL; after setup completion it clears any remaining wizard session state. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Setup/SetupRedirectSubscriberTest.php` | | Exception | `App\Setup\SetupStepFailedException` | Setup-specific RuntimeException for failed subprocess or setup execution steps, with optional structured message payloads for recoverable localized setup failures. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupCompletionMarkerTest.php`, `tests/Setup/SetupRunnerTest.php` | @@ -288,12 +295,15 @@ | Service/model | `App\View\MarkdownRenderer`, `App\View\MarkdownProfile`, `App\View\MarkdownEmbedAdapter` | Profile-aware Markdown renderer for README, trusted design, rich allrounder, and safety-first basic rendering, including controlled YouTube embeds for trusted design Markdown. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/View/MarkdownRendererTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | Service | `App\View\SystemPackageMetadataProvider` | Exposes immutable virtual system package metadata from the root `.manifest`, including name, author, description, version, source, and channel details for package/theme UI and Twig context. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/SystemPackageMetadataProviderTest.php` | | Service | `App\View\PackageMacroRegistry` | Provides namespaced core macro template paths and future package macro namespace slots. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/PackageMacroRegistryTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | +| Service | `App\View\Chart\ChartFactory` | Builds Symfony UX Chart.js line, bar, and doughnut charts with shared labels/datasets/options conventions for dashboard and statistics panels. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Chart/ChartFactoryTest.php` | | Event payload | `App\View\ViewContextEvent` | Public mutable event used by active package extensions to add universal Twig view context before rendering. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/ViewContextProviderTest.php` | | Service | `App\View\ViewContextProvider` | Builds the universal Twig view context with system package metadata, macro namespaces, and event-collected extension variables. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/ViewContextProviderTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | Twig extensions | `App\View\Twig\ViewContextTwigExtension`, `App\View\Twig\ViewRuntimeTwigExtension`, `App\View\Twig\AdminViewTwigExtension` | Expose branding-neutral Twig helpers for view context, macro namespace lookup, public hook descriptors, profile-aware Markdown rendering, actor-aware navigation trees, safe HTML attributes, debug/request trace diagnostics, settings form builders, backend action definitions, package settings, theme/package summaries, and footer copyright rendering through separated helper families. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/View/Twig/ViewTwigExtensionTest.php`, `tests/Controller/BackendControllerTest.php` | | Event payload/policy | `App\View\Event\ResponseHeadersEvent`, `App\View\Http\ResponseHeaderPolicy` | Public mutable extend hook for adding or removing ordinary safe HTTP response headers before sending the main response, with a policy that blocks invalid values plus cookie, authentication, transport, content-length, and core security header mutations from package listeners. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | | Event payload | `App\View\Event\OutputGeneratedEvent` | Public mutable extend hook for adjusting generated HTML output after rendering and before sending the main response. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | | Event subscriber | `App\View\Http\ResponseHookSubscriber` | Dispatches public response header and generated HTML output hooks for the main response while keeping failed hook mutations out of the final response. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | +| Services | `App\View\Alert\UiAlert`, `App\View\Alert\UiAlertTopicFactory`, `App\View\Alert\UiAlertUserIdentityResolverInterface`, `App\View\Alert\DoctrineUiAlertUserIdentityResolver`, `App\View\Alert\UiAlertPublisherInterface`, `App\View\Alert\MercureUiAlertPublisher` | Defines the stable UI-alert payload with level, mode, actions, loading state, system-owned HMAC-bound user/session Mercure URN topic syntax derived from canonical account UIDs, resolvable usernames, or existing session cookies without minting new sessions, and publisher API for targeted frontend alerts without broadcasting unrelated message-layer entries. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertTopicFactoryTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php` | +| Twig extension | `App\View\Twig\UiAlertTwigExtension` | Exposes the current request/user UI-alert stream topics and HMAC-derived alert-storage scope for AlertStack components, including existing session-cookie scope without starting new anonymous sessions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Alert/UiAlertTopicFactoryTest.php`, `tests/View/Twig/UiAlertTwigExtensionTest.php` | ## 8. Controllers @@ -314,27 +324,33 @@ | Routes `backend_admin_index`, `backend_admin_route`, `backend_admin_log_detail`, `backend_editor_*` | `App\Controller\BackendController` | Native backend/editor dispatcher for area route resolution, area access checks, route messages, backend navigation context, generated Admin Settings submissions, and Admin Log detail rendering. Focused package and operation routes live in dedicated Admin controllers. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | | Routes `backend_admin_package_*`, `backend_admin_operation_*` | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Focused Admin package install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | | Routes `api_live_operation_status`, `api_live_operation_continue` | `App\Controller\LiveOperationController` | Public but token-protected JSON endpoints for live ActionLog operation state and provider-declared review continuations below the reserved `/api/live/**` internal API branch. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | +| Route `api_live_package_dispatch` | `App\Controller\LiveEndpointController` | Dispatches package-owned live endpoint definitions below `/api/live/{package_slug}/...` while system routes keep priority for reserved live branches, endpoint minimum access levels are enforced before handler execution, and pattern matches are rejected when the matched route slug does not belong to the endpoint owner. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php` | +| Route `privacy_cookie_consent` | `App\Controller\CookieConsentController` | Validates visitor-bound stateless cookie-consent form tokens, stores selected optional cookie consent in a signed long-lived required cookie, expires optional cookies whose consent was withdrawn, and redirects back only to safe relative paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Privacy/Cookie/CookieConsentManagerTest.php` | | Package routes `/demo`, `/demo/backend`, `/demo/typography` | `packages/demo-module/package.php` | Optional demo module runtime registering portable public demo routes, menu entries, shell previews, Markdown typography guide, and demo module settings through static view injection. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Controller/DemoControllerTest.php` | -| Stimulus `chart` | `assets/controllers/chart_controller.js` | Lazily renders ApexCharts instances from Stimulus values and destroys them on disconnect. | N/A | N/A | | Stimulus `code-editor` | `assets/controllers/code_editor_controller.js` | Lazily mounts CodeMirror editors with CSS, HTML, JavaScript, JSX, JSON, Markdown, PHP, TypeScript, and TSX language support. | N/A | N/A | +| Stimulus `dialog` | `assets/controllers/dialog_controller.js` | Opens and closes native `` overlays through declarative actions and backdrop clicks so backend/frontend templates do not need inline JavaScript for modal behavior. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | +| Stimulus `clipboard` | `assets/controllers/clipboard_controller.js` | Copies explicit, source-target, or element text through the Clipboard API with a textarea fallback and optional status feedback for API keys, tokens, debug snippets, and future copyable admin output. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | +| Stimulus `disclosure`/`tabs` | `assets/controllers/disclosure_controller.js`, `assets/controllers/tabs_controller.js` | Provides small declarative controllers for expandable panels and ARIA tab/panel switching so future Admin panels can stay compact without feature-specific JavaScript. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | ## 9. Console Commands | Command | Handler | Purpose | Docs | Tests | |---------|---------|---------|------|-------| | `bin/init` | `bin/init` | Initializes repository dependencies and assets for automated workflows without requiring a Symfony bootstrap before Composer is installed, including Composer-derived PHP/extension preflight checks, a platform-safe pre-install `vendor/` reset for corrupt dependency trees, and optional Symfony UX icon locking for local referenced icons. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Operations/InitScriptTest.php` | -| `bin/lint` | `bin/lint` | Runs the project-wide validation suite for PHP syntax, Symfony container wiring, Twig/YAML syntax, JavaScript modules, JSON files, Markdown parsing through CommonMark/GFM, local Symfony UX icon references, Tailwind CSS buildability, translation-source key drift, and non-Markdown Git whitespace checks; accepts optional file/directory targets or `--diff`/`--diff=` for focused type-based linting of changed files. | `dev/draft/0.1.x-SetupTestAutomation.md` | N/A | +| `bin/lint` | `bin/lint` | Runs the project-wide validation suite for PHP syntax, Symfony container wiring, Twig/YAML syntax, JavaScript modules, JSON files, Markdown parsing through CommonMark/GFM, local Symfony UX icon references, Tailwind CSS buildability, translation-source key drift, and non-Markdown Git whitespace checks; accepts optional file/directory targets or `--diff`/`--diff=` for focused type-based linting of changed files, with focused CSS lint treating parser errors on known Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks as informational only after reparsing a normalized CSS view while `tailwind:build` remains authoritative. | `dev/draft/0.1.x-SetupTestAutomation.md` | N/A | +| `bin/jstest` | `bin/jstest`, `tests/assets/**/*.test.mjs` | Runs lightweight DOM-free JavaScript behavior tests through Node.js built-in `node --test` without a `node_modules` dependency tree, accepting focused test files or Node test-runner options and skipping successfully with a visible message when Node.js is unavailable. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs` | | `bin/setup` | `bin/setup` | CLI adapter for the shared setup runner with defaults, selected-environment operation logging, and optional JSON output for automation. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Operations/SetupScriptTest.php`, `tests/Setup/SetupRunnerTest.php` | | `packages:discover` | `App\Command\PackageDiscoveryCommand` | Queues package discovery with JSON output and trigger context, with explicit `--run-now` recovery support for synchronous execution. | `dev/manual/package-lifecycle-snippets.md` | `tests/Command/PackageDiscoveryCommandTest.php`, `tests/Core/Package/PackageDiscoveryRunnerTest.php` | | `packages:assets:sync` | `App\Command\PackageAssetSyncCommand` | Mirrors active package assets and rewrites package asset registries with dry-run and JSON output support. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Package/PackageAssetSyncerTest.php` | -| `assets:rebuild` | `App\Command\AssetRebuildCommand` | Runs or queues the full package-aware asset rebuild queue with dry-run, progress, JSON output, Messenger dispatch, and final cache clear. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Asset/AssetRebuildQueueFactoryTest.php` | +| `assets:rebuild` | `App\Command\AssetRebuildCommand` | Runs or queues the full package-aware asset rebuild queue with dry-run, progress, JSON output, Messenger dispatch, optional dependency preparation, and final cache clear. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Asset/AssetRebuildQueueFactoryTest.php` | +| `mercure:install`, `mercure:start`, `mercure:stop`, `mercure:health`, `mercure:check` | `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Manage and inspect the optional local Mercure hub binary and health state; install fails non-zero when the binary cannot be installed, start installs missing binaries, uses the release-provided Caddyfile with protected envfile JWT secrets, and enables anonymous subscribers for HMAC-bound public alert topics, stop targets the stored PID first and then exact-binary fallback processes before reporting success, health retries configured publish endpoint recovery, treats disabled Mercure as a successful configured no-op, requires a successful authenticated publish POST plus public EventSource subscribe reachability before enabling push, stops the hub when only the public endpoint is unreachable, check prints current read-only diagnostics including tracked process, local endpoint reachability, configured publish URL/status, and public endpoint reachability, and polling fallback remains available when the hub is unavailable. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | `statistics:snapshot` | `App\Command\AccessStatisticsSnapshotCommand` | Refreshes the stored access-statistics snapshot for a selected window, returning a clean skipped result when statistics are disabled and a failing exit code when snapshot storage fails. | `dev/draft/0.4.x-Scheduler.md` | `tests/Command/AccessStatisticsSnapshotCommandTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php` | | `operations:run` | `App\Command\LiveOperationRunCommand` | Atomically claims one staged live operation in a detached console process, writes running/finished/review-required ActionLog entries back to the live-operation store for UI polling, and cleans expired operation artifacts after execution. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php`, `tests/Controller/LiveOperationControllerTest.php` | | `operations:cleanup` | `App\Command\LiveOperationCleanupCommand` | Removes expired terminal and stale active live-operation state plus runner output files from `var/operations/{APP_ENV}`. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php` | | `account-tokens:cleanup` | `App\Command\AccountTokenCleanupCommand` | Removes expired account tokens while preserving used security-review dispute tokens until their linked inactive account is resolved. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Command/AccountTokenCleanupCommandTest.php` | | `acl-groups:apply` | `App\Command\AclGroupApplyCommand` | Applies a reviewed ACL group update or delete from the console for maintenance/debugging and reuses the same apply service as the LiveLog action. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/AdminUserControllerTest.php` | | `packages:lifecycle` | `App\Command\PackageLifecycleCommand` | Applies a package lifecycle action from the console so the live operation runner can execute package activation, deactivation, reset, purge, and delete outside the original page request. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/BackendControllerTest.php` | -| Package validator | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageTemplatePathValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy` | Validates package shape, lints package files, inspects package capabilities, enforces template scope, template-reference, macro namespace, and package-owned CSS target class namespace rules, blocks unsafe installable package payload paths, blocks direct filesystem/process/network/environment PHP capabilities, and emits non-blocking package-policy warnings for development-only payloads. | `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Package/PackageValidatorTest.php`, `tests/Core/Package/PackageFixtureTest.php` | +| Package validator | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageTemplatePathValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy` | Validates package shape, lints package files with Tailwind directive tolerance only when a second strict parse without Tailwind directive lines succeeds, inspects package capabilities, enforces template scope, template-reference, macro namespace, and package-owned CSS target class namespace rules, blocks unsafe installable package payload paths, blocks direct filesystem/process/network/environment PHP capabilities, and emits non-blocking package-policy warnings for development-only payloads. | `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Package/PackageValidatorTest.php`, `tests/Core/Package/PackageFixtureTest.php` | ## 10. Components and Templates @@ -343,6 +359,7 @@ | Layout templates | `templates/frontend/frontend.html.twig`, `templates/backend/{admin,editor,setup}.html.twig`, `templates/*/layouts/*.html.twig` | Native frontend, admin, editor, setup, and optional area layout skeletons. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/DemoControllerTest.php` | | Demo module templates | `packages/demo-module/templates/{frontend,backend}/demo-module/*.html.twig` | Portable package-owned render targets for previewing native frontend/backend shells, shared primitives, Markdown typography profiles, forms, status badges, empty states, package tables, and operation panels before production UI routes exist. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Controller/DemoControllerTest.php` | | Shared area partials | `templates/partials/**/*.html.twig`, `templates/frontend/partials/**/*.html.twig`, `templates/backend/partials/**/*.html.twig` | Granular native layout, system footer, navigation, typography, root-scoped alert feedback, action button, toolbar, and form field partials that establish early override points for themes and packages. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | +| Scoped Twig components | `templates/components/*.html.twig`, `templates/frontend/components/*.html.twig`, `templates/backend/components/*.html.twig` | Namespace-aware root, frontend, and backend UI primitives for alerts, the notification-center alert stack, buttons, button groups, page headers, empty states, Chart.js panels, and coordinate-based UX Map views, resolved through the same Twig namespace path order used by active themes and packages. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/TwigComponentNamespaceTest.php`, `php bin/console debug:twig-component root:AlertStack`, `php bin/console debug:twig-component root:ChartPanel`, `php bin/console debug:twig-component root:MapView`, `php bin/console debug:twig-component frontend:Button`, `php bin/console debug:twig-component backend:PageHeader` | | Backend area index/message templates | `templates/backend/{admin,editor,setup}/{index,message}.html.twig`, `templates/backend/admin/{packages,themes,operations,section}.html.twig`, `templates/backend/admin/packages/*.html.twig`, `templates/backend/admin/settings/*.html.twig` | Minimal native render targets for backend area routing, localized message-layer feedback, package/theme/admin placeholder view registration, package detail/lifecycle review screens, transient live-operation inspection, cleanup, retained detail views with review continuation controls, typed settings forms, and backend navigation before feature-specific pages are added. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | | Frontend primary navigation | `templates/frontend/partials/navigation/_primary.html.twig` | Native frontend navigation partial rendering the `main` menu through recursive `navigation()` output with translated route labels, active/ancestor classes, optional safe link metadata attributes, and the default three-level depth. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | Backend area partials | `templates/backend/admin/partials/*.html.twig`, `templates/backend/editor/partials/*.html.twig`, `templates/backend/setup/partials/**/*.html.twig` | Granular backend-scoped admin, editor, and setup partial trees, including setup wizard alerts, step panels, preflight rows, footer navigation, and result logs. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | @@ -352,7 +369,9 @@ | Provider templates | `templates/provider/{captcha,editor}/*.html.twig`, `templates/frontend/partials/forms/fields/captcha.html.twig`, `templates/backend/editor/fields/richtext.html.twig` | Native provider fallbacks and area stubs for optional captcha and editor-provider rendering through `@provider`, with CodeMirror as the base editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/View/Template/PackageTemplatePathConfiguratorTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Renders recoverable HTTP errors through system content, frontend error templates, default error fallback, or anonymous-login `401` response. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | | Frontend error pages | `templates/frontend/error-pages/*.html.twig` | Native frontend-scoped fallback templates for HTTP error pages including lightweight `429` and `503`. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | N/A | -| Action-log overlay | `templates/backend/operations/action-log-overlay.html.twig`, `assets/controllers/operation_overlay_controller.js` | Native backend-scoped action-log overlay skeleton and Stimulus controller for starting live operations through CSRF-protected forms, resuming in-flight and continuation runs from a system-owned session storage key, polling ActionLog entries within the server retention window, and exposing contextual OK, Continue, Retry, UI-only Cancel, Refresh, or Close actions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | +| Live polling controllers | `assets/js/live/live_poll.js`, `assets/controllers/live_poll_controller.js`, `assets/controllers/operation_overlay_controller.js` | Provides a reusable live JSON polling primitive and Stimulus controller for `/api/live/**` endpoints with automatic polling, immediate `has_more` page draining when cursors advance, optional recoverable-error retry for fallback channels, `next_poll_ms: 0` manual-mode support, and one-shot `live-poll#poll`/`live-poll#refresh` actions while operation forms surface progress through notification-center runner alerts, keep the triggering button disabled while running, automatically run the OK/redirect/reload action shortly after successful operations unless the ActionLog overlay is opened, remap warning or failed triggers to details, and open the ActionLog overlay only on demand for details, continuation, retry, close controls, non-terminal hide controls that keep polling alive, and reusable running-alert detail actions that can reopen the overlay after it was hidden. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs`, `tests/Controller/LiveOperationControllerTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\WorkflowResultAlertSelector`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `App\Command\UiAlertInboxCleanupCommand`, `App\Controller\LiveAlertController`, `assets/controllers/alert_stack_controller.js`, `assets/controllers/ui_alert_stream_controller.js`, `assets/controllers/ui_alert_poll_controller.js`, `assets/js/alerts/*.js` | Renders server-created, translated, client-created, Mercure-pushed, or polling-delivered UI alerts through one `addAlert()` interface with explicit `Direct`, robust `Queue`, and volatile low-level `Push` delivery modes, success-preserving workflow-result alert selection, DB-backed user/session topic inbox that only accepts system-owned UI-alert URN topics and stores bounded HMAC topic keys with setup-completion gating, canonical UID topics from account entities, account UIDs, or case-preserved resolvable usernames, portable append success reporting without sequence-specific insert IDs, paginated catch-up cursors, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, private-subscription authorization cookies for rendered alert stream topics, paginated inbox catch-up drains before stream connection, on stream open/reconnect, and during polling fallback, existing session-cookie topics, transient-failure retries, session-scoped sessionStorage-backed notification center with badge counts, visible no-JavaScript server-rendered alerts, silent stored-alert hydration that does not hide fresh server-rendered flashes, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, sanitized quiet text/link actions, presentation modes, and optional titles/actions/loading state. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/View/Alert/UiAlertDispatcherTest.php`, `tests/View/Alert/UiAlertInboxTest.php`, `tests/View/Alert/WorkflowResultAlertSelectorTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| Filter form controller | `assets/controllers/filter_form_controller.js` | Provides a reusable GET-list filter controller with debounced search-input submission, immediate select submission, submit-button busy state, page reset, and focus/caret restoration across GET refreshes for backend list and log filter forms. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | | Frontend user templates | `templates/frontend/user/*.html.twig` | Frontend-scoped templates for login, register, password reset, profile editing and closure, password changes, API-key management/reveal, invitation/registration acceptance, and security-review routes. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Controller/UserControllerTest.php` | ## 11. Packages diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c95bf4b6..c153dfee 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-14 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -14,7 +14,8 @@ - [x] **0.1.x Foundation** - [ ] **0.2.x Security and extension baseline** - - [ ] Admin interface and setup UI + - [ ] Admin interface + - [x] Setup UI - [ ] **0.3.x Structured authoring and resolver foundation** - [ ] Schema-driven content fields @@ -28,10 +29,10 @@ - [ ] **0.4.x External interfaces and operations** - [ ] Operational security and audit coverage - - [ ] API layer + - [x] API layer - [ ] Frontend delivery and caching - [ ] Operational admin workflows - - [ ] Scheduler + - [x] Scheduler - [ ] Import/export and LLM collaboration - [ ] Backup and restore - [ ] Contact, mail, logging, and statistics @@ -64,7 +65,6 @@ - [ ] Add portable read-model/index strategy when JSON-held values such as localized titles need frequent list-view filtering or sorting across MariaDB/MySQL, SQLite, and PostgreSQL. - [ ] Editor/API follow-up: when the final content/editor model lands, replace provisional API content list filtering with a domain-owned actor-aware content list/read resolver covering canonical paths, language, variants, optional version selection, pagination, filtering, and sorting. - [ ] Before production readiness, review public package/developer-facing class, interface, function, and Twig helper names for clarity and ergonomics; decide whether to rename directly or provide stable aliases so extension APIs read as intentional rather than provisional. -- [x] API branch planning: before implementation, turn `dev/draft/0.4.x-ApiLayer.md` into a concrete endpoint/resource plan covering initial read/write scope, API-key method gating, response DTOs, error envelope, pagination, filtering, sorting, audit signals, and tests. - [ ] Audit follow-up: add a durable package lifecycle operation journal/coordinator for multi-step activation, deactivation, install, rollback, and cleanup flows. - [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding intentionally covers visitor changes, not complete cookie-pair duplication. - [ ] Audit follow-up: implement remember-me with Symfony-style persistent server-side tokens, visitor binding, explicit revocation, token rotation, and audit signals in the Security branch. @@ -73,7 +73,57 @@ - [ ] Evaluate whether the documented minimum memory requirement should become 256M after PHPUnit 13.2/full-suite runs needed a higher CLI memory limit; do not fix this requirement until setup/init/lint/runtime memory behavior has been reviewed across target hosting platforms. ## Branch Logs -**Usage:** Keep session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Continue appending new session notes under the active branch so reviewers can see the full PR context in one place. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. Record every meaningful committed or completed change, including verification and follow-ups. +**Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place new entries chronologically under the matching branch/date heading so reviewers can follow the PR context without reading full verification transcripts. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. + +### 2026-06-13 feat-symfony-ux-integration +- Added namespace-aware Twig component primitives for root, frontend, backend, and package-adjacent UI surfaces, then wired shared alert stacks, buttons, page headers, empty states, chart/map wrappers, and form field enhancements without removing the override-friendly partial entry points. +- Added reusable Stimulus/JS foundations for live polling, filter forms, dialog, clipboard, disclosure, tabs, notification-center behavior, Mercure alert streams, and manual one-shot polls; applied the filter/dialog/clipboard pieces to existing Admin logs/statistics/users/package/API-key surfaces where useful. +- Reworked UI alerts into a unified dispatcher and notification center with direct, queued, and low-level push delivery modes; request-time alerts, DB-backed inbox fallback, Mercure best-effort push, polling fallback, titles, actions, loading state, and `auto`/`hidden`/`persistent` presentation now share one public `addAlert()` path. +- Added a package-owned `/api/live/{package_slug}/...` endpoint registry and dispatch boundary for lightweight GET-only polling/manual interactions such as future captcha seed reloads. +- Added a package-extendable cookie-consent foundation with duplicate-name rejection, stateless public CSRF, consent-aware cookie helpers, optional-cookie withdrawal expiry, DNT/GPC-aware defaults, and a reusable overlay that can be reopened from later privacy/footer links. +- Added optional local Mercure tooling with installer/start/stop/health/check commands, fixed versioned Caddy-based release assets, `var/mercure` storage, read-only diagnostics, public subscribe probes, publish probes, setup seeding, scheduler health refresh, and graceful polling fallback when Push is unavailable. +- Switched local Mercure signing to derive from the validated/generated `APP_SECRET` by default, moved local hub secrets out of process arguments, required a 32-byte `APP_SECRET`, and made unsupported short app secrets fail before the app-secret rotation recovery flow can mark them as healed. +- Removed unused Alpine and ApexCharts wiring now covered by Symfony UX packages, kept UX assets lazy, repaired demo package/theme CSS validation, and clarified that `asset-map:compile` is production/release-only. +- Bounded large log reading from the end of the file to avoid Admin log timeouts on large application logs. +- Updated the design-system draft, Mercure web-server notes, class map, and related translation/catalogue entries for the new UI alert, live polling, component, cookie-consent, Mercure, and Symfony UX foundations. + +### 2026-06-14 feat-symfony-ux-integration +- Addressed review findings around live endpoint access and registration by making package live endpoints GET-only, enforcing minimum access levels before handler dispatch, reserving system live slugs, and preferring exact endpoint paths before broad pattern matches. +- Applied the same exact-before-pattern selection to regular API endpoint dispatch and split the oversized API class-map entry into focused API foundation/security, endpoint registry/documentation, admin/settings, content, and package/user rows. +- Folded `ui_alert_inbox` into the pre-`1.0.0` baseline migration and hardened prefixed index/constraint naming for alert-inbox schema objects. +- Hardened alert delivery and storage by scoping notification-center storage by user/session/surface, preserving closed-alert dedupe, giving queued/pushed alerts stable fallback IDs, and making explicit alert-inbox cleanup failures return a failing command status. +- Tightened cookie consent behavior by making rejection work without JavaScript, avoiding anonymous session creation from hidden CSRF tokens, rejecting duplicate consent definitions, and expiring withdrawn optional cookies. +- Kept profile views on cached Mercure availability only, required authenticated publish success for Mercure health, and kept setup/profile paths from starting or installing Mercure implicitly. +- Switched local Mercure downloads from deprecated legacy assets to the Caddy-based `mercure_{OS}_{ARCH}` archives, used the release Caddyfile plus protected env file for secrets, normalized Windows paths, and kept PID plus exact-binary process detection for start/stop/check diagnostics. +- Clarified Mercure public URL/reverse-proxy expectations in the web-server manual while keeping local checks precise enough to distinguish Symfony fallback responses from real Mercure SSE endpoints. +- Replaced committed Mercure JWT defaults with `${APP_SECRET}`, removed setup-time `MERCURE_JWT_SECRET` generation, and kept app-secret rotation naturally coupled to the default Mercure JWT key unless an operator explicitly configures a dedicated JWT secret. +- Deferred Mercure startup out of `bin/init`, made setup stop stale local hubs before health recovery starts them with persisted setup secrets, and coupled app-secret rotation recovery to Mercure stop/health refresh so local hubs do not keep stale signing keys. +- Stripped diagnostic message context from UI alert serialization so UI payloads expose only display-safe alert fields. +- Bound cookie-consent CSRF tokens to the existing visitor identity and hardened package live/API path-pattern guards plus live dispatch route-slug checks so package-owned endpoints cannot escape their namespace through broad regex patterns. +- Hardened late review edge cases for UI alerts, Mercure health, and setup copy by notifying only for newly created alerts, keeping server-rendered flashes visible during storage hydration, retrying transient alert-poll failures, treating disabled Mercure health as a configured success, and deriving setup secret browser constraints from the shared validator. +- Hardened queued alert fallback by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions. +- Hardened follow-up review edges by removing PostgreSQL-sensitive `lastInsertId()` dependency from queued alert appends, making initial server-rendered alerts visible without JavaScript, normalizing colon-only Mercure listen addresses for local probes, and restoring timed removal of transient auto alerts without marking them as manually closed. +- Extended the Mercure colon-only listen hardening to configured hub URLs so `.env`-derived `http://:3000/.well-known/mercure` values normalize before publish/public probes. +- Hardened cookie consent and alert dispatch follow-up edges by clearing all rejected optional cookies even without stored consent, preserving clear-cookie response headers for rejected cookies, enforcing registered cookie identity in the consent jar, skipping topic-specific Mercure publishes while unavailable, and adding a root Twig-component namespace smoke test for `root:*` components. +- Tightened production-readiness edges by SHA256-pinning Mercure release archive downloads, rejecting custom consent cookies that change registered security attributes, signing and TTL-validating consent cookies, and covering safe relative consent redirects. +- Added lightweight native `node --test` JavaScript behavior testing through `bin/jstest` without a `node_modules` dependency tree, with first coverage for alert payload normalization and live polling cursor/retry/error behavior. +- Expanded JavaScript behavior coverage with a small test-only fake DOM and Stimulus controller loader for stable controller contract tests around clipboard/dialog/disclosure/tabs/filter forms, cookie consent, alert stack behavior, alert polling, and Mercure stream reconnect handling. +- Hardened final review edges by verifying stored Mercure PIDs against the exact binary before termination, avoiding parallel alert stream/poll delivery while adding one-shot stream catch-up from the inbox and stable Mercure event IDs, remembering auto-dismissed alert IDs, constraining package-owned necessary cookies to package-scoped host-only names, and marking Mercure unavailable when app-secret rotation cannot safely stop the local hub. +- Hardened cookie-consent package review edges by rejecting duplicate or core-reserved package cookie definitions during package loading and validating optional-cookie privacy links before they can render in the public consent UI. +- Hardened follow-up setup, redirect, alert-inbox, and naming edges by passing the persisted setup `APP_SECRET` as the Mercure setup health JWT secret, rejecting backslash/control-character local redirect targets, storing queued alert topics as bounded HMAC keys, and renaming the consent cookie to a system-owned name. +- Replaced host-derived UI-alert Mercure topic URLs with system-owned URN topics so alert transport identifiers no longer consume HTTP route namespace or depend on `DEFAULT_URI` length. +- Hardened additional review edges by running consent cookie filtering after response cookie writers, standardizing queued user alert topics on canonical account UIDs with username-to-UID normalization when resolvable, and rejecting package live endpoint root paths that cannot be routed by `/api/live/{packageSlug}/{resourcePath}`. +- Hardened follow-up alert edges by preserving username case during username-to-UID topic resolution. +- Removed the native browser notification opt-in and `symfony/ux-notify` dependency because UX Notify only works while a page keeps an active Mercure/EventSource stream; real closed-browser notifications need a separate future Web Push design. +- Completed another broad review pass across URL/link sinks, browser storage naming, and Mercure secret-file handling; hardened alert action links, package metadata URLs, content redirect targets, filter-form storage names, and protected Mercure env-file rewrites. +- Hardened the alert stream fallback so browsers without `EventSource` support or streams that fail before their first open switch to inbox polling without enabling parallel normal stream/poll delivery. +- Kept operation detail overlays dismissible during running operations by showing close controls in non-terminal states and hiding details without stopping the live poller. +- Hardened additional alert/CSS review edges by draining paginated queued-alert catch-up pages after Mercure stream opens, authorizing rendered stream subscriptions for private alert pushes, and suppressing strict-parser CSS limitations for Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks only when a normalized second parse finds no adjacent syntax error. +- Follow-up hardening pass: removed unused legacy CSS-linter wrapper names after broadening the parser-normalization scope, made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream catch-up, and made Mercure stream views drain queued alerts before connecting while queuing a follow-up drain when the stream opens mid-catch-up. +- Follow-up: when the next feature branch starts, evaluate focused controller foundations for public consent/privacy settings, package live endpoint documentation/navigation, captcha provider/live seed flows, notification preference detail settings, and package-owned webhook/job callback endpoints before adding more broad UI surface. +- Follow-up: add a public privacy/footer trigger for `cookie_consent_trigger_attributes()` so visitors with stored consent can reopen cookie preferences and withdraw or adjust optional-cookie consent. +- Follow-up: evaluate converting high-use backend filters from GET-refresh enhancement to Symfony UX LiveComponent slices with URL-bound writable `LiveProp`s so filter input updates can re-render only the list component while keeping shareable query parameters. +- Follow-up: revisit the full operation overlay controller after the first real UI/UX feature slice; the polling core is now shared, but renderer/storage responsibilities can still be split further when more live consumers exist. ### 2026-06-12 docs-cleanup - Refreshed the `.codex` context inventory: marked the branding-neutral naming migration and first readiness audit as completed/historical, removed the obsolete standalone Symfony docs notes, made the framework recap the version-pinned dependency documentation cache, and updated it with current installed-dependency guidance for Symfony 8.1, Doctrine ORM/DBAL, Twig 3.27, Tailwind v4/TailwindBundle, Symfony UX, CommonMark, and PHPUnit 13. @@ -97,6 +147,7 @@ - Clarified `AGENTS.md` wording around session notes with branch/PR context and the boundary between agent-only `.codex` helpers and project-wide tooling. - Normalized `AGENTS.md` wording so the document reads as a standalone first-version guide rather than as a patch over earlier agent habits. - Disabled UX Translator TypeScript type dumps in production because the current AssetMapper setup uses JavaScript, not TypeScript, and recorded the UX Turbo 3.1 stream-listen deprecation in the dependency recap. +- Added cache warmup to `bin/init` and `ux:translator:warm-cache` to the package-aware asset rebuild queue so `var/translations/index.js` exists before AssetMapper resolves `assets/translator.js`. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/draft/0.1.x-SetupTestAutomation.md b/dev/draft/0.1.x-SetupTestAutomation.md index 62512aa9..00ca3bda 100644 --- a/dev/draft/0.1.x-SetupTestAutomation.md +++ b/dev/draft/0.1.x-SetupTestAutomation.md @@ -48,7 +48,7 @@ The test environment should be deterministic. It should use `env:test`, SQLite b - Use `php bin/console lint:container` for service wiring and configuration changes. - Use `.codex/compare_translations.php` for translation-key drift. - Use `php bin/console render:route /` for Twig and translation review. -- Use `php bin/console tailwind:build` and `php bin/console asset-map:compile` when frontend assets or importmap behavior changes. +- Use `php bin/console tailwind:build` when frontend CSS changes; keep `asset-map:compile` production-only. - Run `php bin/console assets:rebuild` when package changes affect templates, CSS, JavaScript, imported assets, or Tailwind class usage. - Keep local frontend watchers optional for development convenience; release/admin workflows should use explicit build commands. - Provide setup or reset commands only when they are deterministic and environment-aware. diff --git a/dev/draft/0.1.x-SystemThemeDesignSystem.md b/dev/draft/0.1.x-SystemThemeDesignSystem.md index aaf2861f..3c71919d 100644 --- a/dev/draft/0.1.x-SystemThemeDesignSystem.md +++ b/dev/draft/0.1.x-SystemThemeDesignSystem.md @@ -31,6 +31,7 @@ The first design system should be small but real. It should define enough shared - Place error pages under `templates/frontend/error-pages/**`, including `default.html.twig` and lightweight standalone candidates for `429` and `503`. - Place context-specific partials under `templates/frontend/partials/**`, `templates/backend/partials/**`, or narrower area directories. Do not place generic page partials at template root. - Keep partials intentionally small and override-friendly: layout fragments, brand/navigation fragments, typography headers, feedback states, form labels/help/errors/actions, action buttons/toolbars, and individual field types should be separate templates where practical. +- Place reusable Twig components under the matching Twig namespace component directory: `templates/components/**` for `root:*`, `templates/frontend/components/**` for `frontend:*`, `templates/backend/components/**` for `backend:*`, and provider/package component templates below namespace-aware `components/**` paths so active package template ordering can override or extend them through the existing Twig loader. - Place reusable Twig macros/functions under namespaced macro files and aggregate provider macro namespaces rather than treating them as replaceable override files. - Provide a base admin layout with sidebar or top navigation, content area, page header, action area, status area, and flash/error rendering. - Provide a setup/login layout for unauthenticated system workflows. @@ -60,7 +61,7 @@ The first design system should be small but real. It should define enough shared - Keep translation catalogues modular: `messages` is reserved for the structured message layer, while UI copy belongs in narrower domains such as `ui`, `admin`, `editor`, `setup`, `operations`, `demo`, or future feature slices. - Provide responsive behavior for desktop and tablet first, with safe mobile fallback for urgent administration. - Meet baseline accessibility expectations: semantic landmarks, keyboard navigation, visible focus, color contrast, labels, error association, and reduced-motion safety. -- Keep JavaScript progressive and feature-specific through Stimulus controllers. +- Keep JavaScript progressive and feature-specific through Stimulus controllers. Alpine and ApexCharts are not baseline dependencies; prefer Symfony UX, native Stimulus controllers, and the project live-polling helper before adding another frontend runtime. - Do not let frontend-theme packages override backend templates. - Do not let packages override `@root` shared templates unless they declare `system-template`. - Allow active packages to contribute admin navigation entries, dashboard widgets, form sections, or action buttons only through documented extension points. @@ -76,7 +77,7 @@ The first design system should be small but real. It should define enough shared - Test form error rendering, flash messages, empty states, pagination, and destructive confirmations. - Test keyboard navigation and focus states for core controls. - Test responsive layout behavior for narrow and wide viewports. -- Run `php bin/console tailwind:build` and `php bin/console asset-map:compile` after asset changes. +- Run `php bin/console tailwind:build` after CSS asset changes; keep `asset-map:compile` production-only. - Use browser screenshots for major UI changes once routes exist. ## Implementation Notes @@ -91,6 +92,10 @@ The first design system should be small but real. It should define enough shared - **Decision recorded:** Error-page templates live under `templates/frontend/error-pages/**`; `429` and `503` may use lightweight standalone templates. - **Decision recorded:** Packages may contribute system UI elements only through documented extension points. - **Decision recorded:** Long-running or high-impact operations should have a reusable action-log panel component in addition to simple progress indicators. +- **Decision recorded:** UI alerts use root-scoped Twig components and a stable targeted Mercure contract. Alert producers should publish explicit UI alerts to private user or session topics instead of broadcasting arbitrary structured message-layer entries. +- **Decision recorded:** Alerts are managed through a lightweight notification center with badge counts, `auto`/`hidden`/`persistent` modes, quiet text actions, sessionStorage restoration when available, and close semantics that remove active alerts rather than merely hiding the panel. +- **Decision recorded:** Live JSON polling belongs in a reusable Stimulus/helper layer for `/api/live/**` endpoints. Operation forms should default to notification-center runner alerts with optional ActionLog overlay details, and provider packages can reuse the polling helper for later dynamic mechanisms such as captcha seed refreshes. +- **Decision recorded:** Scoped Twig components should mirror the existing Twig namespaces (`root:*`, `frontend:*`, `backend:*`, and provider/package namespaces) so themes and active packages can override or extend reusable UI primitives through the same template path order as partials. - **Decision recorded:** Global tokens and shared UI primitives are native system assets. Frontend and backend theme packages should use their matching area asset roots for area-specific styles, while root/shared package assets stay in the global extension bucket only for packages that declare a global runtime scope. - **Decision recorded:** Tailwind builds a single stylesheet for now. Native area CSS and package CSS should therefore use owner/surface root selectors for practical isolation when a rule is not intentionally global. - **Decision recorded:** Temporary `/demo`, `/demo/backend`, and `/demo/typography` routes may exist before production readiness through the demo module so the native shells, partials, Markdown profiles, status colors, forms, empty states, and operation panels have real render targets without loose Core demo routes. Demo copy belongs to the demo module package language files. diff --git a/dev/draft/0.1.x-ThemeEngine.md b/dev/draft/0.1.x-ThemeEngine.md index 88ee1cd3..a6f59a7a 100644 --- a/dev/draft/0.1.x-ThemeEngine.md +++ b/dev/draft/0.1.x-ThemeEngine.md @@ -125,7 +125,7 @@ Package dependency resolution is deferred to the package installer and activatio - Test `assets:rebuild` reports planned and current steps through the ActionLog payload. - Test `cache:clear` runs after package asset sync, Tailwind, and production AssetMapper compilation. - Run `php bin/console tailwind:build` after Tailwind-related changes. -- Run `php bin/console asset-map:compile` after AssetMapper/importmap changes. +- Keep `php bin/console asset-map:compile` production-only; do not use it as a local development verification step. - Render representative public routes with `php bin/console render:route /`. ## Implementation Notes diff --git a/dev/manual/frontend-asset-snippets.md b/dev/manual/frontend-asset-snippets.md index 76ef9d6d..27839ddd 100644 --- a/dev/manual/frontend-asset-snippets.md +++ b/dev/manual/frontend-asset-snippets.md @@ -17,7 +17,7 @@ Composer auto-scripts currently handle: - ImportMap install; - Tailwind build. -`bin/init` reruns the asset setup commands after Composer has restored dependencies so clean checkouts and recovered `vendor/` trees have deterministic local assets. It only runs `asset-map:compile` in `prod`. +`bin/init` reruns the asset setup commands after Composer has restored dependencies so clean checkouts and recovered `vendor/` trees have deterministic local assets, then warms the Symfony cache so UX Translator can dump JavaScript translation assets. It only runs `asset-map:compile` in `prod`. The global package-aware rebuild entry point is `php bin/console assets:rebuild`. Package lifecycle workflows and manual admin recovery actions should call this command through the operational ActionLog runner, not rebuild assets during normal page requests. @@ -27,10 +27,11 @@ The command publishes a planned step count in dry-run mode and reports current s 2. aggregate core and active package translation sources into the runtime `messages` catalogues; 3. run `assets:install`; 4. run `importmap:install`; -5. run `ux:icons:lock` as a non-blocking step so core and package template icon references are imported locally when Iconify is reachable; -6. run `tailwind:build`; -7. only in `prod`, remove `public/assets` and run `asset-map:compile`; -8. run `cache:clear` as the finalizer. +5. run `ux:translator:warm-cache` so AssetMapper can resolve `var/translations/index.js`; +6. run `ux:icons:lock` as a non-blocking step so core and package template icon references are imported locally when Iconify is reachable; +7. run `tailwind:build`; +8. only in `prod`, remove `public/assets` and run `asset-map:compile`; +9. run `cache:clear` as the finalizer. Symfony UX icons render inline from local SVG files under `assets/icons`; they do not need to be copied to `public/assets`. The lock step is intentionally non-blocking because offline CI, restricted production networks, or temporary Iconify outages should not break an otherwise valid asset rebuild. Missing icons are still visible as warnings in the ActionLog and should be locked manually during development or before release when network access is available. Before `ux:icons:lock`, `ux:icons:warm-cache`, or `asset-map:compile` run in the console, the package template path configurator registers active package template paths on Twig so scans include package-owned Twig files under `packages/**/templates`. @@ -42,7 +43,7 @@ Locked SVG files under `assets/icons` are committed as small, reviewable UI depe Use `php bin/console packages:assets:sync` when only the active package mirror and generated registry files need to be refreshed without running the full Symfony asset lifecycle. -Package asset sync and translation aggregation should preserve the previous generated state until the replacement is ready. Package assets are mirrored into a temporary `assets/.packages.tmp-*` directory before `assets/packages` is swapped, generated CSS/JavaScript registries are replaced through temporary files, and runtime translation catalogues are aggregated into a temporary `translations/runtime/{APP_ENV}.tmp-*` directory before the environment runtime directory is replaced. Production rebuilds still remove `public/assets` before `asset-map:compile` because AssetMapper writes versioned files and repeated compiles would otherwise leave stale compiled assets behind. +Package asset sync and translation aggregation should preserve the previous generated state until the replacement is ready. Package assets are mirrored into a temporary `assets/.packages.tmp-*` directory before `assets/packages` is swapped, generated CSS/JavaScript registries are replaced through temporary files, and runtime translation catalogues are aggregated into a temporary `translations/runtime/{APP_ENV}.tmp-*` directory before the environment runtime directory is replaced. `ux:translator:warm-cache` runs after translation aggregation so the JavaScript translation assets in `var/translations` exist before AssetMapper resolves `assets/translator.js`. Production rebuilds still remove `public/assets` before `asset-map:compile` because AssetMapper writes versioned files and repeated compiles would otherwise leave stale compiled assets behind. ## Theme asset notes diff --git a/dev/manual/package-lifecycle-snippets.md b/dev/manual/package-lifecycle-snippets.md index 26da082c..7cd5c916 100644 --- a/dev/manual/package-lifecycle-snippets.md +++ b/dev/manual/package-lifecycle-snippets.md @@ -112,7 +112,7 @@ $queueResult = $planner->copyFiles($candidate, $projectDir, [ Package assets must be self-contained. Packages should vendor any external JavaScript or CSS dependencies they need instead of making the project inject third-party dependency declarations into the global importmap. -Active package assets are exposed through generated registries rather than direct runtime discovery. The lifecycle mirrors active package assets into `assets/packages//` and rewrites stable CSS/JS registry files under `assets/styles/packages/` and `assets/js/packages/`. CSS registries may include Tailwind `@source` directives for active package templates and `@import` directives for mirrored active package CSS. JavaScript registries use static ESM imports for mirrored active package JavaScript. Package-aware asset rebuilds also run `ux:icons:lock`; before that command scans templates, active package template paths are registered on Twig so package-owned icon references can be imported locally when Iconify is reachable. +Active package assets are exposed through generated registries rather than direct runtime discovery. The lifecycle mirrors active package assets into `assets/packages//` and rewrites stable CSS/JS registry files under `assets/styles/packages/` and `assets/js/packages/`. CSS registries may include Tailwind `@source` directives for active package templates and `@import` directives for mirrored active package CSS. JavaScript registries use static ESM imports for mirrored active package JavaScript. Package-aware asset rebuilds run `ux:translator:warm-cache` after translation aggregation so JavaScript translation assets are present for AssetMapper. They also run `ux:icons:lock`; before that command scans templates, active package template paths are registered on Twig so package-owned icon references can be imported locally when Iconify is reachable. Static package assets such as images, fonts, SVGs, videos, and vendored dependency files are mirrored but not registered as standalone CSS/JS entries. They are served by AssetMapper only when referenced through mirrored package CSS, JavaScript, or templates. Source paths under `packages//...` must not leak into public output. @@ -134,7 +134,7 @@ Simple package settings are registered through `PackageSettingProviderInterface` Asset ordering should be deterministic: native system assets first, active module/provider package assets next, active frontend theme package assets next, and active backend theme package assets last so scoped theme overrides win where CSS/JS order matters. Project-local or entity-local assets remain closer to the rendered element and may be more specific by design. -Use `php bin/console assets:rebuild` as the global rebuild operation after package activation, deactivation, update, uninstall, setup, or manual admin recovery. Add `--queue --trigger=` when the caller only needs to enqueue the rebuild through Messenger. Setup runs the command synchronously in its own subprocess so memory and execution time are isolated from the setup runner process. Lifecycle services trigger one rebuild after all package state changes in the operation have been flushed, not once per package. Runtime package exits such as hook failures, missing active packages during discovery, or PHP loader failures queue the same rebuild through Messenger so stale active assets/templates/translations are cleaned up without running Tailwind and cache clearing inside the current request. If the rebuild message cannot be queued during registry sync, the registry handler runs one synchronous fallback rebuild and records the dispatch failure in the result context/messages. It should run as an ActionLog-backed operation with persisted step entries and progress metadata. The package mirror and registry rewrite step runs before Tailwind; translation aggregation writes `translations/runtime/{APP_ENV}/messages.{locale}.yaml` before `cache:clear`. `cache:clear` runs last so the live operation UI is not invalidated before the rebuild has already produced mirrored assets, registries, Tailwind output, active translation catalogues, and production asset-map output. +Use `php bin/console assets:rebuild` as the global rebuild operation after package activation, deactivation, update, uninstall, setup, or manual admin recovery. Add `--queue --trigger=` when the caller only needs to enqueue the rebuild through Messenger. Setup runs the command synchronously in its own subprocess so memory and execution time are isolated from the setup runner process. Lifecycle services trigger one rebuild after all package state changes in the operation have been flushed, not once per package. Runtime package exits such as hook failures, missing active packages during discovery, or PHP loader failures queue the same rebuild through Messenger so stale active assets/templates/translations are cleaned up without running Tailwind and cache clearing inside the current request. If the rebuild message cannot be queued during registry sync, the registry handler runs one synchronous fallback rebuild and records the dispatch failure in the result context/messages. It should run as an ActionLog-backed operation with persisted step entries and progress metadata. The package mirror and registry rewrite step runs before Tailwind; translation aggregation writes `translations/runtime/{APP_ENV}/messages.{locale}.yaml`, and `ux:translator:warm-cache` writes `var/translations` before AssetMapper work. `cache:clear` runs last so the live operation UI is not invalidated before the rebuild has already produced mirrored assets, registries, Tailwind output, active translation catalogues, JavaScript translation assets, and production asset-map output. Live package actions should enter the Operations/ActionLog layer through tagged `LiveOperationQueueProviderInterface` providers. A provider owns one operation key, validates its serialized payload, builds an `ActionQueue`, and keeps destructive confirmation or dependency review outside the apply queue unless it intentionally returns a review-required result. If review is needed during a live flow, the provider should emit a user-facing action-required prompt and a safe continuation descriptor; the follow-up apply step is a new live operation, not a suspended process. diff --git a/dev/manual/setup-init-snippets.md b/dev/manual/setup-init-snippets.md index c3b95aa7..47fc5396 100644 --- a/dev/manual/setup-init-snippets.md +++ b/dev/manual/setup-init-snippets.md @@ -49,7 +49,7 @@ Symfony environment resolution should match Symfony precedence as closely as pra - dry-run planning without writing env files, running commands, or seeding the database; - setup action logs with halt-on-error results. -After migrations and initial data seeding, setup clears the cache and then runs two serial subprocesses in order: `packages:discover --run-now --trigger=setup`, then `assets:rebuild --trigger=setup --json`. This keeps cold setup memory bounded per process while still allowing system-default active packages to contribute assets and translations after the package registry is available. Setup asks the asset rebuild for JSON output so non-blocking rebuild warnings can be surfaced in the setup action log. `bin/init` still generates core-only runtime catalogues before Symfony console consumers run; setup runs the package-aware rebuild afterwards so active package translations can be aggregated once the database is initialized. +After migrations and initial data seeding, setup clears the cache and then runs serial subprocesses in order: `packages:discover --run-now --trigger=setup`, `assets:rebuild --trigger=setup --json`, `mercure:stop`, and `mercure:health`. This keeps cold setup memory bounded per process while still allowing system-default active packages to contribute assets and translations after the package registry is available. Setup asks the asset rebuild for JSON output so non-blocking rebuild warnings can be surfaced in the setup action log. The Mercure stop/health pair ensures a local optional hub is not left running with pre-setup placeholder secrets before health can restart it with the persisted setup secrets. `bin/init` still generates core-only runtime catalogues before Symfony console consumers run; setup runs the package-aware rebuild afterwards so active package translations can be aggregated once the database is initialized. Setup subprocesses provide a local `COMPOSER_HOME` under `var/composer-home` when no explicit Composer home is present, and fall back to `var` as `HOME` when the web server environment omits it. This keeps web setup compatible with Composer without relying on shell-only environment variables. diff --git a/dev/manual/web-server-configuration.md b/dev/manual/web-server-configuration.md index 6f931add..ddf5c9db 100644 --- a/dev/manual/web-server-configuration.md +++ b/dev/manual/web-server-configuration.md @@ -35,6 +35,91 @@ Keep this setting scoped to the web server service. Other setup subprocess check When Apache runs behind a reverse proxy such as Cloudflare, prefer `mod_remoteip` at the web-server layer. This rewrites `REMOTE_ADDR` before PHP handles the request, so Symfony's normal `Request::getClientIp()` resolution and Studio access logging use the verified client IP without application-level proxy lists. +### Mercure push notifications + +Studio treats Mercure push delivery as an optional enhancement. The polling alert inbox remains the portable fallback and must keep working on shared hosting without reverse-proxy support. + +For push delivery, configure the public Mercure endpoint so browser `EventSource` requests reach the Mercure hub: + +```text +Browser -> https://example.com/.well-known/mercure -> reverse proxy -> http://127.0.0.1:3000/.well-known/mercure +Symfony -> http://127.0.0.1:3000/.well-known/mercure +``` + +Default environment: + +```dotenv +MERCURE_HUB_LISTEN=127.0.0.1:3000 +MERCURE_URL=http://${MERCURE_HUB_LISTEN}/.well-known/mercure +MERCURE_PUBLIC_URL=${DEFAULT_URI}/.well-known/mercure +MERCURE_JWT_SECRET=... +``` + +`MERCURE_JWT_SECRET` must provide at least 256 bits of HMAC-SHA256 key material. The committed default derives it from `APP_SECRET`, and setup validates/generates a long enough `APP_SECRET`; manual environments should keep that derivation or use a dedicated high-entropy `MERCURE_JWT_SECRET` with at least 32 bytes. + +Override `MERCURE_PUBLIC_URL` only when the browser-facing URL differs from the canonical `DEFAULT_URI` host, for example when Mercure is exposed through a dedicated subdomain, Cloudflare Tunnel, or a supported public HTTPS port. + +The reverse proxy must keep Server-Sent Events usable: disable response buffering for `/.well-known/mercure`, use a long read timeout, preserve the request host and scheme with forwarded headers, and forward the request to the local Mercure hub port. + +Studio UI-alert push uses unguessable HMAC-bound public URN topics under `urn:system:ui-alerts:*`. The local `mercure:start` command therefore starts the hub with anonymous subscribers enabled. External Mercure hub deployments must allow anonymous subscribers for public UI-alert topics or provide an equivalent subscriber authorization strategy before `mercure:health` can mark push delivery as available. + +If no public Mercure endpoint is reachable, `mercure:health` stores Mercure as unavailable. Studio then skips EventSource stream URLs and push publishing attempts while continuing to deliver alerts through the polling inbox. Use `php bin/console mercure:check` for read-only diagnostics without starting or stopping the hub. + +Apache example: + +```apache +# Required modules: mod_proxy, mod_proxy_http, mod_headers. +ProxyPreserveHost On + +ProxyPass "/.well-known/mercure" "http://127.0.0.1:3000/.well-known/mercure" retry=0 timeout=86400 flushpackets=on +ProxyPassReverse "/.well-known/mercure" "http://127.0.0.1:3000/.well-known/mercure" + +# Use "http" instead when this virtual host is intentionally served without TLS. +RequestHeader set X-Forwarded-Proto "https" early +SetEnvIf Request_URI "^/\.well-known/mercure" no-gzip=1 +``` + +nginx example: + +```nginx +location /.well-known/mercure { + proxy_pass http://127.0.0.1:3000/.well-known/mercure; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 24h; +} +``` + +IIS example using URL Rewrite and Application Request Routing: + +```xml + + + + + + + + + + + + + + + + +``` + +For IIS, enable ARR proxying at server level and allow the `HTTP_X_FORWARDED_PROTO` and `HTTP_X_FORWARDED_HOST` server variables if IIS blocks them by default. Keep ARR response buffering disabled or minimized for this route when available; if the hosting environment cannot stream long responses reliably, leave Mercure unavailable and use the polling fallback. + ## nginx Use `config/webserver/nginx.conf` as a template. Adjust `server_name`, `root`, `fastcgi_pass`, TLS, log paths, and upload limits for the target system. diff --git a/importmap.php b/importmap.php index 983f2058..e3f26ccb 100755 --- a/importmap.php +++ b/importmap.php @@ -27,8 +27,6 @@ '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], '@hotwired/stimulus' => ['version' => '3.2.2'], '@hotwired/turbo' => ['version' => '8.0.23'], - 'alpinejs' => ['version' => '3.15.12'], - 'apexcharts' => ['version' => '5.12.0'], 'codemirror' => ['version' => '6.0.2'], '@codemirror/view' => ['version' => '6.43.0'], '@codemirror/state' => ['version' => '6.6.0'], diff --git a/migrations/Version20260531000000.php b/migrations/Version20260531000000.php index 39d228df..45e309be 100644 --- a/migrations/Version20260531000000.php +++ b/migrations/Version20260531000000.php @@ -30,6 +30,16 @@ public function up(Schema $schema): void $this->addPrimaryKey($messenger, 'id'); $this->addIndex($messenger, ['queue_name', 'available_at', 'delivered_at', 'id'], 'idx_messenger_queue_available'); + $uiAlertInbox = $schema->createTable('ui_alert_inbox'); + $uiAlertInbox->addColumn('id', 'bigint', ['autoincrement' => true]); + $uiAlertInbox->addColumn('topic', 'string', ['length' => 80]); + $uiAlertInbox->addColumn('payload', 'json'); + $uiAlertInbox->addColumn('created_at', 'datetime_immutable'); + $uiAlertInbox->addColumn('expires_at', 'datetime_immutable', ['notnull' => false]); + $this->addPrimaryKey($uiAlertInbox, 'id'); + $this->addIndex($uiAlertInbox, ['topic', 'id'], 'idx_ui_alert_inbox_topic_cursor'); + $this->addIndex($uiAlertInbox, ['expires_at'], 'idx_ui_alert_inbox_expires_at'); + $config = $schema->createTable('config_entry'); $config->addColumn('config_key', 'string', ['length' => 160]); $config->addColumn('value', 'json'); @@ -386,6 +396,7 @@ public function down(Schema $schema): void 'config_entry', 'access_statistic_event', 'state_marker', + 'ui_alert_inbox', 'messenger_messages', ] as $table) { $schema->dropTable($this->tableName($table)); diff --git a/packages/demo-frontend-theme/assets/frontend/theme.css b/packages/demo-frontend-theme/assets/frontend/theme.css index 9d5f8309..a4d2fce5 100644 --- a/packages/demo-frontend-theme/assets/frontend/theme.css +++ b/packages/demo-frontend-theme/assets/frontend/theme.css @@ -1,3 +1,3 @@ -.demo-frontend-theme { +.demo-frontend-theme-frontend-preview { color: #1f6feb; } diff --git a/packages/demo-frontend-theme/templates/frontend/preview.html.twig b/packages/demo-frontend-theme/templates/frontend/preview.html.twig index c5295c33..aa258c5b 100644 --- a/packages/demo-frontend-theme/templates/frontend/preview.html.twig +++ b/packages/demo-frontend-theme/templates/frontend/preview.html.twig @@ -1,3 +1,3 @@ -
+
{{ 'pkg.demo-frontend-theme.preview'|trans }}
diff --git a/src/Api/Endpoint/ApiEndpointRegistry.php b/src/Api/Endpoint/ApiEndpointRegistry.php index b322e80e..f3524c79 100644 --- a/src/Api/Endpoint/ApiEndpointRegistry.php +++ b/src/Api/Endpoint/ApiEndpointRegistry.php @@ -64,16 +64,28 @@ public function endpointForRequest(Request $request): ?ApiEndpointDefinition public function endpointForPath(string $path, string $method): ?ApiEndpointDefinition { - foreach ($this->endpoints() as $endpoint) { - if (!$endpoint->matchesPath($path)) { - continue; - } + $candidates = array_values(array_filter( + $this->endpoints(), + static fn (ApiEndpointDefinition $endpoint): bool => $endpoint->matchesPath($path) + && ($endpoint->method() === $method || ('HEAD' === $method && 'GET' === $endpoint->method())), + )); + usort($candidates, static function (ApiEndpointDefinition $left, ApiEndpointDefinition $right) use ($path): int { + $leftExact = $left->path() === $path ? 1 : 0; + $rightExact = $right->path() === $path ? 1 : 0; - if ($endpoint->method() === $method || ('HEAD' === $method && 'GET' === $endpoint->method())) { - return $endpoint; - } - } + return [ + $rightExact, + strlen($right->path()), + $right->path(), + $right->operationId(), + ] <=> [ + $leftExact, + strlen($left->path()), + $left->path(), + $left->operationId(), + ]; + }); - return null; + return $candidates[0] ?? null; } } diff --git a/src/Backend/BackendActionResponder.php b/src/Backend/BackendActionResponder.php index d76ccd97..a989215c 100644 --- a/src/Backend/BackendActionResponder.php +++ b/src/Backend/BackendActionResponder.php @@ -8,9 +8,11 @@ use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; -use App\Core\Operation\OperationMessageKey; use App\Core\Workflow\WorkflowResult; use App\Form\FormTokenValidator; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\WorkflowResultAlertSelector; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -22,6 +24,8 @@ public function __construct( private AdminControllerContext $adminContext, private LiveOperationHttpResponder $liveOperationResponder, private FormTokenValidator $formTokenValidator, + private UiAlertDispatcherInterface $alerts, + private WorkflowResultAlertSelector $alertSelector, ) { } @@ -47,7 +51,7 @@ public function respond(Request $request, mixed $user): Response } $result = $validToken ? $this->backendActions->run($action) : $this->invalidCsrfResult($action); - $this->flashResult($request, $result); + $this->flashResult($result); $this->audit($user, $action, $result, 'sync'); return new RedirectResponse($request->getPathInfo()); @@ -82,16 +86,9 @@ private function audit(mixed $user, string $action, WorkflowResult $result, stri /** * @param WorkflowResult $result */ - private function flashResult(Request $request, WorkflowResult $result): void + private function flashResult(WorkflowResult $result): void { - $message = $result->isSuccess() - ? ($result->messages()[0] ?? Message::success(BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED)) - : ($result->firstIssue() ?? Message::error(CommonMessageCode::E_OPERATION_FAILED, OperationMessageKey::OPERATION_EXCEPTION)); - - $request->getSession()->getFlashBag()->add($result->isSuccess() ? 'success' : 'error', [ - 'translation_key' => $message->translationKey(), - 'parameters' => $message->parameters(), - ]); + $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } private function stringField(Request $request, string $name): string diff --git a/src/Backend/PackageAdminLinkResolver.php b/src/Backend/PackageAdminLinkResolver.php index a14d7cd2..d05802e4 100644 --- a/src/Backend/PackageAdminLinkResolver.php +++ b/src/Backend/PackageAdminLinkResolver.php @@ -38,7 +38,7 @@ public function safeExternalUrl(?string $url): ?string $url = trim($url); - if (1 === preg_match('/[\x00-\x1F\x7F]/', $url)) { + if (str_contains($url, '\\') || 1 === preg_match('/[\x00-\x1F\x7F]/', $url)) { return null; } diff --git a/src/Command/MercureCheckCommand.php b/src/Command/MercureCheckCommand.php new file mode 100644 index 00000000..ba5c3bf3 --- /dev/null +++ b/src/Command/MercureCheckCommand.php @@ -0,0 +1,44 @@ +runtime->processId(); + + $io->title('Mercure status'); + $io->definitionList( + ['Binary' => $this->runtime->binaryInstalled() ? 'available at '.$this->runtime->binaryPath() : 'not installed at '.$this->runtime->binaryPath()], + ['Hub process' => $this->runtime->isRunning() ? 'running'.(null === $pid ? '' : ' (PID '.$pid.')') : 'not running'], + ['Listen address' => $this->runtime->listenAddress()], + ['Hub endpoint' => $this->runtime->hubReachable() ? 'reachable' : 'not reachable'], + ['Publish endpoint' => $this->runtime->publishHubUrl()], + ['Publish endpoint status' => $this->runtime->publishHealthProbe() ? 'functional' : 'not functional'], + ['Public endpoint' => $this->runtime->publicHubUrl()], + ['Public endpoint status' => $this->runtime->publicSubscribeProbe() ? 'reachable' : 'not reachable'], + ); + + return Command::SUCCESS; + } +} diff --git a/src/Command/MercureHealthCommand.php b/src/Command/MercureHealthCommand.php new file mode 100644 index 00000000..526b547e --- /dev/null +++ b/src/Command/MercureHealthCommand.php @@ -0,0 +1,56 @@ +availability->refreshStatus(); + + if ($status['available']) { + $output->writeln($status['started'] + ? 'Mercure hub was started and public endpoint is available.' + : 'Mercure publish and public endpoints are available.'); + + return Command::SUCCESS; + } + + if (!$status['enabled']) { + $output->writeln('Mercure is disabled; polling fallback remains active.'); + + return Command::SUCCESS; + } + + if ($status['publish']) { + $output->writeln($status['stopped'] + ? 'Mercure publish endpoint is available, but public endpoint is not reachable; hub was stopped and polling fallback remains active.' + : 'Mercure publish endpoint is available, but public endpoint is not reachable; polling fallback remains active.'); + + return Command::FAILURE; + } + + $output->writeln($status['started'] + ? 'Mercure hub start was requested, but publish endpoint is still not available; polling fallback remains active.' + : 'Mercure publish endpoint is not available; polling fallback remains active.'); + + return Command::FAILURE; + } +} diff --git a/src/Command/MercureInstallCommand.php b/src/Command/MercureInstallCommand.php new file mode 100644 index 00000000..6a0b9c81 --- /dev/null +++ b/src/Command/MercureInstallCommand.php @@ -0,0 +1,36 @@ +binaryManager->install()) { + $output->writeln(sprintf('Mercure binary is available at %s', $this->binaryManager->binaryPath())); + + return Command::SUCCESS; + } + + $output->writeln('Mercure binary is not available for this platform or could not be installed.'); + + return Command::FAILURE; + } +} diff --git a/src/Command/MercureStartCommand.php b/src/Command/MercureStartCommand.php new file mode 100644 index 00000000..c0d9aa16 --- /dev/null +++ b/src/Command/MercureStartCommand.php @@ -0,0 +1,48 @@ +runtime->canStart()) { + $output->writeln('Mercure binary is not available; polling fallback remains active.'); + + return Command::FAILURE; + } + + $started = $this->starter->start( + $this->runtime->startCommand(), + $this->projectDir, + $this->runtime->logPath(), + $this->runtime->pidPath(), + $this->runtime->startEnvironment(), + ); + + $output->writeln($started ? 'Mercure hub start was requested.' : 'Mercure hub could not be started.'); + + return $started ? Command::SUCCESS : Command::FAILURE; + } +} diff --git a/src/Command/MercureStopCommand.php b/src/Command/MercureStopCommand.php new file mode 100644 index 00000000..1901c7ee --- /dev/null +++ b/src/Command/MercureStopCommand.php @@ -0,0 +1,36 @@ +runtime->stop()) { + $output->writeln('Mercure hub stop was requested.'); + + return Command::SUCCESS; + } + + $output->writeln('Mercure hub could not be stopped.'); + + return Command::FAILURE; + } +} diff --git a/src/Command/UiAlertInboxCleanupCommand.php b/src/Command/UiAlertInboxCleanupCommand.php new file mode 100644 index 00000000..edbdac5a --- /dev/null +++ b/src/Command/UiAlertInboxCleanupCommand.php @@ -0,0 +1,39 @@ +inbox->cleanupExpired(); + } catch (Throwable $exception) { + $output->writeln(sprintf('UI alert inbox cleanup failed: %s', $exception->getMessage())); + + return Command::FAILURE; + } + + $output->writeln(sprintf('UI alert inbox cleanup removed %d expired row(s).', $removed)); + + return Command::SUCCESS; + } +} diff --git a/src/Content/Routing/ContentRedirectResolver.php b/src/Content/Routing/ContentRedirectResolver.php index 21130a7d..831f21ed 100644 --- a/src/Content/Routing/ContentRedirectResolver.php +++ b/src/Content/Routing/ContentRedirectResolver.php @@ -96,7 +96,13 @@ private function normalizeRedirectTarget(?string $redirectRoute): ContentRedirec $redirectRoute = trim($redirectRoute); if (1 === preg_match('/^https?:\/\//i', $redirectRoute)) { - if (str_contains($redirectRoute, "\0") || str_contains($redirectRoute, '\\')) { + $host = parse_url($redirectRoute, PHP_URL_HOST); + if ( + !is_string($host) + || '' === trim($host) + || str_contains($redirectRoute, '\\') + || 1 === preg_match('/[\x00-\x1F\x7F]/', $redirectRoute) + ) { return null; } @@ -104,7 +110,7 @@ private function normalizeRedirectTarget(?string $redirectRoute): ContentRedirec } if ( - str_contains($redirectRoute, "\0") + 1 === preg_match('/[\x00-\x1F\x7F]/', $redirectRoute) || str_contains($redirectRoute, '\\') || str_contains($redirectRoute, '?') || str_contains($redirectRoute, '#') diff --git a/src/Controller/AdminAclGroupController.php b/src/Controller/AdminAclGroupController.php index be47807b..38883bf6 100644 --- a/src/Controller/AdminAclGroupController.php +++ b/src/Controller/AdminAclGroupController.php @@ -7,6 +7,8 @@ use App\Backend\AdminControllerContext; use App\Core\Access\AccessLevel; use App\Core\Id\UuidFactory; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationHttpResponder; use App\Core\Operation\Live\LiveOperationQueueFactory; use App\Core\Operation\Live\LiveOperationStarter; @@ -15,6 +17,9 @@ use App\Security\AclGroupMemberProvider; use App\Security\AdminUserAccessPolicy; use App\Security\AdminUserListViewFactory; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -36,6 +41,7 @@ public function __construct( private readonly LiveOperationStarter $liveOperationStarter, private readonly LiveOperationHttpResponder $liveOperationResponder, private readonly UuidFactory $uuidFactory, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -104,13 +110,13 @@ public function delete(Request $request, string $identifier): Response } if (!$this->isCsrfTokenValid('admin_group_delete_'.$group->identifier(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_user_group_detail', ['identifier' => $group->identifier()]); } if ($error = $this->adminUserPolicy->validateGroupDelete($this->adminContext->actor($this->getUser()), $group)) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); return $this->redirectToRoute('backend_admin_user_group_detail', ['identifier' => $group->identifier()]); } @@ -138,7 +144,7 @@ public function delete(Request $request, string $identifier): Response 'group' => $group->identifier(), 'impact' => $cleanupImpact['summary'], ]); - $this->addFlash('success', 'admin.groups.deleted'); + $this->alertKey('success', 'admin.groups.deleted'); return $this->redirectToRoute('backend_admin_user_groups'); } @@ -146,7 +152,7 @@ public function delete(Request $request, string $identifier): Response private function createGroup(Request $request): void { if (!$this->isCsrfTokenValid('admin_group_create', $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return; } @@ -155,7 +161,7 @@ private function createGroup(Request $request): void $accessLevel = AccessLevel::assert((int) $this->field($request, 'min_role')); if ($error = $this->adminUserPolicy->validateGroupCreate($this->adminContext->actor($this->getUser()), $accessLevel)) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); return; } @@ -169,16 +175,16 @@ private function createGroup(Request $request): void $this->entityManager->persist($group); $this->entityManager->flush(); $this->adminContext->audit($this->getUser(), 'acl.group_created', ['group' => $group->identifier()]); - $this->addFlash('success', 'admin.groups.created'); + $this->alertKey('success', 'admin.groups.created'); } catch (Throwable) { - $this->addFlash('error', 'admin.groups.form.invalid'); + $this->alertKey('error', 'admin.groups.form.invalid'); } } private function updateGroup(Request $request, AclGroup $group): ?Response { if (!$this->isCsrfTokenValid('admin_group_'.$group->identifier(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return null; } @@ -189,7 +195,7 @@ private function updateGroup(Request $request, AclGroup $group): ?Response ]; if ('' === $pending['name']) { - $this->addFlash('error', 'admin.groups.form.invalid'); + $this->alertKey('error', 'admin.groups.form.invalid'); return null; } @@ -197,13 +203,13 @@ private function updateGroup(Request $request, AclGroup $group): ?Response try { AccessLevel::assert($pending['min_role']); } catch (Throwable) { - $this->addFlash('error', 'admin.groups.form.invalid'); + $this->alertKey('error', 'admin.groups.form.invalid'); return null; } if ($error = $this->adminUserPolicy->validateGroupUpdate($this->adminContext->actor($this->getUser()), $group, $pending['min_role'])) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); return null; } @@ -240,9 +246,9 @@ private function updateGroup(Request $request, AclGroup $group): ?Response 'impact' => $impact['summary'], 'floor_cleanup' => $floorCleanup, ]); - $this->addFlash('success', 'admin.groups.saved'); + $this->alertKey('success', 'admin.groups.saved'); } catch (Throwable) { - $this->addFlash('error', 'admin.groups.form.invalid'); + $this->alertKey('error', 'admin.groups.form.invalid'); } return null; @@ -287,4 +293,9 @@ private function groupByIdentifier(string $identifier): ?AclGroup return $group instanceof AclGroup ? $group : null; } + + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } } diff --git a/src/Controller/AdminOperationController.php b/src/Controller/AdminOperationController.php index 0304fbfc..216386cf 100644 --- a/src/Controller/AdminOperationController.php +++ b/src/Controller/AdminOperationController.php @@ -6,9 +6,13 @@ use App\Backend\AdminControllerContext; use App\Backend\BackendArea; +use App\Core\Message\Message; use App\Core\Operation\Live\LiveOperationRunStore; use App\Core\Operation\Live\LiveOperationStarter; use App\Form\FormTokenValidator; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -23,6 +27,7 @@ public function __construct( private readonly LiveOperationRunStore $liveOperationRunStore, private readonly LiveOperationStarter $liveOperationStarter, private readonly FormTokenValidator $formTokenValidator, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -36,7 +41,7 @@ public function maintenance(Request $request): Response } if (!$this->formTokenValidator->isValid('admin-operations', $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.operations.actions.invalid_csrf'); + $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); return $this->redirect($request->getPathInfo()); } @@ -49,10 +54,7 @@ public function maintenance(Request $request): Response 'removed' => $result['removed'], 'ttl_seconds' => 3600, ]); - $this->addFlash('success', [ - 'translation_key' => 'admin.operations.actions.cleanup_completed', - 'parameters' => ['%removed%' => $result['removed']], - ]); + $this->alertKey('success', 'admin.operations.actions.cleanup_completed', ['%removed%' => $result['removed']]); return $this->redirect($request->getPathInfo()); } @@ -61,7 +63,7 @@ public function maintenance(Request $request): Response $this->audit('operations.clear_stale_lock', [ 'ttl_seconds' => 3600, ]); - $this->addFlash('success', 'admin.operations.actions.stale_lock_cleared'); + $this->alertKey('success', 'admin.operations.actions.stale_lock_cleared'); return $this->redirect($request->getPathInfo()); } @@ -75,10 +77,9 @@ public function maintenance(Request $request): Response 'pid' => $result['pid'] ?? null, 'ttl_seconds' => 3600, ]); - $this->addFlash($result['killed'] || $result['lock_cleared'] ? 'success' : 'warning', [ - 'translation_key' => 'admin.operations.actions.kill_'.$result['reason'], - 'parameters' => ['%pid%' => (string) ($result['pid'] ?? '')], - ]); + $message = 'admin.operations.actions.kill_'.$result['reason']; + $parameters = ['%pid%' => (string) ($result['pid'] ?? '')]; + $this->alertKey($result['killed'] || $result['lock_cleared'] ? 'success' : 'warning', $message, $parameters); return $this->redirect($request->getPathInfo()); } @@ -86,7 +87,7 @@ public function maintenance(Request $request): Response $this->audit('operations.noop', [ 'requested_action' => $action, ]); - $this->addFlash('warning', 'admin.operations.actions.noop'); + $this->alertKey('warning', 'admin.operations.actions.noop'); return $this->redirect($request->getPathInfo()); } @@ -126,7 +127,7 @@ public function continue(Request $request, string $operationId): Response } if (!$this->formTokenValidator->isValid('admin-operations', 'admin-operations', $this->stringField($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.operations.actions.invalid_csrf'); + $this->alertKey('warning', 'admin.operations.actions.invalid_csrf'); return $this->redirectToRoute('backend_admin_operation_detail', ['operationId' => $operationId]); } @@ -152,7 +153,7 @@ public function continue(Request $request, string $operationId): Response } foreach ($result->issues() as $issue) { - $this->addFlash('error', $issue->translationKey()); + $this->alert($issue); } return $this->redirectToRoute('backend_admin_operation_detail', ['operationId' => $operationId]); @@ -175,4 +176,17 @@ private function stringField(Request $request, string $name): string return is_string($value) ? $value : ''; } + + private function alert(Message $message): void + { + $this->alerts->addAlert($message, UiAlertDelivery::Direct); + } + + /** + * @param array $parameters + */ + private function alertKey(string $level, string $key, array $parameters = []): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key, $parameters), UiAlertDelivery::Direct); + } } diff --git a/src/Controller/AdminPackageController.php b/src/Controller/AdminPackageController.php index ef0c10e0..2846a519 100644 --- a/src/Controller/AdminPackageController.php +++ b/src/Controller/AdminPackageController.php @@ -14,10 +14,12 @@ use App\Core\Operation\Live\LiveOperationHttpResponder; use App\Core\Operation\Live\LiveOperationQueueFactory; use App\Core\Operation\Live\LiveOperationStarter; -use App\Core\Operation\OperationMessageKey; use App\Core\Package\Install\PackageZipInstaller; use App\Core\Workflow\WorkflowResult; use App\Form\FormTokenValidator; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\WorkflowResultAlertSelector; use App\View\Http\HttpErrorRenderer; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -36,6 +38,8 @@ public function __construct( private readonly BackendActionResponder $backendActionResponder, private readonly LiveOperationHttpResponder $liveOperationResponder, private readonly FormTokenValidator $formTokenValidator, + private readonly UiAlertDispatcherInterface $alerts, + private readonly WorkflowResultAlertSelector $alertSelector, ) { } @@ -165,10 +169,10 @@ public function lifecycle(Request $request, string $packageName, string $action) $formId = 'package-lifecycle-'.$action.'-'.$packageName; if (!$this->formTokenValidator->isValid($formId, $this->stringField($request, '_form_id'), $this->stringField($request, '_csrf_token'))) { - $this->addFlash('error', [ - 'translation_key' => BackendMessageKey::BACKEND_ACTION_INVALID_CSRF, - 'parameters' => [], - ]); + $this->alerts->addAlert( + Message::invalidArgument(BackendMessageKey::BACKEND_ACTION_INVALID_CSRF), + UiAlertDelivery::Direct, + ); return $this->redirect($request->getPathInfo()); } @@ -233,14 +237,7 @@ private function auditResult(string $action, WorkflowResult $result, array $cont */ private function flashResult(WorkflowResult $result): void { - $message = $result->isSuccess() - ? ($result->messages()[0] ?? Message::success(BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED)) - : ($result->firstIssue() ?? Message::error(CommonMessageCode::E_OPERATION_FAILED, OperationMessageKey::OPERATION_EXCEPTION)); - - $this->addFlash($result->isSuccess() ? 'success' : 'error', [ - 'translation_key' => $message->translationKey(), - 'parameters' => $message->parameters(), - ]); + $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } private function stringField(Request $request, string $name): string diff --git a/src/Controller/AdminSchedulerController.php b/src/Controller/AdminSchedulerController.php index c8b6eb06..90dcbf00 100644 --- a/src/Controller/AdminSchedulerController.php +++ b/src/Controller/AdminSchedulerController.php @@ -5,6 +5,8 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Entity\SchedulerTask; use App\Entity\SchedulerTaskRun; use App\Scheduler\SchedulerCron; @@ -13,6 +15,9 @@ use App\Scheduler\SchedulerTaskStatus; use App\Scheduler\SchedulerTaskType; use App\Scheduler\SchedulerTaskSynchronizer; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -30,6 +35,7 @@ public function __construct( private readonly SchedulerSettings $settings, private readonly SchedulerRunner $runner, private readonly HttpErrorRenderer $httpError, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -42,7 +48,7 @@ public function runNow(Request $request, string $identifier): Response $token = $request->request->get('_csrf_token'); if (!is_string($token) || !$this->isCsrfTokenValid('scheduler-task-run-'.$identifier, $token)) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.form.errors.invalid_csrf', 'parameters' => []]); + $this->alertKey('error', 'admin.scheduler.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_scheduler_detail', ['identifier' => $identifier]); } @@ -55,7 +61,7 @@ public function runNow(Request $request, string $identifier): Response } if (SchedulerTaskStatus::Active !== $task->status()) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.actions.run_now_inactive', 'parameters' => []]); + $this->alertKey('error', 'admin.scheduler.actions.run_now_inactive'); return $this->redirectToRoute('backend_admin_scheduler_detail', ['identifier' => $identifier]); } @@ -120,15 +126,13 @@ private function flashRunNowResult(array $result): void $status = is_string($result['status'] ?? null) ? $result['status'] : 'unknown'; if ('locked' === $status) { - $this->addFlash('warning', ['translation_key' => 'admin.scheduler.actions.run_now_locked', 'parameters' => []]); + $this->alertKey('warning', 'admin.scheduler.actions.run_now_locked'); return; } if ('completed' !== $status) { - $this->addFlash('warning', ['translation_key' => 'admin.scheduler.actions.run_now_unavailable', 'parameters' => [ - '%status%' => $status, - ]]); + $this->alertKey('warning', 'admin.scheduler.actions.run_now_unavailable', ['%status%' => $status]); return; } @@ -139,31 +143,25 @@ private function flashRunNowResult(array $result): void ))); if (in_array('failed', $taskStatuses, true)) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.actions.run_now_failed', 'parameters' => [ - '%status%' => $status, - ]]); + $this->alertKey('error', 'admin.scheduler.actions.run_now_failed', ['%status%' => $status]); return; } if ([] === $taskStatuses || in_array('skipped', $taskStatuses, true)) { - $this->addFlash('warning', ['translation_key' => 'admin.scheduler.actions.run_now_skipped', 'parameters' => [ - '%status%' => $status, - ]]); + $this->alertKey('warning', 'admin.scheduler.actions.run_now_skipped', ['%status%' => $status]); return; } - $this->addFlash('success', ['translation_key' => 'admin.scheduler.actions.run_now_started', 'parameters' => [ - '%status%' => $status, - ]]); + $this->alertKey('success', 'admin.scheduler.actions.run_now_started', ['%status%' => $status]); } private function handleUpdate(Request $request, SchedulerTask $task): void { $token = $request->request->get('_csrf_token'); if (!is_string($token) || !$this->isCsrfTokenValid('scheduler-task-'.$task->identifier(), $token)) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.form.errors.invalid_csrf', 'parameters' => []]); + $this->alertKey('error', 'admin.scheduler.form.errors.invalid_csrf'); return; } @@ -172,14 +170,14 @@ private function handleUpdate(Request $request, SchedulerTask $task): void $cronExpression = is_string($cronExpression) ? trim($cronExpression) : ''; if (!SchedulerCron::isValid($cronExpression)) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.form.errors.cron_invalid', 'parameters' => []]); + $this->alertKey('error', 'admin.scheduler.form.errors.cron_invalid'); return; } $enabled = '1' === $request->request->get('enabled'); if ($enabled && !$task->trusted() && SchedulerTaskType::ActionQueue === $task->type() && '1' !== $request->request->get('confirm_package_action_queue')) { - $this->addFlash('error', ['translation_key' => 'admin.scheduler.form.errors.package_action_queue_confirmation_required', 'parameters' => []]); + $this->alertKey('error', 'admin.scheduler.form.errors.package_action_queue_confirmation_required'); return; } @@ -191,7 +189,7 @@ private function handleUpdate(Request $request, SchedulerTask $task): void } $this->entityManager->flush(); - $this->addFlash('success', ['translation_key' => 'admin.scheduler.form.saved', 'parameters' => []]); + $this->alertKey('success', 'admin.scheduler.form.saved'); } /** @@ -216,4 +214,12 @@ private function registeredTask(string $identifier): ?SchedulerTask return null; } + + /** + * @param array $parameters + */ + private function alertKey(string $level, string $key, array $parameters = []): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key, $parameters), UiAlertDelivery::Direct); + } } diff --git a/src/Controller/AdminUserController.php b/src/Controller/AdminUserController.php index ddf76c1d..94c19f97 100644 --- a/src/Controller/AdminUserController.php +++ b/src/Controller/AdminUserController.php @@ -5,6 +5,8 @@ namespace App\Controller; use App\Backend\AdminControllerContext; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Core\State\StateMarkerRecorder; use App\Core\State\StateSubjectType; use App\Entity\AccountToken; @@ -17,6 +19,9 @@ use App\Security\DeletedUserCleanup; use App\Security\UserAccountStatus; use App\Security\UserRole; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -36,6 +41,7 @@ public function __construct( private readonly AdminUserListViewFactory $adminUserLists, private readonly StateMarkerRecorder $stateMarkers, private readonly DeletedUserCleanup $deletedUserCleanup, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -85,7 +91,7 @@ public function cleanupDeletedUsers(Request $request): Response } if (!$this->isCsrfTokenValid('admin_deleted_users_cleanup', $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_deleted_users'); } @@ -97,7 +103,7 @@ public function cleanupDeletedUsers(Request $request): Response 'cutoff' => $result['cutoff']->format(DATE_ATOM), 'user_uids' => $result['user_uids'], ]); - $this->addFlash('success', 'admin.users.deleted.cleanup_done'); + $this->alertKey('success', 'admin.users.deleted.cleanup_done'); return $this->redirectToRoute('backend_admin_deleted_users'); } @@ -172,13 +178,13 @@ public function passwordReset(Request $request, string $username): Response } if (!$this->isCsrfTokenValid('admin_user_password_reset_'.$user->username(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_user_detail', ['username' => $user->username()]); } $result = $this->passwordResetService->create($this->adminContext->actor($this->getUser()), $user); - $this->addFlash($result->successLevel(), $result->flashKey()); + $this->alertKey($result->successLevel(), $result->flashKey()); return $this->redirectToRoute('backend_admin_user_detail', ['username' => $user->username()]); } @@ -186,7 +192,7 @@ public function passwordReset(Request $request, string $username): Response private function updateUser(Request $request, UserAccount $user): void { if (!$this->isCsrfTokenValid('admin_user_'.$user->username(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return; } @@ -194,7 +200,7 @@ private function updateUser(Request $request, UserAccount $user): void $status = UserAccountStatus::tryFrom($this->field($request, 'status')); if (!$status instanceof UserAccountStatus) { - $this->addFlash('error', 'admin.users.form.errors.invalid_status'); + $this->alertKey('error', 'admin.users.form.errors.invalid_status'); return; } @@ -203,7 +209,7 @@ private function updateUser(Request $request, UserAccount $user): void $role = UserRole::tryFrom($this->field($request, 'role')); if (!$role instanceof UserRole || UserRole::Public === $role) { - $this->addFlash('error', 'admin.users.form.errors.invalid_role'); + $this->alertKey('error', 'admin.users.form.errors.invalid_role'); return; } @@ -217,7 +223,7 @@ private function updateUser(Request $request, UserAccount $user): void $newGroupIdentifiers, ); $this->adminContext->audit($this->getUser(), $result->auditAction(), $result->auditContext()); - $this->addFlash($result->flashLevel(), $result->flashKey()); + $this->alertKey($result->flashLevel(), $result->flashKey()); } private function changeDeletedUserStatus(Request $request, string $username, UserAccountStatus $status): Response @@ -233,13 +239,13 @@ private function changeDeletedUserStatus(Request $request, string $username, Use } if (!$this->isCsrfTokenValid('admin_deleted_user_status_'.$user->username(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_deleted_users'); } if (UserAccountStatus::Deleted !== $user->status()) { - $this->addFlash('error', 'admin.users.deleted.not_deleted'); + $this->alertKey('error', 'admin.users.deleted.not_deleted'); return $this->redirectToRoute('backend_admin_deleted_users'); } @@ -251,7 +257,7 @@ private function changeDeletedUserStatus(Request $request, string $username, Use $status, ); $this->adminContext->audit($this->getUser(), $result->auditAction(), $result->auditContext()); - $this->addFlash($result->flashLevel(), $result->flashKey()); + $this->alertKey($result->flashLevel(), $result->flashKey()); return $this->redirectToRoute('backend_admin_deleted_users'); } @@ -286,4 +292,9 @@ private function field(Request $request, string $name): string return is_scalar($value) ? trim((string) $value) : ''; } + + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } } diff --git a/src/Controller/AdminUserInvitationController.php b/src/Controller/AdminUserInvitationController.php index c858a6cc..559750e1 100644 --- a/src/Controller/AdminUserInvitationController.php +++ b/src/Controller/AdminUserInvitationController.php @@ -7,9 +7,14 @@ use App\Backend\BackendAccessGuard; use App\Backend\BackendArea; use App\Core\Access\AccessActor; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Entity\UserAccount; use App\Security\AdminUserInvitationWorkflow; use App\Security\UserRole; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -22,6 +27,7 @@ public function __construct( private readonly BackendAccessGuard $accessGuard, private readonly HttpErrorRenderer $httpError, private readonly AdminUserInvitationWorkflow $invitationWorkflow, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -33,7 +39,7 @@ public function invite(Request $request): Response } if (!$this->isCsrfTokenValid('admin_user_invite', $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectAfterTokenAction($request); } @@ -41,7 +47,7 @@ public function invite(Request $request): Response $groups = $this->groupIdentifiers($request->request->all('groups')); $role = UserRole::tryFrom($this->field($request, 'role')); $result = $this->invitationWorkflow->invite($this->actor(), $this->field($request, 'email'), $role, $groups); - $this->addFlash($result->successLevel(), $result->flashKey()); + $this->alertKey($result->successLevel(), $result->flashKey()); return $this->redirectToRoute('backend_admin_users'); } @@ -54,13 +60,13 @@ public function approve(Request $request, string $uid): Response } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectAfterTokenAction($request); } $result = $this->invitationWorkflow->approve($this->actor(), $uid); - $this->addFlash($result->successLevel(), $result->flashKey()); + $this->alertKey($result->successLevel(), $result->flashKey()); return $this->redirectAfterTokenAction($request); } @@ -73,13 +79,13 @@ public function reissue(Request $request, string $uid): Response } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectAfterTokenAction($request); } $result = $this->invitationWorkflow->reissue($this->actor(), $uid); - $this->addFlash($result->successLevel(), $result->flashKey()); + $this->alertKey($result->successLevel(), $result->flashKey()); return $this->redirectAfterTokenAction($request); } @@ -92,13 +98,13 @@ public function revoke(Request $request, string $uid): Response } if (!$this->isCsrfTokenValid('admin_user_token_'.$uid, $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectAfterTokenAction($request); } $result = $this->invitationWorkflow->revoke($this->actor(), $uid); - $this->addFlash($result->successLevel(), $result->flashKey()); + $this->alertKey($result->successLevel(), $result->flashKey()); return $this->redirectAfterTokenAction($request); } @@ -155,4 +161,9 @@ private function field(Request $request, string $name): string return is_scalar($value) ? trim((string) $value) : ''; } + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } + } diff --git a/src/Controller/AdminUserReviewController.php b/src/Controller/AdminUserReviewController.php index 6556a9a2..27151746 100644 --- a/src/Controller/AdminUserReviewController.php +++ b/src/Controller/AdminUserReviewController.php @@ -8,6 +8,8 @@ use App\Backend\BackendArea; use App\Core\Access\AccessActor; use App\Core\Log\AuditLoggerInterface; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Core\State\StateMarkerKey; use App\Core\State\StateMarkerRecorder; use App\Core\State\StateSubjectType; @@ -23,6 +25,9 @@ use App\Security\AdminUserReviewViewFactory; use App\Security\UserAccountLifecycle; use App\Security\UserAccountStatus; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -47,6 +52,7 @@ public function __construct( private readonly AuditLoggerInterface $auditLogger, private readonly UserPasswordHasherInterface $passwordHasher, private readonly StateMarkerRecorder $stateMarkers, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -82,19 +88,19 @@ public function reactivate(Request $request, string $username): Response } if (!$this->isCsrfTokenValid('admin_user_review_'.$user->username(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_user_reviews'); } if (!$this->hasUnresolvedSecurityReview($user)) { - $this->addFlash('error', 'admin.users.invitation.unavailable'); + $this->alertKey('error', 'admin.users.invitation.unavailable'); return $this->redirectToRoute('backend_admin_user_reviews'); } if ($error = $this->adminUserPolicy->validateUserAction($this->actor(), $user)) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); return $this->redirectToRoute('backend_admin_user_reviews'); } @@ -109,7 +115,7 @@ public function reactivate(Request $request, string $username): Response 'user_uid' => $user->uid(), ]); $this->audit('user.security_review_reactivated', ['target_user' => $user->uid()]); - $this->addFlash('success', 'admin.user_reviews.actions.reactivated'); + $this->alertKey('success', 'admin.user_reviews.actions.reactivated'); return $this->redirectToRoute('backend_admin_user_reviews'); } @@ -128,31 +134,31 @@ public function delete(Request $request, string $username): Response } if (!$this->isCsrfTokenValid('admin_user_review_'.$user->username(), $this->field($request, '_csrf_token'))) { - $this->addFlash('error', 'admin.users.form.errors.invalid_csrf'); + $this->alertKey('error', 'admin.users.form.errors.invalid_csrf'); return $this->redirectToRoute('backend_admin_user_reviews'); } if ('1' !== $this->field($request, 'confirm_delete')) { - $this->addFlash('error', 'admin.user_reviews.actions.delete_confirmation_required'); + $this->alertKey('error', 'admin.user_reviews.actions.delete_confirmation_required'); return $this->redirectToRoute('backend_admin_user_reviews'); } if (!$this->hasUnresolvedSecurityReview($user)) { - $this->addFlash('error', 'admin.users.invitation.unavailable'); + $this->alertKey('error', 'admin.users.invitation.unavailable'); return $this->redirectToRoute('backend_admin_user_reviews'); } if ($error = $this->adminUserPolicy->validateUserAction($this->actor(), $user)) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); return $this->redirectToRoute('backend_admin_user_reviews'); } if (!$this->adminUserPolicy->allowsAccountClosure($user)) { - $this->addFlash('error', 'admin.users.form.errors.last_owner'); + $this->alertKey('error', 'admin.users.form.errors.last_owner'); return $this->redirectToRoute('backend_admin_user_reviews'); } @@ -161,7 +167,7 @@ public function delete(Request $request, string $username): Response $this->deleteUsedSecurityReviewTokens($user); $this->entityManager->flush(); $this->audit('user.security_review_deleted', ['target_user' => $user->uid(), ...$effects]); - $this->addFlash('success', 'admin.user_reviews.actions.deleted'); + $this->alertKey('success', 'admin.user_reviews.actions.deleted'); return $this->redirectToRoute('backend_admin_user_reviews'); } @@ -259,4 +265,9 @@ private function userByUsername(string $username): ?UserAccount return $user instanceof UserAccount ? $user : null; } + + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } } diff --git a/src/Controller/BackendController.php b/src/Controller/BackendController.php index 2d80224b..9607b37a 100644 --- a/src/Controller/BackendController.php +++ b/src/Controller/BackendController.php @@ -11,6 +11,7 @@ use App\Backend\BackendRouteResolver; use App\Backend\BackendViewDefinition; use App\Core\Access\AccessActor; +use App\Core\Message\Message; use App\Core\Config\Settings\CoreSettingsFormHandler; use App\Core\Log\AuditLoggerInterface; use App\Core\Log\LogFileBrowser; @@ -20,6 +21,9 @@ use App\Form\FormSubmissionResult; use App\Form\FormTokenValidator; use App\Navigation\NavigationBuilder; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -41,6 +45,7 @@ public function __construct( private readonly LogFileBrowser $logFileBrowser, private readonly AuditLoggerInterface $auditLogger, private readonly FormTokenValidator $formTokenValidator, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -247,7 +252,7 @@ private function handleAdminPost(Request $request, BackendViewDefinition $view): ]); } - $this->addFlash('success', 'admin.settings.form.saved'); + $this->alerts->addAlert(UiAlertTranslation::success('admin.settings.form.saved'), UiAlertDelivery::Direct); return $this->redirect($request->getPathInfo()); } diff --git a/src/Controller/CookieConsentController.php b/src/Controller/CookieConsentController.php new file mode 100644 index 00000000..1e4c0751 --- /dev/null +++ b/src/Controller/CookieConsentController.php @@ -0,0 +1,59 @@ +consent->validCsrfToken($request, (string) $request->request->get('_csrf_token', ''))) { + return $this->redirectBack($request); + } + + $accepted = []; + if ('reject_optional' !== (string) $request->request->get('_cookie_consent_action', 'save_selection')) { + $accepted = $request->request->all('cookies'); + $accepted = is_array($accepted) + ? array_values(array_filter(array_map('strval', $accepted), 'strlen')) + : []; + } + + $response = $this->redirectBack($request); + $this->consent->attachConsentCookie($request, $response, $accepted); + + return $response; + } + + private function redirectBack(Request $request): RedirectResponse + { + $target = (string) $request->request->get('_cookie_consent_target_path', ''); + if (!$this->isSafeLocalTarget($target)) { + $target = '/'; + } + + return new RedirectResponse($target); + } + + private function isSafeLocalTarget(string $target): bool + { + return '' !== $target + && str_starts_with($target, '/') + && !str_starts_with($target, '//') + && !str_contains($target, '\\') + && 1 !== preg_match('/[\x00-\x1F\x7F]/', $target); + } +} diff --git a/src/Controller/LiveAlertController.php b/src/Controller/LiveAlertController.php new file mode 100644 index 00000000..577ffe70 --- /dev/null +++ b/src/Controller/LiveAlertController.php @@ -0,0 +1,65 @@ +setupCompletion->isComplete($this->projectDir, $this->environment)) { + return $this->json->render([ + 'cursor' => $this->cursor($request), + 'alerts' => [], + 'next_poll_ms' => self::POLL_INTERVAL_MS, + ]); + } + + $cursor = $this->cursor($request); + $user = $this->getUser(); + $topics = $this->topicFactory->topicsFor( + $request, + $user instanceof UserAccount ? $user : null, + ); + $payload = $this->inbox->poll($topics, $cursor); + + return $this->json->render([ + 'cursor' => $payload['cursor'], + 'alerts' => $payload['alerts'], + 'has_more' => $payload['has_more'], + 'next_poll_ms' => self::POLL_INTERVAL_MS, + ]); + } + + private function cursor(Request $request): int + { + $cursorValue = $request->query->get('cursor', 0); + return is_scalar($cursorValue) && false !== filter_var((string) $cursorValue, FILTER_VALIDATE_INT) + ? max(0, (int) $cursorValue) + : 0; + } +} diff --git a/src/Controller/LiveEndpointController.php b/src/Controller/LiveEndpointController.php new file mode 100644 index 00000000..655ed03b --- /dev/null +++ b/src/Controller/LiveEndpointController.php @@ -0,0 +1,83 @@ + '[a-z0-9]+(?:-[a-z0-9]+)*', 'resourcePath' => '.+'], methods: ['GET'], priority: -100)] + public function dispatch(Request $request): Response + { + $endpoint = $this->endpoints->endpointForRequest($request); + if (null === $endpoint) { + return $this->json->render([ + 'status' => 'not_found', + 'message' => $this->translator->trans('ui.live_endpoint.not_found'), + 'next_poll_ms' => 0, + ], Response::HTTP_NOT_FOUND); + } + + if (!$this->endpointMatchesRouteSlug($request, $endpoint->path())) { + return $this->json->render([ + 'status' => 'not_found', + 'message' => $this->translator->trans('ui.live_endpoint.not_found'), + 'next_poll_ms' => 0, + ], Response::HTTP_NOT_FOUND); + } + + $minimumAccessLevel = $endpoint->minimumAccessLevel() ?? AccessLevel::PUBLIC; + $user = $this->security->getUser(); + $actor = $user instanceof UserAccount ? AccessActor::fromUserAccount($user) : AccessActor::anonymous(); + + if ($actor->accessLevel() < $minimumAccessLevel) { + return $this->json->render([ + 'status' => 'forbidden', + 'message' => $this->translator->trans('ui.live_endpoint.forbidden'), + 'next_poll_ms' => 0, + ], Response::HTTP_FORBIDDEN); + } + + $handler = $this->handlers->handler($endpoint->handlerKey()); + if (null === $handler) { + return $this->json->render([ + 'status' => 'unavailable', + 'message' => $this->translator->trans('ui.live_endpoint.handler_unavailable'), + 'next_poll_ms' => 0, + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $handler->handleLiveRequest($request, $endpoint); + } + + private function endpointMatchesRouteSlug(Request $request, string $endpointPath): bool + { + $slug = (string) $request->attributes->get('packageSlug', ''); + if ('' === $slug && 1 === preg_match('#^/api/live/([a-z0-9]+(?:-[a-z0-9]+)*)/#', $request->getPathInfo(), $matches)) { + $slug = $matches[1]; + } + + return '' !== $slug && str_starts_with($endpointPath, '/api/live/'.$slug.'/'); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index fa8cb691..22620b76 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -46,6 +46,15 @@ private function returnTo(Request $request): ?string { $returnTo = $request->query->get('return_to'); - return is_string($returnTo) && str_starts_with($returnTo, '/') && !str_starts_with($returnTo, '//') ? $returnTo : null; + return is_string($returnTo) && $this->isSafeLocalTarget($returnTo) ? $returnTo : null; + } + + private function isSafeLocalTarget(string $target): bool + { + return '' !== $target + && str_starts_with($target, '/') + && !str_starts_with($target, '//') + && !str_contains($target, '\\') + && 1 !== preg_match('/[\x00-\x1F\x7F]/', $target); } } diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php index d4e72644..199e85a1 100644 --- a/src/Controller/SetupController.php +++ b/src/Controller/SetupController.php @@ -152,6 +152,7 @@ public function __invoke(Request $request, string $step = 'language'): Response 'setup_previous_step' => $this->wizardFlow->previousStep($step), 'setup_next_step' => $this->wizardFlow->nextStep($step), 'setup_app_name' => $this->systemPackageMetadata->metadata()['name'], + 'setup_min_app_secret_length' => SetupWebInputFactory::MIN_APP_SECRET_LENGTH, ]); } diff --git a/src/Controller/UserApiKeyController.php b/src/Controller/UserApiKeyController.php index 1c88b210..77262453 100644 --- a/src/Controller/UserApiKeyController.php +++ b/src/Controller/UserApiKeyController.php @@ -8,10 +8,15 @@ use App\Core\Access\AccessActor; use App\Core\Id\UuidFactory; use App\Core\Log\AuditLoggerInterface; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Entity\ApiKey; use App\Entity\UserAccount; use App\Security\ApiKeyStatus; use App\Security\ApiKeyVault; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use App\View\Http\HttpErrorRenderer; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -31,6 +36,7 @@ public function __construct( private readonly ApiKeyVault $apiKeyVault, private readonly UuidFactory $uuidFactory, private readonly ApiFeaturePolicy $apiFeaturePolicy, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -51,7 +57,7 @@ public function index(Request $request): Response if ($request->isMethod('POST')) { if (!$this->isCsrfTokenValid('user_api_key_create', $this->stringField($request, '_csrf_token'))) { - $this->addFlash('error', 'ui.user.api_keys.errors.invalid_csrf'); + $this->alertKey('error', 'ui.user.api_keys.errors.invalid_csrf'); } else { $prefix = $this->stringField($request, 'prefix'); $plainKey = $this->apiKeyVault->generatePlainKey($prefix); @@ -64,7 +70,7 @@ public function index(Request $request): Response $this->audit($user, 'api_key.created', ['api_key_uid' => $apiKey->uid(), 'prefix' => $apiKey->prefix(), 'status' => $status->value]); $newPlainKey = $plainKey; } catch (Throwable) { - $this->addFlash('error', 'ui.user.api_keys.errors.create_failed'); + $this->alertKey('error', 'ui.user.api_keys.errors.create_failed'); } } } @@ -185,4 +191,9 @@ private function audit(UserAccount $user, string $action, array $context): void } } + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } + } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index dedd5292..f682b986 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -7,6 +7,8 @@ use App\Api\ApiFeaturePolicy; use App\Core\Access\AccessActor; use App\Core\Log\AuditLoggerInterface; +use App\Core\Message\CommonMessageCode; +use App\Core\Message\Message; use App\Core\Message\MessageException; use App\Core\State\StateMarkerKey; use App\Core\State\StateMarkerRecorder; @@ -21,6 +23,9 @@ use App\Security\UserFlowConfig; use App\Security\UserPasswordChangeService; use App\View\Http\HttpErrorRenderer; +use App\View\Alert\UiAlertDelivery; +use App\View\Alert\UiAlertDispatcherInterface; +use App\View\Alert\UiAlertTranslation; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -43,6 +48,7 @@ public function __construct( private readonly UserAccountClosureService $accountClosureService, private readonly UserProfileLocaleService $profileLocales, private readonly ApiFeaturePolicy $apiFeaturePolicy, + private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -125,19 +131,19 @@ public function profile(Request $request): Response } if ([] === $errors) { + $settings = $user->settings(); + $settings['language'] = $language; + $user->updateProfile([ 'display_name' => $this->stringField($request, 'display_name'), ]); - $user->updateSettings([ - ...$user->settings(), - 'language' => $language, - ]); + $user->updateSettings($settings); try { $this->stateMarkers->record(StateSubjectType::USER_ACCOUNT, $user->uid(), StateMarkerKey::MODIFIED, $user->username(), 'profile'); $this->entityManager->flush(); $this->audit($user, 'user.profile_updated', ['result_status' => 'success']); $this->profileLocales->apply($request, $user); - $this->addFlash('success', 'ui.user.profile.success'); + $this->alertKey('success', 'ui.user.profile.success'); return $this->redirectToRoute('user_profile'); } catch (MessageException $exception) { @@ -190,7 +196,7 @@ public function closeProfile(Request $request): Response if ([] !== $errors) { foreach ($errors as $error) { - $this->addFlash('error', $error); + $this->alertKey('error', $error); } $this->audit($user, 'user.account_close_failed', [ @@ -258,6 +264,11 @@ private function stringField(Request $request, string $name): string return is_string($value) ? $value : ''; } + private function alertKey(string $level, string $key): void + { + $this->alerts->addAlert(UiAlertTranslation::forLevel($level, $key), UiAlertDelivery::Direct); + } + private function userByUsername(string $username): ?UserAccount { $user = $this->entityManager->getRepository(UserAccount::class)->findOneBy(['username' => $username]); diff --git a/src/Core/Asset/AssetRebuildQueueFactory.php b/src/Core/Asset/AssetRebuildQueueFactory.php index 36d4aa25..6456edad 100644 --- a/src/Core/Asset/AssetRebuildQueueFactory.php +++ b/src/Core/Asset/AssetRebuildQueueFactory.php @@ -37,6 +37,7 @@ public function create(string $environment, array $packages, string $trigger = ' new TranslationAggregateAction($this->translationCatalogueAggregator, $packages), $this->consoleCommand('assets:install', $environment, $persistPhpBinaryPreference), $this->consoleCommand('importmap:install', $environment, $persistPhpBinaryPreference), + $this->consoleCommand('ux:translator:warm-cache', $environment, $persistPhpBinaryPreference), $this->consoleCommand('ux:icons:lock', $environment, $persistPhpBinaryPreference, failOnError: false), $this->consoleCommand('tailwind:build', $environment, $persistPhpBinaryPreference, timeout: 300.0), ]; diff --git a/src/Core/Lint/CssLinter.php b/src/Core/Lint/CssLinter.php index 70270c61..3555a8aa 100644 --- a/src/Core/Lint/CssLinter.php +++ b/src/Core/Lint/CssLinter.php @@ -12,8 +12,100 @@ final class CssLinter implements LinterInterface { + public static function hasStrictParserUnsupportedContext(string $contents, ?int $line): bool + { + if (null === $line) { + return false; + } + + return self::isStrictParserUnsupportedLine($contents, $line - 1) + || self::isStrictParserUnsupportedLine($contents, $line) + || self::isStrictParserUnsupportedLine($contents, $line + 1); + } + + public static function isStrictParserUnsupportedLine(string $contents, ?int $line): bool + { + if (null === $line) { + return false; + } + + $lines = preg_split('/\R/', $contents) ?: []; + $text = trim((string) ($lines[$line - 1] ?? '')); + + return self::isTailwindDirectiveText($text) + || self::isUnsupportedGroupAtRuleText($text) + || self::containsEmptyCustomPropertyFallback($text); + } + + public static function forStrictParser(string $contents): string + { + $lines = preg_split('/\R/', $contents) ?: []; + $depth = 0; + $unwrapGroupClosingDepths = []; + + foreach ($lines as $index => $line) { + $text = trim((string) $line); + + if (self::isTailwindDirectiveText($text)) { + $lines[$index] = '/* Tailwind directive omitted for strict CSS parsing. */'; + $depth += self::braceDelta((string) $line); + + continue; + } + + if (self::isUnsupportedGroupAtRuleText($text)) { + $unwrapGroupClosingDepths[] = $depth; + $lines[$index] = '/* CSS group at-rule omitted for strict CSS parsing. */'; + $depth += self::braceDelta((string) $line); + + continue; + } + + $delta = self::braceDelta((string) $line); + if ('}' === $text && [] !== $unwrapGroupClosingDepths && $depth - 1 === end($unwrapGroupClosingDepths)) { + array_pop($unwrapGroupClosingDepths); + $lines[$index] = '/* CSS group at-rule closing brace omitted for strict CSS parsing. */'; + } + + $depth += $delta; + } + + return preg_replace('/var\((--[A-Za-z0-9_-]+),\)/', 'var($1, initial)', implode("\n", $lines)) + ?? implode("\n", $lines); + } + + private static function isTailwindDirectiveText(string $text): bool + { + return str_starts_with($text, '@apply ') + || str_starts_with($text, '@theme ') + || str_starts_with($text, '@custom-variant ') + || str_starts_with($text, '@source '); + } + + private static function isUnsupportedGroupAtRuleText(string $text): bool + { + return (str_starts_with($text, '@supports ') + || str_starts_with($text, '@container ') + || str_starts_with($text, '@media ')) + && str_contains($text, '{'); + } + + private static function containsEmptyCustomPropertyFallback(string $text): bool + { + return 1 === preg_match('/var\(--[A-Za-z0-9_-]+,\)/', $text); + } + + private static function braceDelta(string $line): int + { + return substr_count($line, '{') - substr_count($line, '}'); + } + public function lint(string $contents, ?string $path = null): LintResult { + if (self::isEffectivelyEmpty($contents)) { + return LintResult::success(); + } + try { (new Parser($contents, Settings::create()->beStrict()))->parse(); } catch (SourceException $error) { @@ -30,4 +122,11 @@ public function lint(string $contents, ?string $path = null): LintResult return LintResult::success(); } + + private static function isEffectivelyEmpty(string $contents): bool + { + $withoutComments = preg_replace('/\/\*.*?\*\//s', '', $contents); + + return '' === trim((string) $withoutComments); + } } diff --git a/src/Core/Log/LogLineReader.php b/src/Core/Log/LogLineReader.php index 86dd84a3..5d609753 100644 --- a/src/Core/Log/LogLineReader.php +++ b/src/Core/Log/LogLineReader.php @@ -4,32 +4,62 @@ namespace App\Core\Log; -use SplFileObject; - final readonly class LogLineReader { private const MAX_SCAN_LINES = 5000; + private const READ_CHUNK_BYTES = 65536; /** * @return list */ public function readLines(string $file): array { - $object = new SplFileObject($file, 'r'); - $object->seek(PHP_INT_MAX); - $lastLine = $object->key(); - $start = max(0, $lastLine - self::MAX_SCAN_LINES); + $handle = fopen($file, 'rb'); + if (false === $handle) { + return []; + } + + $position = filesize($file); + if (false === $position || $position <= 0) { + fclose($handle); + + return []; + } + $lines = []; + $partialLine = ''; - for ($lineNumber = $lastLine; $lineNumber >= $start; --$lineNumber) { - $object->seek($lineNumber); - $line = trim((string) $object->current()); + while ($position > 0 && count($lines) < self::MAX_SCAN_LINES) { + $chunkSize = min(self::READ_CHUNK_BYTES, $position); + $position -= $chunkSize; + + if (0 !== fseek($handle, $position)) { + break; + } - if ('' !== $line) { - $lines[] = $line; + $chunk = fread($handle, $chunkSize); + if (false === $chunk) { + break; + } + + $parts = preg_split('/\r\n|\n|\r/', $chunk.$partialLine); + if (!is_array($parts)) { + break; + } + + $partialLine = $position > 0 ? (string) array_shift($parts) : ''; + + for ($index = count($parts) - 1; $index >= 0 && count($lines) < self::MAX_SCAN_LINES; --$index) { + $line = trim((string) $parts[$index]); + + if ('' !== $line) { + $lines[] = $line; + } } } + fclose($handle); + return $lines; } } diff --git a/src/Core/Mercure/MercureBinaryManager.php b/src/Core/Mercure/MercureBinaryManager.php new file mode 100644 index 00000000..c560c752 --- /dev/null +++ b/src/Core/Mercure/MercureBinaryManager.php @@ -0,0 +1,241 @@ + [ + 'mercure_Darwin_arm64.tar.gz' => '69a22d63c30fb6820395eb0abdd0b2c48fa8bc4a6e799f6c7efc05d86a8e9b35', + 'mercure_Darwin_x86_64.tar.gz' => 'f726f9edbd721452ab3797546141934a760a3f70b376bb05484fb2540fc23381', + 'mercure_Linux_arm64.tar.gz' => '3917e836f94f5a0ba94effd7e4aabd73dfda4c6c52fbf379592331af8dcf33ab', + 'mercure_Linux_armv5.tar.gz' => '697b8d659493728f3b6c8702aa544eeb509a440d39ce225d1f921774ab6d25e9', + 'mercure_Linux_armv6.tar.gz' => '20a097869d9f5070491f67ca00a81030d6aa6632fb37069218032ca83fa35ef8', + 'mercure_Linux_armv7.tar.gz' => '617d8c7fc7fd934e385463c9413cf1fc61b6f5e8218d0c09855e797eeb356233', + 'mercure_Linux_i386.tar.gz' => 'e5254c1c03f1e180dbe12958336ebdc1efb1456ff9d3b6ec38309f6016aebf47', + 'mercure_Linux_x86_64.tar.gz' => '0447e2db7f7819692c72544f19371a93c4162a50d9fae849b3c99df50e212fd0', + 'mercure_Windows_arm64.zip' => 'b38ec4b39cb464d6578ec6c0639b6ab4ed1c8cf57fb722e6a5d9a8f2251e4bc8', + 'mercure_Windows_i386.zip' => '50ac0165b0c714c56d9bb434c739a088cb4c640c79ba2d2eea45507933e2449c', + 'mercure_Windows_x86_64.zip' => '6cf9330d079778cf6f118de68a55dca477685b4bbd23d560ab06b9fb7a547158', + ], + ]; + + public function __construct( + private string $projectDir, + private string $version = self::DEFAULT_VERSION, + private ?HttpClientInterface $httpClient = null, + ) { + } + + public function binaryPath(): string + { + return $this->installDir().DIRECTORY_SEPARATOR.$this->binaryName(); + } + + public function caddyfilePath(): string + { + return $this->installDir().DIRECTORY_SEPARATOR.'Caddyfile'; + } + + public function isInstalled(): bool + { + $path = $this->binaryPath(); + + return is_file($path) + && is_executable($path) + && is_file($this->assetMarkerPath()) + && trim((string) @file_get_contents($this->assetMarkerPath())) === $this->assetName(); + } + + public function install(): bool + { + if ($this->isInstalled()) { + return true; + } + + $asset = $this->assetName(); + $checksum = null === $asset ? null : $this->assetChecksum($asset); + if (null === $asset || null === $checksum) { + return false; + } + + $archivePath = $this->cacheDir().DIRECTORY_SEPARATOR.$asset; + + try { + $this->ensureDirectory($this->cacheDir()); + $this->ensureDirectory($this->installDir()); + + if (!is_file($archivePath) || !$this->archiveChecksumMatches($archivePath, $checksum)) { + @unlink($archivePath); + $response = $this->httpClient()->request('GET', $this->downloadUrl($asset)); + file_put_contents($archivePath, $response->getContent(), LOCK_EX); + } + + if (!$this->archiveChecksumMatches($archivePath, $checksum)) { + @unlink($archivePath); + + return false; + } + + $this->extract($archivePath); + $binary = $this->binaryPath(); + if (!is_file($binary)) { + return false; + } + + @chmod($binary, 0755); + $this->releaseMacQuarantine($binary); + file_put_contents($this->assetMarkerPath(), (string) $asset, LOCK_EX); + + return $this->isInstalled(); + } catch (Throwable) { + return false; + } + } + + private function installDir(): string + { + return $this->projectDir + .DIRECTORY_SEPARATOR.'var' + .DIRECTORY_SEPARATOR.'mercure' + .DIRECTORY_SEPARATOR.$this->safeVersion(); + } + + private function cacheDir(): string + { + return $this->projectDir + .DIRECTORY_SEPARATOR.'var' + .DIRECTORY_SEPARATOR.'mercure' + .DIRECTORY_SEPARATOR.'cache'; + } + + private function binaryName(): string + { + return '\\' === DIRECTORY_SEPARATOR ? 'mercure.exe' : 'mercure'; + } + + private function assetName(): ?string + { + return self::assetNameFor(PHP_OS_FAMILY, php_uname('m')); + } + + private static function assetNameFor(string $osFamily, string $machine): ?string + { + $os = match ($osFamily) { + 'Darwin' => 'Darwin', + 'Linux' => 'Linux', + 'Windows' => 'Windows', + default => null, + }; + $arch = match (strtolower($machine)) { + 'x86_64', 'amd64' => 'x86_64', + 'aarch64', 'arm64' => 'arm64', + 'armv5', 'armv5l' => 'armv5', + 'armv6', 'armv6l' => 'armv6', + 'armv7', 'armv7l' => 'armv7', + 'i386', 'i686' => 'i386', + default => null, + }; + + if (null === $os || null === $arch) { + return null; + } + + $extension = 'Windows' === $os ? 'zip' : 'tar.gz'; + + return sprintf('mercure_%s_%s.%s', $os, $arch, $extension); + } + + private function assetMarkerPath(): string + { + return $this->installDir().DIRECTORY_SEPARATOR.'.asset-name'; + } + + private function downloadUrl(string $asset): string + { + return sprintf('https://github.com/dunglas/mercure/releases/download/v%s/%s', $this->safeVersion(), $asset); + } + + private function assetChecksum(string $asset): ?string + { + return self::SHA256_BY_VERSION_AND_ASSET[$this->safeVersion()][$asset] ?? null; + } + + private function archiveChecksumMatches(string $archivePath, string $expected): bool + { + return is_file($archivePath) && hash_equals($expected, hash_file('sha256', $archivePath) ?: ''); + } + + private function httpClient(): HttpClientInterface + { + return $this->httpClient ?? HttpClient::create(['timeout' => 30]); + } + + private function safeVersion(): string + { + $version = trim($this->version); + + return 1 === preg_match('/^\d+\.\d+\.\d+(?:[-.][A-Za-z0-9]+)?$/', $version) + ? $version + : self::DEFAULT_VERSION; + } + + private function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException(sprintf('Directory "%s" could not be created.', $directory)); + } + } + + private function extract(string $archivePath): void + { + if (str_ends_with($archivePath, '.zip')) { + if (!class_exists(ZipArchive::class)) { + return; + } + + $zip = new ZipArchive(); + if (true !== $zip->open($archivePath)) { + return; + } + + $zip->extractTo($this->installDir()); + $zip->close(); + + return; + } + + $process = new Process(['tar', '-xzf', $archivePath, '-C', $this->installDir()]); + $process->setTimeout(30); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException('Mercure archive could not be extracted.'); + } + } + + private function releaseMacQuarantine(string $binary): void + { + if ('Darwin' !== PHP_OS_FAMILY) { + return; + } + + try { + $process = new Process(['xattr', '-d', 'com.apple.quarantine', $binary]); + $process->setTimeout(5); + $process->run(); + } catch (Throwable) { + return; + } + } +} diff --git a/src/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php new file mode 100644 index 00000000..62f58bd9 --- /dev/null +++ b/src/Core/Mercure/MercureRuntime.php @@ -0,0 +1,590 @@ + + */ + public function startCommand(): array + { + return [ + $this->binaryManager->binaryPath(), + 'run', + '--envfile', + $this->envFilePath(), + '--config', + $this->binaryManager->caddyfilePath(), + '--adapter', + 'caddyfile', + ]; + } + + /** + * @return array + */ + public function startEnvironment(): array + { + return [ + 'MERCURE_EXTRA_DIRECTIVES' => implode("\n", [ + 'anonymous', + 'cors_origins *', + 'transport bolt {', + ' path '.$this->caddyfileString($this->transportPath()), + ' size 1000', + ' cleanup_frequency 0.3', + '}', + ]), + 'HOME' => $this->mercureCachePath(), + 'XDG_CONFIG_HOME' => $this->mercureCachePath(), + 'XDG_DATA_HOME' => $this->mercureCachePath(), + ]; + } + + public function logPath(): string + { + return $this->projectDir.'/var/log/mercure.log'; + } + + public function pidPath(): string + { + return $this->projectDir.'/var/mercure/mercure.pid'; + } + + public function binaryPath(): string + { + return $this->binaryManager->binaryPath(); + } + + public function binaryInstalled(): bool + { + return $this->binaryManager->isInstalled(); + } + + public function processId(): ?int + { + $pid = $this->pid(); + + return null !== $pid && $this->pidBelongsToBinary($pid) ? $pid : null; + } + + public function isRunning(): bool + { + $pid = $this->pid(); + + if (null !== $pid && $this->pidBelongsToBinary($pid)) { + return true; + } + + return [] !== $this->binaryProcessIds(); + } + + public function hubReachable(): bool + { + $url = $this->localHubUrl(); + + return $this->hubEndpointProbe($url) || $this->publishDirectly($url); + } + + public function canStart(): bool + { + return $this->binaryManager->isInstalled() || $this->binaryManager->install(); + } + + public function stop(): bool + { + $pid = $this->pid(); + if (null === $pid) { + if ($this->terminateBinaryProcesses()) { + $this->removePidFile(); + + return $this->waitUntilNoBinaryProcesses(); + } + + $this->removePidFile(); + + return [] === $this->binaryProcessIds(); + } + + if (!$this->pidBelongsToBinary($pid)) { + if ($this->terminateBinaryProcesses()) { + $this->removePidFile(); + + return $this->waitUntilNoBinaryProcesses(); + } + + $this->removePidFile(); + + return [] === $this->binaryProcessIds(); + } + + if (!$this->terminate($pid)) { + if ($this->terminateBinaryProcesses()) { + $this->removePidFile(); + + return $this->waitUntilNoBinaryProcesses(); + } + + return false; + } + + if (!$this->waitUntilProcessStopped($pid)) { + return false; + } + + if ([] !== $this->binaryProcessIds()) { + if (!$this->terminateBinaryProcesses()) { + return false; + } + + if (!$this->waitUntilNoBinaryProcesses()) { + return false; + } + } + + $this->removePidFile(); + + return true; + } + + public function publishHealthProbe(): bool + { + return $this->publishDirectly($this->publishHubUrl()); + } + + public function publicSubscribeProbe(): bool + { + $url = $this->publicHubUrl(); + if ('' === $url) { + return false; + } + + return $this->subscriberEndpointProbe($this->urlWithTopic($url)); + } + + public function listenAddress(): string + { + $listen = trim((string) ($_SERVER['MERCURE_HUB_LISTEN'] ?? $_ENV['MERCURE_HUB_LISTEN'] ?? getenv('MERCURE_HUB_LISTEN') ?: '')); + if ('' !== $listen) { + return $listen; + } + + $url = (string) ($_SERVER['MERCURE_URL'] ?? $_ENV['MERCURE_URL'] ?? getenv('MERCURE_URL') ?: ''); + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + + if (is_string($host) && '' !== $host && is_int($port)) { + $defaultHost = parse_url($this->defaultUri, PHP_URL_HOST); + $defaultPort = parse_url($this->defaultUri, PHP_URL_PORT) ?? ('https' === parse_url($this->defaultUri, PHP_URL_SCHEME) ? 443 : 80); + if ($host === $defaultHost && $port === $defaultPort) { + return self::DEFAULT_LISTEN_ADDRESS; + } + + return $host.':'.$port; + } + + return self::DEFAULT_LISTEN_ADDRESS; + } + + public function localHubUrl(): string + { + $listen = $this->listenAddress(); + if (str_starts_with($listen, ':')) { + return 'http://127.0.0.1'.$listen.'/.well-known/mercure'; + } + + if (str_starts_with($listen, 'http://') || str_starts_with($listen, 'https://')) { + return rtrim($listen, '/').'/.well-known/mercure'; + } + + return 'http://'.$listen.'/.well-known/mercure'; + } + + public function publishHubUrl(): string + { + $url = trim((string) ($_SERVER['MERCURE_URL'] ?? $_ENV['MERCURE_URL'] ?? getenv('MERCURE_URL') ?: '')); + + return '' !== $url ? $this->normalizeHubUrl($url) : $this->localHubUrl(); + } + + public function publicHubUrl(): string + { + $url = trim((string) ($_SERVER['MERCURE_PUBLIC_URL'] ?? $_ENV['MERCURE_PUBLIC_URL'] ?? getenv('MERCURE_PUBLIC_URL') ?: '')); + + return '' !== $url ? $this->normalizeHubUrl($url) : $this->localHubUrl(); + } + + private function normalizeHubUrl(string $url): string + { + if (1 === preg_match('#^(https?://):(\d+)(/.*)?$#', $url, $matches)) { + return $matches[1].'127.0.0.1:'.$matches[2].($matches[3] ?? ''); + } + + return $url; + } + + private function publishDirectly(string $url): bool + { + try { + if (!method_exists($this->hub, 'getProvider')) { + return false; + } + + $response = $this->httpClient()->request('POST', $url, [ + 'auth_bearer' => $this->hub->getProvider()->getJwt(), + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => QueryBuilder::build([ + 'topic' => $this->healthTopic(), + 'data' => '{}', + 'type' => 'ui-alert-health', + ]), + ]); + + return self::publishStatusAccepted($response->getStatusCode()); + } catch (Throwable) { + return false; + } + } + + private function hubEndpointProbe(string $url): bool + { + try { + $response = $this->httpClient()->request('GET', $url); + + return self::probeStatusAccepted($response->getStatusCode()); + } catch (Throwable) { + return false; + } + } + + private static function probeStatusAccepted(int $status): bool + { + return ($status >= 200 && $status < 300) || 400 === $status || 401 === $status; + } + + private static function publishStatusAccepted(int $status): bool + { + return $status >= 200 && $status < 300; + } + + private function subscriberEndpointProbe(string $url): bool + { + try { + $response = $this->httpClient()->request('GET', $url, [ + 'headers' => ['Accept' => 'text/event-stream'], + ]); + $status = $response->getStatusCode(); + $headers = $response->getHeaders(false); + $contentType = strtolower($headers['content-type'][0] ?? ''); + + return 200 === $status && str_contains($contentType, 'text/event-stream'); + } catch (Throwable) { + return false; + } + } + + private function httpClient(): HttpClientInterface + { + return $this->httpClient ?? HttpClient::create([ + 'timeout' => 2.0, + 'max_duration' => 2.0, + ]); + } + + private function jwtSecret(): string + { + $secret = $_SERVER['MERCURE_JWT_SECRET'] + ?? $_ENV['MERCURE_JWT_SECRET'] + ?? getenv('MERCURE_JWT_SECRET') + ?: null; + + if (is_string($secret) && '' !== $secret) { + return $secret; + } + + $appSecret = $_SERVER['APP_SECRET'] + ?? $_ENV['APP_SECRET'] + ?? getenv('APP_SECRET') + ?: ''; + + return (string) $appSecret; + } + + private function healthTopic(): string + { + return 'urn:system:ui-alerts:health'; + } + + private function urlWithTopic(string $url): string + { + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.QueryBuilder::build(['topic' => $this->healthTopic()]); + } + + private function serverName(): string + { + $listen = $this->listenAddress(); + if (str_starts_with($listen, 'http://') || str_starts_with($listen, 'https://') || str_starts_with($listen, ':')) { + return $listen; + } + + return 'http://'.$listen; + } + + private function envFilePath(): string + { + $path = $this->mercureDirectory().'/mercure.env'; + $jwtSecret = $this->jwtSecret(); + $contents = implode("\n", [ + 'SERVER_NAME='.$this->envFileValue($this->serverName()), + 'MERCURE_PUBLISHER_JWT_KEY='.$this->envFileValue($jwtSecret), + 'MERCURE_SUBSCRIBER_JWT_KEY='.$this->envFileValue($jwtSecret), + 'MERCURE_PUBLISHER_JWT_ALG=HS256', + 'MERCURE_SUBSCRIBER_JWT_ALG=HS256', + '', + ]); + + if (!is_file($path) || (string) @file_get_contents($path) !== $contents) { + $temporaryPath = $path.'.tmp.'.str_replace('.', '', uniqid('', true)); + @touch($temporaryPath); + @chmod($temporaryPath, 0600); + $written = @file_put_contents($temporaryPath, $contents, LOCK_EX); + if (strlen($contents) === $written) { + @chmod($temporaryPath, 0600); + if (!@rename($temporaryPath, $path)) { + @unlink($temporaryPath); + } + } else { + @unlink($temporaryPath); + } + } + + @chmod($path, 0600); + + return $path; + } + + private function transportPath(): string + { + return str_replace('\\', '/', $this->mercureDirectory().'/updates.db'); + } + + private function mercureCachePath(): string + { + $directory = $this->mercureDirectory().'/cache'; + if (!is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + return $directory; + } + + private function mercureDirectory(): string + { + $directory = $this->projectDir.'/var/mercure'; + if (!is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + return $directory; + } + + private function caddyfileString(string $value): string + { + return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"'; + } + + private function envFileValue(string $value): string + { + $value = str_replace(["\r", "\n"], ['', '\n'], $value); + if (1 === preg_match('/^[A-Za-z0-9_@%+=:,.\/-]*$/', $value)) { + return $value; + } + + return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"'; + } + + private function pid(): ?int + { + $path = $this->pidPath(); + if (!is_file($path)) { + return null; + } + + $contents = trim((string) @file_get_contents($path)); + + return 1 === preg_match('/^\d+$/', $contents) ? (int) $contents : null; + } + + private function isProcessRunning(int $pid): bool + { + if ($pid <= 0) { + return false; + } + + if (function_exists('posix_kill')) { + return @posix_kill($pid, 0); + } + + return false; + } + + private function pidBelongsToBinary(int $pid): bool + { + return in_array($pid, $this->binaryProcessIds(), true); + } + + private function terminate(int $pid): bool + { + try { + if ('\\' === DIRECTORY_SEPARATOR) { + $process = new Process(['taskkill', '/PID', (string) $pid, '/T', '/F']); + } else { + $process = new Process(['kill', '-TERM', (string) $pid]); + } + $process->setTimeout(5); + $process->run(); + + return $process->isSuccessful(); + } catch (Throwable) { + return false; + } + } + + /** + * @return list + */ + private function binaryProcessIds(): array + { + $binary = $this->binaryManager->binaryPath(); + if (!is_file($binary)) { + return []; + } + + try { + if ('\\' === DIRECTORY_SEPARATOR) { + $script = 'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -eq $args[0] } | ForEach-Object { $_.ProcessId }'; + $process = new Process(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $script, $binary]); + } else { + $process = new Process(['ps', '-eo', 'pid=,command=']); + } + + $process->setTimeout(5); + $process->run(); + + if (!$process->isSuccessful()) { + return []; + } + + return '\\' === DIRECTORY_SEPARATOR + ? $this->windowsProcessIds($process->getOutput()) + : $this->posixProcessIds($process->getOutput(), $binary); + } catch (Throwable) { + return []; + } + } + + private function terminateBinaryProcesses(): bool + { + $processIds = $this->binaryProcessIds(); + + if ([] === $processIds) { + return false; + } + + $stopped = true; + + foreach ($processIds as $processId) { + $stopped = $this->terminate($processId) && $stopped; + } + + return $stopped; + } + + private function waitUntilProcessStopped(int $pid): bool + { + for ($attempt = 0; $attempt < 10; ++$attempt) { + usleep(100000); + if (!$this->isProcessRunning($pid)) { + return true; + } + } + + return !$this->isProcessRunning($pid); + } + + private function waitUntilNoBinaryProcesses(): bool + { + for ($attempt = 0; $attempt < 10; ++$attempt) { + if ([] === $this->binaryProcessIds()) { + return true; + } + + usleep(100000); + } + + return [] === $this->binaryProcessIds(); + } + + /** + * @return list + */ + private function windowsProcessIds(string $output): array + { + return array_values(array_filter( + array_map(static fn (string $line): int => (int) trim($line), explode("\n", $output)), + static fn (int $processId): bool => $processId > 0, + )); + } + + /** + * @return list + */ + private function posixProcessIds(string $output, string $binary): array + { + $processIds = []; + + foreach (explode("\n", $output) as $line) { + if (1 !== preg_match('/^\s*(\d+)\s+(.+)$/', $line, $matches)) { + continue; + } + + $command = trim($matches[2]); + if (str_starts_with($command, $binary.' ') || $command === $binary) { + $processIds[] = (int) $matches[1]; + } + } + + return $processIds; + } + + private function removePidFile(): void + { + $path = $this->pidPath(); + if (is_file($path)) { + @unlink($path); + } + } +} diff --git a/src/Core/Package/PackageApiContributionGuard.php b/src/Core/Package/PackageApiContributionGuard.php index 1a5961d5..766544b4 100644 --- a/src/Core/Package/PackageApiContributionGuard.php +++ b/src/Core/Package/PackageApiContributionGuard.php @@ -57,11 +57,7 @@ private static function assertPathPattern(string $expectedPrefix, ?string $pathP return; } - $body = self::pathPatternBody($pathPattern); - $delimiter = $pathPattern[0] ?? '#'; - $expectedStart = '^'.preg_quote($expectedPrefix, $delimiter); - - if (null !== $body && str_starts_with($body, $expectedStart)) { + if (PackagePathPatternScope::isScopedToPrefix($pathPattern, $expectedPrefix)) { return; } @@ -70,25 +66,6 @@ private static function assertPathPattern(string $expectedPrefix, ?string $pathP ]); } - private static function pathPatternBody(string $pathPattern): ?string - { - if ('' === $pathPattern) { - return null; - } - - $delimiter = $pathPattern[0]; - if (ctype_alnum($delimiter) || '\\' === $delimiter || ctype_space($delimiter)) { - return null; - } - - $end = strrpos($pathPattern, $delimiter); - if (false === $end || 0 === $end) { - return null; - } - - return substr($pathPattern, 1, $end - 1); - } - /** * @param list $tags */ diff --git a/src/Core/Package/PackageContributions.php b/src/Core/Package/PackageContributions.php index a90c089d..2a3a7c72 100644 --- a/src/Core/Package/PackageContributions.php +++ b/src/Core/Package/PackageContributions.php @@ -10,6 +10,12 @@ use App\Api\Endpoint\ApiEndpointProviderInterface; use App\Core\Package\Settings\PackageSettingDefinition; use App\Core\Package\Settings\PackageSettingProviderInterface; +use App\Live\LiveEndpointDefinition; +use App\Live\LiveEndpointHandlerInterface; +use App\Live\LiveEndpointHandlerProviderInterface; +use App\Live\LiveEndpointProviderInterface; +use App\Privacy\Cookie\CookieConsentDefinition; +use App\Privacy\Cookie\CookieConsentProviderInterface; use App\Scheduler\SchedulerActionQueueProviderInterface; use App\Scheduler\SchedulerCallableProviderInterface; use App\Scheduler\SchedulerTaskDefinition; @@ -38,7 +44,7 @@ public static function create(): self } public function add( - StaticViewInjection|ConfigurableStaticViewInjectionSet|DynamicViewInjection|PackageSettingDefinition|SchedulerTaskDefinition|ApiEndpointDefinition|ApiEndpointHandlerInterface|StaticViewInjectionProviderInterface|DynamicViewInjectionProviderInterface|PackageSettingProviderInterface|ApiEndpointProviderInterface|ApiEndpointHandlerProviderInterface|SchedulerTaskProviderInterface|SchedulerCallableProviderInterface|SchedulerActionQueueProviderInterface $contribution, + StaticViewInjection|ConfigurableStaticViewInjectionSet|DynamicViewInjection|PackageSettingDefinition|SchedulerTaskDefinition|ApiEndpointDefinition|ApiEndpointHandlerInterface|LiveEndpointDefinition|LiveEndpointHandlerInterface|CookieConsentDefinition|StaticViewInjectionProviderInterface|DynamicViewInjectionProviderInterface|PackageSettingProviderInterface|ApiEndpointProviderInterface|ApiEndpointHandlerProviderInterface|LiveEndpointProviderInterface|LiveEndpointHandlerProviderInterface|CookieConsentProviderInterface|SchedulerTaskProviderInterface|SchedulerCallableProviderInterface|SchedulerActionQueueProviderInterface $contribution, ): self { $this->items[] = $contribution; @@ -80,6 +86,21 @@ public function apiEndpointHandler(ApiEndpointHandlerInterface $handler): self return $this->add($handler); } + public function liveEndpoint(LiveEndpointDefinition $definition): self + { + return $this->add($definition); + } + + public function liveEndpointHandler(LiveEndpointHandlerInterface $handler): self + { + return $this->add($handler); + } + + public function cookie(CookieConsentDefinition $definition): self + { + return $this->add($definition); + } + public function staticViewProvider(StaticViewInjectionProviderInterface $provider): self { return $this->add($provider); @@ -105,6 +126,21 @@ public function apiEndpointHandlerProvider(ApiEndpointHandlerProviderInterface $ return $this->add($provider); } + public function liveEndpointProvider(LiveEndpointProviderInterface $provider): self + { + return $this->add($provider); + } + + public function liveEndpointHandlerProvider(LiveEndpointHandlerProviderInterface $provider): self + { + return $this->add($provider); + } + + public function cookieProvider(CookieConsentProviderInterface $provider): self + { + return $this->add($provider); + } + public function schedulerTaskProvider(SchedulerTaskProviderInterface $provider): self { return $this->add($provider); diff --git a/src/Core/Package/PackageFileSyntaxValidator.php b/src/Core/Package/PackageFileSyntaxValidator.php index 2bc30b3e..6a259261 100644 --- a/src/Core/Package/PackageFileSyntaxValidator.php +++ b/src/Core/Package/PackageFileSyntaxValidator.php @@ -84,6 +84,13 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte $lintResult = $linter->lint($contents, $file); foreach ($lintResult->issues() as $lintIssue) { + if ($linter instanceof CssLinter + && CssLinter::hasStrictParserUnsupportedContext($contents, $lintIssue->line()) + && $linter->lint(CssLinter::forStrictParser($contents), $file)->isSuccess() + ) { + continue; + } + $issues[] = Message::create( $issueCode, $translationKey, diff --git a/src/Core/Package/PackageLiveContributionGuard.php b/src/Core/Package/PackageLiveContributionGuard.php new file mode 100644 index 00000000..b0e410b7 --- /dev/null +++ b/src/Core/Package/PackageLiveContributionGuard.php @@ -0,0 +1,78 @@ +packageName()); + + if (in_array($slug, self::RESERVED_SLUGS, true)) { + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_LIVE_ENDPOINT_RESERVED, [ + '%package%' => $package->packageName(), + '%slug%' => $slug, + ]); + } + + $expectedPrefix = PackageLiveEndpointPath::prefix($package->packageName()); + if ($definition->path() === $expectedPrefix || !str_starts_with($definition->path(), $expectedPrefix)) { + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_LIVE_ENDPOINT_PATH_INVALID, [ + '%package%' => $package->packageName(), + '%path%' => $definition->path(), + ]); + } + + self::assertPathPattern($expectedPrefix, $definition->pathPattern()); + self::assertHandlerKey($package, $definition->handlerKey()); + } + + public static function assertHandler(ExtensionPackage $package, LiveEndpointHandlerInterface $handler): void + { + self::assertHandlerKey($package, $handler->liveEndpointHandlerKey()); + } + + private static function assertHandlerKey(ExtensionPackage $package, string $handlerKey): void + { + $prefix = 'packages.'.PackageLiveEndpointPath::slug($package->packageName()).'.live.'; + + if (str_starts_with($handlerKey, $prefix)) { + return; + } + + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_LIVE_ENDPOINT_HANDLER_INVALID, [ + '%package%' => $package->packageName(), + '%handler%' => $handlerKey, + ]); + } + + private static function assertPathPattern(string $expectedPrefix, ?string $pathPattern): void + { + if (null === $pathPattern) { + return; + } + + if (PackagePathPatternScope::isScopedToPrefix($pathPattern, $expectedPrefix)) { + return; + } + + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_LIVE_ENDPOINT_PATH_INVALID, [ + '%package%' => '', + '%path%' => $pathPattern, + ]); + } + + private function __construct() + { + } +} diff --git a/src/Core/Package/PackageMessageCode.php b/src/Core/Package/PackageMessageCode.php index 3875373d..e8d664af 100644 --- a/src/Core/Package/PackageMessageCode.php +++ b/src/Core/Package/PackageMessageCode.php @@ -65,6 +65,9 @@ final class PackageMessageCode public const PACKAGE_LIFECYCLE_RUNTIME_FAILURE = 'package.lifecycle.runtime_failure'; public const PACKAGE_LIFECYCLE_PHP_LOAD_FAILED = 'package.lifecycle.php_load_failed'; public const PACKAGE_LIFECYCLE_ROLLED_BACK = 'package.lifecycle.rolled_back'; + public const PACKAGE_LIVE_ENDPOINT_PATH_INVALID = 'package.live.endpoint_path_invalid'; + public const PACKAGE_LIVE_ENDPOINT_HANDLER_INVALID = 'package.live.endpoint_handler_invalid'; + public const PACKAGE_LIVE_ENDPOINT_RESERVED = 'package.live.endpoint_reserved'; public const PACKAGE_SETTING_READ_FAILED = 'package.setting.read_failed'; public const PACKAGE_SETTING_WRITE_FAILED = 'package.setting.write_failed'; public const PACKAGE_SETTING_DELETE_FAILED = 'package.setting.delete_failed'; diff --git a/src/Core/Package/PackageMessageKey.php b/src/Core/Package/PackageMessageKey.php index bd63b5bc..cf6ee51b 100644 --- a/src/Core/Package/PackageMessageKey.php +++ b/src/Core/Package/PackageMessageKey.php @@ -66,6 +66,9 @@ final class PackageMessageKey public const PACKAGE_LIFECYCLE_PHP_LOAD_FAILED = 'message.package.lifecycle.php_load_failed'; public const PACKAGE_LIFECYCLE_ROLLED_BACK = 'message.package.lifecycle.rolled_back'; public const PACKAGE_RUNTIME_CONTRIBUTION_UNSUPPORTED = 'message.package.runtime.contribution_unsupported'; + public const PACKAGE_LIVE_ENDPOINT_PATH_INVALID = 'message.package.live.endpoint_path_invalid'; + public const PACKAGE_LIVE_ENDPOINT_HANDLER_INVALID = 'message.package.live.endpoint_handler_invalid'; + public const PACKAGE_LIVE_ENDPOINT_RESERVED = 'message.package.live.endpoint_reserved'; public const PACKAGE_SETTING_READ_FAILED = 'message.package.setting.read_failed'; public const PACKAGE_SETTING_WRITE_FAILED = 'message.package.setting.write_failed'; public const PACKAGE_SETTING_DELETE_FAILED = 'message.package.setting.delete_failed'; diff --git a/src/Core/Package/PackagePathPatternScope.php b/src/Core/Package/PackagePathPatternScope.php new file mode 100644 index 00000000..64d5c2d5 --- /dev/null +++ b/src/Core/Package/PackagePathPatternScope.php @@ -0,0 +1,108 @@ + $prefixes + */ + private static function startsWithAny(string $value, array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if (str_starts_with($value, $prefix)) { + return true; + } + } + + return false; + } + + private static function containsTopLevelAlternation(string $body): bool + { + $escaped = false; + $classDepth = 0; + $groupDepth = 0; + + foreach (str_split($body) as $char) { + if ($escaped) { + $escaped = false; + continue; + } + + if ('\\' === $char) { + $escaped = true; + continue; + } + + if ('[' === $char) { + ++$classDepth; + continue; + } + + if (']' === $char && $classDepth > 0) { + --$classDepth; + continue; + } + + if ($classDepth > 0) { + continue; + } + + if ('(' === $char) { + ++$groupDepth; + continue; + } + + if (')' === $char && $groupDepth > 0) { + --$groupDepth; + continue; + } + + if ('|' === $char && 0 === $groupDepth) { + return true; + } + } + + return false; + } + + private function __construct() + { + } +} diff --git a/src/Core/Package/PackageRuntimeContributionRegistry.php b/src/Core/Package/PackageRuntimeContributionRegistry.php index 3557456a..17c384cc 100644 --- a/src/Core/Package/PackageRuntimeContributionRegistry.php +++ b/src/Core/Package/PackageRuntimeContributionRegistry.php @@ -9,11 +9,19 @@ use App\Api\Endpoint\ApiEndpointHandlerProviderInterface; use App\Api\Endpoint\ApiEndpointProviderInterface; use App\Core\Message\MessageException; +use App\Core\Operation\ActionQueue; use App\Core\Package\Settings\PackageSettingDefinition; use App\Core\Package\Settings\PackageSettingProviderInterface; use App\Core\Package\Settings\PackageSettings; -use App\Core\Operation\ActionQueue; +use App\Core\Statistics\VisitorIdGenerator; use App\Entity\ExtensionPackage; +use App\Live\LiveEndpointDefinition; +use App\Live\LiveEndpointHandlerInterface; +use App\Live\LiveEndpointHandlerProviderInterface; +use App\Live\LiveEndpointProviderInterface; +use App\Privacy\Cookie\CookieConsentDefinition; +use App\Privacy\Cookie\CookieConsentManager; +use App\Privacy\Cookie\CookieConsentProviderInterface; use App\Scheduler\SchedulerActionQueueProviderInterface; use App\Scheduler\SchedulerCallableProviderInterface; use App\Scheduler\SchedulerTaskDefinition; @@ -23,9 +31,16 @@ use App\View\Injection\DynamicViewInjectionProviderInterface; use App\View\Injection\StaticViewInjection; use App\View\Injection\StaticViewInjectionProviderInterface; +use Symfony\Component\HttpFoundation\Cookie; -final class PackageRuntimeContributionRegistry implements StaticViewInjectionProviderInterface, DynamicViewInjectionProviderInterface, PackageSettingProviderInterface, ApiEndpointProviderInterface, ApiEndpointHandlerProviderInterface, SchedulerTaskProviderInterface, SchedulerCallableProviderInterface, SchedulerActionQueueProviderInterface +final class PackageRuntimeContributionRegistry implements StaticViewInjectionProviderInterface, DynamicViewInjectionProviderInterface, PackageSettingProviderInterface, ApiEndpointProviderInterface, ApiEndpointHandlerProviderInterface, LiveEndpointProviderInterface, LiveEndpointHandlerProviderInterface, CookieConsentProviderInterface, SchedulerTaskProviderInterface, SchedulerCallableProviderInterface, SchedulerActionQueueProviderInterface { + private const RESERVED_COOKIE_NAMES = [ + CookieConsentManager::CONSENT_COOKIE_NAME, + 'PHPSESSID', + VisitorIdGenerator::COOKIE_NAME, + ]; + public function __construct(private ?PackageSettings $packageSettingsStore = null) { } @@ -42,6 +57,12 @@ public function __construct(private ?PackageSettings $packageSettingsStore = nul private array $apiEndpointHandlers = []; + private array $liveEndpointDefinitions = []; + + private array $liveEndpointHandlers = []; + + private array $cookieConsentDefinitions = []; + private array $schedulerTaskDefinitions = []; private array $schedulerCallableProviders = []; @@ -103,6 +124,24 @@ private function addToRegistry(ExtensionPackage $package, mixed $contribution): return; } + if ($contribution instanceof LiveEndpointDefinition) { + $this->addLiveEndpointDefinition($package, $contribution); + + return; + } + + if ($contribution instanceof LiveEndpointHandlerInterface) { + $this->addLiveEndpointHandler($package, $contribution); + + return; + } + + if ($contribution instanceof CookieConsentDefinition) { + $this->addCookieConsentDefinition($package, $contribution); + + return; + } + $providerHandled = false; if ($contribution instanceof StaticViewInjectionProviderInterface) { @@ -145,6 +184,30 @@ private function addToRegistry(ExtensionPackage $package, mixed $contribution): $providerHandled = true; } + if ($contribution instanceof LiveEndpointProviderInterface) { + foreach ($contribution->liveEndpoints() as $definition) { + $this->addToRegistry($package, $definition); + } + + $providerHandled = true; + } + + if ($contribution instanceof LiveEndpointHandlerProviderInterface) { + foreach ($contribution->liveEndpointHandlers() as $handler) { + $this->addToRegistry($package, $handler); + } + + $providerHandled = true; + } + + if ($contribution instanceof CookieConsentProviderInterface) { + foreach ($contribution->cookieConsentDefinitions() as $definition) { + $this->addToRegistry($package, $definition); + } + + $providerHandled = true; + } + if ($contribution instanceof SchedulerTaskProviderInterface) { foreach ($contribution->schedulerTasks() as $definition) { $this->addToRegistry($package, $definition); @@ -189,6 +252,9 @@ private function replaceWith(self $registry): void $this->packageSettingDefinitions = $registry->packageSettingDefinitions; $this->apiEndpointDefinitions = $registry->apiEndpointDefinitions; $this->apiEndpointHandlers = $registry->apiEndpointHandlers; + $this->liveEndpointDefinitions = $registry->liveEndpointDefinitions; + $this->liveEndpointHandlers = $registry->liveEndpointHandlers; + $this->cookieConsentDefinitions = $registry->cookieConsentDefinitions; $this->schedulerTaskDefinitions = $registry->schedulerTaskDefinitions; $this->schedulerCallableProviders = $registry->schedulerCallableProviders; $this->schedulerActionQueueProviders = $registry->schedulerActionQueueProviders; @@ -226,6 +292,88 @@ private function addApiEndpointHandler(ExtensionPackage $package, ApiEndpointHan $this->apiEndpointHandlers[] = $handler; } + private function addLiveEndpointDefinition(ExtensionPackage $package, LiveEndpointDefinition $definition): void + { + PackageLiveContributionGuard::assertEndpoint($package, $definition); + $this->liveEndpointDefinitions[] = $definition; + } + + private function addLiveEndpointHandler(ExtensionPackage $package, LiveEndpointHandlerInterface $handler): void + { + PackageLiveContributionGuard::assertHandler($package, $handler); + $this->liveEndpointHandlers[] = $handler; + } + + private function addCookieConsentDefinition(ExtensionPackage $package, CookieConsentDefinition $definition): void + { + if (in_array($definition->name(), $this->existingCookieConsentNames(), true)) { + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_RUNTIME_CONTRIBUTION_UNSUPPORTED, [ + '%package%' => $package->packageName(), + '%type%' => CookieConsentDefinition::class.'('.$definition->name().') duplicate', + ]); + } + + if ($definition->isNecessary() && !$this->necessaryPackageCookieAllowed($package, $definition->cookie())) { + throw MessageException::invalidArgument(PackageMessageKey::PACKAGE_RUNTIME_CONTRIBUTION_UNSUPPORTED, [ + '%package%' => $package->packageName(), + '%type%' => CookieConsentDefinition::class.'::necessary('.$definition->name().')', + ]); + } + + $this->cookieConsentDefinitions[] = $definition; + } + + /** + * @return list + */ + private function existingCookieConsentNames(): array + { + return [ + ...self::RESERVED_COOKIE_NAMES, + ...array_map( + static fn (CookieConsentDefinition $definition): string => $definition->name(), + $this->cookieConsentDefinitions, + ), + ]; + } + + private function necessaryPackageCookieAllowed(ExtensionPackage $package, Cookie $cookie): bool + { + $prefixes = $this->cookieNamePrefixes($package); + $sameSite = $cookie->getSameSite(); + + return $this->cookieNameHasPackagePrefix($cookie->getName(), $prefixes) + && (null === $cookie->getDomain() || '' === trim($cookie->getDomain())) + && in_array($sameSite, [Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT], true); + } + + /** + * @return list + */ + private function cookieNamePrefixes(ExtensionPackage $package): array + { + $slug = strtolower($package->packageName()); + + return array_values(array_unique([ + $slug.'_', + str_replace('-', '_', $slug).'_', + ])); + } + + /** + * @param list $prefixes + */ + private function cookieNameHasPackagePrefix(string $name, array $prefixes): bool + { + foreach ($prefixes as $prefix) { + if (str_starts_with($name, $prefix)) { + return true; + } + } + + return false; + } + public function staticViewInjections(): array { $injections = $this->staticViewInjections; @@ -262,6 +410,21 @@ public function apiEndpointHandlers(): array return $this->apiEndpointHandlers; } + public function liveEndpoints(): array + { + return $this->liveEndpointDefinitions; + } + + public function liveEndpointHandlers(): array + { + return $this->liveEndpointHandlers; + } + + public function cookieConsentDefinitions(): array + { + return $this->cookieConsentDefinitions; + } + public function schedulerTasks(): array { return $this->schedulerTaskDefinitions; diff --git a/src/Core/Process/DetachedProcessStarter.php b/src/Core/Process/DetachedProcessStarter.php index b5867a37..d5fc46cf 100644 --- a/src/Core/Process/DetachedProcessStarter.php +++ b/src/Core/Process/DetachedProcessStarter.php @@ -29,8 +29,8 @@ public function start(array $command, string $cwd, string $outputPath, string $p return $this->startWindows($command, $cwd, $outputPath, $pidPath, $environment); } - $shellCommand = implode(' ', array_map('escapeshellarg', $command)) - .' > '.escapeshellarg($outputPath).' 2>&1 & echo $! > '.escapeshellarg($pidPath); + $shellCommand = 'nohup '.implode(' ', array_map('escapeshellarg', $command)) + .' > '.escapeshellarg($outputPath).' 2>&1 < /dev/null & echo $! > '.escapeshellarg($pidPath); return $this->runShellCommand($shellCommand, $cwd, $environment); } diff --git a/src/Database/TablePrefix.php b/src/Database/TablePrefix.php index 9525e0d7..17426340 100644 --- a/src/Database/TablePrefix.php +++ b/src/Database/TablePrefix.php @@ -30,6 +30,7 @@ 'site_menu', 'site_menu_item', 'state_marker', + 'ui_alert_inbox', 'user_account', 'user_acl_group', ]; diff --git a/src/Form/Autocomplete/AdminAclGroupAutocomplete.php b/src/Form/Autocomplete/AdminAclGroupAutocomplete.php new file mode 100644 index 00000000..e5229ee4 --- /dev/null +++ b/src/Form/Autocomplete/AdminAclGroupAutocomplete.php @@ -0,0 +1,33 @@ +setDefaults([ + 'class' => AclGroup::class, + 'choice_label' => 'name', + 'placeholder' => 'admin.autocomplete.acl_group.placeholder', + 'searchable_fields' => ['identifier', 'name'], + 'security' => 'ROLE_ADMIN', + 'max_results' => 20, + 'min_characters' => 2, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Form/Autocomplete/AdminUserAutocomplete.php b/src/Form/Autocomplete/AdminUserAutocomplete.php new file mode 100644 index 00000000..96f743fc --- /dev/null +++ b/src/Form/Autocomplete/AdminUserAutocomplete.php @@ -0,0 +1,33 @@ +setDefaults([ + 'class' => UserAccount::class, + 'choice_label' => 'username', + 'placeholder' => 'admin.autocomplete.user.placeholder', + 'searchable_fields' => ['username', 'email'], + 'security' => 'ROLE_ADMIN', + 'max_results' => 20, + 'min_characters' => 2, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Live/LiveEndpointDefinition.php b/src/Live/LiveEndpointDefinition.php new file mode 100644 index 00000000..d5c1e1c9 --- /dev/null +++ b/src/Live/LiveEndpointDefinition.php @@ -0,0 +1,155 @@ +assertOwner($owner); + $this->assertMethod($method); + $this->assertPath($path); + Identifier::assertSnakeCase($routeName, ApiMessageKey::API_ENDPOINT_ROUTE_INVALID, '%route%'); + $this->assertOperationId($operationId); + $this->assertHandlerKey($handlerKey); + $this->assertSummary($summary); + AccessLevel::assert($minimumAccessLevel); + $this->assertPathPattern($pathPattern); + } + + public function owner(): string + { + return $this->owner; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function routeName(): string + { + return $this->routeName; + } + + public function operationId(): string + { + return $this->operationId; + } + + public function summary(): string + { + return $this->summary; + } + + public function handlerKey(): string + { + return $this->handlerKey; + } + + public function minimumAccessLevel(): ?int + { + return $this->minimumAccessLevel; + } + + public function pathPattern(): ?string + { + return $this->pathPattern; + } + + public function matchesPath(string $path): bool + { + if ($this->path === $path) { + return true; + } + + return null !== $this->pathPattern && 1 === preg_match($this->pathPattern, $path); + } + + private function assertOwner(string $owner): void + { + if (1 !== preg_match('/^[a-z0-9][a-z0-9_-]{1,158}[a-z0-9]$/', $owner)) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_OWNER_INVALID, [ + '%owner%' => $owner, + ]); + } + } + + private function assertMethod(string $method): void + { + if (Request::METHOD_GET !== $method) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_METHOD_INVALID, [ + '%method%' => $method, + ]); + } + } + + private function assertPath(string $path): void + { + if (!str_starts_with($path, '/api/live/')) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_PATH_INVALID, [ + '%path%' => $path, + ]); + } + } + + private function assertOperationId(string $operationId): void + { + if (1 !== preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $operationId)) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_OPERATION_INVALID, [ + '%operation%' => $operationId, + ]); + } + } + + private function assertHandlerKey(string $handlerKey): void + { + if (1 !== preg_match('/^[a-z0-9][a-z0-9_.-]*$/', $handlerKey)) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_HANDLER_INVALID, [ + '%handler%' => $handlerKey, + ]); + } + } + + private function assertSummary(string $summary): void + { + if ('' === trim($summary)) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_SUMMARY_EMPTY); + } + } + + private function assertPathPattern(?string $pattern): void + { + if (null === $pattern) { + return; + } + + if (false === @preg_match($pattern, '')) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_PATH_INVALID, [ + '%path%' => $pattern, + ]); + } + } +} diff --git a/src/Live/LiveEndpointHandlerInterface.php b/src/Live/LiveEndpointHandlerInterface.php new file mode 100644 index 00000000..35f8cdd5 --- /dev/null +++ b/src/Live/LiveEndpointHandlerInterface.php @@ -0,0 +1,15 @@ + + */ + public function liveEndpointHandlers(): array; +} diff --git a/src/Live/LiveEndpointHandlerRegistry.php b/src/Live/LiveEndpointHandlerRegistry.php new file mode 100644 index 00000000..19c92ec8 --- /dev/null +++ b/src/Live/LiveEndpointHandlerRegistry.php @@ -0,0 +1,36 @@ + $handlers + */ + public function __construct(private iterable $handlers) + { + } + + public function handler(string $key): ?LiveEndpointHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler instanceof LiveEndpointHandlerInterface && $handler->liveEndpointHandlerKey() === $key) { + return $handler; + } + + if (!$handler instanceof LiveEndpointHandlerProviderInterface) { + continue; + } + + foreach ($handler->liveEndpointHandlers() as $providedHandler) { + if ($providedHandler->liveEndpointHandlerKey() === $key) { + return $providedHandler; + } + } + } + + return null; + } +} diff --git a/src/Live/LiveEndpointProviderInterface.php b/src/Live/LiveEndpointProviderInterface.php new file mode 100644 index 00000000..c1d66c4e --- /dev/null +++ b/src/Live/LiveEndpointProviderInterface.php @@ -0,0 +1,13 @@ + + */ + public function liveEndpoints(): array; +} diff --git a/src/Live/LiveEndpointRegistry.php b/src/Live/LiveEndpointRegistry.php new file mode 100644 index 00000000..d0d83bb0 --- /dev/null +++ b/src/Live/LiveEndpointRegistry.php @@ -0,0 +1,76 @@ + $providers + */ + public function __construct(private iterable $providers) + { + } + + /** + * @return list + */ + public function endpoints(): array + { + $endpoints = []; + + foreach ($this->providers as $provider) { + array_push($endpoints, ...$provider->liveEndpoints()); + } + + usort( + $endpoints, + static fn (LiveEndpointDefinition $left, LiveEndpointDefinition $right): int => [ + $left->path(), + $left->method(), + $left->operationId(), + ] <=> [ + $right->path(), + $right->method(), + $right->operationId(), + ], + ); + + return $endpoints; + } + + public function endpointForRequest(Request $request): ?LiveEndpointDefinition + { + return $this->endpointForPath($request->getPathInfo(), $request->getMethod()); + } + + public function endpointForPath(string $path, string $method): ?LiveEndpointDefinition + { + $candidates = array_values(array_filter( + $this->endpoints(), + static fn (LiveEndpointDefinition $endpoint): bool => $endpoint->matchesPath($path) + && ($endpoint->method() === $method || ('HEAD' === $method && 'GET' === $endpoint->method())), + )); + usort($candidates, static function (LiveEndpointDefinition $left, LiveEndpointDefinition $right) use ($path): int { + $leftExact = $left->path() === $path ? 1 : 0; + $rightExact = $right->path() === $path ? 1 : 0; + + return [ + $rightExact, + strlen($right->path()), + $right->path(), + $right->operationId(), + ] <=> [ + $leftExact, + strlen($left->path()), + $left->path(), + $left->operationId(), + ]; + }); + + return $candidates[0] ?? null; + } +} diff --git a/src/Live/PackageLiveEndpointPath.php b/src/Live/PackageLiveEndpointPath.php new file mode 100644 index 00000000..bf921fa8 --- /dev/null +++ b/src/Live/PackageLiveEndpointPath.php @@ -0,0 +1,46 @@ + $packageName, + ]); + } + + return $slug; + } + + public static function path(string $packageName, string $path): string + { + $path = trim($path, '/'); + if ('' === $path) { + throw MessageException::invalidArgument(ApiMessageKey::API_ENDPOINT_PATH_INVALID, [ + '%path%' => '/api/live/'.self::slug($packageName).'/', + ]); + } + + return self::prefix($packageName).$path; + } + + public static function prefix(string $packageName): string + { + return '/api/live/'.self::slug($packageName).'/'; + } + + private function __construct() + { + } +} diff --git a/src/Navigation/NavigationUrlResolver.php b/src/Navigation/NavigationUrlResolver.php index 33e73df9..3962c9b9 100644 --- a/src/Navigation/NavigationUrlResolver.php +++ b/src/Navigation/NavigationUrlResolver.php @@ -49,11 +49,11 @@ private function safeNavigationUrl(string $targetValue): string { $targetValue = trim($targetValue); - if ('' === $targetValue || 1 === preg_match('/[\x00-\x1F\x7F]/', $targetValue)) { + if ('' === $targetValue || str_contains($targetValue, '\\') || 1 === preg_match('/[\x00-\x1F\x7F]/', $targetValue)) { return '#'; } - if (str_starts_with($targetValue, '//') || str_starts_with($targetValue, '/\\')) { + if (str_starts_with($targetValue, '//')) { return '#'; } diff --git a/src/Privacy/Cookie/ConsentCookieJar.php b/src/Privacy/Cookie/ConsentCookieJar.php new file mode 100644 index 00000000..86cfc801 --- /dev/null +++ b/src/Privacy/Cookie/ConsentCookieJar.php @@ -0,0 +1,44 @@ +consent->allowed($request, $definition)) { + return null; + } + + $name = $definition instanceof CookieConsentDefinition ? $definition->name() : $definition; + $value = $request->cookies->get($name); + + return is_scalar($value) ? (string) $value : null; + } + + public function set(Request $request, Response $response, CookieConsentDefinition $definition, ?Cookie $cookie = null): bool + { + if (!$this->consent->allowed($request, $definition)) { + return false; + } + + $cookie ??= $definition->cookie(); + if (!$definition->matchesCookieIdentity($cookie)) { + return false; + } + + $response->headers->setCookie($cookie); + + return true; + } +} diff --git a/src/Privacy/Cookie/CookieConsentDefinition.php b/src/Privacy/Cookie/CookieConsentDefinition.php new file mode 100644 index 00000000..c5d6e45f --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentDefinition.php @@ -0,0 +1,111 @@ +getName())) { + throw new InvalidArgumentException('Cookie consent definitions require a cookie name.'); + } + + if (!$necessary && ('' === trim($provider) || '' === trim($purpose) || '' === trim($privacyUrl))) { + throw new InvalidArgumentException('Optional cookies require provider, purpose, and privacy URL metadata.'); + } + + if (!$necessary && !$this->privacyUrlAllowed($privacyUrl)) { + throw new InvalidArgumentException('Optional cookie privacy URLs must be HTTP(S) or relative URLs.'); + } + } + + public static function necessary(Cookie $cookie): self + { + return new self($cookie, true); + } + + public static function optional(Cookie $cookie, string $provider, string $purpose, string $privacyUrl): self + { + return new self($cookie, false, $provider, $purpose, $privacyUrl); + } + + public function cookie(): Cookie + { + return $this->cookie; + } + + public function name(): string + { + return $this->cookie->getName(); + } + + public function isNecessary(): bool + { + return $this->necessary; + } + + public function provider(): string + { + return $this->provider; + } + + public function purpose(): string + { + return $this->purpose; + } + + public function privacyUrl(): string + { + return $this->privacyUrl; + } + + public function matchesCookieIdentity(Cookie $cookie): bool + { + return $this->matchesCookieIdentityWithSecurity($cookie, true); + } + + public function matchesResponseCookie(Cookie $cookie): bool + { + return $this->matchesCookieIdentityWithSecurity($cookie, !$this->necessary); + } + + private function matchesCookieIdentityWithSecurity(Cookie $cookie, bool $compareSecure): bool + { + return $this->cookie->getName() === $cookie->getName() + && $this->cookie->getPath() === $cookie->getPath() + && $this->cookie->getDomain() === $cookie->getDomain() + && (!$compareSecure || $this->cookie->isSecure() === $cookie->isSecure()) + && $this->cookie->isHttpOnly() === $cookie->isHttpOnly() + && $this->cookie->getSameSite() === $cookie->getSameSite(); + } + + private function privacyUrlAllowed(string $url): bool + { + $url = trim($url); + if ('' === $url || str_starts_with($url, '//') || str_contains($url, '\\') || preg_match('/[\x00-\x1F\x7F]/', $url)) { + return false; + } + + $parts = parse_url($url); + if (false === $parts) { + return false; + } + + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + if ('' === $scheme) { + return true; + } + + return in_array($scheme, ['http', 'https'], true) && '' !== trim((string) ($parts['host'] ?? '')); + } +} diff --git a/src/Privacy/Cookie/CookieConsentManager.php b/src/Privacy/Cookie/CookieConsentManager.php new file mode 100644 index 00000000..09f6249d --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentManager.php @@ -0,0 +1,235 @@ +visitorIdGenerator->generate($request), + $this->secret, + ); + } + + public function validCsrfToken(Request $request, string $token): bool + { + return hash_equals($this->csrfToken($request), $token); + } + + public function bannerRequired(Request $request): bool + { + return [] !== $this->registry->optionalDefinitions() && !$this->hasStoredConsent($request); + } + + public function formRequired(Request $request): bool + { + return [] !== $this->registry->optionalDefinitions(); + } + + public function allowed(Request $request, CookieConsentDefinition|string $definition): bool + { + $definition = is_string($definition) ? $this->registry->definition($definition) : $definition; + if (!$definition instanceof CookieConsentDefinition) { + return false; + } + + if ($definition->isNecessary()) { + return true; + } + + return in_array($definition->name(), $this->acceptedOptionalNames($request), true); + } + + /** + * @param list $acceptedOptionalNames + */ + public function attachConsentCookie(Request $request, Response $response, array $acceptedOptionalNames): void + { + $allowedNames = array_map( + static fn (CookieConsentDefinition $definition): string => $definition->name(), + $this->registry->optionalDefinitions(), + ); + $accepted = array_values(array_intersect($allowedNames, array_unique($acceptedOptionalNames))); + $rejected = array_values(array_diff($allowedNames, $accepted)); + + $response->headers->setCookie(Cookie::create( + self::CONSENT_COOKIE_NAME, + $this->encodeConsent([ + 'accepted' => $accepted, + 'created_at' => time(), + 'version' => 1, + ]), + time() + self::TTL_SECONDS, + '/', + null, + $request->isSecure(), + true, + false, + Cookie::SAMESITE_LAX, + )); + + foreach ($this->registry->optionalDefinitions() as $definition) { + if (!in_array($definition->name(), $rejected, true)) { + continue; + } + + $cookie = $definition->cookie(); + $response->headers->clearCookie( + $cookie->getName(), + $cookie->getPath(), + $cookie->getDomain(), + $cookie->isSecure(), + $cookie->isHttpOnly(), + $cookie->getSameSite(), + ); + } + } + + public function defaultOptionalSelected(Request $request): bool + { + return '1' !== (string) $request->headers->get('DNT', '') + && '1' !== (string) $request->headers->get('Sec-GPC', ''); + } + + /** + * @return list + */ + public function selectedOptionalNames(Request $request): array + { + if ($this->hasStoredConsent($request)) { + return $this->acceptedOptionalNames($request); + } + + if (!$this->defaultOptionalSelected($request)) { + return []; + } + + return array_map( + static fn (CookieConsentDefinition $definition): string => $definition->name(), + $this->registry->optionalDefinitions(), + ); + } + + /** + * @return list + */ + public function acceptedOptionalNames(Request $request): array + { + $payload = $this->storedConsent($request); + $accepted = $payload['accepted'] ?? []; + + return is_array($accepted) + ? array_values(array_filter(array_map('strval', $accepted), 'strlen')) + : []; + } + + private function hasStoredConsent(Request $request): bool + { + return null !== $this->storedConsent($request); + } + + /** + * @return array|null + */ + private function storedConsent(Request $request): ?array + { + $value = (string) $request->cookies->get(self::CONSENT_COOKIE_NAME, ''); + if ('' === $value) { + return null; + } + + $payload = $this->decodeConsent($value); + if (null === $payload) { + return null; + } + + $createdAt = $payload['created_at'] ?? null; + if (($payload['version'] ?? null) !== 1 || !is_int($createdAt)) { + return null; + } + + $now = time(); + if ($createdAt > $now + self::CLOCK_SKEW_SECONDS || $createdAt < $now - self::TTL_SECONDS) { + return null; + } + + return $payload; + } + + /** + * @param array $payload + */ + private function encodeConsent(array $payload): string + { + $body = $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + + return $body.'.'.$this->signature($body); + } + + /** + * @return array|null + */ + private function decodeConsent(string $value): ?array + { + $parts = explode('.', trim($value)); + if (2 !== count($parts) || !hash_equals($this->signature($parts[0]), $parts[1])) { + return null; + } + + $json = $this->base64UrlDecode($parts[0]); + if (null === $json) { + return null; + } + + try { + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : null; + } catch (\Throwable) { + return null; + } + } + + private function signature(string $body): string + { + return hash_hmac('sha256', 'privacy-cookie-consent|'.$body, $this->secret); + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): ?string + { + if (1 !== preg_match('/\A[A-Za-z0-9_-]+\z/', $value)) { + return null; + } + + $base64 = strtr($value, '-_', '+/'); + $base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4); + $decoded = base64_decode($base64, true); + + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Privacy/Cookie/CookieConsentProviderInterface.php b/src/Privacy/Cookie/CookieConsentProviderInterface.php new file mode 100644 index 00000000..3bcf9eb0 --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentProviderInterface.php @@ -0,0 +1,13 @@ + + */ + public function cookieConsentDefinitions(): array; +} diff --git a/src/Privacy/Cookie/CookieConsentRegistry.php b/src/Privacy/Cookie/CookieConsentRegistry.php new file mode 100644 index 00000000..2f17ac78 --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentRegistry.php @@ -0,0 +1,62 @@ + $providers + */ + public function __construct(private iterable $providers) + { + } + + /** + * @return list + */ + public function definitions(): array + { + $definitions = []; + + foreach ($this->providers as $provider) { + foreach ($provider->cookieConsentDefinitions() as $definition) { + $name = $definition->name(); + if (isset($definitions[$name])) { + throw new LogicException(sprintf('Duplicate cookie consent definition for "%s".', $name)); + } + + $definitions[$name] = $definition; + } + } + + ksort($definitions); + + return array_values($definitions); + } + + /** + * @return list + */ + public function optionalDefinitions(): array + { + return array_values(array_filter( + $this->definitions(), + static fn (CookieConsentDefinition $definition): bool => !$definition->isNecessary(), + )); + } + + public function definition(string $name): ?CookieConsentDefinition + { + foreach ($this->definitions() as $definition) { + if ($definition->name() === $name) { + return $definition; + } + } + + return null; + } +} diff --git a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php new file mode 100644 index 00000000..bb36a1fd --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php @@ -0,0 +1,50 @@ + ['filterCookies', self::RESPONSE_PRIORITY], + ]; + } + + public function filterCookies(ResponseEvent $event): void + { + $response = $event->getResponse(); + $request = $event->getRequest(); + + foreach ($response->headers->getCookies() as $cookie) { + if (0 !== $cookie->getExpiresTime() && $cookie->getExpiresTime() <= time()) { + continue; + } + + $definition = $this->registry->definition($cookie->getName()); + if (!$definition instanceof CookieConsentDefinition) { + continue; + } + + if ($definition->matchesResponseCookie($cookie) && $this->consent->allowed($request, $definition)) { + continue; + } + + $response->headers->removeCookie($cookie->getName(), $cookie->getPath(), $cookie->getDomain()); + } + } +} diff --git a/src/Privacy/Cookie/CookieConsentTwigExtension.php b/src/Privacy/Cookie/CookieConsentTwigExtension.php new file mode 100644 index 00000000..3027ebee --- /dev/null +++ b/src/Privacy/Cookie/CookieConsentTwigExtension.php @@ -0,0 +1,97 @@ +required(...)), + new TwigFunction('cookie_consent_form_required', $this->formRequired(...)), + new TwigFunction('cookie_consent_csrf_token', $this->csrfToken(...)), + new TwigFunction('cookie_consent_optional', $this->optional(...)), + new TwigFunction('cookie_consent_default_selected', $this->defaultSelected(...)), + new TwigFunction('cookie_consent_selected_names', $this->selectedNames(...)), + new TwigFunction('cookie_consent_trigger_attributes', $this->triggerAttributes(...)), + ]; + } + + public function required(): bool + { + $request = $this->requestStack->getMainRequest(); + + return null !== $request && $this->consent->bannerRequired($request); + } + + public function formRequired(): bool + { + $request = $this->requestStack->getMainRequest(); + + return null !== $request && $this->consent->formRequired($request); + } + + public function csrfToken(): string + { + $request = $this->requestStack->getMainRequest(); + + return null !== $request ? $this->consent->csrfToken($request) : ''; + } + + /** + * @return list + */ + public function optional(): array + { + return array_map( + static fn (CookieConsentDefinition $definition): array => [ + 'name' => $definition->name(), + 'provider' => $definition->provider(), + 'purpose' => $definition->purpose(), + 'privacy_url' => $definition->privacyUrl(), + ], + $this->registry->optionalDefinitions(), + ); + } + + public function defaultSelected(): bool + { + $request = $this->requestStack->getMainRequest(); + + return null !== $request && $this->consent->defaultOptionalSelected($request); + } + + /** + * @return list + */ + public function selectedNames(): array + { + $request = $this->requestStack->getMainRequest(); + + return null !== $request ? $this->consent->selectedOptionalNames($request) : []; + } + + /** + * @return array + */ + public function triggerAttributes(): array + { + return [ + 'aria-controls' => 'cookie-consent', + 'data-cookie-consent-open' => true, + ]; + } +} diff --git a/src/Privacy/Cookie/CoreCookieConsentProvider.php b/src/Privacy/Cookie/CoreCookieConsentProvider.php new file mode 100644 index 00000000..069aff47 --- /dev/null +++ b/src/Privacy/Cookie/CoreCookieConsentProvider.php @@ -0,0 +1,20 @@ +assertSupportedSecret(); + if (null !== $this->databaseReadyState && !$this->databaseReadyState->isReady()) { return; } @@ -85,12 +93,17 @@ public function handle(): void return; } + $mercureStopped = $this->stopMercureBeforeSecretRotation(); + if (!$mercureStopped) { + $this->markMercureUnavailableAfterFailedStop(); + } $apiKeysRevoked = $this->revokeActiveApiKeys(); $resetLinks = $this->issueOwnerPasswordResetLinks(); $this->storeFingerprint($fingerprints, $environmentKey, $currentFingerprint); $this->auditLogger->log(AccessActor::fromAccess(9, [], username: 'system'), 'security.app_secret_rotated', [ 'environment' => $this->environment, + 'mercure_stopped' => $mercureStopped, 'api_keys_revoked' => $apiKeysRevoked, 'password_reset_link_owners' => $resetLinks['owners'], 'password_reset_links_issued' => $resetLinks['issued'], @@ -113,6 +126,47 @@ public function handle(): void ], ); } + + if ($mercureStopped) { + $this->refreshMercureAfterSecretRotation(); + } + } + + private function assertSupportedSecret(): void + { + if (strlen($this->secret) >= SetupInputValidator::MIN_APP_SECRET_LENGTH) { + return; + } + + throw new RuntimeException(sprintf( + 'The configured APP_SECRET is unsupported: it must be at least %d bytes.', + SetupInputValidator::MIN_APP_SECRET_LENGTH, + )); + } + + private function stopMercureBeforeSecretRotation(): bool + { + try { + return null === $this->mercureRuntime || $this->mercureRuntime->stop(); + } catch (Throwable) { + return false; + } + } + + private function markMercureUnavailableAfterFailedStop(): void + { + try { + $this->config->set(MercureAvailability::AVAILABLE_KEY, false, ConfigValueType::Boolean); + } catch (Throwable) { + } + } + + private function refreshMercureAfterSecretRotation(): void + { + try { + $this->mercureAvailability?->refresh(recover: true); + } catch (Throwable) { + } } private function schemaReady(): bool diff --git a/src/Setup/SetupDryRunPlanner.php b/src/Setup/SetupDryRunPlanner.php index ccbe22df..2a00d8cc 100644 --- a/src/Setup/SetupDryRunPlanner.php +++ b/src/Setup/SetupDryRunPlanner.php @@ -83,6 +83,11 @@ public function steps(string $projectDir, SetupInput $input, string $appSecret, 'dry_run' => true, 'command' => [...$phpCommand, $projectDir.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env='.$input->appEnv(), '--json'], ], ActionLogStatus::Skipped], + ['run_mercure_health', fn (): array => [ + 'dry_run' => true, + 'stop_command' => [...$phpCommand, $projectDir.'/bin/console', 'mercure:stop', '--env='.$input->appEnv()], + 'command' => [...$phpCommand, $projectDir.'/bin/console', 'mercure:health', '--env='.$input->appEnv()], + ], ActionLogStatus::Skipped], ['mark_setup_completed', fn (): array => [ 'dry_run' => true, 'would_write' => [ diff --git a/src/Setup/SetupInputValidator.php b/src/Setup/SetupInputValidator.php index 7f6d870d..17db5b3e 100644 --- a/src/Setup/SetupInputValidator.php +++ b/src/Setup/SetupInputValidator.php @@ -11,7 +11,7 @@ final readonly class SetupInputValidator { - public const MIN_APP_SECRET_LENGTH = 12; + public const MIN_APP_SECRET_LENGTH = 32; public function __construct( private SetupPasswordPolicy $passwordPolicy = new SetupPasswordPolicy(), diff --git a/src/Setup/SetupRunner.php b/src/Setup/SetupRunner.php index 9c8eacba..5e936320 100644 --- a/src/Setup/SetupRunner.php +++ b/src/Setup/SetupRunner.php @@ -186,6 +186,7 @@ private function steps(SetupInput $input, string $appSecret, string $databaseUrl ['clear_cache', fn (): array => $this->runtimeCommands->clearCache($this->projectDir, $input, $environment, $this->commandExecutor)], ['run_package_discovery', fn (): array => $this->runtimeCommands->runPackageDiscovery($this->projectDir, $input, $environment, $this->commandExecutor)], ['run_asset_rebuild', fn (): array => $this->runtimeCommands->runAssetRebuild($this->projectDir, $input, $environment, $this->commandExecutor)], + ['run_mercure_health', fn (): array => $this->runtimeCommands->runMercureHealth($this->projectDir, $input, $environment, $this->commandExecutor)], ['mark_setup_completed', fn (): array => $this->completionMarker->markComplete($this->projectDir, $input->appEnv())], ]; } diff --git a/src/Setup/SetupRuntimeCommandRunner.php b/src/Setup/SetupRuntimeCommandRunner.php index 5002bc8d..3fe16836 100644 --- a/src/Setup/SetupRuntimeCommandRunner.php +++ b/src/Setup/SetupRuntimeCommandRunner.php @@ -144,6 +144,51 @@ public function runAssetRebuild( ]; } + /** + * @param array $environment + * + * @return array + */ + public function runMercureHealth( + string $projectDir, + SetupInput $input, + array $environment, + SetupCommandExecutorInterface $commandExecutor, + ): array { + $commandEnvironment = $this->mercureHealthCommandEnvironment($environment); + $phpCommand = $this->phpCliCommandPrefix($projectDir, $input, $environment, true); + $stopCommand = [ + ...$phpCommand, + $projectDir.'/bin/console', + 'mercure:stop', + '--env='.$input->appEnv(), + ]; + $healthCommand = [ + ...$phpCommand, + $projectDir.'/bin/console', + 'mercure:health', + '--env='.$input->appEnv(), + ]; + $stopResult = $commandExecutor->run($stopCommand, $projectDir, $commandEnvironment); + if (!$stopResult->isSuccessful()) { + return [ + 'stop_command' => $stopCommand, + 'command' => $healthCommand, + 'stopped' => false, + 'available' => false, + ]; + } + + $result = $commandExecutor->run($healthCommand, $projectDir, $commandEnvironment); + + return [ + 'stop_command' => $stopCommand, + 'command' => $healthCommand, + 'stopped' => true, + 'available' => $result->isSuccessful(), + ]; + } + /** * @return list */ @@ -251,6 +296,23 @@ private function assetRebuildCommandEnvironment(SetupInput $input, array $enviro ]; } + /** + * @param array $environment + * + * @return array + */ + private function mercureHealthCommandEnvironment(array $environment): array + { + $environment = $this->databaseEnvironmentScope->commandEnvironment($environment); + $appSecret = trim($environment['APP_SECRET'] ?? ''); + + if ('' !== $appSecret) { + $environment['MERCURE_JWT_SECRET'] = $appSecret; + } + + return $environment; + } + /** * @param array $environment * diff --git a/src/View/Alert/DispatchUiAlertMessage.php b/src/View/Alert/DispatchUiAlertMessage.php new file mode 100644 index 00000000..1e5e99ac --- /dev/null +++ b/src/View/Alert/DispatchUiAlertMessage.php @@ -0,0 +1,21 @@ + $payload + */ + public function __construct( + public string $topic, + public array $payload, + public UiAlertDelivery $delivery = UiAlertDelivery::Queue, + public bool $private = false, + public ?int $ttlSeconds = 86400, + public ?string $locale = null, + ) { + } +} diff --git a/src/View/Alert/DispatchUiAlertMessageHandler.php b/src/View/Alert/DispatchUiAlertMessageHandler.php new file mode 100644 index 00000000..6c7ffe5d --- /dev/null +++ b/src/View/Alert/DispatchUiAlertMessageHandler.php @@ -0,0 +1,49 @@ +payload['message'] ?? '')); + if ('' === $text) { + return; + } + + try { + $alert = new UiAlert( + $text, + (string) ($message->payload['level'] ?? 'info'), + (bool) ($message->payload['persistent'] ?? false), + is_string($message->payload['code'] ?? null) ? $message->payload['code'] : null, + is_string($message->payload['translation_key'] ?? null) ? $message->payload['translation_key'] : null, + [], + (string) ($message->payload['mode'] ?? 'auto'), + is_string($message->payload['id'] ?? null) ? $message->payload['id'] : null, + is_array($message->payload['actions'] ?? null) ? $message->payload['actions'] : [], + (bool) ($message->payload['loading'] ?? false), + is_string($message->payload['title'] ?? null) ? $message->payload['title'] : null, + ); + } catch (Throwable) { + return; + } + + $this->dispatcher->addAlertToTopic($message->topic, $alert, new UiAlertDeliveryOptions( + $message->delivery, + $message->private, + $message->ttlSeconds, + $message->locale, + )); + } +} diff --git a/src/View/Alert/DoctrineUiAlertUserIdentityResolver.php b/src/View/Alert/DoctrineUiAlertUserIdentityResolver.php new file mode 100644 index 00000000..67eb7108 --- /dev/null +++ b/src/View/Alert/DoctrineUiAlertUserIdentityResolver.php @@ -0,0 +1,32 @@ +users->findOneBy(['username' => $identifier]); + } catch (Throwable) { + return null; + } + + return $user instanceof UserAccount ? $user->uid() : null; + } +} diff --git a/src/View/Alert/MercureAvailability.php b/src/View/Alert/MercureAvailability.php new file mode 100644 index 00000000..c506b9db --- /dev/null +++ b/src/View/Alert/MercureAvailability.php @@ -0,0 +1,150 @@ +config->get(self::ENABLED_KEY, true); + } + + public function available(bool $refreshIfStale = false): bool + { + if (!$this->enabled()) { + return false; + } + + if ($refreshIfStale && $this->isStale()) { + return $this->refresh(recover: true); + } + + return true === $this->config->get(self::AVAILABLE_KEY, false); + } + + public function refresh(bool $recover = true): bool + { + return $this->refreshStatus($recover)['available']; + } + + /** + * @return array{available: bool, enabled: bool, publish: bool, public: bool, started: bool, stopped: bool} + */ + public function refreshStatus(bool $recover = true): array + { + if (!$this->enabled()) { + $this->store(false); + + return [ + 'available' => false, + 'enabled' => false, + 'publish' => false, + 'public' => false, + 'started' => false, + 'stopped' => false, + ]; + } + + $publishAvailable = $this->runtime->publishHealthProbe(); + $started = false; + $stopped = false; + + if (!$publishAvailable && $recover && $this->startHub()) { + $started = true; + usleep(self::STARTUP_WAIT_MICROSECONDS); + $publishAvailable = $this->runtime->publishHealthProbe(); + } + + if ($publishAvailable) { + if ($this->runtime->publicSubscribeProbe()) { + $this->store(true); + + return [ + 'available' => true, + 'enabled' => true, + 'publish' => true, + 'public' => true, + 'started' => $started, + 'stopped' => false, + ]; + } + + $stopped = $this->runtime->stop(); + } + + $this->store(false); + + return [ + 'available' => false, + 'enabled' => true, + 'publish' => $publishAvailable, + 'public' => false, + 'started' => $started, + 'stopped' => $stopped, + ]; + } + + private function isStale(): bool + { + $checkedAt = $this->config->get(self::CHECKED_AT_KEY); + if (!is_string($checkedAt) || '' === trim($checkedAt)) { + return true; + } + + try { + $checked = new DateTimeImmutable($checkedAt); + } catch (Throwable) { + return true; + } + + return time() - $checked->getTimestamp() >= self::CHECK_INTERVAL_SECONDS; + } + + private function store(bool $available): void + { + $this->config->set(self::AVAILABLE_KEY, $available, ConfigValueType::Boolean); + $this->config->set(self::CHECKED_AT_KEY, (new DateTimeImmutable())->format(DATE_ATOM), ConfigValueType::String); + } + + private function startHub(): bool + { + if (!$this->runtime->canStart()) { + return false; + } + + try { + return $this->starter->start( + $this->runtime->startCommand(), + $this->projectDir, + $this->runtime->logPath(), + $this->runtime->pidPath(), + $this->runtime->startEnvironment(), + ); + } catch (Throwable) { + return false; + } + } +} diff --git a/src/View/Alert/MercureUiAlertPublisher.php b/src/View/Alert/MercureUiAlertPublisher.php new file mode 100644 index 00000000..0b997950 --- /dev/null +++ b/src/View/Alert/MercureUiAlertPublisher.php @@ -0,0 +1,62 @@ +alertFactory->create($alert, $locale)->toArray(); + + try { + $data = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + $id = $payload['id'] ?? null; + + return $this->hub->publish(new Update( + $topic, + $data, + private: $private, + id: is_string($id) ? $id : null, + type: 'ui-alert', + )); + } + + public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string + { + try { + $topic = $this->topicFactory->userTopic($user); + } catch (InvalidArgumentException) { + return null; + } + + return $this->publish($topic, $alert, $locale); + } + + public function publishToSession(SessionInterface|string $session, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string + { + return $this->publish($this->topicFactory->sessionTopic($session), $alert, $locale); + } + +} diff --git a/src/View/Alert/RequestUiAlertFlasher.php b/src/View/Alert/RequestUiAlertFlasher.php new file mode 100644 index 00000000..355aa417 --- /dev/null +++ b/src/View/Alert/RequestUiAlertFlasher.php @@ -0,0 +1,32 @@ +requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + return false; + } + + try { + $payload = ['_ui_alert' => true, ...$alert->toArray()]; + $request->getSession()->getFlashBag()->add((string) $payload['level'], $payload); + + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/src/View/Alert/UiAlert.php b/src/View/Alert/UiAlert.php new file mode 100644 index 00000000..d8a6b2f6 --- /dev/null +++ b/src/View/Alert/UiAlert.php @@ -0,0 +1,202 @@ +> $actions + */ + public function __construct( + private string $message, + private string $level = 'info', + private bool $persistent = false, + private ?string $code = null, + private ?string $translationKey = null, + private array $context = [], + private string $mode = 'auto', + private ?string $id = null, + private array $actions = [], + private bool $loading = false, + private ?string $title = null, + ) { + if ('' === trim($this->message)) { + throw new InvalidArgumentException('UI alert message must not be empty.'); + } + } + + /** + * @param list> $actions + */ + public static function fromLevel( + string $level, + string $message, + bool $persistent = false, + string $mode = 'auto', + ?string $id = null, + array $actions = [], + bool $loading = false, + ?string $title = null, + ): self + { + return new self($message, $level, $persistent, mode: $mode, id: $id, actions: $actions, loading: $loading, title: $title); + } + + /** + * @param array $context + * @param list> $actions + */ + public static function translated( + string $message, + MessageLevel|string $level, + string $code, + string $translationKey, + array $context = [], + bool $persistent = false, + string $mode = 'auto', + ?string $id = null, + array $actions = [], + bool $loading = false, + ?string $title = null, + ): self + { + return new self( + $message, + $level instanceof MessageLevel ? $level->value : $level, + $persistent, + $code, + $translationKey, + $context, + $mode, + $id, + $actions, + $loading, + $title, + ); + } + + public function withPresentation(?UiAlertPresentation $presentation): self + { + if (!$presentation instanceof UiAlertPresentation) { + return $this; + } + + $mode = $presentation->mode() ?? $this->mode; + $actions = $presentation->actions(); + + return new self( + $this->message, + $this->level, + UiAlertMode::Persistent->value === $mode || (null === $presentation->mode() && $this->persistent), + $this->code, + $this->translationKey, + $this->context, + $mode, + $presentation->id() ?? $this->id, + [] !== $actions ? $actions : $this->actions, + $presentation->isLoading() ?? $this->loading, + $presentation->title() ?? $this->title, + ); + } + + public function withId(string $id): self + { + $id = trim($id); + if ('' === $id || $id === $this->id) { + return $this; + } + + return new self( + $this->message, + $this->level, + $this->persistent, + $this->code, + $this->translationKey, + $this->context, + $this->mode, + $id, + $this->actions, + $this->loading, + $this->title, + ); + } + + public function hasId(): bool + { + return null !== $this->id && '' !== trim($this->id); + } + + /** + * @return array{message: string, level: string, persistent: bool, mode: string, loading: bool, title?: string, id?: string, actions?: list>, code?: string, translation_key?: string} + */ + public function toArray(): array + { + $payload = [ + 'message' => $this->message, + 'level' => $this->normalizedLevel(), + 'persistent' => $this->persistent, + 'mode' => $this->normalizedMode(), + 'loading' => $this->loading, + ]; + + if (null !== $this->title && '' !== trim($this->title)) { + $payload['title'] = $this->title; + } + + if (null !== $this->id && '' !== trim($this->id)) { + $payload['id'] = $this->id; + } + + $actions = $this->normalizedActions(); + if ([] !== $actions) { + $payload['actions'] = $actions; + } + + if (null !== $this->code) { + $payload['code'] = $this->code; + } + + if (null !== $this->translationKey) { + $payload['translation_key'] = $this->translationKey; + } + + return $payload; + } + + private function normalizedLevel(): string + { + return match (strtolower($this->level)) { + 'success' => 'success', + 'warn', 'warning' => 'warning', + 'error', 'danger' => 'error', + 'exception' => 'exception', + 'debug' => 'debug', + default => 'info', + }; + } + + private function normalizedMode(): string + { + return match (strtolower($this->mode)) { + 'hidden' => 'hidden', + 'persistent' => 'persistent', + default => 'auto', + }; + } + + /** + * @return list> + */ + private function normalizedActions(): array + { + return array_values(array_filter(array_map( + static fn (array $action): ?array => UiAlertAction::normalize($action), + $this->actions, + ))); + } +} diff --git a/src/View/Alert/UiAlertAction.php b/src/View/Alert/UiAlertAction.php new file mode 100644 index 00000000..2ed8510b --- /dev/null +++ b/src/View/Alert/UiAlertAction.php @@ -0,0 +1,130 @@ + $detail + */ + private function __construct( + private string $label, + private ?string $href = null, + private ?string $target = null, + private ?string $event = null, + private array $detail = [], + ) { + } + + public static function link(string $label, string $href, ?string $target = null): self + { + return new self($label, href: $href, target: $target); + } + + /** + * @param array $detail + */ + public static function event(string $label, string $event, array $detail = []): self + { + return new self($label, event: $event, detail: $detail); + } + + /** + * @param array $action + * + * @return array{label: string, href?: string, target?: string, event?: string, detail?: array}|null + */ + public static function normalize(array $action): ?array + { + $label = trim((string) ($action['label'] ?? '')); + if ('' === $label) { + return null; + } + + $normalized = ['label' => $label]; + $href = isset($action['href']) ? trim((string) $action['href']) : ''; + $event = isset($action['event']) ? trim((string) $action['event']) : ''; + + if ('' !== $href) { + if (!self::hrefAllowed($href)) { + return null; + } + + $normalized['href'] = $href; + $target = isset($action['target']) ? trim((string) $action['target']) : ''; + if ('' !== $target && self::targetAllowed($target)) { + $normalized['target'] = $target; + } + + return $normalized; + } + + if ('' !== $event) { + $normalized['event'] = $event; + } + + if (isset($action['detail']) && is_array($action['detail'])) { + $normalized['detail'] = $action['detail']; + } + + return isset($normalized['event']) ? $normalized : null; + } + + /** + * @return array{label: string, href?: string, target?: string, event?: string, detail?: array} + */ + public function toArray(): array + { + $action = ['label' => trim($this->label)]; + + if (null !== $this->href && '' !== trim($this->href) && self::hrefAllowed($this->href)) { + $action['href'] = trim($this->href); + } + + if (isset($action['href']) && null !== $this->target && '' !== trim($this->target) && self::targetAllowed($this->target)) { + $action['target'] = trim($this->target); + } + + if (null !== $this->event && '' !== trim($this->event)) { + $action['event'] = $this->event; + } + + if ([] !== $this->detail) { + $action['detail'] = $this->detail; + } + + return $action; + } + + private static function hrefAllowed(string $href): bool + { + $href = trim($href); + if ('' === $href || str_contains($href, '\\') || preg_match('/[\x00-\x1F\x7F]/', $href)) { + return false; + } + + if (str_starts_with($href, '/')) { + return !str_starts_with($href, '//'); + } + + $scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME)); + if ('' === $scheme) { + return false; + } + + if (in_array($scheme, ['http', 'https'], true)) { + $host = parse_url($href, PHP_URL_HOST); + + return is_string($host) && '' !== trim($host); + } + + return 'mailto' === $scheme; + } + + private static function targetAllowed(string $target): bool + { + return in_array(trim($target), ['_blank', '_self', '_parent', '_top'], true); + } +} diff --git a/src/View/Alert/UiAlertDelivery.php b/src/View/Alert/UiAlertDelivery.php new file mode 100644 index 00000000..476a25c2 --- /dev/null +++ b/src/View/Alert/UiAlertDelivery.php @@ -0,0 +1,21 @@ + UiAlertDeliveryOptions::direct(), + self::Queue => UiAlertDeliveryOptions::queued(), + self::Push => UiAlertDeliveryOptions::push(), + }; + } +} diff --git a/src/View/Alert/UiAlertDeliveryOptions.php b/src/View/Alert/UiAlertDeliveryOptions.php new file mode 100644 index 00000000..21b4bc90 --- /dev/null +++ b/src/View/Alert/UiAlertDeliveryOptions.php @@ -0,0 +1,66 @@ +delivery; + } + + public function queues(): bool + { + return UiAlertDelivery::Queue === $this->delivery; + } + + public function pushes(): bool + { + return UiAlertDelivery::Queue === $this->delivery || UiAlertDelivery::Push === $this->delivery; + } + + public function flashes(): bool + { + return UiAlertDelivery::Direct === $this->delivery; + } + + public function private(): bool + { + return $this->private; + } + + public function ttlSeconds(): ?int + { + return $this->ttlSeconds; + } + + public function locale(): ?string + { + return $this->locale; + } +} diff --git a/src/View/Alert/UiAlertDispatcher.php b/src/View/Alert/UiAlertDispatcher.php new file mode 100644 index 00000000..f004e5e2 --- /dev/null +++ b/src/View/Alert/UiAlertDispatcher.php @@ -0,0 +1,158 @@ +options($delivery); + $uiAlert = $this->alertFactory->create($alert, $options->locale(), $presentation); + + if ($options->flashes()) { + return $this->flasher->flash($uiAlert); + } + + $uiAlert = $this->ensureAlertId($uiAlert); + $topics = $this->currentTopics(); + if ([] === $topics) { + return $options->queues() ? $this->flasher->flash($uiAlert) : false; + } + + $queued = $options->queues() && null !== $this->inbox->append($topics, $uiAlert, $options->ttlSeconds()); + $pushed = $options->pushes() && $this->pushTopics($topics, $uiAlert, $options); + + return $queued || $pushed; + } + + public function addAlertToTopic( + string $topic, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool + { + if (!$this->topicFactory->isUiAlertTopic($topic)) { + return false; + } + + $options = $this->options($delivery); + $uiAlert = $this->alertFactory->create($alert, $options->locale(), $presentation); + if (!$options->flashes()) { + $uiAlert = $this->ensureAlertId($uiAlert); + } + $queued = false; + $pushed = false; + $flashed = false; + + if ($options->queues()) { + $queued = null !== $this->inbox->append([$topic], $uiAlert, $options->ttlSeconds()); + } elseif ($options->flashes()) { + $flashed = $this->flasher->flash($uiAlert); + } + + $pushed = $options->pushes() && $this->pushTopics([$topic], $uiAlert, $options); + + return $queued || $pushed || $flashed; + } + + public function addAlertToUser( + UserAccount|UserInterface|string $user, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool + { + try { + $topic = $this->topicFactory->userTopic($user); + } catch (InvalidArgumentException) { + return false; + } + + return $this->addAlertToTopic($topic, $alert, $delivery, $presentation); + } + + public function addAlertToSession( + SessionInterface|string $session, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool + { + return $this->addAlertToTopic($this->topicFactory->sessionTopic($session), $alert, $delivery, $presentation); + } + + private function options(UiAlertDelivery|UiAlertDeliveryOptions $delivery): UiAlertDeliveryOptions + { + return $delivery instanceof UiAlertDeliveryOptions ? $delivery : $delivery->toOptions(); + } + + private function ensureAlertId(UiAlert $alert): UiAlert + { + return $alert->hasId() ? $alert : $alert->withId('ui-alert-'.$this->uuidFactory->generate()); + } + + /** + * @return list + */ + private function currentTopics(): array + { + $user = $this->security->getUser(); + + return $this->topicFactory->topicsFor( + $this->requestStack->getMainRequest(), + $user instanceof UserInterface ? $user : null, + ); + } + + /** + * @param list $topics + */ + private function pushTopics(array $topics, UiAlert $alert, UiAlertDeliveryOptions $options): bool + { + if (!$this->mercureAvailability->available()) { + return false; + } + + $pushed = false; + + foreach ($topics as $topic) { + try { + $pushed = null !== $this->publisher->publish($topic, $alert, $options->locale(), $options->private()) || $pushed; + } catch (Throwable) { + continue; + } + } + + return $pushed; + } +} diff --git a/src/View/Alert/UiAlertDispatcherInterface.php b/src/View/Alert/UiAlertDispatcherInterface.php new file mode 100644 index 00000000..ad2fbe78 --- /dev/null +++ b/src/View/Alert/UiAlertDispatcherInterface.php @@ -0,0 +1,40 @@ + $topics + */ + public function append(array $topics, UiAlert $alert, ?int $ttlSeconds = 86400): ?int + { + $topics = $this->normalizeTopics($topics); + if ([] === $topics) { + return null; + } + + $now = new DateTimeImmutable(); + $expiresAt = null; + if (null !== $ttlSeconds && $ttlSeconds > 0) { + $expiresAt = $now->add(new DateInterval('PT'.$ttlSeconds.'S')); + } + + try { + $inserted = 0; + + foreach ($topics as $topic) { + $this->connection->insert('ui_alert_inbox', [ + 'topic' => $topic, + 'payload' => $alert->toArray(), + 'created_at' => $now, + 'expires_at' => $expiresAt, + ], [ + 'topic' => ParameterType::STRING, + 'payload' => Types::JSON, + 'created_at' => Types::DATETIME_IMMUTABLE, + 'expires_at' => Types::DATETIME_IMMUTABLE, + ]); + ++$inserted; + } + + return $inserted; + } catch (Throwable) { + return null; + } + } + + /** + * @param list $topics + * + * @return array{cursor: int, alerts: list>, has_more: bool} + */ + public function poll(array $topics, int $cursor = 0, int $limit = self::DEFAULT_LIMIT): array + { + $topics = $this->normalizeTopics($topics); + if ([] === $topics) { + return ['cursor' => max(0, $cursor), 'alerts' => [], 'has_more' => false]; + } + + try { + $now = new DateTimeImmutable(); + $limit = max(1, min(250, $limit)); + $rows = $this->connection->fetchAllAssociative( + 'SELECT id, payload FROM ui_alert_inbox WHERE topic IN (?) AND id > ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY id ASC LIMIT ?', + [$topics, max(0, $cursor), $now, $limit + 1], + [ArrayParameterType::STRING, ParameterType::INTEGER, Types::DATETIME_IMMUTABLE, ParameterType::INTEGER], + ); + } catch (Throwable) { + return ['cursor' => max(0, $cursor), 'alerts' => [], 'has_more' => false]; + } + + $alerts = []; + $nextCursor = max(0, $cursor); + $hasMore = count($rows) > $limit; + $rows = array_slice($rows, 0, $limit); + + foreach ($rows as $row) { + $nextCursor = max($nextCursor, (int) ($row['id'] ?? 0)); + $payload = $this->decodePayload($row['payload'] ?? null); + if ([] !== $payload) { + $alerts[] = $payload; + } + } + + return ['cursor' => $nextCursor, 'alerts' => $alerts, 'has_more' => $hasMore]; + } + + public function cleanupExpired(): int + { + return $this->connection->executeStatement( + 'DELETE FROM ui_alert_inbox WHERE expires_at IS NOT NULL AND expires_at <= ?', + [new DateTimeImmutable()], + [Types::DATETIME_IMMUTABLE], + ); + } + + /** + * @param list $topics + * + * @return list + */ + private function normalizeTopics(array $topics): array + { + return array_values(array_unique(array_filter( + array_map(static fn (mixed $topic): string => self::topicKey(trim((string) $topic)), $topics), + static fn (string $topic): bool => '' !== $topic, + ))); + } + + private static function topicKey(string $topic): string + { + return '' === $topic ? '' : 'sha256:'.hash('sha256', $topic); + } + + /** + * @return array + */ + private function decodePayload(mixed $payload): array + { + try { + $decoded = json_decode((string) $payload, true, 512, JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : []; + } catch (Throwable) { + return []; + } + } +} diff --git a/src/View/Alert/UiAlertMessageFactory.php b/src/View/Alert/UiAlertMessageFactory.php new file mode 100644 index 00000000..c8460d7f --- /dev/null +++ b/src/View/Alert/UiAlertMessageFactory.php @@ -0,0 +1,37 @@ +withPresentation($presentation); + } + + if ($alert instanceof UiAlertTranslation) { + return UiAlert::fromLevel( + $alert->level(), + $this->translator->trans($alert->translationKey(), $alert->parameters(), locale: $locale), + )->withPresentation($presentation); + } + + return UiAlert::translated( + $this->translator->trans($alert->translationKey(), $alert->parameters(), locale: $locale), + $alert->level(), + $alert->code(), + $alert->translationKey(), + )->withPresentation($presentation); + } + +} diff --git a/src/View/Alert/UiAlertMode.php b/src/View/Alert/UiAlertMode.php new file mode 100644 index 00000000..49a88f9f --- /dev/null +++ b/src/View/Alert/UiAlertMode.php @@ -0,0 +1,12 @@ +> $actions + */ + public function __construct( + private UiAlertMode|string|null $mode = null, + private ?string $title = null, + private ?string $id = null, + private array $actions = [], + private ?bool $loading = null, + ) { + } + + /** + * @param list> $actions + */ + public static function auto(?string $title = null, array $actions = [], ?string $id = null): self + { + return new self(UiAlertMode::Auto, $title, $id, $actions); + } + + /** + * @param list> $actions + */ + public static function hidden(?string $title = null, array $actions = [], ?string $id = null): self + { + return new self(UiAlertMode::Hidden, $title, $id, $actions); + } + + /** + * @param list> $actions + */ + public static function persistent(?string $title = null, array $actions = [], ?string $id = null): self + { + return new self(UiAlertMode::Persistent, $title, $id, $actions); + } + + /** + * @param list> $actions + */ + public static function loading(?string $title = null, array $actions = [], ?string $id = null): self + { + return new self(UiAlertMode::Persistent, $title, $id, $actions, true); + } + + public function mode(): ?string + { + if ($this->mode instanceof UiAlertMode) { + return $this->mode->value; + } + + $mode = strtolower(trim((string) $this->mode)); + + return match ($mode) { + 'auto', 'hidden', 'persistent' => $mode, + default => null, + }; + } + + public function title(): ?string + { + return null !== $this->title && '' !== trim($this->title) ? $this->title : null; + } + + public function id(): ?string + { + return null !== $this->id && '' !== trim($this->id) ? $this->id : null; + } + + /** + * @return list> + */ + public function actions(): array + { + return array_values(array_filter(array_map( + static fn (UiAlertAction|array $action): ?array => $action instanceof UiAlertAction + ? UiAlertAction::normalize($action->toArray()) + : UiAlertAction::normalize($action), + $this->actions, + ))); + } + + public function isLoading(): ?bool + { + return $this->loading; + } +} diff --git a/src/View/Alert/UiAlertPublisherInterface.php b/src/View/Alert/UiAlertPublisherInterface.php new file mode 100644 index 00000000..9e2fe60e --- /dev/null +++ b/src/View/Alert/UiAlertPublisherInterface.php @@ -0,0 +1,19 @@ +topic('user', $this->userIdentity($user)); + } + + public function sessionTopic(SessionInterface|string $session): string + { + $sessionId = $session instanceof SessionInterface ? $session->getId() : $session; + + return $this->topic('session', $sessionId); + } + + /** + * @return list + */ + public function topicsFor(?Request $request, ?UserInterface $user): array + { + $topics = []; + + if ($user instanceof UserAccount) { + $topics[] = $this->userTopic($user); + } + + if (null !== $request && $request->hasSession()) { + $session = $request->getSession(); + $sessionId = $this->sessionId($request, $session); + if (null !== $sessionId) { + $topics[] = $this->sessionTopic($sessionId); + } + } + + return array_values(array_unique($topics)); + } + + public function isUiAlertTopic(string $topic): bool + { + $matches = preg_match('/^'.preg_quote(self::PREFIX, '/').'(user|session):[a-f0-9]{64}$/', $topic); + + return 1 === $matches; + } + + private function topic(string $scope, string $identity): string + { + return self::PREFIX.$scope.':'.$this->hash($scope, $identity); + } + + private function hash(string $scope, string $identity): string + { + return hash_hmac('sha256', $scope.':'.$identity, $this->secret); + } + + private function userIdentity(UserAccount|UserInterface|string $user): string + { + $identity = $user instanceof UserAccount + ? $user->uid() + : ($user instanceof UserInterface ? $user->getUserIdentifier() : $user); + + $identity = trim($identity); + $normalizedUid = strtolower($identity); + if (1 === preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $normalizedUid)) { + return $normalizedUid; + } + + $resolvedUid = $this->userIdentityResolver?->resolveUid($identity); + if (is_string($resolvedUid) && 1 === preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', strtolower($resolvedUid))) { + return strtolower($resolvedUid); + } + + throw new InvalidArgumentException('UI alert user topics require an account UID or resolvable username.'); + } + + private function sessionId(Request $request, SessionInterface $session): ?string + { + if ($session->isStarted()) { + return $session->getId(); + } + + $cookieValue = $request->cookies->get($session->getName()); + if (!is_string($cookieValue)) { + return null; + } + + $sessionId = trim($cookieValue); + + return '' !== $sessionId ? $sessionId : null; + } +} diff --git a/src/View/Alert/UiAlertTranslation.php b/src/View/Alert/UiAlertTranslation.php new file mode 100644 index 00000000..5cd4cc2d --- /dev/null +++ b/src/View/Alert/UiAlertTranslation.php @@ -0,0 +1,68 @@ + $parameters + */ + public function __construct( + private string $level, + private string $translationKey, + private array $parameters = [], + ) { + } + + /** + * @param array $parameters + */ + public static function success(string $translationKey, array $parameters = []): self + { + return new self('success', $translationKey, $parameters); + } + + /** + * @param array $parameters + */ + public static function warning(string $translationKey, array $parameters = []): self + { + return new self('warning', $translationKey, $parameters); + } + + /** + * @param array $parameters + */ + public static function error(string $translationKey, array $parameters = []): self + { + return new self('error', $translationKey, $parameters); + } + + /** + * @param array $parameters + */ + public static function forLevel(string $level, string $translationKey, array $parameters = []): self + { + return new self($level, $translationKey, $parameters); + } + + public function level(): string + { + return $this->level; + } + + public function translationKey(): string + { + return $this->translationKey; + } + + /** + * @return array + */ + public function parameters(): array + { + return $this->parameters; + } +} diff --git a/src/View/Alert/UiAlertUserIdentityResolverInterface.php b/src/View/Alert/UiAlertUserIdentityResolverInterface.php new file mode 100644 index 00000000..1d4169f8 --- /dev/null +++ b/src/View/Alert/UiAlertUserIdentityResolverInterface.php @@ -0,0 +1,10 @@ + $result + */ + public function fromResult(WorkflowResult $result, string $fallbackSuccessKey = BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED): Message + { + if (!$result->isSuccess()) { + return $result->firstIssue() + ?? Message::error(CommonMessageCode::E_OPERATION_FAILED, OperationMessageKey::OPERATION_EXCEPTION); + } + + foreach ($result->messages() as $message) { + if ($message->level() === MessageLevel::Success) { + return $message; + } + } + + $message = $result->messages()[0] ?? null; + if ($message instanceof Message) { + return Message::success($message->translationKey(), $message->parameters()); + } + + return Message::success($fallbackSuccessKey); + } +} diff --git a/src/View/Chart/ChartFactory.php b/src/View/Chart/ChartFactory.php new file mode 100644 index 00000000..9fb3e9b3 --- /dev/null +++ b/src/View/Chart/ChartFactory.php @@ -0,0 +1,74 @@ + $labels + * @param list> $datasets + * @param array $options + */ + public function line(array $labels, array $datasets, array $options = []): Chart + { + return $this->chart(Chart::TYPE_LINE, $labels, $datasets, $options); + } + + /** + * @param list $labels + * @param list> $datasets + * @param array $options + */ + public function bar(array $labels, array $datasets, array $options = []): Chart + { + return $this->chart(Chart::TYPE_BAR, $labels, $datasets, $options); + } + + /** + * @param list $labels + * @param list> $datasets + * @param array $options + */ + public function doughnut(array $labels, array $datasets, array $options = []): Chart + { + return $this->chart(Chart::TYPE_DOUGHNUT, $labels, $datasets, $options); + } + + /** + * @param list $data + * @param array $options + * + * @return array + */ + public function dataset(string $label, array $data, array $options = []): array + { + return ['label' => $label, 'data' => $data] + $options; + } + + /** + * @param list $labels + * @param list> $datasets + * @param array $options + */ + private function chart(string $type, array $labels, array $datasets, array $options): Chart + { + $chart = $this->chartBuilder->createChart($type); + $chart->setData([ + 'labels' => $labels, + 'datasets' => $datasets, + ]); + $chart->setOptions($options); + + return $chart; + } +} diff --git a/src/View/Http/HttpErrorRenderer.php b/src/View/Http/HttpErrorRenderer.php index 4c5df1a1..cfd862f0 100644 --- a/src/View/Http/HttpErrorRenderer.php +++ b/src/View/Http/HttpErrorRenderer.php @@ -176,7 +176,16 @@ private function returnTo(Request $request): ?string { $uri = $request->getRequestUri(); - return str_starts_with($uri, '/') && !str_starts_with($uri, '//') ? $uri : null; + return $this->isSafeLocalTarget($uri) ? $uri : null; + } + + private function isSafeLocalTarget(string $target): bool + { + return '' !== $target + && str_starts_with($target, '/') + && !str_starts_with($target, '//') + && !str_contains($target, '\\') + && 1 !== preg_match('/[\x00-\x1F\x7F]/', $target); } private function isAuthenticated(): bool diff --git a/src/View/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php new file mode 100644 index 00000000..1f246517 --- /dev/null +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -0,0 +1,111 @@ +streamTopics(...)), + new TwigFunction('ui_alert_stream_url', $this->streamUrl(...)), + new TwigFunction('ui_alert_storage_scope', $this->storageScope(...)), + ]; + } + + /** + * @return list + */ + public function streamTopics(): array + { + $user = $this->security->getUser(); + + return $this->topicFactory->topicsFor( + $this->requestStack->getMainRequest(), + $user instanceof UserInterface ? $user : null, + ); + } + + /** + * @param list|null $topics + */ + public function streamUrl(?array $topics = null): ?string + { + $topics ??= $this->streamTopics(); + + if ([] === $topics || null === $this->mercure || !$this->mercureAvailability->available()) { + return null; + } + + try { + return $this->mercure->mercure($topics, $this->authorizationOptions($topics)); + } catch (Throwable) { + return null; + } + } + + public function storageScope(): string + { + $request = $this->requestStack->getMainRequest(); + $surface = str_starts_with((string) $request?->getPathInfo(), '/admin') ? 'backend' : 'frontend'; + $user = $this->security->getUser(); + $userScope = $user instanceof UserInterface ? $user->getUserIdentifier() : 'anonymous'; + $sessionScope = 'no-session'; + + if ($request?->hasSession()) { + try { + $session = $request->getSession(); + if ($session instanceof SessionInterface && $session->isStarted()) { + $sessionScope = $session->getId(); + } elseif ($session instanceof SessionInterface) { + $cookieValue = $request->cookies->get($session->getName()); + $sessionScope = is_string($cookieValue) && '' !== trim($cookieValue) + ? trim($cookieValue) + : $sessionScope; + } + } catch (Throwable) { + $sessionScope = 'no-session'; + } + } + + return $surface.'.'.substr(hash_hmac('sha256', $surface.'|'.$userScope.'|'.$sessionScope, $this->secret), 0, 32); + } + + /** + * @param list $topics + * + * @return array{subscribe?: list} + */ + private function authorizationOptions(array $topics): array + { + $request = $this->requestStack->getMainRequest(); + if (null !== $request && [] !== $request->attributes->get('_mercure_authorization_cookies', [])) { + return []; + } + + return ['subscribe' => $topics]; + } +} diff --git a/symfony.lock b/symfony.lock index 6601635e..9b06cad9 100644 --- a/symfony.lock +++ b/symfony.lock @@ -192,15 +192,6 @@ "config/packages/mercure.yaml" ] }, - "symfony/mercure-notifier": { - "version": "8.1", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "5.3", - "ref": "4eaf30b6f48b69934a49e26dd0348e6ebfb97f12" - } - }, "symfony/messenger": { "version": "8.1", "recipe": { @@ -410,9 +401,6 @@ "symfony/ux-native": { "version": "v3.1.0" }, - "symfony/ux-notify": { - "version": "v3.1.0" - }, "symfony/ux-react": { "version": "3.1", "recipe": { diff --git a/templates/backend/admin/logs.html.twig b/templates/backend/admin/logs.html.twig index 15f8cc0f..e94d3287 100644 --- a/templates/backend/admin/logs.html.twig +++ b/templates/backend/admin/logs.html.twig @@ -16,7 +16,7 @@

{{ 'admin.logs.filters.title'|trans }}

-
+
+ {% endblock %} diff --git a/templates/backend/admin/partials/_page-header.html.twig b/templates/backend/admin/partials/_page-header.html.twig index 730ecf7a..ee3819a5 100644 --- a/templates/backend/admin/partials/_page-header.html.twig +++ b/templates/backend/admin/partials/_page-header.html.twig @@ -1,9 +1 @@ -
-
-
{{ eyebrow|default('ui.admin.area'|trans) }}
-

{{ title }}

-
- {% if actions is defined and actions is not empty %} -
{{ actions|raw }}
- {% endif %} -
+ diff --git a/templates/backend/admin/statistics.html.twig b/templates/backend/admin/statistics.html.twig index 10743e84..0d59b296 100644 --- a/templates/backend/admin/statistics.html.twig +++ b/templates/backend/admin/statistics.html.twig @@ -20,7 +20,7 @@

{{ 'admin.statistics.disabled'|trans }}

{% endif %}

{{ 'admin.statistics.summary'|trans({'%count%': access_statistics.total_requests}) }}

- +
diff --git a/templates/components/MapView.html.twig b/templates/components/MapView.html.twig new file mode 100644 index 00000000..0257bfe2 --- /dev/null +++ b/templates/components/MapView.html.twig @@ -0,0 +1,10 @@ +{% props center = [51.1657, 10.4515], zoom = 6, markers = [], fit_bounds_to_markers = false %} +
+ +
diff --git a/templates/frontend/components/Button.html.twig b/templates/frontend/components/Button.html.twig new file mode 100644 index 00000000..63f73807 --- /dev/null +++ b/templates/frontend/components/Button.html.twig @@ -0,0 +1,9 @@ +{% props label, href = null, type = null, variant = 'primary', class = '' %} +{% set button_type = type|default(href ? null : 'button') %} +{% set button_class = ['system-button', 'system-button-' ~ variant, class]|filter(item => item is not empty)|join(' ') %} + +{% if href %} + {{ label }} +{% else %} + +{% endif %} diff --git a/templates/frontend/components/ButtonGroup.html.twig b/templates/frontend/components/ButtonGroup.html.twig new file mode 100644 index 00000000..565079b6 --- /dev/null +++ b/templates/frontend/components/ButtonGroup.html.twig @@ -0,0 +1,13 @@ +{% props actions = [], class = '' %} + +
item is not empty)|join(' ')}) }}> + {% for action in actions %} + + {% endfor %} +
diff --git a/templates/frontend/components/EmptyState.html.twig b/templates/frontend/components/EmptyState.html.twig new file mode 100644 index 00000000..e77d90c8 --- /dev/null +++ b/templates/frontend/components/EmptyState.html.twig @@ -0,0 +1,8 @@ +{% props title = 'ui.empty_state.title'|trans, message = null %} + +
+

{{ title }}

+ {% if message %} +

{{ message }}

+ {% endif %} +
diff --git a/templates/frontend/components/PageHeader.html.twig b/templates/frontend/components/PageHeader.html.twig new file mode 100644 index 00000000..7c450bf6 --- /dev/null +++ b/templates/frontend/components/PageHeader.html.twig @@ -0,0 +1,13 @@ +{% props title, kicker = null, subtitle = null %} + +
+
+ {% if kicker %} +
{{ kicker }}
+ {% endif %} +

{{ title }}

+ {% if subtitle %} +

{{ subtitle }}

+ {% endif %} +
+
diff --git a/templates/frontend/partials/_flash.html.twig b/templates/frontend/partials/_flash.html.twig index e4651700..85ee170e 100644 --- a/templates/frontend/partials/_flash.html.twig +++ b/templates/frontend/partials/_flash.html.twig @@ -1,10 +1,14 @@ {% set alerts = [] %} {% for label, messages in app.flashes %} {% for message in messages %} - {% set alerts = alerts|merge([{ - level: label, - message: message is iterable and message.translation_key is defined ? message.translation_key|trans(message.parameters|default({})) : message|trans, - }]) %} + {% if message is iterable and message._ui_alert|default(false) %} + {% set alerts = alerts|merge([message]) %} + {% else %} + {% set alerts = alerts|merge([{ + level: label, + message: message is iterable and message.translation_key is defined ? message.translation_key|trans(message.parameters|default({})) : message|trans, + }]) %} + {% endif %} {% endfor %} {% endfor %} {% include '@root/partials/feedback/_alert-stack.html.twig' with {alerts} only %} diff --git a/templates/frontend/partials/actions/_button-group.html.twig b/templates/frontend/partials/actions/_button-group.html.twig index beb41427..665783d5 100644 --- a/templates/frontend/partials/actions/_button-group.html.twig +++ b/templates/frontend/partials/actions/_button-group.html.twig @@ -1,11 +1 @@ -
- {% for action in actions|default([]) %} - {% include '@frontend/partials/actions/_button.html.twig' with { - label: action.label, - href: action.href|default(null), - type: action.type|default(null), - variant: action.variant|default('secondary'), - class: action.class|default(''), - } only %} - {% endfor %} -
+ diff --git a/templates/frontend/partials/actions/_button.html.twig b/templates/frontend/partials/actions/_button.html.twig index 79ea03e1..cf51fe8b 100644 --- a/templates/frontend/partials/actions/_button.html.twig +++ b/templates/frontend/partials/actions/_button.html.twig @@ -1,9 +1,7 @@ -{% set button_type = type|default(href|default(null) ? null : 'button') %} -{% set button_variant = variant|default('primary') %} -{% set button_class = ['system-button', 'system-button-' ~ button_variant, class|default('')]|filter(item => item is not empty)|join(' ') %} - -{% if href|default(null) %} - {{ label }} -{% else %} - -{% endif %} + diff --git a/templates/frontend/partials/feedback/_empty-state.html.twig b/templates/frontend/partials/feedback/_empty-state.html.twig index 5c8ce511..1ea47a00 100644 --- a/templates/frontend/partials/feedback/_empty-state.html.twig +++ b/templates/frontend/partials/feedback/_empty-state.html.twig @@ -1,6 +1 @@ -
-

{{ title|default('ui.empty_state.title'|trans) }}

- {% if message|default(null) %} -

{{ message }}

- {% endif %} -
+ diff --git a/templates/frontend/partials/forms/fields/select.html.twig b/templates/frontend/partials/forms/fields/select.html.twig index bdff41bd..351be08f 100644 --- a/templates/frontend/partials/forms/fields/select.html.twig +++ b/templates/frontend/partials/forms/fields/select.html.twig @@ -1,6 +1,17 @@ {% set field_id = id|default(name|default('field')) %} +{% set autocomplete_attributes = autocomplete_enabled|default(false) + ? stimulus_controller('symfony/ux-autocomplete/autocomplete', autocomplete_options|default({})) + : '' +%} {% set select_control %} - {% for option_value, option_label in options|default({}) %} {% endfor %} diff --git a/templates/frontend/partials/forms/fields/toggle.html.twig b/templates/frontend/partials/forms/fields/toggle.html.twig index f7408750..065ac7e4 100644 --- a/templates/frontend/partials/forms/fields/toggle.html.twig +++ b/templates/frontend/partials/forms/fields/toggle.html.twig @@ -8,6 +8,9 @@ role="switch" name="{{ name }}" value="{{ value|default('1') }}" + {% for attr_name, attr_value in attr|default({}) %} + {{ attr_name }}="{{ attr_value }}" + {% endfor %} {% if checked|default(false) %}checked{% endif %} {% if disabled|default(false) %}disabled{% endif %} > diff --git a/templates/frontend/partials/typography/_page-header.html.twig b/templates/frontend/partials/typography/_page-header.html.twig index 222a2607..40697a23 100644 --- a/templates/frontend/partials/typography/_page-header.html.twig +++ b/templates/frontend/partials/typography/_page-header.html.twig @@ -1,11 +1 @@ -
-
- {% if kicker|default(null) %} -
{{ kicker }}
- {% endif %} -

{{ title }}

- {% if subtitle|default(null) %} -

{{ subtitle }}

- {% endif %} -
-
+ diff --git a/templates/frontend/user/api-key-reveal.html.twig b/templates/frontend/user/api-key-reveal.html.twig index 4cf3c4b4..d648a020 100644 --- a/templates/frontend/user/api-key-reveal.html.twig +++ b/templates/frontend/user/api-key-reveal.html.twig @@ -9,9 +9,20 @@ subtitle: 'ui.user.api_keys.reveal.subtitle'|trans, } only %} {% if plain_api_key|default(null) %} -
+

{{ api_key.prefix }}

- {{ plain_api_key }} + {{ plain_api_key }} +
+ + +
{% else %} {% if errors|default([]) is not empty %} diff --git a/templates/frontend/user/profile.html.twig b/templates/frontend/user/profile.html.twig index be8cc638..6dcb9e75 100644 --- a/templates/frontend/user/profile.html.twig +++ b/templates/frontend/user/profile.html.twig @@ -29,7 +29,10 @@
{{ ('ui.user.roles.' ~ user_account.role.value)|trans }}
-
+ {% if username_change_enabled|default(false) %} {% include '@frontend/partials/forms/fields/input.html.twig' with { diff --git a/templates/partials/feedback/_alert-stack.html.twig b/templates/partials/feedback/_alert-stack.html.twig index c0856bf4..7330cb94 100644 --- a/templates/partials/feedback/_alert-stack.html.twig +++ b/templates/partials/feedback/_alert-stack.html.twig @@ -1,23 +1 @@ -{% if alerts|default([]) is not empty %} -
- {% for alert in alerts %} - {% set raw_level = alert.level|default('info') %} - {% set level = {notice: 'info', danger: 'error'}[raw_level]|default(raw_level) %} - {% set level = level in ['debug', 'info', 'success', 'warning', 'error', 'exception'] ? level : 'info' %} - {% set persistent = alert.persistent|default(false) %} -
-
- {{ alert.message }} -
- -
- {% endfor %} -
-{% endif %} + diff --git a/tests/Api/Endpoint/ApiEndpointRegistryTest.php b/tests/Api/Endpoint/ApiEndpointRegistryTest.php new file mode 100644 index 00000000..23dcfe68 --- /dev/null +++ b/tests/Api/Endpoint/ApiEndpointRegistryTest.php @@ -0,0 +1,93 @@ +provider([ + $this->endpoint( + '/api/v1/content/items', + 'listItems', + 'system.api.items', + '#^/api/v1/content/items(?:/.*)?$#', + ), + $this->endpoint( + '/api/v1/content/items/special', + 'specialItems', + 'system.api.items_special', + ), + ])]); + + $endpoint = $registry->endpointForPath('/api/v1/content/items/special', Request::METHOD_GET); + + self::assertInstanceOf(ApiEndpointDefinition::class, $endpoint); + self::assertSame('specialItems', $endpoint->operationId()); + } + + public function testItPrefersMoreSpecificPatternEndpoint(): void + { + $registry = new ApiEndpointRegistry([$this->provider([ + $this->endpoint( + '/api/v1/content/items', + 'listItems', + 'system.api.items', + '#^/api/v1/content/items(?:/.*)?$#', + ), + $this->endpoint( + '/api/v1/content/items/special', + 'specialChildren', + 'system.api.items_special_children', + '#^/api/v1/content/items/special(?:/.*)?$#', + ), + ])]); + + $endpoint = $registry->endpointForPath('/api/v1/content/items/special/child', Request::METHOD_GET); + + self::assertInstanceOf(ApiEndpointDefinition::class, $endpoint); + self::assertSame('specialChildren', $endpoint->operationId()); + } + + /** + * @param list $endpoints + */ + private function provider(array $endpoints): ApiEndpointProviderInterface + { + return new readonly class($endpoints) implements ApiEndpointProviderInterface { + /** + * @param list $endpoints + */ + public function __construct(private array $endpoints) + { + } + + public function apiEndpoints(): array + { + return $this->endpoints; + } + }; + } + + private function endpoint(string $path, string $operationId, string $handlerKey, ?string $pathPattern = null): ApiEndpointDefinition + { + return new ApiEndpointDefinition( + 'system', + Request::METHOD_GET, + $path, + 'api_v1_endpoint_dispatch', + $operationId, + 'Run a demo API endpoint.', + $handlerKey, + pathPattern: $pathPattern, + ); + } +} diff --git a/tests/Backend/PackageAdminLinkResolverTest.php b/tests/Backend/PackageAdminLinkResolverTest.php index 9bd75e4a..eff21108 100644 --- a/tests/Backend/PackageAdminLinkResolverTest.php +++ b/tests/Backend/PackageAdminLinkResolverTest.php @@ -16,6 +16,7 @@ public function testItAcceptsOnlyHttpUrlsWithHosts(): void self::assertSame('https://example.test/package', $resolver->safeExternalUrl(' https://example.test/package ')); self::assertNull($resolver->safeExternalUrl('javascript:alert(1)')); self::assertNull($resolver->safeExternalUrl('https:///missing-host')); + self::assertNull($resolver->safeExternalUrl('https://example.test\\@evil.example.test/package')); self::assertNull($resolver->safeExternalUrl("https://example.test/\nheader")); } diff --git a/tests/Backend/PackageDependencyLabelParserTest.php b/tests/Backend/PackageDependencyLabelParserTest.php index 82b7b876..4b4c8fcf 100644 --- a/tests/Backend/PackageDependencyLabelParserTest.php +++ b/tests/Backend/PackageDependencyLabelParserTest.php @@ -14,10 +14,10 @@ public function testItParsesDependencyLabelsFromManifestJson(): void $parser = new PackageDependencyLabelParser(); self::assertSame([ - 'system 0.2.0', + 'system 0.2.4', 'demo-module', 'provider 1.0', - ], $parser->parse('[["system","0.2.0"],"demo-module",["provider","1.0",{"ignored":true}]]')); + ], $parser->parse('[["system","0.2.4"],"demo-module",["provider","1.0",{"ignored":true}]]')); } public function testItKeepsMalformedDependencyValuesVisible(): void diff --git a/tests/Command/AssetRebuildCommandTest.php b/tests/Command/AssetRebuildCommandTest.php index 68584a74..861c4d68 100644 --- a/tests/Command/AssetRebuildCommandTest.php +++ b/tests/Command/AssetRebuildCommandTest.php @@ -63,7 +63,7 @@ public function testAssetRebuildDryRunSurvivesMissingPackageStorage(): void self::assertSame(Command::SUCCESS, $exitCode); self::assertSame('asset rebuild', $payload['name']); - self::assertCount(7, $payload['actions']); + self::assertCount(8, $payload['actions']); self::assertSame(RuntimeException::class, $payload['context']['package_provider_error']['exception']); self::assertFileDoesNotExist($this->root.'/.env.test.local'); } diff --git a/tests/Command/MercureHealthCommandTest.php b/tests/Command/MercureHealthCommandTest.php new file mode 100644 index 00000000..9c844f3b --- /dev/null +++ b/tests/Command/MercureHealthCommandTest.php @@ -0,0 +1,68 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false, ConfigValueType::Boolean)); + + $tester = new CommandTester(new MercureHealthCommand(new MercureAvailability( + $config, + new MercureRuntime( + new MercureBinaryManager(sys_get_temp_dir().'/studio-mercure-disabled-command-test'), + $this->hub(), + 'http://127.0.0.1:8000', + sys_get_temp_dir(), + ), + new DetachedProcessStarter(), + sys_get_temp_dir(), + ))); + + $exitCode = $tester->execute([]); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('Mercure is disabled', $tester->getDisplay()); + } + + private function hub(): HubInterface + { + return new class implements HubInterface { + public function getPublicUrl(): string + { + return 'http://127.0.0.1:3000/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + return 'test'; + } + }; + } +} diff --git a/tests/Command/UiAlertInboxCleanupCommandTest.php b/tests/Command/UiAlertInboxCleanupCommandTest.php new file mode 100644 index 00000000..a352c378 --- /dev/null +++ b/tests/Command/UiAlertInboxCleanupCommandTest.php @@ -0,0 +1,51 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $connection->insert('ui_alert_inbox', [ + 'topic' => 'test.expired', + 'payload' => '{}', + 'created_at' => '2026-06-14 00:00:00', + 'expires_at' => '2026-06-14 00:00:00', + ]); + $connection->insert('ui_alert_inbox', [ + 'topic' => 'test.active', + 'payload' => '{}', + 'created_at' => '2026-06-14 00:00:00', + 'expires_at' => '2999-01-01 00:00:00', + ]); + + $tester = new CommandTester(new UiAlertInboxCleanupCommand(new UiAlertInbox($connection))); + $exitCode = $tester->execute([]); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('UI alert inbox cleanup removed 1 expired row(s).', $tester->getDisplay()); + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } + + public function testItFailsWhenCleanupQueryFails(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + + $tester = new CommandTester(new UiAlertInboxCleanupCommand(new UiAlertInbox($connection))); + $exitCode = $tester->execute([]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('UI alert inbox cleanup failed:', $tester->getDisplay()); + } +} diff --git a/tests/Content/Routing/ContentRedirectResolverTest.php b/tests/Content/Routing/ContentRedirectResolverTest.php index 96a47f8d..d42ff76e 100644 --- a/tests/Content/Routing/ContentRedirectResolverTest.php +++ b/tests/Content/Routing/ContentRedirectResolverTest.php @@ -11,6 +11,7 @@ use App\Repository\ContentItemRepository; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class ContentRedirectResolverTest extends KernelTestCase @@ -90,6 +91,28 @@ public function testItRejectsUnsupportedRedirectSchemes(): void self::assertSame('javascript:alert(1)', $result->redirectRoute()); } + #[DataProvider('unsafeExternalRedirectTargets')] + public function testItRejectsUnsafeExternalRedirectTargets(string $target): void + { + $this->connection->update('content_item', ['redirect_target' => $target], ['slug' => 'about']); + $this->entityManager->clear(); + + $result = $this->resolver->resolveByPath('/about'); + + self::assertSame(ContentRedirectResolveStatus::InvalidTarget, $result->status()); + self::assertSame($target, $result->redirectRoute()); + } + + /** + * @return iterable + */ + public static function unsafeExternalRedirectTargets(): iterable + { + yield 'missing host' => ['https:///target']; + yield 'backslash' => ['https://example.test\\@evil.example.test/target']; + yield 'control character' => ["https://example.test/target\nLocation: https://evil.example.test"]; + } + public function testItDetectsRedirectLoops(): void { $this->connection->update('content_item', ['redirect_target' => '/home'], ['slug' => 'about']); diff --git a/tests/Controller/BackendControllerTest.php b/tests/Controller/BackendControllerTest.php index 4cd9005e..f575fcc6 100644 --- a/tests/Controller/BackendControllerTest.php +++ b/tests/Controller/BackendControllerTest.php @@ -22,6 +22,7 @@ use App\Security\UserAccountStatus; use App\Security\UserFlowConfig; use App\Setup\SetupCompletionMarker; +use App\Setup\SetupInputValidator; use App\Setup\SetupWizardState; use App\View\Injection\Event\StaticViewInjectionRegistryEvent; use App\View\Injection\StaticViewInjection; @@ -206,6 +207,42 @@ public function testSetupDatabaseStepDoesNotRequireServerFieldsForInitialSqliteR } } + public function testSetupAdminStepUsesConfiguredAppSecretMinimumLength(): void + { + $previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null; + $previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null; + $previousPutenvValue = getenv(SetupCompletionMarker::KEY); + + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + putenv(SetupCompletionMarker::KEY); + + try { + $client = self::createClient(); + $client->request('GET', '/setup'); + $this->setSetupWizardState($client, [ + 'values' => [ + 'language' => 'en', + 'site_title' => 'Wizard Studio', + 'default_uri' => 'http://localhost', + 'database_driver' => 'sqlite', + 'database_url' => 'sqlite:///%kernel.project_dir%/var/data_test.db', + ], + 'completed' => ['language', 'site', 'database'], + 'workflow' => null, + 'action_log' => null, + ]); + $crawler = $client->request('GET', '/setup/admin'); + + self::assertResponseIsSuccessful(); + self::assertSame( + (string) SetupInputValidator::MIN_APP_SECRET_LENGTH, + $crawler->filter('input[name="app_secret"]')->attr('minlength'), + ); + } finally { + $this->restoreSetupMarker($previousServerValue, $previousEnvValue, $previousPutenvValue); + } + } + public function testSetupDatabaseStepCanClearStoredDatabasePassword(): void { $previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null; @@ -273,7 +310,7 @@ public function testSetupApplyWithoutJavaScriptRendersHtmlResultFallback(): void 'admin_password' => 'Safe1!pass', 'admin_password_confirm' => 'Safe1!pass', 'admin_email' => 'admin@localhost.local', - 'app_secret' => 'custom-secret-12', + 'app_secret' => 'custom-setup-app-secret-not-secure', 'dry_run' => true, ], 'completed' => ['language', 'site', 'database', 'admin'], @@ -291,7 +328,7 @@ public function testSetupApplyWithoutJavaScriptRendersHtmlResultFallback(): void $encodedState = json_encode($storedState, JSON_THROW_ON_ERROR); self::assertIsString($encodedState); self::assertStringNotContainsString('Safe1!pass', $encodedState); - self::assertStringNotContainsString('custom-secret-12', $encodedState); + self::assertStringNotContainsString('custom-setup-app-secret-not-secure', $encodedState); } finally { $this->restoreSetupMarker($previousServerValue, $previousEnvValue, $previousPutenvValue); } diff --git a/tests/Controller/LiveAlertControllerTest.php b/tests/Controller/LiveAlertControllerTest.php new file mode 100644 index 00000000..c3a15f90 --- /dev/null +++ b/tests/Controller/LiveAlertControllerTest.php @@ -0,0 +1,71 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + + try { + self::ensureKernelShutdown(); + $client = self::createClient(); + $client->request('GET', '/api/live/alerts?cursor=7'); + + self::assertResponseIsSuccessful(); + $payload = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame(7, $payload['cursor']); + self::assertSame([], $payload['alerts']); + self::assertSame(15000, $payload['next_poll_ms']); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + self::ensureKernelShutdown(); + } + } + + /** + * @return array{server_exists: bool, server: mixed, env_exists: bool, env: mixed, getenv: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + 'getenv' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server: mixed, env_exists: bool, env: mixed, getenv: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv'] ? putenv($key) : putenv($key.'='.$state['getenv']); + } +} diff --git a/tests/Controller/LiveEndpointControllerTest.php b/tests/Controller/LiveEndpointControllerTest.php new file mode 100644 index 00000000..3f4ddd66 --- /dev/null +++ b/tests/Controller/LiveEndpointControllerTest.php @@ -0,0 +1,158 @@ +controller($this->endpoint(AccessLevel::ADMIN), $this->user(AccessLevel::USER)); + + $response = $controller->dispatch(Request::create('/api/live/demo-pack/admin-action', Request::METHOD_GET)); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertStringContainsString('forbidden', (string) $response->getContent()); + } + + public function testItDispatchesLiveEndpointWhenAccessLevelMatches(): void + { + $controller = $this->controller($this->endpoint(AccessLevel::ADMIN), $this->user(AccessLevel::ADMIN)); + + $response = $controller->dispatch(Request::create('/api/live/demo-pack/admin-action', Request::METHOD_GET)); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('{"status":"ok","next_poll_ms":0}', (string) $response->getContent()); + } + + public function testExplicitMinimumAccessLevelWinsOverPublicFlag(): void + { + $endpoint = new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + '/api/live/demo-pack/admin-action', + 'api_live_package_dispatch', + 'runAdminAction', + 'Run an admin live action.', + 'packages.demo-pack.live.admin_action', + minimumAccessLevel: AccessLevel::ADMIN, + ); + $controller = $this->controller($endpoint, null); + + $response = $controller->dispatch(Request::create('/api/live/demo-pack/admin-action', Request::METHOD_GET)); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + public function testItRejectsPatternMatchesOutsideTheRequestedPackageSlug(): void + { + $endpoint = new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + '/api/live/demo-pack/admin-action', + 'api_live_package_dispatch', + 'runAdminAction', + 'Run an admin live action.', + 'packages.demo-pack.live.admin_action', + pathPattern: '#^/api/live/other-pack/.*$#', + ); + $controller = $this->controller($endpoint, null, expectsUser: false); + + $response = $controller->dispatch(Request::create('/api/live/other-pack/admin-action', Request::METHOD_GET)); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + private function controller(LiveEndpointDefinition $endpoint, ?UserAccount $user, bool $expectsUser = true): LiveEndpointController + { + $handler = new class implements LiveEndpointHandlerInterface { + public function liveEndpointHandlerKey(): string + { + return 'packages.demo-pack.live.admin_action'; + } + + public function handleLiveRequest(Request $request, LiveEndpointDefinition $endpoint): Response + { + return new JsonResponse(['status' => 'ok', 'next_poll_ms' => 0]); + } + }; + $provider = new class($endpoint) implements LiveEndpointProviderInterface { + public function __construct(private LiveEndpointDefinition $endpoint) + { + } + + public function liveEndpoints(): array + { + return [$this->endpoint]; + } + }; + $security = $this->createMock(Security::class); + $security->expects($expectsUser ? $this->once() : $this->never())->method('getUser')->willReturn($user); + + return new LiveEndpointController( + new LiveEndpointRegistry([$provider]), + new LiveEndpointHandlerRegistry([$handler]), + new JsonOutputRenderer(), + $security, + $this->translator(), + ); + } + + private function endpoint(int $minimumAccessLevel): LiveEndpointDefinition + { + return new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + '/api/live/demo-pack/admin-action', + 'api_live_package_dispatch', + 'runAdminAction', + 'Run an admin live action.', + 'packages.demo-pack.live.admin_action', + minimumAccessLevel: $minimumAccessLevel, + ); + } + + private function user(int $accessLevel): UserAccount + { + return new UserAccount( + '10000000-0000-7000-8000-0000000000'.str_pad((string) $accessLevel, 2, '0', STR_PAD_LEFT), + 'liveuser'.$accessLevel, + 'liveuser'.$accessLevel.'@example.test', + 'hash', + role: UserRole::fromAccessLevel($accessLevel), + ); + } + + private function translator(): TranslatorInterface + { + return new class implements TranslatorInterface { + public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + return strtr((string) $id, $parameters); + } + + public function getLocale(): string + { + return 'en'; + } + }; + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php index ce68c94a..17553129 100644 --- a/tests/Controller/SecurityControllerTest.php +++ b/tests/Controller/SecurityControllerTest.php @@ -157,9 +157,11 @@ public function testLoginRouteAllowsOnlyLocalReturnTargets(): void self::assertSelectorExists('input[name="_target_path"][value="/admin"]'); - $client->request('GET', '/user/login?return_to=//example.test'); + foreach (['//example.test', '/\\example.test/path', "/admin\nLocation: https://example.test"] as $target) { + $client->request('GET', '/user/login?return_to='.rawurlencode($target)); - self::assertSelectorNotExists('input[name="_target_path"]'); + self::assertSelectorNotExists('input[name="_target_path"]'); + } } public function testRegistrationRouteIsHiddenWhenRegistrationIsDisabled(): void diff --git a/tests/Core/Asset/AssetRebuildQueueFactoryTest.php b/tests/Core/Asset/AssetRebuildQueueFactoryTest.php index fdbfed77..d638f4a8 100644 --- a/tests/Core/Asset/AssetRebuildQueueFactoryTest.php +++ b/tests/Core/Asset/AssetRebuildQueueFactoryTest.php @@ -38,15 +38,16 @@ public function testItBuildsDevelopmentRebuildQueueWithCacheClearAsFinalAction() ]); $actions = $queue->actions(); - self::assertCount(7, $actions); + self::assertCount(8, $actions); self::assertSame('package_asset_sync', $actions[0]->type()); self::assertSame('translation_aggregate', $actions[1]->type()); self::assertStringContainsString('assets:install', $actions[2]->label()); self::assertStringContainsString('importmap:install', $actions[3]->label()); - self::assertStringContainsString('ux:icons:lock', $actions[4]->label()); - self::assertSame('tailwind_build', $actions[5]->type()); - self::assertSame('Build Tailwind CSS', $actions[5]->label()); - self::assertStringContainsString('cache:clear', $actions[6]->label()); + self::assertStringContainsString('ux:translator:warm-cache', $actions[4]->label()); + self::assertStringContainsString('ux:icons:lock', $actions[5]->label()); + self::assertSame('tailwind_build', $actions[6]->type()); + self::assertSame('Build Tailwind CSS', $actions[6]->label()); + self::assertStringContainsString('cache:clear', $actions[7]->label()); self::assertFalse($queue->context()['production_compile']); self::assertSame('manual', $queue->context()['trigger']); self::assertSame(1, count(array_filter( @@ -60,12 +61,13 @@ public function testItAddsProductionAssetMapCompileAfterRemovingCompiledAssets() $queue = $this->factory()->create('prod', [], 'setup'); $actions = $queue->actions(); - self::assertCount(9, $actions); - self::assertStringContainsString('ux:icons:lock', $actions[4]->label()); - self::assertSame('remove_path', $actions[6]->type()); - self::assertStringContainsString('public/assets', $actions[6]->label()); - self::assertStringContainsString('asset-map:compile', $actions[7]->label()); - self::assertStringContainsString('cache:clear', $actions[8]->label()); + self::assertCount(10, $actions); + self::assertStringContainsString('ux:translator:warm-cache', $actions[4]->label()); + self::assertStringContainsString('ux:icons:lock', $actions[5]->label()); + self::assertSame('remove_path', $actions[7]->type()); + self::assertStringContainsString('public/assets', $actions[7]->label()); + self::assertStringContainsString('asset-map:compile', $actions[8]->label()); + self::assertStringContainsString('cache:clear', $actions[9]->label()); self::assertTrue($queue->context()['production_compile']); self::assertSame('setup', $queue->context()['trigger']); } diff --git a/tests/Core/Lint/LinterTest.php b/tests/Core/Lint/LinterTest.php index 55183aab..cadd1a03 100644 --- a/tests/Core/Lint/LinterTest.php +++ b/tests/Core/Lint/LinterTest.php @@ -62,4 +62,14 @@ public function testItReportsInvalidSource(LinterInterface $linter, string $sour self::assertSame('virtual/path', $result->firstIssue()?->details()['path']); self::assertArrayHasKey('error', $result->firstIssue()?->context()); } + + public function testCssLinterAcceptsCommentOnlyRegistryStubs(): void + { + $result = (new CssLinter())->lint(<<<'CSS' +/* Generated CSS package asset registry. */ +/* Package lifecycle owns this file after activation changes. */ +CSS); + + self::assertTrue($result->isSuccess()); + } } diff --git a/tests/Core/Log/LogLineReaderTest.php b/tests/Core/Log/LogLineReaderTest.php new file mode 100644 index 00000000..87c19f2b --- /dev/null +++ b/tests/Core/Log/LogLineReaderTest.php @@ -0,0 +1,58 @@ +directory = $this->createTemporaryDirectory('system-log-line-reader'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->directory); + } + + public function testItReadsNewestNonEmptyLinesFirst(): void + { + $file = $this->directory.'/dev.log'; + file_put_contents($file, implode(PHP_EOL, [ + 'oldest', + '', + 'middle', + 'newest', + '', + ])); + + self::assertSame(['newest', 'middle', 'oldest'], (new LogLineReader())->readLines($file)); + } + + public function testItCapsReturnedLines(): void + { + $file = $this->directory.'/dev.log'; + $lines = []; + + for ($index = 1; $index <= 5100; ++$index) { + $lines[] = sprintf('line-%04d %s', $index, str_repeat('x', 80)); + } + + file_put_contents($file, implode(PHP_EOL, $lines)); + + $read = (new LogLineReader())->readLines($file); + + self::assertCount(5000, $read); + self::assertStringStartsWith('line-5100 ', $read[0]); + self::assertStringStartsWith('line-0101 ', $read[4999]); + } +} diff --git a/tests/Core/Manifest/ManifestParserTest.php b/tests/Core/Manifest/ManifestParserTest.php index 0a090f24..30a2b412 100644 --- a/tests/Core/Manifest/ManifestParserTest.php +++ b/tests/Core/Manifest/ManifestParserTest.php @@ -68,7 +68,7 @@ public function testItReportsDuplicateKeys(): void { $result = (new ManifestParser())->parse(<<<'MANIFEST' APP_VERSION=0.1.0 - APP_VERSION=0.2.0 + APP_VERSION=0.2.4 MANIFEST); self::assertFalse($result->isSuccess()); diff --git a/tests/Core/Mercure/MercureRuntimeTest.php b/tests/Core/Mercure/MercureRuntimeTest.php new file mode 100644 index 00000000..6d4540c9 --- /dev/null +++ b/tests/Core/Mercure/MercureRuntimeTest.php @@ -0,0 +1,351 @@ +hub(), + 'http://127.0.0.1:8000', + $root, + ); + + try { + self::assertSame([ + $binaryManager->binaryPath(), + 'run', + '--envfile', + $root.'/var/mercure/mercure.env', + '--config', + $binaryManager->caddyfilePath(), + '--adapter', + 'caddyfile', + ], $runtime->startCommand()); + self::assertNotContains('--publisher-jwt-key', $runtime->startCommand()); + self::assertNotContains('--subscriber-jwt-key', $runtime->startCommand()); + self::assertArrayNotHasKey('SERVER_NAME', $runtime->startEnvironment()); + self::assertArrayNotHasKey('MERCURE_PUBLISHER_JWT_KEY', $runtime->startEnvironment()); + self::assertArrayNotHasKey('MERCURE_SUBSCRIBER_JWT_KEY', $runtime->startEnvironment()); + self::assertStringContainsString('anonymous', $runtime->startEnvironment()['MERCURE_EXTRA_DIRECTIVES']); + self::assertStringContainsString('cors_origins *', $runtime->startEnvironment()['MERCURE_EXTRA_DIRECTIVES']); + self::assertStringContainsString('path "'.str_replace('\\', '/', $root.'/var/mercure/updates.db').'"', $runtime->startEnvironment()['MERCURE_EXTRA_DIRECTIVES']); + self::assertStringContainsString('SERVER_NAME=http://127.0.0.1:3000', (string) file_get_contents($root.'/var/mercure/mercure.env')); + if ('\\' !== DIRECTORY_SEPARATOR) { + self::assertSame('0600', substr(sprintf('%o', fileperms($root.'/var/mercure/mercure.env')), -4)); + } + } finally { + $this->removeDirectory($root); + } + } + + public function testItResolvesCurrentCaddyBasedReleaseAssetNames(): void + { + $manager = new MercureBinaryManager('/tmp/studio'); + $method = new ReflectionMethod(MercureBinaryManager::class, 'assetName'); + $asset = $method->invoke($manager); + + self::assertIsString($asset); + self::assertStringStartsWith('mercure_', $asset); + self::assertStringNotContainsString('legacy', $asset); + } + + public function testItMapsSupportedHostPlatformsToReleaseAssetNames(): void + { + $method = new ReflectionMethod(MercureBinaryManager::class, 'assetNameFor'); + + $expectedAssets = [ + ['Darwin', 'arm64', 'mercure_Darwin_arm64.tar.gz'], + ['Darwin', 'x86_64', 'mercure_Darwin_x86_64.tar.gz'], + ['Darwin', 'amd64', 'mercure_Darwin_x86_64.tar.gz'], + ['Linux', 'aarch64', 'mercure_Linux_arm64.tar.gz'], + ['Linux', 'arm64', 'mercure_Linux_arm64.tar.gz'], + ['Linux', 'armv5', 'mercure_Linux_armv5.tar.gz'], + ['Linux', 'armv5l', 'mercure_Linux_armv5.tar.gz'], + ['Linux', 'armv6', 'mercure_Linux_armv6.tar.gz'], + ['Linux', 'armv6l', 'mercure_Linux_armv6.tar.gz'], + ['Linux', 'armv7', 'mercure_Linux_armv7.tar.gz'], + ['Linux', 'armv7l', 'mercure_Linux_armv7.tar.gz'], + ['Linux', 'i386', 'mercure_Linux_i386.tar.gz'], + ['Linux', 'i686', 'mercure_Linux_i386.tar.gz'], + ['Linux', 'x86_64', 'mercure_Linux_x86_64.tar.gz'], + ['Linux', 'amd64', 'mercure_Linux_x86_64.tar.gz'], + ['Windows', 'arm64', 'mercure_Windows_arm64.zip'], + ['Windows', 'i386', 'mercure_Windows_i386.zip'], + ['Windows', 'i686', 'mercure_Windows_i386.zip'], + ['Windows', 'x86_64', 'mercure_Windows_x86_64.zip'], + ['Windows', 'amd64', 'mercure_Windows_x86_64.zip'], + ]; + + foreach ($expectedAssets as [$osFamily, $machine, $asset]) { + self::assertSame($asset, $method->invoke(null, $osFamily, $machine), $osFamily.' '.$machine); + } + + self::assertNull($method->invoke(null, 'FreeBSD', 'x86_64')); + self::assertNull($method->invoke(null, 'Linux', 'riscv64')); + } + + public function testItPinsReleaseAssetChecksums(): void + { + $method = new ReflectionMethod(MercureBinaryManager::class, 'assetChecksum'); + $manager = new MercureBinaryManager('/tmp/studio'); + + self::assertSame( + '0447e2db7f7819692c72544f19371a93c4162a50d9fae849b3c99df50e212fd0', + $method->invoke($manager, 'mercure_Linux_x86_64.tar.gz'), + ); + self::assertNull($method->invoke($manager, 'mercure_Linux_riscv64.tar.gz')); + } + + public function testItRejectsDownloadedArchivesWithUnexpectedChecksum(): void + { + $root = sys_get_temp_dir().'/studio-mercure-download-test-'.bin2hex(random_bytes(4)); + $manager = new MercureBinaryManager( + $root, + MercureBinaryManager::DEFAULT_VERSION, + new MockHttpClient(new MockResponse('not a valid mercure archive')), + ); + + try { + self::assertFalse($manager->install()); + self::assertFalse($manager->isInstalled()); + self::assertFileDoesNotExist($manager->binaryPath()); + } finally { + $this->removeDirectory($root); + } + } + + public function testStopIgnoresStalePidFilesThatPointToAnotherProcess(): void + { + $root = sys_get_temp_dir().'/studio-mercure-stale-pid-test-'.bin2hex(random_bytes(4)); + $binaryManager = new MercureBinaryManager($root); + $runtime = new MercureRuntime( + $binaryManager, + $this->hub(), + 'http://127.0.0.1:8000', + $root, + ); + $process = new Process([PHP_BINARY, '-r', 'sleep(30);']); + $process->start(); + + try { + $pid = $process->getPid(); + self::assertIsInt($pid); + @mkdir(dirname($runtime->pidPath()), 0775, true); + @mkdir(dirname($binaryManager->binaryPath()), 0775, true); + file_put_contents($binaryManager->binaryPath(), 'not the running process'); + file_put_contents($runtime->pidPath(), (string) $pid); + + self::assertTrue($runtime->stop()); + self::assertTrue($process->isRunning()); + self::assertFileDoesNotExist($runtime->pidPath()); + } finally { + $process->stop(0); + $this->removeDirectory($root); + } + } + + public function testItAcceptsReachabilityProbeStatusCodes(): void + { + $method = new ReflectionMethod(MercureRuntime::class, 'probeStatusAccepted'); + + foreach ([200, 201, 204, 400, 401] as $status) { + self::assertTrue($method->invoke(null, $status), sprintf('Status %d should be accepted.', $status)); + } + + foreach ([0, 301, 403, 404, 500] as $status) { + self::assertFalse($method->invoke(null, $status), sprintf('Status %d should not be accepted.', $status)); + } + } + + public function testItNormalizesColonOnlyListenAddressForLocalHubUrls(): void + { + $state = $this->setEnvironment('MERCURE_HUB_LISTEN', ':3000'); + $runtime = new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + $this->hub(), + 'http://127.0.0.1:8000', + '/tmp/studio', + ); + + try { + self::assertSame(':3000', $runtime->listenAddress()); + self::assertSame('http://127.0.0.1:3000/.well-known/mercure', $runtime->localHubUrl()); + } finally { + $this->restoreEnvironment('MERCURE_HUB_LISTEN', $state); + } + } + + public function testItNormalizesColonOnlyConfiguredHubUrls(): void + { + $mercureUrlState = $this->setEnvironment('MERCURE_URL', 'http://:3000/.well-known/mercure'); + $publicUrlState = $this->setEnvironment('MERCURE_PUBLIC_URL', 'https://:3443/.well-known/mercure'); + $runtime = new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + $this->hub(), + 'http://127.0.0.1:8000', + '/tmp/studio', + ); + + try { + self::assertSame('http://127.0.0.1:3000/.well-known/mercure', $runtime->publishHubUrl()); + self::assertSame('https://127.0.0.1:3443/.well-known/mercure', $runtime->publicHubUrl()); + } finally { + $this->restoreEnvironment('MERCURE_URL', $mercureUrlState); + $this->restoreEnvironment('MERCURE_PUBLIC_URL', $publicUrlState); + } + } + + public function testPublishHealthProbeRequiresSuccessfulPublishResponse(): void + { + foreach ([200, 201, 204] as $status) { + $runtime = new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + $this->hubWithProvider(), + 'http://127.0.0.1:8000', + '/tmp/studio', + new MockHttpClient(static function (string $method, string $url, array $options = []) use ($status): MockResponse { + self::assertSame('POST', $method); + self::assertStringContainsString('/.well-known/mercure', $url); + + return new MockResponse('', ['http_code' => $status]); + }), + ); + + self::assertTrue($runtime->publishHealthProbe(), sprintf('Status %d should make the publish endpoint functional.', $status)); + } + + foreach ([400, 401, 403, 500] as $status) { + $runtime = new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + $this->hubWithProvider(), + 'http://127.0.0.1:8000', + '/tmp/studio', + new MockHttpClient(new MockResponse('', ['http_code' => $status])), + ); + + self::assertFalse($runtime->publishHealthProbe(), sprintf('Status %d should not make the publish endpoint functional.', $status)); + } + } + + private function hub(): HubInterface + { + return new class implements HubInterface { + public function getPublicUrl(): string + { + return 'http://127.0.0.1:3000/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + return 'test'; + } + }; + } + + private function hubWithProvider(): HubInterface + { + return new class implements HubInterface { + public function getProvider(): StaticTokenProvider + { + return new StaticTokenProvider('jwt'); + } + + public function getPublicUrl(): string + { + return 'http://127.0.0.1:3000/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + return 'test'; + } + }; + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($path); + } + + /** + * @return array{server_exists: bool, server: mixed, env_exists: bool, env: mixed, getenv: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + 'getenv' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server: mixed, env_exists: bool, env: mixed, getenv: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv'] ? putenv($key) : putenv($key.'='.$state['getenv']); + } +} diff --git a/tests/Core/Package/PackageApiContributionGuardTest.php b/tests/Core/Package/PackageApiContributionGuardTest.php index aaff69a0..aa479018 100644 --- a/tests/Core/Package/PackageApiContributionGuardTest.php +++ b/tests/Core/Package/PackageApiContributionGuardTest.php @@ -71,6 +71,44 @@ public function testItRejectsPackageEndpointPatternsOutsideOwnedNamespace(): voi )); } + public function testItRejectsPackageEndpointPatternsWithEscapingAlternation(): void + { + $package = $this->package('demo-module'); + + $this->expectException(MessageException::class); + + PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( + 'package', + 'GET', + PackageApiEndpointPath::path($package->packageName(), 'demo'), + 'api_v1_endpoint_dispatch', + 'getDemoModuleContribution', + 'Return demo contribution.', + 'packages.demo-module.demo', + ['packages-demo-module-demo'], + pathPattern: '#^/api/v1/packages/demo-module/.*|^/api/v1/packages/other/#', + )); + } + + public function testItAllowsGroupedPackageEndpointPatternAlternationInsideOwnedNamespace(): void + { + $package = $this->package('demo-module'); + + PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( + 'package', + 'GET', + PackageApiEndpointPath::path($package->packageName(), 'demo'), + 'api_v1_endpoint_dispatch', + 'getDemoModuleContribution', + 'Return demo contribution.', + 'packages.demo-module.demo', + ['packages-demo-module-demo'], + pathPattern: '#^/api/v1/packages/demo-module/(demo|status)$#', + )); + + self::addToAssertionCount(1); + } + public function testItRejectsForeignHandlerNamespaces(): void { $package = $this->package('demo-module'); diff --git a/tests/Core/Package/PackageAssetPathRewriterTest.php b/tests/Core/Package/PackageAssetPathRewriterTest.php index 61a65787..f9fa56fa 100644 --- a/tests/Core/Package/PackageAssetPathRewriterTest.php +++ b/tests/Core/Package/PackageAssetPathRewriterTest.php @@ -41,9 +41,9 @@ public function testItRewritesPackageJavaScriptImportsToThePublicMirror(): void export { widget } from "../shared/widget.js"; const lazy = () => import("./lib/lazy.js"); const shared = await import('../shared/chunk.mjs?v=1#lazy'); -const external = () => import("alpinejs"); +const external = () => import("external-library"); const variable = (path) => import(path); -import "alpinejs"; +import "external-library"; JS, 'packages/demo/assets/frontend/app.js', 'packages/demo/assets', @@ -55,9 +55,9 @@ public function testItRewritesPackageJavaScriptImportsToThePublicMirror(): void self::assertStringContainsString('export { widget } from "./shared/widget.js";', $javaScript); self::assertStringContainsString('const lazy = () => import("./frontend/lib/lazy.js");', $javaScript); self::assertStringContainsString("const shared = await import('./shared/chunk.mjs?v=1#lazy');", $javaScript); - self::assertStringContainsString('const external = () => import("alpinejs");', $javaScript); + self::assertStringContainsString('const external = () => import("external-library");', $javaScript); self::assertStringContainsString('const variable = (path) => import(path);', $javaScript); - self::assertStringContainsString('import "alpinejs";', $javaScript); + self::assertStringContainsString('import "external-library";', $javaScript); } public function testItDoesNotRewriteVendoredCssOrJavaScript(): void diff --git a/tests/Core/Package/PackageLifecycleBoundaryTest.php b/tests/Core/Package/PackageLifecycleBoundaryTest.php index 15494ef3..148f9f7f 100644 --- a/tests/Core/Package/PackageLifecycleBoundaryTest.php +++ b/tests/Core/Package/PackageLifecycleBoundaryTest.php @@ -31,6 +31,7 @@ use App\View\ViewContextEvent; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Uid\Uuid; @@ -299,6 +300,198 @@ public function testPackagePhpLoaderDoesNotKeepPartialRuntimeContributionsAfterF self::assertSame('faulty', $this->packageStatus('broken-module')); } + public function testPackagePhpLoaderAcceptsScopedNecessaryCookieConsentContributions(): void + { + $this->insertPackage('captcha-provider', ['captcha-provider'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/captcha-provider/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertTrue($result->isSuccess()); + self::assertSame('captcha_provider_state', $registry->cookieConsentDefinitions()[0]->name()); + } + + public function testPackagePhpLoaderRejectsUnscopedNecessaryCookieConsentContributions(): void + { + $this->insertPackage('tracking-module', ['module'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/tracking-module/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); + self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); + self::assertSame([], $registry->cookieConsentDefinitions()); + self::assertSame('faulty', $this->packageStatus('tracking-module')); + } + + public function testPackagePhpLoaderRejectsCrossSiteNecessaryCookieConsentContributions(): void + { + $this->insertPackage('captcha-provider', ['captcha-provider'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/captcha-provider/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); + self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); + self::assertSame([], $registry->cookieConsentDefinitions()); + self::assertSame('faulty', $this->packageStatus('captcha-provider')); + } + + public function testPackagePhpLoaderRejectsDuplicateCookieConsentContributions(): void + { + $this->insertPackage('cookie-module', ['module'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/cookie-module/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); + self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); + self::assertSame([], $registry->cookieConsentDefinitions()); + self::assertSame('faulty', $this->packageStatus('cookie-module')); + } + + public function testPackagePhpLoaderRejectsReservedCoreCookieConsentContributions(): void + { + $this->insertPackage('session-module', ['module'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/session-module/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); + self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); + self::assertSame([], $registry->cookieConsentDefinitions()); + self::assertSame('faulty', $this->packageStatus('session-module')); + } + + public function testPackagePhpLoaderRejectsUnsafeOptionalCookiePrivacyUrls(): void + { + $this->insertPackage('tracking-module', ['module'], 'active'); + $this->writeTestFile($this->projectDir, 'packages/tracking-module/package.php', <<<'PHP' + entityManager), + $this->entityManager, + $this->projectDir, + new NullWorkflowResultMessageReporter(), + runtimeContributions: $registry, + ))->loadActivePackages(); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); + self::assertSame(InvalidArgumentException::class, $result->firstIssue()?->context()['exception'] ?? null); + self::assertSame([], $registry->cookieConsentDefinitions()); + self::assertSame('faulty', $this->packageStatus('tracking-module')); + } + public function testPackagePhpLoaderRejectsElevatedSchedulerContributions(): void { $this->insertPackage('scheduler-module', ['module'], 'active'); diff --git a/tests/Core/Package/PackageLiveContributionGuardTest.php b/tests/Core/Package/PackageLiveContributionGuardTest.php new file mode 100644 index 00000000..774ed13b --- /dev/null +++ b/tests/Core/Package/PackageLiveContributionGuardTest.php @@ -0,0 +1,212 @@ +package('captcha-pack'); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'seed'), + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.live.seed', + )); + + self::addToAssertionCount(1); + } + + public function testItRejectsReservedSystemLiveSlugs(): void + { + $package = $this->package('alerts'); + + $this->expectException(MessageException::class); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'demo'), + 'api_live_package_dispatch', + 'getAlertDemo', + 'Return alert demo payload.', + 'packages.alerts.live.demo', + )); + } + + public function testItRejectsPathsOutsideOwnedLiveNamespace(): void + { + $package = $this->package('captcha-pack'); + + $this->expectException(MessageException::class); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + '/api/live/other-pack/seed', + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.live.seed', + )); + } + + public function testItRejectsPackageLiveRootPaths(): void + { + $package = $this->package('captcha-pack'); + + $this->expectException(MessageException::class); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + '/api/live/captcha-pack/', + 'api_live_package_dispatch', + 'getCaptchaRoot', + 'Return a captcha root payload.', + 'packages.captcha-pack.live.root', + )); + } + + public function testPackageLivePathHelperRejectsEmptyResourcePaths(): void + { + $this->expectException(MessageException::class); + + PackageLiveEndpointPath::path('captcha-pack', ''); + } + + public function testItRejectsLivePatternsThatEscapeOwnedNamespace(): void + { + $package = $this->package('captcha-pack'); + + $this->expectException(MessageException::class); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'seed'), + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.live.seed', + pathPattern: '#^/api/live/captcha-pack/.*|^/api/live/other-pack/#', + )); + } + + public function testItAllowsGroupedLivePatternAlternationInsideOwnedNamespace(): void + { + $package = $this->package('captcha-pack'); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'seed'), + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.live.seed', + pathPattern: '#^/api/live/captcha-pack/(seed|refresh)$#', + )); + + self::addToAssertionCount(1); + } + + public function testItRejectsForeignHandlerNamespaces(): void + { + $package = $this->package('captcha-pack'); + + $this->expectException(MessageException::class); + + PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'seed'), + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.seed', + )); + } + + public function testItRejectsMutatingLiveEndpointMethods(): void + { + $this->expectException(MessageException::class); + $this->expectExceptionMessage(ApiMessageKey::API_ENDPOINT_METHOD_INVALID); + + new LiveEndpointDefinition( + 'package', + Request::METHOD_POST, + PackageLiveEndpointPath::path('captcha-pack', 'seed'), + 'api_live_package_dispatch', + 'refreshCaptchaSeed', + 'Refresh a captcha seed.', + 'packages.captcha-pack.live.seed', + minimumAccessLevel: AccessLevel::PUBLIC, + ); + } + + public function testRuntimeRegistryExposesLiveEndpointAndHandlerContributions(): void + { + $package = $this->package('captcha-pack'); + $definition = new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + PackageLiveEndpointPath::path($package->packageName(), 'seed'), + 'api_live_package_dispatch', + 'getCaptchaSeed', + 'Return a captcha seed.', + 'packages.captcha-pack.live.seed', + ); + $handler = new class implements LiveEndpointHandlerInterface { + public function liveEndpointHandlerKey(): string + { + return 'packages.captcha-pack.live.seed'; + } + + public function handleLiveRequest(Request $request, LiveEndpointDefinition $endpoint): Response + { + return new JsonResponse(['next_poll_ms' => 0]); + } + }; + + $registry = new PackageRuntimeContributionRegistry(); + $registry->add($package, [$definition, $handler]); + + self::assertSame([$definition], $registry->liveEndpoints()); + self::assertSame([$handler], $registry->liveEndpointHandlers()); + } + + private function package(string $name): ExtensionPackage + { + return new ExtensionPackage( + Uuid::v7()->toRfc4122(), + [PackageScope::Module], + $name, + 'packages/'.$name, + ExtensionPackageStatus::Active, + ); + } +} diff --git a/tests/Core/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php index e887a64d..c1ddfcde 100644 --- a/tests/Core/Package/PackageValidatorTest.php +++ b/tests/Core/Package/PackageValidatorTest.php @@ -808,6 +808,82 @@ public function testItAcceptsPackageOwnedCssClasses(): void self::assertTrue($result->isSuccess()); } + public function testItAcceptsTailwindDirectivesInPackageCssSyntaxChecks(): void + { + $this->writeFile('assets/module.css', <<<'CSS' +.demo-module-card { + @apply grid gap-4 rounded-lg border p-4; +} +CSS); + + $result = (new PackageValidator())->validate( + $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), + PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), + ); + + self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItRejectsCssSyntaxErrorsNextToTailwindDirectives(): void + { + $this->writeFile('assets/module.css', <<<'CSS' +.demo-module-card { + @apply grid gap-4 rounded-lg border p-4; + color red; +} +CSS); + + $result = (new PackageValidator())->validate( + $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), + PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), + ); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.css_syntax_error', $result->issues()[0]->code()); + } + + public function testItAcceptsModernCssAtRulesInPackageCssSyntaxChecks(): void + { + $this->writeFile('assets/module.css', <<<'CSS' +.demo-module-card { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,); + @custom-variant demo-module-dark (&:where(.demo-module-dark, .demo-module-dark *)); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + @media (width >= 40rem) { + max-width: 40rem; + } +} +CSS); + + $result = (new PackageValidator())->validate( + $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), + PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), + ); + + self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItRejectsCssSyntaxErrorsInsideModernAtRules(): void + { + $this->writeFile('assets/module.css', <<<'CSS' +.demo-module-card { + @supports (color: color-mix(in lab, red, red)) { + color red; + } +} +CSS); + + $result = (new PackageValidator())->validate( + $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), + PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), + ); + + self::assertFalse($result->isSuccess()); + self::assertSame('package.css_syntax_error', $result->issues()[0]->code()); + } + public function testItRejectsCssRulesTargetingClassesOutsidePackageNamespace(): void { $this->writeFile('assets/module.css', <<<'CSS' diff --git a/tests/Form/Autocomplete/AdminAutocompleteTest.php b/tests/Form/Autocomplete/AdminAutocompleteTest.php new file mode 100644 index 00000000..66c7c5d2 --- /dev/null +++ b/tests/Form/Autocomplete/AdminAutocompleteTest.php @@ -0,0 +1,50 @@ +defaults($type); + + self::assertSame(BaseEntityAutocompleteType::class, $type->getParent()); + self::assertSame(UserAccount::class, $options['class']); + self::assertSame('username', $options['choice_label']); + self::assertSame(['username', 'email'], $options['searchable_fields']); + self::assertSame('ROLE_ADMIN', $options['security']); + } + + public function testAdminAclGroupAutocompleteDefinesSecureSearchDefaults(): void + { + $type = new AdminAclGroupAutocomplete(); + $options = $this->defaults($type); + + self::assertSame(BaseEntityAutocompleteType::class, $type->getParent()); + self::assertSame(AclGroup::class, $options['class']); + self::assertSame('name', $options['choice_label']); + self::assertSame(['identifier', 'name'], $options['searchable_fields']); + self::assertSame('ROLE_ADMIN', $options['security']); + } + + /** + * @return array + */ + private function defaults(object $type): array + { + $resolver = new \Symfony\Component\OptionsResolver\OptionsResolver(); + $type->configureOptions($resolver); + + return $resolver->resolve(); + } +} diff --git a/tests/Live/LiveEndpointRegistryTest.php b/tests/Live/LiveEndpointRegistryTest.php new file mode 100644 index 00000000..69be71d4 --- /dev/null +++ b/tests/Live/LiveEndpointRegistryTest.php @@ -0,0 +1,93 @@ +provider([ + $this->endpoint( + '/api/live/demo-pack/items', + 'listItems', + 'packages.demo-pack.live.items', + '#^/api/live/demo-pack/items(?:/.*)?$#', + ), + $this->endpoint( + '/api/live/demo-pack/items/special', + 'specialItems', + 'packages.demo-pack.live.items_special', + ), + ])]); + + $endpoint = $registry->endpointForPath('/api/live/demo-pack/items/special', Request::METHOD_GET); + + self::assertInstanceOf(LiveEndpointDefinition::class, $endpoint); + self::assertSame('specialItems', $endpoint->operationId()); + } + + public function testItPrefersMoreSpecificPatternEndpoint(): void + { + $registry = new LiveEndpointRegistry([$this->provider([ + $this->endpoint( + '/api/live/demo-pack/items', + 'listItems', + 'packages.demo-pack.live.items', + '#^/api/live/demo-pack/items(?:/.*)?$#', + ), + $this->endpoint( + '/api/live/demo-pack/items/special', + 'specialChildren', + 'packages.demo-pack.live.items_special_children', + '#^/api/live/demo-pack/items/special(?:/.*)?$#', + ), + ])]); + + $endpoint = $registry->endpointForPath('/api/live/demo-pack/items/special/child', Request::METHOD_GET); + + self::assertInstanceOf(LiveEndpointDefinition::class, $endpoint); + self::assertSame('specialChildren', $endpoint->operationId()); + } + + /** + * @param list $endpoints + */ + private function provider(array $endpoints): LiveEndpointProviderInterface + { + return new readonly class($endpoints) implements LiveEndpointProviderInterface { + /** + * @param list $endpoints + */ + public function __construct(private array $endpoints) + { + } + + public function liveEndpoints(): array + { + return $this->endpoints; + } + }; + } + + private function endpoint(string $path, string $operationId, string $handlerKey, ?string $pathPattern = null): LiveEndpointDefinition + { + return new LiveEndpointDefinition( + 'package', + Request::METHOD_GET, + $path, + 'api_live_package_dispatch', + $operationId, + 'Run a demo live endpoint.', + $handlerKey, + pathPattern: $pathPattern, + ); + } +} diff --git a/tests/Navigation/NavigationBuilderTest.php b/tests/Navigation/NavigationBuilderTest.php index fe1fc0e0..4e03e473 100644 --- a/tests/Navigation/NavigationBuilderTest.php +++ b/tests/Navigation/NavigationBuilderTest.php @@ -145,6 +145,13 @@ static function (NavigationBuilderEvent $event): void { 'https://example.test/docs', sortOrder: 33, )); + $event->addItem(new NavigationItem( + '30000000-0000-7000-8000-000000000957', + 'Backslash Redirect', + 'url', + '/\\evil.example.test/path', + sortOrder: 32, + )); }, ); @@ -156,6 +163,7 @@ static function (NavigationBuilderEvent $event): void { self::assertSame('https://example.test/docs', $urlsByLabel['External Docs']); self::assertSame('#', $urlsByLabel['Hook Script']); + self::assertSame('#', $urlsByLabel['Backslash Redirect']); self::assertSame('#', $urlsByLabel['Persisted Script']); } finally { $connection->delete('site_menu_item', ['uid' => $persistedUid]); diff --git a/tests/Operations/InitScriptTest.php b/tests/Operations/InitScriptTest.php index 4b0b1807..d72681c1 100644 --- a/tests/Operations/InitScriptTest.php +++ b/tests/Operations/InitScriptTest.php @@ -65,6 +65,7 @@ public function testInitScriptCoversRequiredInitializationSteps(): void self::assertStringContainsString("'ux:icons:lock'", $contents); self::assertStringContainsString('runOptionalCommand', $contents); self::assertStringContainsString("'tailwind:build'", $contents); + self::assertStringContainsString("'cache:warmup'", $contents); self::assertStringContainsString("'asset-map:compile'", $contents); self::assertStringNotContainsString("'doctrine:migrations:migrate'", $contents); self::assertStringNotContainsString("'doctrine:schema:validate'", $contents); diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 4d77389f..014cbd1e 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -35,6 +35,7 @@ public function testMigrationsApplyToConfiguredSqliteDatabase(): void self::assertContains('doctrine_migration_versions', $tables); self::assertContains('messenger_messages', $tables); + self::assertContains('ui_alert_inbox', $tables); self::assertContains('config_entry', $tables); self::assertContains('state_marker', $tables); self::assertContains('access_statistic_event', $tables); @@ -94,6 +95,10 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void static fn ($index): string => $index->getName(), $schema->getTable('user_account')->getIndexes(), ); + $alertIndexes = array_map( + static fn ($index): string => $index->getName(), + $schema->getTable('ui_alert_inbox')->getIndexes(), + ); $userGroupForeignKeys = array_map( static fn ($foreignKey): string => $foreignKey->getName(), $schema->getTable('user_acl_group')->getForeignKeys(), @@ -102,6 +107,9 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void self::assertContains('studio_uniq_user_account_username', $userIndexes); self::assertContains('studio_uniq_user_account_email', $userIndexes); self::assertContains('studio_pk_user_account', $userIndexes); + self::assertContains('studio_pk_ui_alert_inbox', $alertIndexes); + self::assertContains('studio_idx_ui_alert_inbox_topic_cursor', $alertIndexes); + self::assertContains('studio_idx_ui_alert_inbox_expires_at', $alertIndexes); self::assertContains('studio_fk_user_acl_group_user', $userGroupForeignKeys); self::assertContains('studio_fk_user_acl_group_group', $userGroupForeignKeys); } finally { @@ -132,7 +140,7 @@ public function testPrefixedMigrationsUsePrefixedNamesWhenReverting(): void $schema = new Schema(); $migration = new Version20260531000000($connection, new NullLogger()); - foreach (TablePrefix::TABLES as $tableName) { + foreach ($this->initialMigrationTables() as $tableName) { $table = $schema->createTable('studio_'.$tableName); $table->addColumn('uid', 'string', ['length' => 36]); @@ -178,6 +186,36 @@ public function testPrefixedMigrationsUsePrefixedNamesWhenReverting(): void } } + /** + * @return list + */ + private function initialMigrationTables(): array + { + return [ + 'messenger_messages', + 'ui_alert_inbox', + 'config_entry', + 'package_setting_entry', + 'scheduler_task', + 'scheduler_task_run', + 'state_marker', + 'access_statistic_event', + 'acl_group', + 'user_account', + 'user_acl_group', + 'account_token', + 'api_key', + 'extension_package', + 'site_menu', + 'site_menu_item', + 'content_schema', + 'content_schema_version', + 'content_item', + 'content_revision', + 'content_field_value', + ]; + } + private function insertContentProbe(PDO $pdo, string $uid, string $slug): void { $statement = $pdo->prepare(<<<'SQL' diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php new file mode 100644 index 00000000..c66c5afe --- /dev/null +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -0,0 +1,641 @@ +cacheDir = $this->createTemporaryDirectory('cookie-consent-visitors'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->cacheDir); + } + + public function testItRequiresBannerOnlyForOptionalCookiesWithoutStoredConsent(): void + { + $manager = $this->manager([$this->provider([ + CookieConsentDefinition::necessary(Cookie::create('PHPSESSID')), + ])]); + + self::assertFalse($manager->bannerRequired(Request::create('/'))); + + $optionalManager = $this->manager([$this->provider([ + CookieConsentDefinition::optional(Cookie::create('analytics_id'), 'Analytics', 'Measure visits.', 'https://example.test/privacy'), + ])]); + + self::assertTrue($optionalManager->bannerRequired(Request::create('/'))); + } + + public function testItDefaultsOptionalCookiesOffWhenDoNotTrackIsEnabled(): void + { + $request = Request::create('/'); + $request->headers->set('DNT', '1'); + + self::assertFalse($this->manager()->defaultOptionalSelected($request)); + } + + public function testItAllowsOptionalCookiesAfterConsentCookieWasStored(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $response = new Response(); + + $manager->attachConsentCookie($request, $response, ['analytics_id']); + $cookie = $response->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $cookie); + + $nextRequest = Request::create('/'); + $nextRequest->cookies->set($cookie->getName(), $cookie->getValue()); + + self::assertTrue($manager->allowed($nextRequest, $definition)); + self::assertFalse($manager->bannerRequired($nextRequest)); + } + + public function testItIgnoresTamperedConsentCookies(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $response = new Response(); + + $manager->attachConsentCookie($request, $response, ['analytics_id']); + $cookie = $response->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $cookie); + + $nextRequest = Request::create('/'); + $nextRequest->cookies->set($cookie->getName(), $cookie->getValue().'tampered'); + + self::assertFalse($manager->allowed($nextRequest, $definition)); + self::assertTrue($manager->bannerRequired($nextRequest)); + } + + public function testItIgnoresExpiredConsentCookies(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $request->cookies->set(CookieConsentManager::CONSENT_COOKIE_NAME, $this->signedConsentCookie([ + 'accepted' => ['analytics_id'], + 'created_at' => time() - 31_536_001, + 'version' => 1, + ])); + + self::assertFalse($manager->allowed($request, $definition)); + self::assertTrue($manager->bannerRequired($request)); + } + + public function testItExpiresWithdrawnOptionalCookies(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $acceptedResponse = new Response(); + $manager->attachConsentCookie($request, $acceptedResponse, ['analytics_id']); + $consentCookie = $acceptedResponse->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $consentCookie); + + $withdrawRequest = Request::create('/'); + $withdrawRequest->cookies->set($consentCookie->getName(), $consentCookie->getValue()); + $withdrawResponse = new Response(); + $manager->attachConsentCookie($withdrawRequest, $withdrawResponse, []); + + $expired = array_values(array_filter( + $withdrawResponse->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + )); + + self::assertCount(1, $expired); + self::assertSame('/tracking', $expired[0]->getPath()); + self::assertLessThan(time(), $expired[0]->getExpiresTime()); + } + + public function testItExpiresRejectedOptionalCookiesWithoutStoredConsent(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $request->cookies->set('analytics_id', 'legacy-value'); + $response = new Response(); + + $manager->attachConsentCookie($request, $response, []); + + $expired = array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + )); + + self::assertCount(1, $expired); + self::assertSame('/tracking', $expired[0]->getPath()); + self::assertLessThan(time(), $expired[0]->getExpiresTime()); + } + + public function testResponseSubscriberKeepsOptionalCookieClearHeaders(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $registry = new CookieConsentRegistry([$this->provider([$definition])]); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $response = new Response(); + $manager->attachConsentCookie($request, $response, []); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + $expired = array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + )); + + self::assertCount(1, $expired); + self::assertLessThan(time(), $expired[0]->getExpiresTime()); + } + + public function testResponseSubscriberRunsAfterCookieWriters(): void + { + $subscription = CookieConsentResponseSubscriber::getSubscribedEvents()[\Symfony\Component\HttpKernel\KernelEvents::RESPONSE] ?? null; + + self::assertIsArray($subscription); + self::assertSame('filterCookies', $subscription[0] ?? null); + self::assertLessThanOrEqual(-4096, $subscription[1] ?? 0); + } + + public function testResponseSubscriberRemovesActiveOptionalCookiesWithoutConsent(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $registry = new CookieConsentRegistry([$this->provider([$definition])]); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $response = new Response(); + $response->headers->setCookie(Cookie::create('analytics_id', 'value', 0, '/tracking')); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + self::assertSame([], array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + ))); + } + + public function testResponseSubscriberKeepsAutoSecureNecessaryCookies(): void + { + $definition = CookieConsentDefinition::necessary(Cookie::create('PHPSESSID')); + $registry = new CookieConsentRegistry([$this->provider([$definition])]); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('https://example.test/'); + $response = new Response(); + $response->headers->setCookie(Cookie::create('PHPSESSID', 'session', 0, '/', null, true)); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + $remaining = array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'PHPSESSID' === $cookie->getName(), + )); + + self::assertCount(1, $remaining); + self::assertTrue($remaining[0]->isSecure()); + } + + public function testResponseSubscriberStillRejectsNecessaryCookiesWithDifferentNonSecureIdentity(): void + { + $definition = CookieConsentDefinition::necessary(Cookie::create('PHPSESSID')); + $registry = new CookieConsentRegistry([$this->provider([$definition])]); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('https://example.test/'); + $response = new Response(); + $response->headers->setCookie(Cookie::create('PHPSESSID', 'session', 0, '/', null, true, false)); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + self::assertSame([], array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'PHPSESSID' === $cookie->getName(), + ))); + } + + public function testResponseSubscriberRejectsAcceptedOptionalCookieWithDifferentIdentity(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking', 'example.test'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $registry = new CookieConsentRegistry([$this->provider([$definition])]); + $manager = $this->manager([$this->provider([$definition])]); + $consentResponse = new Response(); + $manager->attachConsentCookie(Request::create('/'), $consentResponse, ['analytics_id']); + $consentCookie = $consentResponse->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $consentCookie); + + $request = Request::create('/'); + $request->cookies->set($consentCookie->getName(), $consentCookie->getValue()); + $response = new Response(); + $response->headers->setCookie(Cookie::create('analytics_id', 'allowed', 0, '/tracking', 'example.test')); + $response->headers->setCookie(Cookie::create('analytics_id', 'wrong-path', 0, '/other', 'example.test')); + $response->headers->setCookie(Cookie::create('analytics_id', 'wrong-domain', 0, '/tracking', 'other.example.test')); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + $remaining = array_values(array_filter( + $response->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + )); + + self::assertCount(1, $remaining); + self::assertSame('allowed', $remaining[0]->getValue()); + self::assertSame('/tracking', $remaining[0]->getPath()); + self::assertSame('example.test', $remaining[0]->getDomain()); + + $securityVariantResponse = new Response(); + $securityVariantResponse->headers->setCookie(Cookie::create('analytics_id', 'wrong-secure', 0, '/tracking', 'example.test', true)); + + (new CookieConsentResponseSubscriber($registry, $manager))->filterCookies(new ResponseEvent( + new NullKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $securityVariantResponse, + )); + + self::assertSame([], array_values(array_filter( + $securityVariantResponse->headers->getCookies(), + static fn (Cookie $cookie): bool => 'analytics_id' === $cookie->getName(), + ))); + } + + public function testItRejectsDuplicateCookieDefinitions(): void + { + $registry = new CookieConsentRegistry([ + $this->provider([CookieConsentDefinition::necessary(Cookie::create('PHPSESSID'))]), + $this->provider([CookieConsentDefinition::optional( + Cookie::create('PHPSESSID'), + 'Other', + 'Override the session cookie.', + 'https://example.test/privacy', + )]), + ]); + + $this->expectException(LogicException::class); + $registry->definitions(); + } + + #[DataProvider('unsafePrivacyUrls')] + public function testItRejectsUnsafeOptionalCookiePrivacyUrls(string $url): void + { + $this->expectException(InvalidArgumentException::class); + + CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + $url, + ); + } + + public function testItAcceptsHttpAndRelativeOptionalCookiePrivacyUrls(): void + { + foreach (['https://example.test/privacy', 'http://example.test/privacy', '/privacy', './privacy', '../privacy', 'privacy'] as $url) { + self::assertSame($url, CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + $url, + )->privacyUrl()); + } + } + + public function testItReturnsSelectedOptionalNamesFromStoredConsentOrDefaults(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + + self::assertSame(['analytics_id'], $manager->selectedOptionalNames($request)); + + $requestWithDnt = Request::create('/'); + $requestWithDnt->headers->set('DNT', '1'); + + self::assertSame([], $manager->selectedOptionalNames($requestWithDnt)); + + $response = new Response(); + $manager->attachConsentCookie($request, $response, []); + $cookie = $response->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $cookie); + + $nextRequest = Request::create('/'); + $nextRequest->cookies->set($cookie->getName(), $cookie->getValue()); + + self::assertSame([], $manager->selectedOptionalNames($nextRequest)); + } + + public function testCookieConsentRejectActionIgnoresPostedOptionalCookies(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $controller = new CookieConsentController($manager); + $request = Request::create('/privacy/cookie-consent', 'POST', [ + '_cookie_consent_target_path' => '/', + '_cookie_consent_action' => 'reject_optional', + 'cookies' => ['analytics_id'], + ]); + $request->request->set('_csrf_token', $manager->csrfToken($request)); + + $response = $controller->store($request); + $cookie = $response->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $cookie); + + $nextRequest = Request::create('/'); + $nextRequest->cookies->set($cookie->getName(), $cookie->getValue()); + + self::assertFalse($manager->allowed($nextRequest, $definition)); + } + + public function testCookieConsentRedirectsOnlyToSafeRelativeTargets(): void + { + $manager = $this->manager(); + $controller = new CookieConsentController($manager); + + foreach (['https://evil.example.test', '//evil.example.test/path', '/\\evil.example.test/path', "/privacy\nLocation: https://evil.example.test", 'relative/path', ''] as $target) { + $request = Request::create('/privacy/cookie-consent', 'POST', [ + '_cookie_consent_target_path' => $target, + '_cookie_consent_action' => 'reject_optional', + ]); + $request->request->set('_csrf_token', $manager->csrfToken($request)); + + self::assertSame('/', $controller->store($request)->headers->get('Location')); + } + + $request = Request::create('/privacy/cookie-consent', 'POST', [ + '_cookie_consent_target_path' => '/privacy', + '_cookie_consent_action' => 'reject_optional', + ]); + $request->request->set('_csrf_token', $manager->csrfToken($request)); + + self::assertSame('/privacy', $controller->store($request)->headers->get('Location')); + } + + public function testConsentCookieUsesSystemOwnedName(): void + { + self::assertSame('system_cookie_consent', CookieConsentManager::CONSENT_COOKIE_NAME); + } + + public function testCookieConsentCsrfTokenIsVisitorBound(): void + { + $manager = $this->manager(); + $firstRequest = Request::create('/privacy/cookie-consent', 'POST', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Studio Browser/1.0', + ]); + $secondRequest = Request::create('/privacy/cookie-consent', 'POST', server: [ + 'REMOTE_ADDR' => '198.51.100.24', + 'HTTP_USER_AGENT' => 'Other Browser/2.0', + ]); + + $token = $manager->csrfToken($firstRequest); + + self::assertTrue($manager->validCsrfToken($firstRequest, $token)); + self::assertFalse($manager->validCsrfToken($secondRequest, $token)); + } + + public function testConsentCookieJarBlocksOptionalCookiesWithoutConsent(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $jar = new ConsentCookieJar($this->manager([$this->provider([$definition])])); + $request = Request::create('/'); + $response = new Response(); + + self::assertFalse($jar->set($request, $response, $definition)); + self::assertSame([], $response->headers->getCookies()); + } + + public function testConsentCookieJarRejectsCustomCookiesWithDifferentIdentity(): void + { + $definition = CookieConsentDefinition::optional( + Cookie::create('analytics_id', 'value', 0, '/tracking', 'example.test'), + 'Analytics', + 'Measure visits.', + 'https://example.test/privacy', + ); + $manager = $this->manager([$this->provider([$definition])]); + $request = Request::create('/'); + $consentResponse = new Response(); + $manager->attachConsentCookie($request, $consentResponse, ['analytics_id']); + $consentCookie = $consentResponse->headers->getCookies()[0] ?? null; + self::assertInstanceOf(Cookie::class, $consentCookie); + + $requestWithConsent = Request::create('/'); + $requestWithConsent->cookies->set($consentCookie->getName(), $consentCookie->getValue()); + $jar = new ConsentCookieJar($manager); + + foreach ([ + Cookie::create('other_cookie', 'value', 0, '/tracking', 'example.test'), + Cookie::create('analytics_id', 'value', 0, '/other', 'example.test'), + Cookie::create('analytics_id', 'value', 0, '/tracking', 'other.example.test'), + Cookie::create('analytics_id', 'value', 0, '/tracking', 'example.test', true), + Cookie::create('analytics_id', 'value', 0, '/tracking', 'example.test', false, false), + Cookie::create('analytics_id', 'value', 0, '/tracking', 'example.test', false, true, false, Cookie::SAMESITE_STRICT), + ] as $cookie) { + $response = new Response(); + + self::assertFalse($jar->set($requestWithConsent, $response, $definition, $cookie)); + self::assertSame([], $response->headers->getCookies()); + } + + $response = new Response(); + self::assertTrue($jar->set( + $requestWithConsent, + $response, + $definition, + Cookie::create('analytics_id', 'updated', 0, '/tracking', 'example.test'), + )); + self::assertCount(1, $response->headers->getCookies()); + } + + public function testTwigExtensionExposesConsentTriggerAttributes(): void + { + $extension = new CookieConsentTwigExtension(new RequestStack(), new CookieConsentRegistry([]), $this->manager()); + + self::assertSame([ + 'aria-controls' => 'cookie-consent', + 'data-cookie-consent-open' => true, + ], $extension->triggerAttributes()); + } + + public function testCoreProviderRegistersOnlyNecessaryCookies(): void + { + $definitions = (new CoreCookieConsentProvider())->cookieConsentDefinitions(); + + self::assertNotSame([], $definitions); + self::assertSame([], array_values(array_filter( + $definitions, + static fn (CookieConsentDefinition $definition): bool => !$definition->isNecessary(), + ))); + } + + /** + * @param iterable $providers + */ + private function manager(iterable $providers = []): CookieConsentManager + { + return new CookieConsentManager( + new CookieConsentRegistry($providers), + new VisitorIdGenerator('test-secret', new FileVisitorIdentityStore($this->cacheDir, 'test')), + 'test-secret', + ); + } + + /** + * @param list $definitions + */ + private function provider(array $definitions): CookieConsentProviderInterface + { + return new class($definitions) implements CookieConsentProviderInterface { + public function __construct(private array $definitions) + { + } + + public function cookieConsentDefinitions(): array + { + return $this->definitions; + } + }; + } + + /** + * @return iterable + */ + public static function unsafePrivacyUrls(): iterable + { + yield 'javascript scheme' => ['javascript:alert(1)']; + yield 'data scheme' => ['data:text/html,']; + yield 'protocol relative' => ['//evil.example.test/privacy']; + yield 'backslash redirect' => ['/\\evil.example.test/privacy']; + yield 'http without host' => ['http:/privacy']; + yield 'control character' => ["https://example.test/privacy\njavascript:alert(1)"]; + } + + /** + * @param array $payload + */ + private function signedConsentCookie(array $payload): string + { + $body = rtrim(strtr(base64_encode(json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), '+/', '-_'), '='); + + return $body.'.'.hash_hmac('sha256', 'privacy-cookie-consent|'.$body, 'test-secret'); + } +} + +final class NullKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/AppSecretRotationGuardTest.php b/tests/Security/AppSecretRotationGuardTest.php new file mode 100644 index 00000000..fd856e39 --- /dev/null +++ b/tests/Security/AppSecretRotationGuardTest.php @@ -0,0 +1,57 @@ +guardWithSecret('short'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf( + 'The configured APP_SECRET is unsupported: it must be at least %d bytes.', + SetupInputValidator::MIN_APP_SECRET_LENGTH, + )); + + $guard->handle(); + } + + public function testItMarksMercureUnavailableWhenRotationCannotStopHub(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement( + 'CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at VARCHAR(32) DEFAULT NULL, modified_by VARCHAR(255) DEFAULT NULL)', + ); + $config = new Config($connection); + self::assertTrue($config->set(MercureAvailability::AVAILABLE_KEY, true, ConfigValueType::Boolean)); + + $reflection = new ReflectionClass(AppSecretRotationGuard::class); + $guard = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('config')->setValue($guard, $config); + $reflection->getMethod('markMercureUnavailableAfterFailedStop')->invoke($guard); + + self::assertFalse($config->get(MercureAvailability::AVAILABLE_KEY, true)); + } + + private function guardWithSecret(string $secret): AppSecretRotationGuard + { + $reflection = new ReflectionClass(AppSecretRotationGuard::class); + $guard = $reflection->newInstanceWithoutConstructor(); + $reflection->getProperty('secret')->setValue($guard, $secret); + + return $guard; + } +} diff --git a/tests/Setup/SetupCliInputFactoryTest.php b/tests/Setup/SetupCliInputFactoryTest.php index 1825a436..0b597573 100644 --- a/tests/Setup/SetupCliInputFactoryTest.php +++ b/tests/Setup/SetupCliInputFactoryTest.php @@ -29,7 +29,7 @@ public function testItCreatesInputFromOptionsWithoutPrompting(): void 'admin-username' => 'owner', 'admin-password' => 'Safe1!pass', 'admin-email' => 'owner@example.test', - 'app-secret' => 'app-secret-12', + 'app-secret' => 'interactive-app-secret-not-secure', 'dry-run' => false, ]); @@ -313,7 +313,7 @@ public function testItPromptsInteractivelyInSelectedLanguage(): void 'Safe1!pass', 'Safe1!pass', 'owner@example.test', - 'app-secret-12', + 'interactive-app-secret-not-secure', '', ])); $outputStream = $this->stream(''); @@ -341,7 +341,7 @@ public function testItPromptsInteractivelyInSelectedLanguage(): void self::assertSame('db.example.test', $input->databaseHost()); self::assertSame(3307, $input->databasePort()); self::assertSame('owner@example.test', $input->adminEmail()); - self::assertSame('app-secret-12', $input->appSecret()); + self::assertSame('interactive-app-secret-not-secure', $input->appSecret()); self::assertStringContainsString('Installer language', $output); self::assertStringContainsString('Seitentitel', $output); self::assertStringContainsString('Datenbank-Treiber', $output); diff --git a/tests/Setup/SetupInputValidatorTest.php b/tests/Setup/SetupInputValidatorTest.php index afb0752e..997e9ebb 100644 --- a/tests/Setup/SetupInputValidatorTest.php +++ b/tests/Setup/SetupInputValidatorTest.php @@ -60,7 +60,7 @@ public function testItRejectsCliInputsWithShortAppSecret(): void (new SetupInputValidator())->assertValidInput($this->input(appSecret: 'short'), ['en']); } - private function input(string $language = 'en', ?string $appSecret = 'app-secret-12'): SetupInput + private function input(string $language = 'en', ?string $appSecret = 'interactive-app-secret-not-secure'): SetupInput { return new SetupInput( appEnv: 'test', diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php index 618145ca..dc28d08f 100644 --- a/tests/Setup/SetupRunnerTest.php +++ b/tests/Setup/SetupRunnerTest.php @@ -71,7 +71,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', ); $seed = new SetupDefaultSeed(); @@ -81,7 +81,8 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void self::assertInstanceOf(ActionLog::class, $result->value()); self::assertFalse($result->context()['halt_on_error']); self::assertFileExists($this->root.'/.env.test.local'); - self::assertStringContainsString("APP_SECRET='test-secret-12'", (string) file_get_contents($this->root.'/.env.test.local')); + self::assertStringContainsString("APP_SECRET='test-setup-app-secret-not-secure'", (string) file_get_contents($this->root.'/.env.test.local')); + self::assertStringNotContainsString('MERCURE_JWT_SECRET', (string) file_get_contents($this->root.'/.env.test.local')); $storedPhpBinary = (new PhpCliBinaryPreferenceStore())->read($this->root, 'test'); self::assertIsString($storedPhpBinary); self::assertTrue((new PhpCliBinaryValidator())->validate([$storedPhpBinary], $this->root)->isValid()); @@ -105,6 +106,8 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void self::assertFalse($assetRebuildEnvironment['DATABASE_URL'] ?? null); self::assertFalse($assetRebuildEnvironment['APP_DATABASE_PREFIX'] ?? null); self::assertFalse($assetRebuildEnvironment['DEFAULT_URI'] ?? null); + self::assertSame('test-setup-app-secret-not-secure', $executor->environments[6]['MERCURE_JWT_SECRET'] ?? null); + self::assertSame('test-setup-app-secret-not-secure', $executor->environments[7]['MERCURE_JWT_SECRET'] ?? null); self::assertSame([ ['composer', '--version'], ['composer', 'dump-env', 'test'], @@ -112,6 +115,8 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void [PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'], [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'], [PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'], + [PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'], + [PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'], ], $executor->commands); $pdo = new PDO('sqlite:'.$databasePath); @@ -154,6 +159,44 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void self::assertSame($seed->homeContentFields($input)['title'][$input->language()], json_decode((string) $homeTitle, true, flags: JSON_THROW_ON_ERROR)); } + public function testItDoesNotRunMercureHealthWhenMercureStopFails(): void + { + $databasePath = $this->root.'/var/setup.db'; + $this->createSchema($databasePath); + $executor = new RecordingSetupCommandExecutor(onRun: static function (array $command): ?SetupCommandResult { + if (in_array('mercure:stop', $command, true)) { + return new SetupCommandResult(1, '', 'stop failed'); + } + + return null; + }); + $runner = new SetupRunner($this->root, new NullWorkflowResultMessageReporter(), $executor); + + $result = $runner->run(new SetupInput( + appEnv: 'test', + language: 'en', + siteTitle: 'Example Studio', + defaultUri: 'https://example.test', + databaseDriver: DatabaseDriver::SQLite, + databaseUrl: $this->sqliteUrl($databasePath), + adminUsername: 'admin', + adminPassword: 'Secret1!password', + adminEmail: 'admin@example.test', + appSecret: 'test-setup-app-secret-not-secure', + )); + + self::assertTrue($result->isSuccess()); + self::assertContainsEquals([PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'], $executor->commands); + self::assertNotContainsEquals([PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'], $executor->commands); + + $log = $result->value(); + self::assertInstanceOf(ActionLog::class, $log); + $entries = $log->toArray()['entries']; + self::assertSame('run_mercure_health', $entries[11]['name']); + self::assertFalse($entries[11]['context']['stopped']); + self::assertFalse($entries[11]['context']['available']); + } + public function testItRejectsShortAdminPasswordBeforeSetupSteps(): void { $databasePath = $this->root.'/var/setup.db'; @@ -171,7 +214,7 @@ public function testItRejectsShortAdminPasswordBeforeSetupSteps(): void adminUsername: 'admin', adminPassword: 'short', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -221,7 +264,7 @@ public function testItSeedsPrefixedDatabaseTablesInRunnerProcess(): void adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertTrue($result->isSuccess()); @@ -250,7 +293,7 @@ public function testItSeedsTheSameSqliteDatabaseThatSymfonyMigratesWhenUrlUsesKe adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertTrue($result->isSuccess()); @@ -278,7 +321,7 @@ public function testItStopsWhenDefaultSettingsCannotBeWritten(): void adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -302,7 +345,7 @@ public function testItStopsOnCommandFailureAndReturnsActionLogContext(): void defaultUri: 'https://example.test', databaseDriver: DatabaseDriver::SQLite, databaseUrl: $this->sqliteUrl($this->root.'/var/setup.db'), - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -339,7 +382,7 @@ public function testItRollsBackGeneratedFilesAndSqliteTablesWhenFinalCacheClearF adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -374,7 +417,7 @@ public function testItDoesNotDropPreExistingTablesWhenRollbackRuns(): void adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -407,7 +450,7 @@ public function testItRestoresPreExistingEnvironmentFilesWhenRollbackRuns(): voi adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -430,7 +473,7 @@ public function testItStopsWhenEnvironmentOverridesCannotBeWritten(): void defaultUri: 'https://example.test', databaseDriver: DatabaseDriver::SQLite, databaseUrl: $this->sqliteUrl($this->root.'/var/setup.db'), - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertFalse($result->isSuccess()); @@ -459,7 +502,7 @@ public function testItFallsBackToSystemComposerWhenBundledComposerIsUnavailable( defaultUri: 'https://example.test', databaseDriver: DatabaseDriver::SQLite, databaseUrl: $this->sqliteUrl($databasePath), - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertTrue($result->isSuccess()); @@ -471,6 +514,8 @@ public function testItFallsBackToSystemComposerWhenBundledComposerIsUnavailable( [PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'], [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'], [PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'], + [PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'], + [PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'], ], $executor->commands); } @@ -520,7 +565,10 @@ public function testDryRunReturnsPlannedChangesWithoutWriting(): void self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'], $entries[8]['context']['command']); self::assertSame('run_asset_rebuild', $entries[9]['name']); self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'], $entries[9]['context']['command']); - self::assertSame('mark_setup_completed', $entries[10]['name']); + self::assertSame('run_mercure_health', $entries[10]['name']); + self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'], $entries[10]['context']['stop_command']); + self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'], $entries[10]['context']['command']); + self::assertSame('mark_setup_completed', $entries[11]['name']); } public function testDryRunUsesPhpCliPlaceholderWhenResolverValidationFails(): void @@ -599,7 +647,7 @@ public function testItSurfacesNonBlockingAssetRebuildWarningsInSetupActionLog(): adminUsername: 'admin', adminPassword: 'Secret1!password', adminEmail: 'admin@example.test', - appSecret: 'test-secret-12', + appSecret: 'test-setup-app-secret-not-secure', )); self::assertTrue($result->isSuccess()); @@ -832,6 +880,11 @@ final class RecordingSetupCommandExecutor implements SetupCommandExecutorInterfa */ public array $commands = []; + /** + * @var list> + */ + public array $environments = []; + public function __construct( private readonly ?int $failureAt = null, private readonly ?SetupCommandResult $failure = null, @@ -843,6 +896,7 @@ public function __construct( public function run(array $command, string $cwd, array $environment = []): SetupCommandResult { $this->commands[] = $command; + $this->environments[] = $environment; if (is_callable($this->onRun)) { $result = ($this->onRun)($command, $cwd, $environment); diff --git a/tests/View/Alert/MercureUiAlertPublisherTest.php b/tests/View/Alert/MercureUiAlertPublisherTest.php new file mode 100644 index 00000000..a4a8f388 --- /dev/null +++ b/tests/View/Alert/MercureUiAlertPublisherTest.php @@ -0,0 +1,148 @@ +publisher($hub); + + $id = $publisher->publish('urn:system:ui-alerts:session:topic', UiAlert::fromLevel('danger', 'Saved')); + + self::assertSame('update-id', $id); + self::assertInstanceOf(Update::class, $hub->update); + self::assertSame(['urn:system:ui-alerts:session:topic'], $hub->update->getTopics()); + self::assertFalse($hub->update->isPrivate()); + self::assertNull($hub->update->getId()); + self::assertSame('ui-alert', $hub->update->getType()); + self::assertSame([ + 'message' => 'Saved', + 'level' => 'error', + 'persistent' => false, + 'mode' => 'auto', + 'loading' => false, + ], json_decode($hub->update->getData(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testItCanPublishPrivateMercureUpdatesExplicitly(): void + { + $hub = new RecordingHub(); + $publisher = $this->publisher($hub); + + $publisher->publish('urn:system:ui-alerts:session:topic', UiAlert::fromLevel('success', 'Saved'), private: true); + + self::assertTrue($hub->update?->isPrivate()); + } + + public function testItUsesStableAlertIdsAsMercureEventIds(): void + { + $hub = new RecordingHub(); + $publisher = $this->publisher($hub); + + $publisher->publish('urn:system:ui-alerts:session:topic', UiAlert::fromLevel('success', 'Saved', id: 'ui-alert-stable')); + + self::assertSame('ui-alert-stable', $hub->update?->getId()); + } + + public function testItTranslatesStructuredMessagesBeforePublishing(): void + { + $hub = new RecordingHub(); + $publisher = $this->publisher($hub); + + $publisher->publishToSession('session-id', Message::success('message.package.discovery_completed', ['%package%' => 'Demo'])); + + $payload = json_decode($hub->update?->getData() ?? '{}', true, 512, JSON_THROW_ON_ERROR); + self::assertSame('message.package.discovery_completed', $payload['message']); + self::assertSame('success', $payload['level']); + self::assertSame(CommonMessageCode::SUCCESS, $payload['code']); + self::assertSame('message.package.discovery_completed', $payload['translation_key']); + self::assertArrayNotHasKey('context', $payload); + } + + public function testItRejectsUsernameStringUserTopics(): void + { + $hub = new RecordingHub(); + $publisher = $this->publisher($hub); + + self::assertNull($publisher->publishToUser('admin', UiAlert::fromLevel('success', 'Saved'))); + self::assertNull($hub->update); + } + + public function testItNormalizesUsernameStringUserTopicsWhenResolvable(): void + { + $hub = new RecordingHub(); + $publisher = new MercureUiAlertPublisher( + $hub, + new UiAlertTopicFactory('secret', new PublisherUserAlertIdentityResolver([ + 'admin' => '71000000-0000-7000-8000-000000000001', + ])), + new UiAlertMessageFactory(new IdentityTranslator()), + ); + + self::assertSame('update-id', $publisher->publishToUser('admin', UiAlert::fromLevel('success', 'Saved'))); + self::assertInstanceOf(Update::class, $hub->update); + } + + private function publisher(RecordingHub $hub): MercureUiAlertPublisher + { + return new MercureUiAlertPublisher( + $hub, + new UiAlertTopicFactory('secret'), + new UiAlertMessageFactory(new IdentityTranslator()), + ); + } +} + +final readonly class PublisherUserAlertIdentityResolver implements UiAlertUserIdentityResolverInterface +{ + /** + * @param array $uidsByUsername + */ + public function __construct(private array $uidsByUsername) + { + } + + public function resolveUid(string $identifier): ?string + { + return $this->uidsByUsername[$identifier] ?? null; + } +} + +final class RecordingHub implements HubInterface +{ + public ?Update $update = null; + + public function getPublicUrl(): string + { + return 'https://example.test/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + $this->update = $update; + + return 'update-id'; + } +} diff --git a/tests/View/Alert/UiAlertDeliveryTest.php b/tests/View/Alert/UiAlertDeliveryTest.php new file mode 100644 index 00000000..12845ca5 --- /dev/null +++ b/tests/View/Alert/UiAlertDeliveryTest.php @@ -0,0 +1,40 @@ +toOptions(); + $queue = UiAlertDelivery::Queue->toOptions(); + $push = UiAlertDelivery::Push->toOptions(); + + self::assertTrue($direct->flashes()); + self::assertFalse($direct->queues()); + self::assertFalse($direct->pushes()); + + self::assertFalse($queue->flashes()); + self::assertTrue($queue->queues()); + self::assertTrue($queue->pushes()); + + self::assertFalse($push->flashes()); + self::assertFalse($push->queues()); + self::assertTrue($push->pushes()); + } + + public function testDeliveryOptionsKeepQueueAsDefault(): void + { + $options = new UiAlertDeliveryOptions(); + + self::assertSame(UiAlertDelivery::Queue, $options->delivery()); + self::assertTrue($options->queues()); + self::assertTrue($options->pushes()); + } +} diff --git a/tests/View/Alert/UiAlertDispatcherTest.php b/tests/View/Alert/UiAlertDispatcherTest.php new file mode 100644 index 00000000..5db713c9 --- /dev/null +++ b/tests/View/Alert/UiAlertDispatcherTest.php @@ -0,0 +1,219 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $config = new Config($connection); + self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false, ConfigValueType::Boolean)); + $publisher = new RecordingPublisher(); + $topicFactory = new UiAlertTopicFactory('test-secret'); + $dispatcher = new UiAlertDispatcher( + $topicFactory, + new UiAlertMessageFactory(new IdentityTranslator()), + new UiAlertInbox($connection), + $publisher, + new MercureAvailability( + $config, + new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + new SilentHub(), + 'https://studio.example.test', + '/tmp/studio', + ), + new DetachedProcessStarter(), + '/tmp/studio', + ), + new RequestUiAlertFlasher(new RequestStack()), + new RequestStack(), + new Security(new Container()), + ); + + self::assertTrue($dispatcher->addAlertToTopic( + $topicFactory->userTopic('71000000-0000-7000-8000-000000000001'), + UiAlert::fromLevel('success', 'Queued alert'), + UiAlertDelivery::Queue, + )); + + self::assertSame([], $publisher->publishedTopics); + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } + + public function testTopicDeliveryRejectsNonUiAlertTopics(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $publisher = new RecordingPublisher(); + $dispatcher = $this->dispatcher($connection, $publisher); + + self::assertFalse($dispatcher->addAlertToTopic( + 'https://example.test/ui-alerts/user/topic', + UiAlert::fromLevel('success', 'Queued alert'), + UiAlertDelivery::Queue, + )); + + self::assertSame([], $publisher->publishedTopics); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } + + public function testUserDeliveryRejectsUsernameStrings(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $publisher = new RecordingPublisher(); + $dispatcher = $this->dispatcher($connection, $publisher); + + self::assertFalse($dispatcher->addAlertToUser( + 'admin', + UiAlert::fromLevel('success', 'Queued alert'), + UiAlertDelivery::Queue, + )); + + self::assertSame([], $publisher->publishedTopics); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } + + public function testUserDeliveryNormalizesResolvedUsernameStrings(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $publisher = new RecordingPublisher(); + $dispatcher = $this->dispatcher($connection, $publisher, new RecordingUserAlertIdentityResolver([ + 'admin' => '71000000-0000-7000-8000-000000000001', + ])); + + self::assertTrue($dispatcher->addAlertToUser( + 'admin', + UiAlert::fromLevel('success', 'Queued alert'), + UiAlertDelivery::Queue, + )); + + self::assertSame([], $publisher->publishedTopics); + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } + + private function dispatcher(Connection $connection, RecordingPublisher $publisher, ?UiAlertUserIdentityResolverInterface $resolver = null): UiAlertDispatcher + { + $config = new Config($connection); + self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false, ConfigValueType::Boolean)); + + return new UiAlertDispatcher( + new UiAlertTopicFactory('test-secret', $resolver), + new UiAlertMessageFactory(new IdentityTranslator()), + new UiAlertInbox($connection), + $publisher, + new MercureAvailability( + $config, + new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + new SilentHub(), + 'https://studio.example.test', + '/tmp/studio', + ), + new DetachedProcessStarter(), + '/tmp/studio', + ), + new RequestUiAlertFlasher(new RequestStack()), + new RequestStack(), + new Security(new Container()), + ); + } +} + +final readonly class RecordingUserAlertIdentityResolver implements UiAlertUserIdentityResolverInterface +{ + /** + * @param array $uidsByUsername + */ + public function __construct(private array $uidsByUsername) + { + } + + public function resolveUid(string $identifier): ?string + { + return $this->uidsByUsername[$identifier] ?? null; + } +} + +final class RecordingPublisher implements UiAlertPublisherInterface +{ + /** + * @var list + */ + public array $publishedTopics = []; + + public function publish(string $topic, UiAlert|\App\Core\Message\Message|UiAlertTranslation $alert, ?string $locale = null, bool $private = false): ?string + { + $this->publishedTopics[] = $topic; + + return 'published'; + } + + public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|\App\Core\Message\Message|UiAlertTranslation $alert, ?string $locale = null): ?string + { + return $this->publish((string) ($user instanceof UserInterface ? $user->getUserIdentifier() : $user), $alert, $locale); + } + + public function publishToSession(SessionInterface|string $session, UiAlert|\App\Core\Message\Message|UiAlertTranslation $alert, ?string $locale = null): ?string + { + return $this->publish($session instanceof SessionInterface ? $session->getId() : $session, $alert, $locale); + } +} + +final class SilentHub implements HubInterface +{ + public function getPublicUrl(): string + { + return 'https://studio.example.test/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + return 'published'; + } +} diff --git a/tests/View/Alert/UiAlertInboxTest.php b/tests/View/Alert/UiAlertInboxTest.php new file mode 100644 index 00000000..ef00150a --- /dev/null +++ b/tests/View/Alert/UiAlertInboxTest.php @@ -0,0 +1,76 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $inbox = new UiAlertInbox($connection); + + $result = $inbox->append(['topic.one', 'topic.two'], UiAlert::fromLevel('success', 'Queued')); + + self::assertSame(2, $result); + self::assertSame([ + 'cursor' => 1, + 'alerts' => [[ + 'message' => 'Queued', + 'level' => 'success', + 'persistent' => false, + 'mode' => 'auto', + 'loading' => false, + ]], + 'has_more' => false, + ], $inbox->poll(['topic.one'])); + } + + public function testItReportsWhenAnotherPollPageIsAvailable(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $inbox = new UiAlertInbox($connection); + + self::assertSame(1, $inbox->append(['topic.one'], UiAlert::fromLevel('success', 'First'))); + self::assertSame(1, $inbox->append(['topic.one'], UiAlert::fromLevel('success', 'Second'))); + + $firstPage = $inbox->poll(['topic.one'], limit: 1); + $secondPage = $inbox->poll(['topic.one'], $firstPage['cursor'], limit: 1); + + self::assertSame(['First'], array_column($firstPage['alerts'], 'message')); + self::assertTrue($firstPage['has_more']); + self::assertSame(['Second'], array_column($secondPage['alerts'], 'message')); + self::assertFalse($secondPage['has_more']); + } + + public function testItStoresBoundedTopicKeysForLongPublicTopics(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(80) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL)'); + $inbox = new UiAlertInbox($connection); + $topic = 'urn:system:ui-alerts:user:'.str_repeat('a', 64); + + self::assertSame(1, $inbox->append([$topic], UiAlert::fromLevel('success', 'Queued'))); + + $storedTopic = (string) $connection->fetchOne('SELECT topic FROM ui_alert_inbox'); + self::assertSame(71, strlen($storedTopic)); + self::assertStringStartsWith('sha256:', $storedTopic); + self::assertSame('Queued', $inbox->poll([$topic])['alerts'][0]['message'] ?? null); + } + + public function testAppendReturnsNullForEmptyTopics(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $inbox = new UiAlertInbox($connection); + + self::assertNull($inbox->append([], UiAlert::fromLevel('info', 'Ignored'))); + } +} diff --git a/tests/View/Alert/UiAlertTest.php b/tests/View/Alert/UiAlertTest.php new file mode 100644 index 00000000..920cd454 --- /dev/null +++ b/tests/View/Alert/UiAlertTest.php @@ -0,0 +1,102 @@ +withPresentation(UiAlertPresentation::loading( + title: 'Cache clear', + actions: [UiAlertAction::event('Show details', 'operation:open', ['id' => 'cache-clear'])], + id: 'operation-cache-clear', + )); + + self::assertSame([ + 'message' => 'Operation running', + 'level' => 'info', + 'persistent' => true, + 'mode' => 'persistent', + 'loading' => true, + 'title' => 'Cache clear', + 'id' => 'operation-cache-clear', + 'actions' => [[ + 'label' => 'Show details', + 'event' => 'operation:open', + 'detail' => ['id' => 'cache-clear'], + ]], + ], $alert->toArray()); + } + + public function testHiddenPresentationOverridesPreviousPersistentMode(): void + { + $alert = UiAlert::fromLevel('warning', 'Background alert', persistent: true) + ->withPresentation(new UiAlertPresentation(UiAlertMode::Hidden)); + + self::assertSame('hidden', $alert->toArray()['mode']); + self::assertFalse($alert->toArray()['persistent']); + } + + public function testItCanAttachStableDedupeId(): void + { + $alert = UiAlert::fromLevel('success', 'Saved')->withId('ui-alert-test'); + + self::assertTrue($alert->hasId()); + self::assertSame('ui-alert-test', $alert->toArray()['id']); + } + + public function testItDoesNotSerializeDiagnosticContext(): void + { + $alert = UiAlert::translated( + 'Package failed.', + 'error', + 'package.runtime.failure', + 'message.package.runtime_failure', + ['path' => '/srv/example/private.log', 'exception' => 'RuntimeException'], + ); + + self::assertArrayNotHasKey('context', $alert->toArray()); + self::assertSame('message.package.runtime_failure', $alert->toArray()['translation_key']); + } + + public function testPresentationFiltersUnsafeActionLinks(): void + { + $alert = UiAlert::fromLevel('info', 'Saved')->withPresentation(UiAlertPresentation::persistent(actions: [ + UiAlertAction::link('Open', '/admin/packages', '_blank'), + UiAlertAction::link('Script', 'javascript:alert(1)'), + ['label' => 'Data', 'href' => 'data:text/html,boom'], + ['label' => 'Protocol-relative', 'href' => '//evil.example.test/path'], + ['label' => 'Hostless http', 'href' => 'http:evil.example.test'], + ['label' => 'External', 'href' => 'https://example.test/privacy', 'target' => '_self'], + ['label' => 'Event', 'event' => 'operation-overlay:show', 'detail' => ['id' => 'operation-1']], + ])); + + self::assertSame([ + ['label' => 'Open', 'href' => '/admin/packages', 'target' => '_blank'], + ['label' => 'External', 'href' => 'https://example.test/privacy', 'target' => '_self'], + ['label' => 'Event', 'event' => 'operation-overlay:show', 'detail' => ['id' => 'operation-1']], + ], $alert->toArray()['actions']); + } + + public function testDirectAlertActionsUseTheSameLinkPolicy(): void + { + $alert = UiAlert::fromLevel('info', 'Saved', actions: [ + ['label' => 'Open', 'href' => '/admin/packages'], + ['label' => 'Script', 'href' => 'javascript:alert(1)'], + ['label' => 'Event', 'event' => 'operation-overlay:show'], + ]); + + self::assertSame([ + ['label' => 'Open', 'href' => '/admin/packages'], + ['label' => 'Event', 'event' => 'operation-overlay:show'], + ], $alert->toArray()['actions']); + } +} diff --git a/tests/View/Alert/UiAlertTopicFactoryTest.php b/tests/View/Alert/UiAlertTopicFactoryTest.php new file mode 100644 index 00000000..adc6058a --- /dev/null +++ b/tests/View/Alert/UiAlertTopicFactoryTest.php @@ -0,0 +1,146 @@ +userTopic($user); + $sessionTopic = $factory->sessionTopic('session-id'); + + self::assertStringStartsWith('urn:system:ui-alerts:user:', $userTopic); + self::assertStringStartsWith('urn:system:ui-alerts:session:', $sessionTopic); + self::assertStringNotContainsString($user->uid(), $userTopic); + self::assertStringNotContainsString('session-id', $sessionTopic); + self::assertSame($sessionTopic, $factory->sessionTopic('session-id')); + self::assertSame($userTopic, $factory->userTopic($user->uid())); + self::assertTrue($factory->isUiAlertTopic($userTopic)); + self::assertTrue($factory->isUiAlertTopic($sessionTopic)); + } + + public function testItResolvesUsernameStringsToAccountUidTopics(): void + { + $factory = new UiAlertTopicFactory('secret', new FakeUserAlertIdentityResolver([ + 'AdminUser' => '71000000-0000-7000-8000-000000000001', + ])); + + self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic('AdminUser')); + } + + public function testItRejectsUnresolvedUsernameTopics(): void + { + $factory = new UiAlertTopicFactory('secret'); + + $this->expectException(InvalidArgumentException::class); + $factory->userTopic('admin'); + } + + public function testItAcceptsGenericUserIdentifiersOnlyWhenTheyAreAccountUids(): void + { + $factory = new UiAlertTopicFactory('secret'); + $user = new class implements UserInterface { + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + } + + public function getUserIdentifier(): string + { + return '71000000-0000-7000-8000-000000000001'; + } + }; + + self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic($user)); + } + + public function testItResolvesGenericUserIdentifierUsernamesWithoutChangingCase(): void + { + $factory = new UiAlertTopicFactory('secret', new FakeUserAlertIdentityResolver([ + 'AdminUser' => '71000000-0000-7000-8000-000000000001', + ])); + $user = new class implements UserInterface { + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + } + + public function getUserIdentifier(): string + { + return 'AdminUser'; + } + }; + + self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic($user)); + } + + public function testItRejectsNonUiAlertTopics(): void + { + $factory = new UiAlertTopicFactory('secret'); + + self::assertFalse($factory->isUiAlertTopic('https://example.test/ui-alerts/user/topic')); + self::assertFalse($factory->isUiAlertTopic('urn:system:ui-alerts:health')); + self::assertFalse($factory->isUiAlertTopic('urn:system:ui-alerts:user:not-a-hash')); + self::assertFalse($factory->isUiAlertTopic('urn:other:ui-alerts:user:'.str_repeat('a', 64))); + } + + public function testItUsesExistingSessionCookieForRequestTopicsWithoutStartingSession(): void + { + $factory = new UiAlertTopicFactory('secret'); + $request = Request::create('/api/live/alerts'); + $session = new Session(new MockArraySessionStorage()); + $session->setName('PHPSESSID'); + $request->setSession($session); + $request->cookies->set('PHPSESSID', 'existing-session-id'); + + self::assertSame([ + $factory->sessionTopic('existing-session-id'), + ], $factory->topicsFor($request, null)); + self::assertFalse($session->isStarted()); + } +} + +final readonly class FakeUserAlertIdentityResolver implements UiAlertUserIdentityResolverInterface +{ + /** + * @param array $uidsByUsername + */ + public function __construct(private array $uidsByUsername) + { + } + + public function resolveUid(string $identifier): ?string + { + return $this->uidsByUsername[$identifier] ?? null; + } +} diff --git a/tests/View/Alert/WorkflowResultAlertSelectorTest.php b/tests/View/Alert/WorkflowResultAlertSelectorTest.php new file mode 100644 index 00000000..2941e30f --- /dev/null +++ b/tests/View/Alert/WorkflowResultAlertSelectorTest.php @@ -0,0 +1,55 @@ + 'demo'], + ['internal' => true], + ); + + $alert = $selector->fromResult(WorkflowResult::success(messages: [$message])); + + self::assertSame(MessageLevel::Success, $alert->level()); + self::assertSame(CommonMessageCode::SUCCESS, $alert->code()); + self::assertSame('message.package.dependency.resolved', $alert->translationKey()); + self::assertSame(['%package%' => 'demo'], $alert->parameters()); + self::assertSame([], $alert->context()); + } + + public function testItPrefersExplicitSuccessMessages(): void + { + $selector = new WorkflowResultAlertSelector(); + $debug = Message::debug('package.dependency.resolved', 'message.package.dependency.resolved'); + $success = Message::success('message.package.lifecycle.activated', ['%package%' => 'demo']); + + $alert = $selector->fromResult(WorkflowResult::success(messages: [$debug, $success])); + + self::assertSame($success, $alert); + } + + public function testItUsesFirstIssueForFailedResults(): void + { + $selector = new WorkflowResultAlertSelector(); + $issue = Message::error('package.lifecycle.not_found', 'message.package.lifecycle.not_found'); + + $alert = $selector->fromResult(WorkflowResult::failed([$issue])); + + self::assertSame($issue, $alert); + } +} diff --git a/tests/View/Chart/ChartFactoryTest.php b/tests/View/Chart/ChartFactoryTest.php new file mode 100644 index 00000000..f2dc87ed --- /dev/null +++ b/tests/View/Chart/ChartFactoryTest.php @@ -0,0 +1,30 @@ +dataset('Visits', [12, 18], ['borderColor' => '#3451ff']); + + $line = $factory->line(['Today', 'Yesterday'], [$dataset], ['plugins' => ['legend' => ['display' => false]]]); + $bar = $factory->bar(['Today'], [$factory->dataset('Errors', [2])]); + $doughnut = $factory->doughnut(['Desktop', 'Mobile'], [$factory->dataset('Devices', [60, 40])]); + + self::assertSame(Chart::TYPE_LINE, $line->getType()); + self::assertSame(['Today', 'Yesterday'], $line->getData()['labels']); + self::assertSame([$dataset], $line->getData()['datasets']); + self::assertSame(['plugins' => ['legend' => ['display' => false]]], $line->getOptions()); + self::assertSame(Chart::TYPE_BAR, $bar->getType()); + self::assertSame(Chart::TYPE_DOUGHNUT, $doughnut->getType()); + } +} diff --git a/tests/View/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php new file mode 100644 index 00000000..135041b6 --- /dev/null +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -0,0 +1,70 @@ +get(Environment::class); + + self::assertStringContainsString( + 'system-alert-stack', + $twig->createTemplate('')->render(), + ); + self::assertStringContainsString( + 'system-cookie-consent', + $twig->createTemplate('')->render(), + ); + } + + public function testAlertStackUsesPollingOnlyWhenNoMercureStreamIsRendered(): void + { + self::bootKernel(); + $container = self::getContainer(); + $twig = $container->get(Environment::class); + $config = $container->get(Config::class); + $enabled = $config->get(MercureAvailability::ENABLED_KEY, true); + $available = $config->get(MercureAvailability::AVAILABLE_KEY, false); + + try { + $config->set(MercureAvailability::ENABLED_KEY, true, ConfigValueType::Boolean); + $config->set(MercureAvailability::AVAILABLE_KEY, false, ConfigValueType::Boolean); + $fallback = $this->renderAlertStack($twig); + + self::assertStringContainsString('ui-alert-poll', $fallback); + self::assertStringContainsString('data-ui-alert-poll-url-value', $fallback); + self::assertStringNotContainsString('data-ui-alert-stream-url-value', $fallback); + + $config->set(MercureAvailability::AVAILABLE_KEY, true, ConfigValueType::Boolean); + $stream = $this->renderAlertStack($twig); + + self::assertStringContainsString('ui-alert-stream', $stream); + self::assertStringContainsString('data-ui-alert-stream-url-value', $stream); + self::assertStringContainsString('data-ui-alert-stream-credentials-value', $stream); + self::assertStringContainsString('data-ui-alert-stream-catch-up-url-value', $stream); + self::assertStringContainsString('data-ui-alert-stream-fallback-url-value', $stream); + self::assertStringNotContainsString('ui-alert-poll', $stream); + self::assertStringNotContainsString('data-ui-alert-poll-url-value', $stream); + } finally { + $config->set(MercureAvailability::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + $config->set(MercureAvailability::AVAILABLE_KEY, $available, ConfigValueType::Boolean); + } + } + + private function renderAlertStack(Environment $twig): string + { + return $twig + ->createTemplate('') + ->render(); + } +} diff --git a/tests/View/Twig/UiAlertTwigExtensionTest.php b/tests/View/Twig/UiAlertTwigExtensionTest.php new file mode 100644 index 00000000..e63ea8c9 --- /dev/null +++ b/tests/View/Twig/UiAlertTwigExtensionTest.php @@ -0,0 +1,194 @@ +setName('PHPSESSID'); + $firstRequest = Request::create('/admin'); + $firstRequest->setSession($firstSession); + $firstRequest->cookies->set('PHPSESSID', 'first-session-id'); + + $secondSession = new Session(new MockArraySessionStorage()); + $secondSession->setName('PHPSESSID'); + $secondRequest = Request::create('/admin'); + $secondRequest->setSession($secondSession); + $secondRequest->cookies->set('PHPSESSID', 'second-session-id'); + + $firstScope = $this->extension($firstRequest)->storageScope(); + $secondScope = $this->extension($secondRequest)->storageScope(); + + self::assertNotSame($firstScope, $secondScope); + self::assertFalse($firstSession->isStarted()); + self::assertFalse($secondSession->isStarted()); + } + + public function testStreamUrlAuthorizesSubscriptionsForPrivatePushAlerts(): void + { + $requestStack = new RequestStack(); + $request = Request::create('https://studio.example.test/admin'); + $requestStack->push($request); + $tokenFactory = new RecordingMercureTokenFactory(); + $hub = new UiAlertTwigAuthorizedHub($tokenFactory); + $registry = new HubRegistry($hub); + $mercure = new MercureTwigExtension($registry, new Authorization($registry), $requestStack); + $topic = 'urn:system:ui-alerts:user:'.str_repeat('a', 64); + + $url = $this->extension($request, $mercure, true, $requestStack)->streamUrl([$topic]); + + self::assertStringContainsString('topic='.rawurlencode($topic), (string) $url); + self::assertSame([$topic], $tokenFactory->subscribe); + self::assertArrayHasKey('', $request->attributes->get('_mercure_authorization_cookies', [])); + } + + public function testStreamUrlDoesNotFailWhenAuthorizationCookieWasAlreadyPrepared(): void + { + $requestStack = new RequestStack(); + $request = Request::create('https://studio.example.test/admin'); + $request->attributes->set('_mercure_authorization_cookies', ['' => 'prepared']); + $requestStack->push($request); + $tokenFactory = new RecordingMercureTokenFactory(); + $hub = new UiAlertTwigAuthorizedHub($tokenFactory); + $registry = new HubRegistry($hub); + $mercure = new MercureTwigExtension($registry, new Authorization($registry), $requestStack); + $topic = 'urn:system:ui-alerts:user:'.str_repeat('b', 64); + + $url = $this->extension($request, $mercure, true, $requestStack)->streamUrl([$topic]); + + self::assertStringContainsString('topic='.rawurlencode($topic), (string) $url); + self::assertNull($tokenFactory->subscribe); + } + + private function extension( + Request $request, + ?MercureTwigExtension $mercure = null, + bool $mercureAvailable = false, + ?RequestStack $requestStack = null, + ): UiAlertTwigExtension { + $requestStack ??= new RequestStack(); + if (null === $requestStack->getMainRequest()) { + $requestStack->push($request); + } + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(MercureAvailability::AVAILABLE_KEY, $mercureAvailable, ConfigValueType::Boolean); + + return new UiAlertTwigExtension( + $requestStack, + new Security($this->securityContainer()), + new UiAlertTopicFactory('topic-secret'), + new MercureAvailability( + $config, + new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + new UiAlertTwigSilentHub(), + 'https://studio.example.test', + '/tmp/studio', + ), + new DetachedProcessStarter(), + '/tmp/studio', + ), + 'storage-secret', + $mercure, + ); + } + + private function securityContainer(): Container + { + $container = new Container(); + $container->set('security.token_storage', new TokenStorage()); + + return $container; + } +} + +final class RecordingMercureTokenFactory implements TokenFactoryInterface +{ + /** + * @var list|null + */ + public ?array $subscribe = null; + + /** + * @var list|null + */ + public ?array $publish = null; + + public function create(?array $subscribe = [], ?array $publish = [], array $additionalClaims = []): string + { + $this->subscribe = $subscribe; + $this->publish = $publish; + + return 'jwt-token'; + } +} + +final readonly class UiAlertTwigAuthorizedHub implements HubInterface +{ + public function __construct(private TokenFactoryInterface $tokenFactory) + { + } + + public function getPublicUrl(): string + { + return 'https://studio.example.test/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return $this->tokenFactory; + } + + public function publish(Update $update): string + { + return 'published'; + } +} + +final class UiAlertTwigSilentHub implements HubInterface +{ + public function getPublicUrl(): string + { + return 'https://studio.example.test/.well-known/mercure'; + } + + public function getFactory(): ?TokenFactoryInterface + { + return null; + } + + public function publish(Update $update): string + { + return 'published'; + } +} diff --git a/tests/View/Twig/ViewTwigExtensionTest.php b/tests/View/Twig/ViewTwigExtensionTest.php index 31462f0e..07db3cac 100644 --- a/tests/View/Twig/ViewTwigExtensionTest.php +++ b/tests/View/Twig/ViewTwigExtensionTest.php @@ -18,7 +18,7 @@ public function testItExposesViewGlobalsFunctionsAndMarkdownFilter(): void '{{ view_context().system_package.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ package_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}', )->render(); - self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.0|

ok

', $html); + self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.4|

ok

', $html); } public function testItRendersSafeHtmlAttributes(): void diff --git a/tests/assets/alert_payload.test.mjs b/tests/assets/alert_payload.test.mjs new file mode 100644 index 00000000..b8fed53d --- /dev/null +++ b/tests/assets/alert_payload.test.mjs @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + actionDetailFromElement, + alertIds, + alertMode, + normalizeAlertLevel, + payloadFromAlertElement, + storableAlertPayload, +} from '../../assets/js/alerts/alert_payload.js'; +import { createAlertElement } from '../../assets/js/alerts/alert_element.js'; +import { installDom } from './support/fake_dom.mjs'; + +test('alertIds normalizes single and list values', () => { + assert.deepEqual(alertIds(' alert-1 '), ['alert-1']); + assert.deepEqual(alertIds(['one', '', null, ' two ']), ['one', 'two']); + assert.deepEqual(alertIds(''), []); +}); + +test('alertMode falls back to auto for unknown modes', () => { + assert.equal(alertMode({ mode: 'hidden' }), 'hidden'); + assert.equal(alertMode({ mode: 'persistent' }), 'persistent'); + assert.equal(alertMode({ mode: 'unexpected' }), 'auto'); +}); + +test('normalizeAlertLevel maps aliases to supported levels', () => { + assert.equal(normalizeAlertLevel('danger'), 'error'); + assert.equal(normalizeAlertLevel('warn'), 'warning'); + assert.equal(normalizeAlertLevel('notice'), 'info'); + assert.equal(normalizeAlertLevel('debug'), 'debug'); + assert.equal(normalizeAlertLevel('unknown'), 'info'); +}); + +test('storableAlertPayload keeps only display-safe alert fields', () => { + const payload = storableAlertPayload({ + id: 'alert-1', + title: 'Title', + message: 'Message', + level: 'danger', + mode: 'persistent', + persistent: true, + loading: true, + actions: [{ label: 'Open' }], + context: { localPath: '/secret' }, + }); + + assert.deepEqual(payload, { + id: 'alert-1', + title: 'Title', + message: 'Message', + level: 'error', + mode: 'persistent', + persistent: true, + loading: true, + actions: [{ label: 'Open' }], + }); + assert.equal(Object.hasOwn(payload, 'context'), false); +}); + +test('payloadFromAlertElement reads structured dataset payloads', () => { + const alert = { + dataset: { + alertId: 'server-alert', + alertMode: 'persistent', + alertPayload: JSON.stringify({ + title: 'Server', + message: 'Rendered', + level: 'success', + }), + }, + }; + + assert.deepEqual(payloadFromAlertElement(alert), { + id: 'server-alert', + title: 'Server', + message: 'Rendered', + level: 'success', + mode: 'persistent', + }); +}); + +test('payloadFromAlertElement falls back to text content when JSON is invalid', () => { + const alert = { + dataset: { + alertId: 'fallback-alert', + alertMode: 'auto', + alertPersistent: 'true', + alertPayload: '{broken', + }, + classList: ['system-alert', 'system-alert-warning'], + querySelector(selector) { + const text = { + '.system-alert-title': 'Fallback title', + '.system-alert-message': 'Fallback message', + '.system-alert-content': 'Fallback content', + }[selector]; + + return text ? { textContent: text } : null; + }, + }; + + assert.deepEqual(payloadFromAlertElement(alert), { + id: 'fallback-alert', + title: 'Fallback title', + message: 'Fallback message', + level: 'warning', + mode: 'auto', + persistent: true, + actions: [], + }); +}); + +test('actionDetailFromElement parses action details safely', () => { + assert.deepEqual(actionDetailFromElement({ + dataset: { + alertActionDetail: '{"id":"operation"}', + }, + }), { id: 'operation' }); + + assert.deepEqual(actionDetailFromElement({ + dataset: { + alertActionDetail: '{broken', + }, + }), {}); +}); + +test('createAlertElement filters unsafe action links before rendering and storage', () => { + installDom(); + + const alert = createAlertElement({ + id: 'client-alert', + message: 'Client alert', + actions: [ + { label: 'Open', href: '/admin/packages', target: '_blank' }, + { label: 'Script', href: 'javascript:alert(1)' }, + { label: 'Hostless http', href: 'http:evil.example.test' }, + { label: 'External', href: 'https://example.test/privacy', target: '_self' }, + { label: 'Event', event: 'operation-overlay:show', detail: { id: 'operation-1' } }, + ], + }, 'Close'); + const actions = alert.querySelectorAll('.system-alert-action'); + const payload = JSON.parse(alert.dataset.alertPayload); + + assert.equal(actions.length, 3); + assert.equal(actions[0].href, '/admin/packages'); + assert.equal(actions[0].target, '_blank'); + assert.equal(actions[0].rel, 'noopener noreferrer'); + assert.equal(actions[1].href, 'https://example.test/privacy'); + assert.equal(actions[2].dataset.alertActionEvent, 'operation-overlay:show'); + assert.deepEqual(payload.actions, [ + { label: 'Open', href: '/admin/packages', target: '_blank' }, + { label: 'External', href: 'https://example.test/privacy', target: '_self' }, + { label: 'Event', event: 'operation-overlay:show', detail: { id: 'operation-1' } }, + ]); +}); diff --git a/tests/assets/controller_foundation.test.mjs b/tests/assets/controller_foundation.test.mjs new file mode 100644 index 00000000..590cfb34 --- /dev/null +++ b/tests/assets/controller_foundation.test.mjs @@ -0,0 +1,322 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { loadStimulusController } from './support/controller_loader.mjs'; +import { + FakeElement, + FakeFormElement, + FakeInputElement, + FakeDialogElement, + event, + installDom, +} from './support/fake_dom.mjs'; + +const { default: ClipboardController } = await loadStimulusController('assets/controllers/clipboard_controller.js'); +const { default: CookieConsentController } = await loadStimulusController('assets/controllers/cookie_consent_controller.js'); +const { default: DialogController } = await loadStimulusController('assets/controllers/dialog_controller.js'); +const { default: DisclosureController } = await loadStimulusController('assets/controllers/disclosure_controller.js'); +const { default: FilterFormController } = await loadStimulusController('assets/controllers/filter_form_controller.js'); +const { default: OperationOverlayController } = await loadStimulusController('assets/controllers/operation_overlay_controller.js'); +const { default: TabsController } = await loadStimulusController('assets/controllers/tabs_controller.js'); + +test('disclosure updates panels and trigger state when the open value changes', () => { + installDom(); + + const controller = new DisclosureController(); + const panel = new FakeElement(); + const trigger = new FakeElement('button'); + controller.panelTargets = [panel]; + controller.triggerTargets = [trigger]; + defineReactiveValue(controller, 'open', false); + + controller.connect(); + assert.equal(panel.hidden, true); + assert.equal(trigger.getAttribute('aria-expanded'), 'false'); + + controller.toggle(); + assert.equal(panel.hidden, false); + assert.equal(trigger.getAttribute('aria-expanded'), 'true'); +}); + +test('tabs select the active tab and hide inactive panels', () => { + installDom(); + + const controller = new TabsController(); + const overviewTab = tab('overview'); + const logsTab = tab('logs'); + logsTab.setAttribute('aria-selected', 'true'); + const overviewPanel = panel('overview'); + const logsPanel = panel('logs'); + controller.tabTargets = [overviewTab, logsTab]; + controller.panelTargets = [overviewPanel, logsPanel]; + defineReactiveValue(controller, 'selected', ''); + + controller.connect(); + assert.equal(logsTab.getAttribute('aria-selected'), 'true'); + assert.equal(overviewPanel.hidden, true); + assert.equal(logsPanel.hidden, false); + + controller.select(event({ currentTarget: overviewTab, params: {} })); + assert.equal(overviewTab.getAttribute('aria-selected'), 'true'); + assert.equal(logsTab.tabIndex, -1); + assert.equal(overviewPanel.hidden, false); + assert.equal(logsPanel.hidden, true); +}); + +test('dialog controller opens scoped dialogs and closes from child actions', () => { + installDom(); + + const controller = new DialogController(); + const root = new FakeElement(); + const dialog = new FakeDialogElement(); + const closeButton = new FakeElement('button'); + dialog.id = 'confirm'; + dialog.append(closeButton); + root.append(dialog); + controller.element = root; + controller.hasDialogTarget = false; + + const openEvent = event({ params: { id: 'confirm' } }); + controller.open(openEvent); + assert.equal(openEvent.defaultPrevented, true); + assert.equal(dialog.open, true); + + controller.close(event({ target: closeButton, params: {} })); + assert.equal(dialog.open, false); +}); + +test('clipboard copies explicit text and exposes status feedback', async () => { + installDom(); + + let copied = ''; + globalThis.navigator.clipboard = { + writeText: async (text) => { + copied = text; + }, + }; + + const controller = new ClipboardController(); + const status = new FakeElement(); + controller.hasTextValue = true; + controller.textValue = 'copy-me'; + controller.hasStatusTarget = true; + controller.statusTarget = status; + controller.successValue = 'Copied'; + controller.failureValue = 'Failed'; + + const copyEvent = event(); + await controller.copy(copyEvent); + + assert.equal(copyEvent.defaultPrevented, true); + assert.equal(copied, 'copy-me'); + assert.equal(status.textContent, 'Copied'); + assert.equal(status.hidden, false); +}); + +test('clipboard fallback uses a temporary textarea and removes it again', async () => { + const { document } = installDom(); + globalThis.navigator.clipboard = null; + + const controller = new ClipboardController(); + await controller.copyText('fallback-copy'); + + const textarea = document.created.find((element) => element.tagName === 'TEXTAREA'); + assert.equal(document.lastCommand, 'copy'); + assert.equal(textarea.value, 'fallback-copy'); + assert.equal(textarea.selected, true); + assert.equal(textarea.isConnected, false); +}); + +test('filter form submit stores focus state, resets pagination, and submits the form', () => { + const { sessionStorage } = installDom({ pathname: '/admin/logs' }); + + const controller = new FilterFormController(); + const form = new FakeFormElement(); + form.method = 'get'; + form.setAttribute('action', '/admin/logs'); + const page = new FakeInputElement(); + page.name = 'page'; + page.value = '4'; + const search = new FakeInputElement('search'); + search.name = 'q'; + search.selectionStart = 3; + search.selectionEnd = 7; + form.append(page, search); + controller.element = form; + document.activeElement = search; + + controller.submitNow(); + + assert.equal(page.value, '1'); + assert.equal(form.submitted, true); + assert.match(controller.storageKey, /^system\.filter-form\.focus\./); + assert.equal(JSON.parse([...sessionStorage.entries.values()][0]).name, 'q'); +}); + +test('filter form restores focus and selection from session storage', () => { + const { sessionStorage, window } = installDom({ pathname: '/admin/logs' }); + window.requestAnimationFrame = (callback) => callback(); + + const controller = new FilterFormController(); + const form = new FakeFormElement(); + form.method = 'get'; + form.setAttribute('action', '/admin/logs'); + const search = new FakeInputElement('search'); + search.name = 'q'; + form.append(search); + controller.element = form; + sessionStorage.setItem(controller.storageKey, JSON.stringify({ name: 'q', selectionStart: 2, selectionEnd: 5 })); + + controller.connect(); + + assert.equal(search.focused, true); + assert.equal(search.selectionStart, 2); + assert.equal(search.selectionEnd, 5); + assert.equal(sessionStorage.getItem(controller.storageKey), null); +}); + +test('cookie consent opens from a trigger and can reject optional choices', () => { + installDom(); + + const controller = new CookieConsentController(); + const overlay = new FakeElement(); + const details = new FakeElement(); + const closeButton = new FakeElement('button'); + const option = new FakeInputElement('checkbox'); + option.checked = true; + overlay.hidden = true; + details.hidden = true; + overlay.append(closeButton); + controller.element = overlay; + controller.hasDetailsTarget = true; + controller.detailsTarget = details; + controller.optionTargets = [option]; + controller.openFromTrigger = null; + controller.connect(); + + const trigger = new FakeElement('button'); + trigger.dataset.cookieConsentOpen = ''; + const click = new CustomEvent('click', { bubbles: true }); + click.target = trigger; + document.dispatchEvent(click); + controller.rejectOptional(); + + assert.equal(overlay.hidden, false); + assert.equal(details.hidden, false); + assert.equal(closeButton.focused, true); + assert.equal(option.checked, false); +}); + +test('operation overlay keeps a hide control available while polling continues', () => { + const { document } = installDom(); + const root = operationOverlayRoot(); + document.body.append(root); + + const controller = new OperationOverlayController(); + controller.element = new FakeFormElement(); + controller.reset(); + + assert.equal(controller.closeButton.hidden, false); + assert.equal(controller.closeIconButton.hidden, false); + + const poller = { + stopped: false, + stop() { + this.stopped = true; + }, + }; + controller.livePoller = poller; + controller.polling = true; + root.hidden = false; + + controller.close(); + + assert.equal(root.hidden, true); + assert.equal(controller.polling, true); + assert.equal(poller.stopped, false); +}); + +test('operation overlay marks running detail actions as reusable', () => { + const { document } = installDom(); + const root = operationOverlayRoot(); + document.body.append(root); + + let alertPayload = null; + + const controller = new OperationOverlayController(); + const form = new FakeFormElement(); + form.action = '/admin/operations/run'; + controller.element = form; + controller.storageKey = () => 'system.operation.test'; + controller.dispatchAlert = (payload) => { + alertPayload = payload; + }; + + controller.updateOperationAlert({ + status: 'running', + progress: { index: 1, total: 3 }, + label: 'Package registry refresh', + }); + + assert.equal(alertPayload.actions[0].event, 'operation-overlay:show'); + assert.equal(alertPayload.actions[0].detail.keepAlert, true); + + controller.updateOperationAlert({ + status: 'success', + label: 'Package registry refresh', + }); + + assert.equal(alertPayload.actions[0].detail.keepAlert, false); +}); + +function tab(id) { + const element = new FakeElement('button'); + element.dataset.tabsId = id; + + return element; +} + +function panel(id) { + const element = new FakeElement('section'); + element.dataset.tabsId = id; + + return element; +} + +function operationOverlayRoot() { + const root = new FakeElement('section'); + root.setAttribute('data-operation-overlay-root', ''); + root.setAttribute('data-label-starting', 'Starting'); + root.setAttribute('data-label-close', 'Close'); + const summary = new FakeElement('div'); + summary.setAttribute('data-operation-overlay-summary', ''); + const list = new FakeElement('ol'); + list.setAttribute('data-operation-overlay-list', ''); + const scroll = new FakeElement('div'); + scroll.setAttribute('data-operation-overlay-scroll', ''); + const empty = new FakeElement('li'); + empty.setAttribute('data-operation-overlay-empty', ''); + const spinner = new FakeElement('span'); + spinner.setAttribute('data-operation-overlay-spinner', ''); + scroll.append(list); + root.append(summary, scroll, empty, spinner); + + for (const name of ['ok', 'continue', 'retry', 'refresh', 'cancel', 'close', 'close-icon']) { + const button = new FakeElement('button'); + button.setAttribute(`data-operation-overlay-${name}`, ''); + root.append(button); + } + + return root; +} + +function defineReactiveValue(controller, name, initialValue) { + let value = initialValue; + Object.defineProperty(controller, `${name}Value`, { + get: () => value, + set: (next) => { + value = next; + controller[`${name}ValueChanged`]?.(); + }, + }); +} diff --git a/tests/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs new file mode 100644 index 00000000..bdd269c7 --- /dev/null +++ b/tests/assets/live_alert_controllers.test.mjs @@ -0,0 +1,611 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { loadStimulusController } from './support/controller_loader.mjs'; +import { FakeElement, installDom } from './support/fake_dom.mjs'; + +const { default: LivePollController } = await loadStimulusController('assets/controllers/live_poll_controller.js'); +const { default: AlertStackController } = await loadStimulusController('assets/controllers/alert_stack_controller.js'); +const { default: UiAlertPollController } = await loadStimulusController('assets/controllers/ui_alert_poll_controller.js'); +const { default: UiAlertStreamController } = await loadStimulusController('assets/controllers/ui_alert_stream_controller.js'); + +test('live poll controller resolves relative routes and dispatches payload lifecycle events', () => { + installDom({ origin: 'https://studio.example.test' }); + + const controller = new LivePollController(); + const element = new FakeElement(); + const events = []; + element.addEventListener('payload', (event) => events.push({ type: 'payload', detail: event.detail })); + element.addEventListener('error', (event) => events.push({ type: 'error', detail: event.detail })); + element.addEventListener('done', (event) => events.push({ type: 'done', detail: event.detail })); + controller.element = element; + controller.hasUrlValue = false; + controller.hasRelativeRouteValue = true; + controller.relativeRouteValue = '/captcha/seed'; + + assert.equal(controller.endpoint, 'https://studio.example.test/api/live/captcha/seed'); + + const payload = { status: 'queued' }; + controller.payload(payload, 9); + controller.error({ status: 503 }, null); + controller.done({ status: 'success' }); + + assert.equal(controller.cursorValue, 9); + assert.deepEqual(events, [ + { type: 'payload', detail: { payload, cursor: 9 } }, + { type: 'error', detail: { response: { status: 503 }, error: null } }, + { type: 'done', detail: { payload: { status: 'success' } } }, + ]); +}); + +test('UI alert polling dispatches each received alert and advances the cursor', () => { + installDom(); + + const controller = new UiAlertPollController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + + controller.payload({ + alerts: [ + { id: 'one', message: 'First' }, + { id: 'two', message: 'Second' }, + ], + }, 12); + + assert.equal(controller.cursorValue, 12); + assert.deepEqual(received, [ + { id: 'one', message: 'First' }, + { id: 'two', message: 'Second' }, + ]); +}); + +test('alert stack stores new alerts, deduplicates updates, and closes all active alerts', () => { + const { sessionStorage } = installDom(); + + const controller = new AlertStackController(); + const element = new FakeElement(); + const list = new FakeElement(); + const panel = new FakeElement(); + const badge = new FakeElement(); + const toggle = new FakeElement('button'); + const clearAll = new FakeElement('button'); + const empty = new FakeElement(); + const shown = []; + document.addEventListener('ui-alert:shown', (event) => shown.push(event.detail)); + element.dataset.alertCloseLabel = 'Close'; + panel.hidden = true; + controller.element = element; + controller.listTarget = list; + controller.panelTarget = panel; + controller.badgeTarget = badge; + controller.toggleTarget = toggle; + controller.clearAllTarget = clearAll; + controller.emptyTarget = empty; + controller.hasClearAllTarget = true; + controller.hasEmptyTarget = true; + controller.storageScopeValue = 'session:test'; + controller.dismissDelayValue = 8000; + controller.initialize(); + + controller.upsertAlert({ id: 'alert-1', level: 'success', message: 'Saved' }); + controller.upsertAlert({ id: 'alert-1', level: 'success', message: 'Saved' }); + + assert.equal(controller.activeCount, 1); + assert.equal(list.children.length, 1); + assert.equal(badge.textContent, '1'); + assert.equal(panel.hidden, false); + assert.equal(shown.length, 1); + assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.storageKey)), [{ + id: 'alert-1', + level: 'success', + message: 'Saved', + mode: 'auto', + persistent: false, + loading: false, + actions: [], + }]); + + controller.closeAll({ preventDefault() {} }); + + assert.equal(controller.activeCount, 0); + assert.equal(list.children.length, 0); + assert.equal(badge.hidden, true); + assert.equal(empty.hidden, false); + assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.storageKey)), []); + assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.closedStorageKey)), ['alert-1']); +}); + +test('alert stack auto-dismiss removes transient alerts and remembers delivered ids', () => { + const { sessionStorage, window } = installDom(); + let scheduled = null; + window.setTimeout = (callback) => { + scheduled = callback; + + return 1; + }; + + const controller = new AlertStackController(); + const element = new FakeElement(); + const list = new FakeElement(); + const panel = new FakeElement(); + const badge = new FakeElement(); + const toggle = new FakeElement('button'); + const clearAll = new FakeElement('button'); + const empty = new FakeElement(); + const closed = []; + document.addEventListener('ui-alert:closed', (event) => closed.push(event.detail)); + panel.hidden = true; + controller.element = element; + controller.listTarget = list; + controller.panelTarget = panel; + controller.badgeTarget = badge; + controller.toggleTarget = toggle; + controller.clearAllTarget = clearAll; + controller.emptyTarget = empty; + controller.hasClearAllTarget = true; + controller.hasEmptyTarget = true; + controller.storageScopeValue = 'session:auto'; + controller.dismissDelayValue = 10; + controller.initialize(); + + controller.upsertAlert({ id: 'auto-alert', level: 'success', message: 'Saved', mode: 'auto' }); + scheduled(); + + assert.equal(controller.activeCount, 0); + assert.equal(list.children.length, 0); + assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.storageKey)), []); + assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.closedStorageKey)), ['auto-alert']); + assert.deepEqual(closed, []); + + controller.upsertAlert({ id: 'auto-alert', level: 'success', message: 'Saved again', mode: 'auto' }); + + assert.equal(controller.activeCount, 0); + assert.equal(list.children.length, 0); +}); + +test('alert stack auto-dismiss keeps persistent alerts active', () => { + const { sessionStorage, window } = installDom(); + let scheduled = null; + window.setTimeout = (callback) => { + scheduled = callback; + + return 1; + }; + + const controller = new AlertStackController(); + const element = new FakeElement(); + const list = new FakeElement(); + const panel = new FakeElement(); + const badge = new FakeElement(); + const toggle = new FakeElement('button'); + const clearAll = new FakeElement('button'); + const empty = new FakeElement(); + panel.hidden = true; + controller.element = element; + controller.listTarget = list; + controller.panelTarget = panel; + controller.badgeTarget = badge; + controller.toggleTarget = toggle; + controller.clearAllTarget = clearAll; + controller.emptyTarget = empty; + controller.hasClearAllTarget = true; + controller.hasEmptyTarget = true; + controller.storageScopeValue = 'session:persistent'; + controller.dismissDelayValue = 10; + controller.initialize(); + + controller.upsertAlert({ id: 'persistent-alert', level: 'info', message: 'Review details', mode: 'persistent' }); + controller.scheduleHide(); + scheduled(); + + assert.equal(controller.activeCount, 1); + assert.equal(list.children.length, 1); + assert.equal(JSON.parse(sessionStorage.getItem(controller.storageKey))[0].id, 'persistent-alert'); +}); + +test('alert stack can keep action alerts open for reusable detail actions', () => { + installDom(); + + const controller = new AlertStackController(); + const element = new FakeElement(); + const list = new FakeElement(); + const panel = new FakeElement(); + const badge = new FakeElement(); + const toggle = new FakeElement('button'); + const clearAll = new FakeElement('button'); + const empty = new FakeElement(); + const events = []; + document.addEventListener('operation-overlay:show', (event) => events.push(event.detail)); + panel.hidden = true; + controller.element = element; + controller.listTarget = list; + controller.panelTarget = panel; + controller.badgeTarget = badge; + controller.toggleTarget = toggle; + controller.clearAllTarget = clearAll; + controller.emptyTarget = empty; + controller.hasClearAllTarget = true; + controller.hasEmptyTarget = true; + controller.storageScopeValue = 'session:actions'; + controller.initialize(); + + controller.upsertAlert({ + id: 'operation-alert', + level: 'info', + message: 'Operation running', + mode: 'persistent', + actions: [{ + label: 'Show details', + event: 'operation-overlay:show', + detail: { storageKey: 'operation-key', keepAlert: true }, + }], + }); + + const action = list.children[0].querySelector('.system-alert-action'); + controller.action({ currentTarget: action, preventDefault() {} }); + + assert.deepEqual(events, [{ storageKey: 'operation-key', keepAlert: true }]); + assert.equal(controller.activeCount, 1); + assert.equal(list.children.length, 1); +}); + +test('UI alert stream opens EventSource with credentials and forwards valid alert events', () => { + installDom(); + + const sources = []; + class FakeEventSource { + static CLOSED = 2; + + constructor(url, options) { + this.url = url; + this.options = options; + this.listeners = new Map(); + this.readyState = 0; + this.closed = false; + sources.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + close() { + this.closed = true; + } + + emit(type, data = '{}') { + this.listeners.get(type)?.({ data }); + } + } + window.EventSource = FakeEventSource; + globalThis.EventSource = FakeEventSource; + + const controller = new UiAlertStreamController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.credentialsValue = true; + + controller.connect(); + sources[0].emit('ui-alert', JSON.stringify({ id: 'push', message: 'Pushed' })); + sources[0].emit('message', '{broken'); + controller.disconnect(); + + assert.equal(sources.length, 1); + assert.equal(sources[0].url, controller.urlValue); + assert.deepEqual(sources[0].options, { withCredentials: true }); + assert.deepEqual(received, [{ id: 'push', message: 'Pushed' }]); + assert.equal(sources[0].closed, true); +}); + +test('UI alert stream drains queued catch-up pages when the stream opens', async () => { + const { window } = installDom(); + + const sources = []; + const fetches = []; + window.fetch = async (url, options) => { + fetches.push({ url, options }); + const cursor = Number(new URL(url).searchParams.get('cursor')); + + return { + ok: true, + async json() { + if (cursor === 7) { + return { cursor: 42, has_more: true, alerts: [{ id: 'queued-1', message: 'Queued one' }] }; + } + + if (cursor === 42) { + return { cursor: 45, has_more: false, alerts: [{ id: 'queued-2', message: 'Queued two' }] }; + } + + return { cursor: 45, has_more: false, alerts: [] }; + }, + }; + }; + + class FakeEventSource { + static CLOSED = 2; + + constructor() { + this.listeners = new Map(); + sources.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + close() {} + } + window.EventSource = FakeEventSource; + globalThis.EventSource = FakeEventSource; + + const controller = new UiAlertStreamController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.hasCatchUpUrlValue = true; + controller.catchUpUrlValue = '/api/live/alerts'; + controller.catchUpCursorValue = 7; + controller.credentialsValue = false; + + controller.connect(); + sources[0].listeners.get('open')(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetches.length, 3); + assert.equal(fetches[0].url, 'http://127.0.0.1:8000/api/live/alerts?cursor=7'); + assert.equal(fetches[1].url, 'http://127.0.0.1:8000/api/live/alerts?cursor=42'); + assert.equal(fetches[2].url, 'http://127.0.0.1:8000/api/live/alerts?cursor=45'); + assert.deepEqual(fetches[0].options, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + assert.equal(controller.catchUpCursorValue, 45); + assert.deepEqual(received, [ + { id: 'queued-1', message: 'Queued one' }, + { id: 'queued-2', message: 'Queued two' }, + ]); +}); + +test('UI alert stream drains queued alerts before the stream opens', async () => { + const { window } = installDom(); + + const sources = []; + const fetches = []; + window.fetch = async (url, options) => { + fetches.push({ url, options }); + + return { + ok: true, + async json() { + return { + cursor: 11, + has_more: false, + alerts: [{ id: 'pre-open', message: 'Before open' }], + }; + }, + }; + }; + + class FakeEventSource { + constructor() { + this.listeners = new Map(); + sources.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + close() {} + } + window.EventSource = FakeEventSource; + globalThis.EventSource = FakeEventSource; + + const controller = new UiAlertStreamController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.hasCatchUpUrlValue = true; + controller.catchUpUrlValue = '/api/live/alerts'; + controller.catchUpCursorValue = 4; + controller.credentialsValue = false; + + controller.connect(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(sources.length, 1); + assert.deepEqual(fetches, [{ + url: 'http://127.0.0.1:8000/api/live/alerts?cursor=4', + options: { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }, + }]); + assert.equal(controller.catchUpCursorValue, 11); + assert.deepEqual(received, [{ id: 'pre-open', message: 'Before open' }]); +}); + +test('UI alert stream schedules reconnect when the stream closes', () => { + const { window } = installDom(); + + let scheduledDelay = null; + window.setTimeout = (callback, delay) => { + scheduledDelay = delay; + callback(); + + return 1; + }; + + const sources = []; + class FakeEventSource { + static CLOSED = 2; + + constructor() { + this.listeners = new Map(); + this.readyState = 0; + sources.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + close() {} + } + window.EventSource = FakeEventSource; + globalThis.EventSource = FakeEventSource; + + const controller = new UiAlertStreamController(); + controller.element = new FakeElement(); + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.credentialsValue = false; + + controller.connect(); + sources[0].listeners.get('open')(); + sources[0].readyState = FakeEventSource.CLOSED; + sources[0].listeners.get('error')(); + + assert.equal(scheduledDelay, UiAlertStreamController.reconnectBaseDelay); + assert.equal(sources.length, 2); +}); + +test('UI alert stream falls back to polling when EventSource is unavailable', async () => { + const { window } = installDom(); + window.EventSource = undefined; + globalThis.EventSource = undefined; + + const fetches = []; + window.fetch = async (url, options) => { + fetches.push({ url, options }); + + return { + ok: true, + headers: { get: () => 'application/json' }, + async json() { + return { + cursor: 12, + next_poll_ms: 0, + alerts: [{ id: 'fallback', message: 'Fallback delivery' }], + }; + }, + }; + }; + + const controller = new UiAlertStreamController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.hasFallbackUrlValue = true; + controller.fallbackUrlValue = '/api/live/alerts'; + controller.fallbackIntervalValue = 15000; + controller.catchUpCursorValue = 5; + + controller.connect(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetches.length, 1); + assert.equal(fetches[0].url, 'http://127.0.0.1:8000/api/live/alerts?cursor=5'); + assert.equal(controller.catchUpCursorValue, 12); + assert.deepEqual(received, [{ id: 'fallback', message: 'Fallback delivery' }]); +}); + +test('UI alert stream falls back to polling when the first stream open fails', async () => { + const { window } = installDom(); + + const fetches = []; + window.fetch = async (url) => { + fetches.push(url); + + return { + ok: true, + headers: { get: () => 'application/json' }, + async json() { + return { + cursor: 9, + next_poll_ms: 0, + alerts: [{ id: 'early-fallback', message: 'Early fallback' }], + }; + }, + }; + }; + + const sources = []; + class FakeEventSource { + static CLOSED = 2; + + constructor() { + this.listeners = new Map(); + this.readyState = 0; + this.closed = false; + sources.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + close() { + this.closed = true; + } + } + window.EventSource = FakeEventSource; + globalThis.EventSource = FakeEventSource; + + const controller = new UiAlertStreamController(); + const element = new FakeElement(); + const received = []; + element.addEventListener('ui-alert:received', (event) => received.push(event.detail)); + controller.element = element; + controller.hasUrlValue = true; + controller.urlValue = 'http://127.0.0.1:3000/.well-known/mercure?topic=alerts'; + controller.hasFallbackUrlValue = true; + controller.fallbackUrlValue = '/api/live/alerts'; + controller.catchUpCursorValue = 4; + + controller.connect(); + sources[0].readyState = FakeEventSource.CLOSED; + sources[0].listeners.get('error')(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(sources.length, 1); + assert.equal(sources[0].closed, true); + assert.deepEqual(fetches, ['http://127.0.0.1:8000/api/live/alerts?cursor=4']); + assert.deepEqual(received, [{ id: 'early-fallback', message: 'Early fallback' }]); +}); diff --git a/tests/assets/live_poll.test.mjs b/tests/assets/live_poll.test.mjs new file mode 100644 index 00000000..4902f3c3 --- /dev/null +++ b/tests/assets/live_poll.test.mjs @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { LivePoller, liveRouteUrl } from '../../assets/js/live/live_poll.js'; + +test('pollOnce fetches JSON payloads with the current cursor', async () => { + installWindow(); + + const requestedUrls = []; + const payloads = []; + const poller = new LivePoller({ + fetcher: async (url, options) => { + requestedUrls.push(url); + assert.equal(options.headers.Accept, 'application/json'); + assert.equal(options.headers['X-Requested-With'], 'XMLHttpRequest'); + + return jsonResponse({ cursor: 7, status: 'queued', next_poll_ms: 0 }); + }, + onPayload: (payload, cursor) => payloads.push({ payload, cursor }), + }); + + const result = await poller.pollOnce('/api/live/alerts', 3); + + assert.equal(new URL(requestedUrls[0]).searchParams.get('cursor'), '3'); + assert.deepEqual(result, { cursor: 7, status: 'queued', next_poll_ms: 0 }); + assert.deepEqual(payloads, [{ + payload: { cursor: 7, status: 'queued', next_poll_ms: 0 }, + cursor: 7, + }]); +}); + +test('poll stops and calls onDone for terminal payloads', async () => { + installWindow(); + + const donePayloads = []; + const poller = new LivePoller({ + fetcher: async () => jsonResponse({ cursor: 4, status: 'success', next_poll_ms: 750 }), + onDone: (payload) => donePayloads.push(payload), + }); + + const result = await poller.poll('/api/live/operations/1', 0); + + assert.equal(poller.active, false); + assert.deepEqual(result, { cursor: 4, status: 'success', next_poll_ms: 750 }); + assert.deepEqual(donePayloads, [{ cursor: 4, status: 'success', next_poll_ms: 750 }]); +}); + +test('poll returns null after a non-retryable HTTP failure', async () => { + installWindow(); + + const errors = []; + const poller = new LivePoller({ + fetcher: async () => response({ + ok: false, + status: 503, + contentType: 'application/json', + payload: { status: 'unavailable' }, + }), + onError: (response, error) => errors.push({ response, error }), + }); + + const result = await poller.poll('/api/live/alerts', 0); + + assert.equal(result, null); + assert.equal(errors.length, 1); + assert.equal(errors[0].response.status, 503); + assert.equal(errors[0].error, null); +}); + +test('poll retries transient failures when retryOnError is enabled', async () => { + installWindow({ immediateTimeout: true }); + + let calls = 0; + const errors = []; + const payloads = []; + const poller = new LivePoller({ + interval: 0, + retryOnError: true, + fetcher: async () => { + calls += 1; + + return calls === 1 + ? response({ ok: false, status: 503 }) + : jsonResponse({ cursor: 2, status: 'queued', next_poll_ms: 0 }); + }, + onError: (response, error) => errors.push({ response, error }), + onPayload: (payload, cursor) => payloads.push({ payload, cursor }), + }); + + const result = await poller.poll('/api/live/alerts', 0); + + assert.equal(calls, 2); + assert.equal(errors.length, 1); + assert.equal(errors[0].response.status, 503); + assert.deepEqual(payloads, [{ + payload: { cursor: 2, status: 'queued', next_poll_ms: 0 }, + cursor: 2, + }]); + assert.deepEqual(result, { cursor: 2, status: 'queued', next_poll_ms: 0 }); +}); + +test('poll drains paginated payloads immediately when more pages are available', async () => { + installWindow(); + + const requestedCursors = []; + const payloads = []; + const pages = [ + { cursor: 3, has_more: true, alerts: [{ id: 'first' }], next_poll_ms: 15000 }, + { cursor: 5, has_more: false, alerts: [{ id: 'second' }], next_poll_ms: 0 }, + ]; + const poller = new LivePoller({ + interval: 15000, + fetcher: async (url) => { + requestedCursors.push(new URL(url).searchParams.get('cursor')); + + return jsonResponse(pages.shift()); + }, + onPayload: (payload, cursor) => payloads.push({ payload, cursor }), + }); + + const result = await poller.poll('/api/live/alerts', 1); + + assert.deepEqual(requestedCursors, ['1', '3']); + assert.deepEqual(payloads, [ + { payload: { cursor: 3, has_more: true, alerts: [{ id: 'first' }], next_poll_ms: 15000 }, cursor: 3 }, + { payload: { cursor: 5, has_more: false, alerts: [{ id: 'second' }], next_poll_ms: 0 }, cursor: 5 }, + ]); + assert.deepEqual(result, { cursor: 5, has_more: false, alerts: [{ id: 'second' }], next_poll_ms: 0 }); +}); + +test('readJson rejects non-JSON responses with the configured message', async () => { + installWindow(); + + const errors = []; + const poller = new LivePoller({ + fetcher: async () => response({ ok: true, status: 200, contentType: 'text/html' }), + invalidJsonMessage: 'Translated invalid response.', + onError: (response, error) => errors.push({ response, error }), + }); + + const result = await poller.pollOnce('/api/live/alerts', 0); + + assert.equal(result, null); + assert.equal(errors.length, 1); + assert.equal(errors[0].response, null); + assert.equal(errors[0].error.message, 'Translated invalid response.'); +}); + +test('liveRouteUrl builds relative live API URLs', () => { + installWindow({ origin: 'https://studio.example.test' }); + + assert.equal( + liveRouteUrl('/captcha-pack/seed'), + 'https://studio.example.test/api/live/captcha-pack/seed', + ); +}); + +function jsonResponse(payload, status = 200) { + return response({ + ok: status >= 200 && status < 300, + status, + contentType: 'application/json; charset=utf-8', + payload, + }); +} + +function response({ ok, status = 200, contentType = 'application/json', payload = {} }) { + return { + ok, + status, + headers: { + get(name) { + return name.toLowerCase() === 'content-type' ? contentType : ''; + }, + }, + async json() { + return payload; + }, + }; +} + +function installWindow({ origin = 'http://127.0.0.1:8000', immediateTimeout = false } = {}) { + globalThis.window = { + fetch: async () => { + throw new Error('Unexpected fetch call.'); + }, + location: { origin }, + setTimeout(callback, delay) { + return immediateTimeout ? setTimeout(callback, 0) : setTimeout(callback, delay); + }, + }; +} diff --git a/tests/assets/support/controller_loader.mjs b/tests/assets/support/controller_loader.mjs new file mode 100644 index 00000000..10c13247 --- /dev/null +++ b/tests/assets/support/controller_loader.mjs @@ -0,0 +1,34 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = resolve(import.meta.dirname, '../../..'); + +export async function loadStimulusController(path) { + const absolutePath = resolve(root, path); + let source = await readFile(absolutePath, 'utf8'); + + source = source.replace( + /import\s+\{\s*Controller\s*\}\s+from\s+['"]@hotwired\/stimulus['"];\s*/, + stimulusControllerStub(), + ); + source = source.replace( + /from\s+['"](\.{1,2}\/[^'"]+)['"]/g, + (match, specifier) => `from '${pathToFileURL(resolve(dirname(absolutePath), specifier)).href}'`, + ); + + return import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +} + +function stimulusControllerStub() { + return ` +class Controller { + dispatch(name, options = {}) { + this.element?.dispatchEvent?.(new CustomEvent(name, { + bubbles: true, + detail: options.detail || {}, + })); + } +} +`; +} diff --git a/tests/assets/support/fake_dom.mjs b/tests/assets/support/fake_dom.mjs new file mode 100644 index 00000000..43bc1b1c --- /dev/null +++ b/tests/assets/support/fake_dom.mjs @@ -0,0 +1,2 @@ +export * from './fake_dom_elements.mjs'; +export * from './fake_dom_environment.mjs'; diff --git a/tests/assets/support/fake_dom_elements.mjs b/tests/assets/support/fake_dom_elements.mjs new file mode 100644 index 00000000..571fb7bd --- /dev/null +++ b/tests/assets/support/fake_dom_elements.mjs @@ -0,0 +1,289 @@ +import { selectorListMatches } from './fake_dom_selectors.mjs'; + +export class FakeEventTarget { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + this.listeners.set(type, (this.listeners.get(type) || []).filter((entry) => entry !== listener)); + } + + dispatchEvent(event) { + event.target ??= this; + event.currentTarget = this; + + for (const listener of this.listeners.get(event.type) || []) { + listener.call(this, event); + } + + if (event.bubbles && this.parentElement) { + this.parentElement.dispatchEvent(event); + } + + return !event.defaultPrevented; + } +} + +export class FakeClassList { + constructor(owner) { + this.owner = owner; + this.names = new Set(); + } + + add(...names) { + for (const name of names) { + this.names.add(name); + } + this.sync(); + } + + remove(...names) { + for (const name of names) { + this.names.delete(name); + } + this.sync(); + } + + contains(name) { + return this.names.has(name); + } + + [Symbol.iterator]() { + return this.names[Symbol.iterator](); + } + + sync() { + this.owner._className = [...this.names].join(' '); + } +} + +export class FakeElement extends FakeEventTarget { + constructor(tagName = 'div') { + super(); + this.tagName = tagName.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this.attributes = new Map(); + this.classList = new FakeClassList(this); + this.hidden = false; + this.disabled = false; + this.checked = false; + this.textContent = ''; + this.value = ''; + this.type = ''; + this.name = ''; + this.id = ''; + this.method = 'get'; + this.parentElement = null; + this.isConnected = true; + this.focused = false; + } + + get className() { + return this._className || ''; + } + + set className(value) { + this._className = String(value || ''); + this.classList.names = new Set(this._className.split(/\s+/).filter(Boolean)); + } + + append(...children) { + for (const child of children.flat()) { + child.parentElement = this; + this.children.push(child); + } + } + + replaceChildren(...children) { + for (const child of this.children) { + child.parentElement = null; + } + this.children = []; + this.append(...children); + } + + remove() { + this.isConnected = false; + if (!this.parentElement) { + return; + } + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + this.parentElement = null; + } + + contains(element) { + if (element === this) { + return true; + } + + return this.children.some((child) => child.contains?.(element)); + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + + if (name === 'id') { + this.id = String(value); + } + + if (name === 'class') { + this.className = String(value); + } + + if (name.startsWith('data-')) { + this.dataset[dataName(name)] = String(value); + } + } + + getAttribute(name) { + if (name === 'id') { + return this.id || null; + } + + if (name === 'class') { + return this.className || null; + } + + return this.attributes.get(name) ?? null; + } + + removeAttribute(name) { + this.attributes.delete(name); + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + + querySelectorAll(selector) { + return this.descendants().filter((element) => selectorListMatches(element, selector)); + } + + closest(selector) { + let current = this; + + while (current) { + if (selectorListMatches(current, selector)) { + return current; + } + current = current.parentElement; + } + + return null; + } + + focus() { + this.focused = true; + globalThis.document.activeElement = this; + } + + select() { + this.selected = true; + } + + setSelectionRange(start, end) { + this.selectionStart = start; + this.selectionEnd = end; + } + + descendants() { + return this.children.flatMap((child) => [child, ...child.descendants()]); + } +} + +export class FakeInputElement extends FakeElement { + constructor(type = 'text') { + super('input'); + this.type = type; + this.selectionStart = null; + this.selectionEnd = null; + } +} + +export class FakeSelectElement extends FakeElement { + constructor() { + super('select'); + } +} + +export class FakeTextAreaElement extends FakeElement { + constructor() { + super('textarea'); + } +} + +export class FakeDialogElement extends FakeElement { + constructor() { + super('dialog'); + this.open = false; + } + + showModal() { + this.open = true; + } + + close() { + this.open = false; + } +} + +export class FakeFormElement extends FakeElement { + constructor() { + super('form'); + this.submitted = false; + this.submitter = null; + } + + requestSubmit(submitter = undefined) { + this.submitted = true; + this.submitter = submitter; + } +} + +export class FakeDocument extends FakeElement { + constructor() { + super('#document'); + this.body = new FakeElement('body'); + this.append(this.body); + this.activeElement = null; + this.hidden = false; + this.created = []; + } + + createElement(tagName) { + const normalized = tagName.toLowerCase(); + const element = normalized === 'input' + ? new FakeInputElement() + : normalized === 'textarea' + ? new FakeTextAreaElement() + : normalized === 'select' + ? new FakeSelectElement() + : normalized === 'dialog' + ? new FakeDialogElement() + : normalized === 'form' + ? new FakeFormElement() + : new FakeElement(normalized); + this.created.push(element); + + return element; + } + + execCommand(command) { + this.lastCommand = command; + + return true; + } +} + +function dataName(attribute) { + return attribute.slice(5).replace(/-([a-z])/g, (match, letter) => letter.toUpperCase()); +} diff --git a/tests/assets/support/fake_dom_environment.mjs b/tests/assets/support/fake_dom_environment.mjs new file mode 100644 index 00000000..0ba8656f --- /dev/null +++ b/tests/assets/support/fake_dom_environment.mjs @@ -0,0 +1,75 @@ +import { + FakeDialogElement, + FakeDocument, + FakeElement, + FakeEventTarget, + FakeInputElement, + FakeSelectElement, + FakeTextAreaElement, +} from './fake_dom_elements.mjs'; + +export function installDom({ origin = 'http://127.0.0.1:8000', pathname = '/admin' } = {}) { + const document = new FakeDocument(); + const sessionStorage = createStorage(); + const window = new FakeEventTarget(); + window.location = { origin, pathname }; + window.sessionStorage = sessionStorage; + window.setTimeout = setTimeout; + window.clearTimeout = clearTimeout; + window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + window.cancelAnimationFrame = clearTimeout; + + globalThis.document = document; + globalThis.window = window; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: {}, + }); + globalThis.CustomEvent = class CustomEvent { + constructor(type, options = {}) { + this.type = type; + this.bubbles = Boolean(options.bubbles); + this.detail = options.detail; + this.defaultPrevented = false; + this.target = null; + this.currentTarget = null; + } + + preventDefault() { + this.defaultPrevented = true; + } + }; + globalThis.Element = FakeElement; + globalThis.HTMLInputElement = FakeInputElement; + globalThis.HTMLSelectElement = FakeSelectElement; + globalThis.HTMLTextAreaElement = FakeTextAreaElement; + globalThis.HTMLDialogElement = FakeDialogElement; + globalThis.CSS = { escape: (value) => String(value).replace(/"/g, '\\"') }; + + return { document, sessionStorage, window }; +} + +export function event({ target = null, currentTarget = target, params = {}, submitter = undefined } = {}) { + return { + target, + currentTarget, + params, + submitter, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; +} + +export function createStorage() { + const entries = new Map(); + + return { + getItem: (key) => entries.get(key) ?? null, + setItem: (key, value) => entries.set(key, String(value)), + removeItem: (key) => entries.delete(key), + clear: () => entries.clear(), + entries, + }; +} diff --git a/tests/assets/support/fake_dom_selectors.mjs b/tests/assets/support/fake_dom_selectors.mjs new file mode 100644 index 00000000..ed585453 --- /dev/null +++ b/tests/assets/support/fake_dom_selectors.mjs @@ -0,0 +1,45 @@ +export function selectorListMatches(element, selector) { + return selector.split(',').some((part) => selectorMatches(element, part.trim())); +} + +function selectorMatches(element, selector) { + if (!selector) { + return false; + } + + if (selector.startsWith('#')) { + return element.id === selector.slice(1); + } + + if (selector.startsWith('.')) { + return element.classList.contains(selector.slice(1)); + } + + const dataAttribute = selector.match(/^\[data-([a-z0-9-]+)(?:=["']?([^"'\]]+)["']?)?\]$/i); + if (dataAttribute) { + const value = element.dataset[dataName(`data-${dataAttribute[1]}`)]; + + return dataAttribute[2] === undefined ? value !== undefined : value === dataAttribute[2]; + } + + const nameAttribute = selector.match(/^\[name=["']?([^"'\]]+)["']?\]$/i); + if (nameAttribute) { + return element.name === nameAttribute[1]; + } + + const tagNameAttribute = selector.match(/^([a-z0-9]+)\[name=["']?([^"'\]]+)["']?\]$/i); + if (tagNameAttribute) { + return element.tagName === tagNameAttribute[1].toUpperCase() && element.name === tagNameAttribute[2]; + } + + const buttonType = selector.match(/^button\[type=["']?([^"'\]]+)["']?\]$/i); + if (buttonType) { + return element.tagName === 'BUTTON' && element.type === buttonType[1]; + } + + return element.tagName === selector.toUpperCase(); +} + +function dataName(attribute) { + return attribute.slice(5).replace(/-([a-z])/g, (match, letter) => letter.toUpperCase()); +} diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index 8386a41e..eef80e73 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -6,6 +6,11 @@ ui: navigation: 'Admin-Navigation' admin: + autocomplete: + acl_group: + placeholder: 'ACL-Gruppen suchen' + user: + placeholder: 'Benutzer suchen' navigation: dashboard: 'Dashboard' users: 'Benutzer' @@ -464,6 +469,9 @@ admin: live_operation_cleanup: label: 'Live-Operation-Cleanup' description: 'Entfernt abgelaufene Live-Operation-State-Dateien.' + ui_alert_inbox_cleanup: + label: 'UI-Alert-Inbox-Cleanup' + description: 'Entfernt zwischengespeicherte Benachrichtigungen nach Ablauf ihrer Zustell-Aufbewahrung.' package_discovery: label: 'Paket-Discovery' description: 'Findet Pakete und aktualisiert die Paket-Registry.' @@ -473,6 +481,9 @@ admin: cache_clear: label: 'Cache leeren' description: 'Leert und wärmt den Symfony-Anwendungscache.' + mercure_health: + label: 'Mercure-Health' + description: 'Prüft den Mercure-Hub und startet den lokalen Hub, sofern unterstützt.' table: job: 'Job' source: 'Quelle' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 926a4f26..75db020e 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -77,6 +77,10 @@ message: rolled_back: 'Paket-Lifecycle-Änderungen wurden für %count% Paket(e) zurückgerollt.' runtime: contribution_unsupported: 'Paket "%package%" hat einen nicht unterstützten Runtime-Contribution-Typ "%type%" zurückgegeben.' + live: + endpoint_path_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Pfad "%path%" außerhalb des eigenen /api/live/{package}/-Namespace registrieren.' + endpoint_handler_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Handler "%handler%" außerhalb des eigenen Handler-Namespace registrieren.' + endpoint_reserved: 'Paket "%package%" kann keine Live-Endpunkte mit reserviertem System-Slug "%slug%" registrieren.' setting: read_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht gelesen werden.' write_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht geschrieben werden.' diff --git a/translations/languages/de/operations.yaml b/translations/languages/de/operations.yaml index 740df50d..88cfb628 100644 --- a/translations/languages/de/operations.yaml +++ b/translations/languages/de/operations.yaml @@ -15,6 +15,7 @@ ui: cancel: 'Abbrechen' refresh: 'Aktualisieren' close: 'Schließen' + show_details: 'Details anzeigen' states: starting: 'Operation wird gestartet...' waiting: 'Bitte warten...' diff --git a/translations/languages/de/setup.yaml b/translations/languages/de/setup.yaml index e4daaee5..a24b8f79 100644 --- a/translations/languages/de/setup.yaml +++ b/translations/languages/de/setup.yaml @@ -272,7 +272,7 @@ setup: password_complexity: 'Das Admin-Passwort muss mindestens drei Zeichentypen verwenden.' password_repeated: 'Das Admin-Passwort darf dasselbe Zeichen nicht mehr als dreimal direkt hintereinander enthalten.' password_personal: 'Das Admin-Passwort darf Admin-Username oder E-Mail-Name nicht enthalten.' - app_secret_length: 'Der Hash-Salt muss mindestens 12 Zeichen enthalten, wenn er gesetzt wird.' + app_secret_length: 'Der Hash-Salt muss mindestens 32 Zeichen enthalten, wenn er gesetzt wird.' port: 'Gib einen Port zwischen 1 und 65535 ein.' username: 'Der Admin-Username muss mit einem Buchstaben beginnen und 5 bis 30 Buchstaben, Ziffern, Bindestriche oder Unterstriche enthalten.' required: 'Dieses Feld ist erforderlich.' @@ -318,3 +318,4 @@ setup: clear_cache: 'Cache leeren' run_package_discovery: 'Paket-Discovery ausführen' run_asset_rebuild: 'Assets neu bauen' + run_mercure_health: 'Mercure-Hub prüfen' diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index 2a562eaf..e35dc84b 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -3,6 +3,21 @@ ui: name: 'Studio' alert: close: 'Benachrichtigung schließen' + close_all: 'Alle schließen' + empty_message: 'Es liegen keine aktiven Benachrichtigungen vor.' + empty_title: 'Keine Benachrichtigungen' + hide: 'Benachrichtigungen ausblenden' + notifications: 'Benachrichtigungen' + cookie_consent: + title: 'Cookie-Einstellungen' + message: 'Diese Seite nutzt notwendige Cookies für Kernfunktionen. Optionale Cookies werden nur nach deiner Zustimmung verwendet.' + configure: 'Konfigurieren' + reject_optional: 'Optionale ablehnen' + accept_selected: 'Auswahl speichern' + close: 'Schließen' + optional_title: 'Optionale Cookies' + no_optional: 'Aktuell sind keine optionalen Cookies registriert.' + privacy_link: 'Datenschutzhinweise' navigation: skip_to_content: 'Zum Inhalt springen' frontend: @@ -25,6 +40,10 @@ ui: pending: 'Ausstehend' empty_state: title: 'Nichts anzuzeigen' + live_endpoint: + forbidden: 'Zugriff auf diesen Live-Endpunkt ist nicht erlaubt.' + handler_unavailable: 'Der Live-Endpunkt-Handler ist nicht verfügbar.' + not_found: 'Live-Endpunkt nicht gefunden.' markdown: embed: video_title: 'Eingebettetes Video' @@ -163,6 +182,9 @@ ui: link: 'Anzeigen' password: 'Passwort' submit: 'Key anzeigen' + copy: 'Key kopieren' + copy_success: 'Kopiert.' + copy_failure: 'Kopieren fehlgeschlagen.' errors: invalid_csrf: 'Das API-Key-Formular ist abgelaufen. Bitte versuche es erneut.' create_failed: 'Der API-Key konnte nicht erstellt werden.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c1541c81..c9bf50b3 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -6,6 +6,11 @@ ui: navigation: 'Admin navigation' admin: + autocomplete: + acl_group: + placeholder: 'Search ACL groups' + user: + placeholder: 'Search users' navigation: dashboard: 'Dashboard' users: 'Users' @@ -464,6 +469,9 @@ admin: live_operation_cleanup: label: 'Live operation cleanup' description: 'Removes expired live-operation state files.' + ui_alert_inbox_cleanup: + label: 'UI alert inbox cleanup' + description: 'Removes queued UI alerts after their delivery retention expires.' package_discovery: label: 'Package discovery' description: 'Discovers packages and updates the package registry.' @@ -473,6 +481,9 @@ admin: cache_clear: label: 'Cache clear' description: 'Clears and warms the Symfony application cache.' + mercure_health: + label: 'Mercure health' + description: 'Checks the Mercure hub and starts the local hub when supported.' table: job: 'Job' source: 'Source' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index f23b624a..d88650cc 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -77,6 +77,10 @@ message: rolled_back: 'Package lifecycle changes were rolled back for %count% package(s).' runtime: contribution_unsupported: 'Package "%package%" returned unsupported runtime contribution type "%type%".' + live: + endpoint_path_invalid: 'Package "%package%" tried to register live endpoint path "%path%" outside its own /api/live/{package}/ namespace.' + endpoint_handler_invalid: 'Package "%package%" tried to register live endpoint handler "%handler%" outside its own handler namespace.' + endpoint_reserved: 'Package "%package%" cannot register live endpoints with reserved system slug "%slug%".' setting: read_failed: 'Package setting "%package%:%key%" could not be read.' write_failed: 'Package setting "%package%:%key%" could not be written.' diff --git a/translations/languages/en/operations.yaml b/translations/languages/en/operations.yaml index ff495394..dddcbedf 100644 --- a/translations/languages/en/operations.yaml +++ b/translations/languages/en/operations.yaml @@ -15,6 +15,7 @@ ui: cancel: 'Cancel' refresh: 'Refresh' close: 'Close' + show_details: 'Show details' states: starting: 'Starting operation...' waiting: 'Please wait...' diff --git a/translations/languages/en/setup.yaml b/translations/languages/en/setup.yaml index 2bc7a84d..fedaf852 100644 --- a/translations/languages/en/setup.yaml +++ b/translations/languages/en/setup.yaml @@ -272,7 +272,7 @@ setup: password_complexity: 'The admin password must use at least three character types.' password_repeated: 'The admin password must not repeat the same character more than three times in a row.' password_personal: 'The admin password must not contain the admin username or email name.' - app_secret_length: 'The hash salt must contain at least 12 characters when provided.' + app_secret_length: 'The hash salt must contain at least 32 characters when provided.' port: 'Enter a port between 1 and 65535.' username: 'The admin username must start with a letter and contain 5 to 30 letters, digits, hyphens, or underscores.' required: 'This field is required.' @@ -318,3 +318,4 @@ setup: clear_cache: 'Clear cache' run_package_discovery: 'Run package discovery' run_asset_rebuild: 'Rebuild assets' + run_mercure_health: 'Check Mercure hub' diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index d101faf6..864c372e 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -3,6 +3,21 @@ ui: name: 'Studio' alert: close: 'Close notification' + close_all: 'Close all' + empty_message: 'No active notifications are waiting.' + empty_title: 'No notifications' + hide: 'Hide notifications' + notifications: 'Notifications' + cookie_consent: + title: 'Cookie preferences' + message: 'This site uses required cookies for core functions. Optional cookies are only used after you allow them.' + configure: 'Configure' + reject_optional: 'Reject optional' + accept_selected: 'Save selection' + close: 'Close' + optional_title: 'Optional cookies' + no_optional: 'No optional cookies are registered right now.' + privacy_link: 'Privacy information' navigation: skip_to_content: 'Skip to content' frontend: @@ -25,6 +40,10 @@ ui: pending: 'Pending' empty_state: title: 'Nothing to show' + live_endpoint: + forbidden: 'Access is not allowed for this live endpoint.' + handler_unavailable: 'Live endpoint handler is not available.' + not_found: 'Live endpoint not found.' markdown: embed: video_title: 'Embedded video' @@ -163,6 +182,9 @@ ui: link: 'Reveal' password: 'Password' submit: 'Reveal key' + copy: 'Copy key' + copy_success: 'Copied.' + copy_failure: 'Copy failed.' errors: invalid_csrf: 'The API-key form expired. Please try again.' create_failed: 'The API key could not be created.'