From c7c6079ce5e669ea1f6636c572eb4c7c8f34b2f5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 15:07:48 +0200 Subject: [PATCH 01/67] Warm UX translations before asset resolution --- .codex/framework-version-recap.md | 1 + bin/init | 1 + dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + dev/manual/frontend-asset-snippets.md | 13 +++++----- dev/manual/package-lifecycle-snippets.md | 4 ++-- src/Core/Asset/AssetRebuildQueueFactory.php | 1 + tests/Command/AssetRebuildCommandTest.php | 2 +- .../Asset/AssetRebuildQueueFactoryTest.php | 24 ++++++++++--------- tests/Operations/InitScriptTest.php | 1 + 10 files changed, 29 insertions(+), 21 deletions(-) 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/bin/init b/bin/init index 39b4fba2..17be4067 100755 --- a/bin/init +++ b/bin/init @@ -184,6 +184,7 @@ final class InitCommand $this->runRequiredCommand([...$console, 'importmap:install'], 'Importmap packages are installed.'); $this->runOptionalCommand([...$console, 'ux:icons:lock'], 'Symfony UX icons are locked locally.'); $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/dev/CLASSMAP.md b/dev/CLASSMAP.md index ec2984b3..3f758981 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -56,7 +56,7 @@ | 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 | `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 | `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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c95bf4b6..c0ebf85e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -97,6 +97,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/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/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/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/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/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); From 0bdc888ebdc0e709e2614bfb4cdad1d377f86bd8 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 19:20:59 +0200 Subject: [PATCH 02/67] Add Symfony UX alert center foundation --- assets/app.js | 4 - assets/controllers/alert_stack_controller.js | 230 ++++++++++++++++-- assets/controllers/chart_controller.js | 49 ---- assets/controllers/live_poll_controller.js | 65 +++++ .../operation_overlay_controller.js | 189 ++++++++++---- .../controllers/ui_alert_stream_controller.js | 39 +++ assets/js/alerts/alert_element.js | 94 +++++++ assets/js/alerts/alert_payload.js | 82 +++++++ assets/js/live/live_poll.js | 91 +++++++ assets/styles/app.css | 1 + assets/styles/system/alerts.css | 94 +++++++ bin/lint | 72 +++++- config/services.yaml | 8 + dev/CLASSMAP.md | 9 +- dev/WORKLOG.md | 11 + dev/draft/0.1.x-SystemThemeDesignSystem.md | 7 +- importmap.php | 2 - src/View/Alert/MercureUiAlertPublisher.php | 60 +++++ src/View/Alert/UiAlert.php | 135 ++++++++++ src/View/Alert/UiAlertPublisherInterface.php | 19 ++ src/View/Alert/UiAlertTopicFactory.php | 73 ++++++ src/View/Twig/UiAlertTwigExtension.php | 64 +++++ .../admin/partials/_page-header.html.twig | 10 +- templates/backend/components/Button.html.twig | 9 + .../backend/components/ButtonGroup.html.twig | 13 + .../backend/components/EmptyState.html.twig | 8 + .../backend/components/PageHeader.html.twig | 11 + .../operations/action-log-overlay.html.twig | 1 + .../partials/actions/_button-group.html.twig | 12 +- .../partials/actions/_button.html.twig | 16 +- .../partials/feedback/_empty-state.html.twig | 7 +- templates/components/Alert.html.twig | 76 ++++++ templates/components/AlertStack.html.twig | 31 +++ .../frontend/components/Button.html.twig | 9 + .../frontend/components/ButtonGroup.html.twig | 13 + .../frontend/components/EmptyState.html.twig | 8 + .../frontend/components/PageHeader.html.twig | 13 + .../partials/actions/_button-group.html.twig | 12 +- .../partials/actions/_button.html.twig | 16 +- .../partials/feedback/_empty-state.html.twig | 7 +- .../typography/_page-header.html.twig | 12 +- .../partials/feedback/_alert-stack.html.twig | 24 +- .../Package/PackageAssetPathRewriterTest.php | 8 +- .../Alert/MercureUiAlertPublisherTest.php | 84 +++++++ tests/View/Alert/UiAlertTest.php | 30 +++ tests/View/Alert/UiAlertTopicFactoryTest.php | 34 +++ translations/languages/de/operations.yaml | 1 + translations/languages/de/ui.yaml | 1 + translations/languages/en/operations.yaml | 1 + translations/languages/en/ui.yaml | 1 + 50 files changed, 1635 insertions(+), 231 deletions(-) delete mode 100644 assets/controllers/chart_controller.js create mode 100644 assets/controllers/live_poll_controller.js create mode 100644 assets/controllers/ui_alert_stream_controller.js create mode 100644 assets/js/alerts/alert_element.js create mode 100644 assets/js/alerts/alert_payload.js create mode 100644 assets/js/live/live_poll.js create mode 100644 assets/styles/system/alerts.css create mode 100644 src/View/Alert/MercureUiAlertPublisher.php create mode 100644 src/View/Alert/UiAlert.php create mode 100644 src/View/Alert/UiAlertPublisherInterface.php create mode 100644 src/View/Alert/UiAlertTopicFactory.php create mode 100644 src/View/Twig/UiAlertTwigExtension.php create mode 100644 templates/backend/components/Button.html.twig create mode 100644 templates/backend/components/ButtonGroup.html.twig create mode 100644 templates/backend/components/EmptyState.html.twig create mode 100644 templates/backend/components/PageHeader.html.twig create mode 100644 templates/components/Alert.html.twig create mode 100644 templates/components/AlertStack.html.twig create mode 100644 templates/frontend/components/Button.html.twig create mode 100644 templates/frontend/components/ButtonGroup.html.twig create mode 100644 templates/frontend/components/EmptyState.html.twig create mode 100644 templates/frontend/components/PageHeader.html.twig create mode 100644 tests/View/Alert/MercureUiAlertPublisherTest.php create mode 100644 tests/View/Alert/UiAlertTest.php create mode 100644 tests/View/Alert/UiAlertTopicFactoryTest.php 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/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index b834c150..cca583de 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -1,63 +1,239 @@ import { Controller } from '@hotwired/stimulus'; +import { createAlertElement } 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', 'list', 'panel', 'toggle']; static values = { dismissDelay: { type: Number, default: 8000 }, }; + static memoryAlerts = []; + static storageKey = 'system.alerts.active'; + connect() { - for (const alert of this.alertTargets) { - this.schedule(alert); - } + this.alerts = new Map(); + this.hydrateStoredAlerts(); + this.hydrateServerAlerts(); + this.renderState(); } alertTargetConnected(alert) { - this.schedule(alert); + this.registerAlert(alert, true); + } + + toggle(event) { + event.preventDefault(); + + if (this.panelTarget.hidden) { + this.showPanel(); + + return; + } + + 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') { + action(event) { + const action = event.currentTarget; + const alert = action.closest('[data-alert-stack-target="alert"]'); + const eventName = action.dataset.alertActionEvent || ''; + + if (eventName) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent(eventName, { + detail: actionDetailFromElement(action), + })); + } + + this.closeAlert(alert); + } + + append(event) { + this.upsertAlert(event.detail || {}, true); + } + + upsertAlert(payload, store = true) { + 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); + const existing = this.alerts.get(id); + const alert = createAlertElement({ ...payload, id }, this.closeLabel); + + if (existing?.element?.isConnected) { + existing.element.replaceWith(alert); + } else { + this.listTarget.append(alert); + } + + this.registerAlert(alert, false); + + if (store) { + this.persist(); + } + + if (alertMode(payload) !== 'hidden') { + this.showPanel(); + } + + if (alertMode(payload) === 'auto') { + this.scheduleHide(); + } + + this.renderState(); + + return alert; + } + + registerAlert(alert, store = true) { + if (!alert || alert.dataset.alertRegistered === 'true') { return; } - alert.dataset.alertScheduled = 'true'; - window.setTimeout(() => this.dismiss(alert, true), this.dismissDelayValue); + 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(); } - dismiss(alert, animated) { - if (!alert || alert.dataset.alertDismissing === 'true') { + closeAlert(alert, store = true) { + if (!alert) { return; } - alert.dataset.alertDismissing = 'true'; + this.closeAlertById(alert.dataset.alertId || '', store); + } - if (!animated) { - alert.remove(); + closeAlertById(id, store = true) { + if (!id || !this.alerts.has(id)) { 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', - }); + const entry = this.alerts.get(id); + entry.element?.remove(); + this.alerts.delete(id); + document.dispatchEvent(new CustomEvent('ui-alert:closed', { + detail: { id }, + })); + + if (store) { + this.persist(); + } + + this.renderState(); + } - animation.finished.finally(() => alert.remove()); + hydrateStoredAlerts() { + for (const payload of this.readStoredAlerts()) { + this.upsertAlert({ ...payload, mode: 'hidden' }, false); + } + + this.persist(); + } + + hydrateServerAlerts() { + for (const alert of this.alertTargets) { + 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.hidePanel(), this.dismissDelayValue); + } + showPanel() { + if (this.activeCount === 0) { return; } - alert.classList.add('is-leaving'); - window.setTimeout(() => alert.remove(), 1200); + this.panelTarget.hidden = false; + } + + hidePanel() { + this.panelTarget.hidden = true; + } + + renderState() { + const count = this.activeCount; + this.toggleTarget.hidden = count === 0; + this.badgeTarget.hidden = count === 0; + this.badgeTarget.textContent = String(count); + + if (count === 0) { + this.hidePanel(); + } + } + + persist() { + const payloads = [...this.alerts.values()].map((entry) => storableAlertPayload(entry.payload)); + + try { + window.sessionStorage.setItem(this.constructor.storageKey, JSON.stringify(payloads)); + } catch { + this.constructor.memoryAlerts = payloads; + } + } + + readStoredAlerts() { + try { + const raw = window.sessionStorage.getItem(this.constructor.storageKey); + const parsed = raw ? JSON.parse(raw) : []; + + return Array.isArray(parsed) ? parsed.filter((payload) => payload && typeof payload === 'object') : []; + } catch { + return this.constructor.memoryAlerts; + } + } + + get activeCount() { + return this.alerts.size; + } + + get closeLabel() { + return this.element.dataset.alertCloseLabel || 'Close notification'; } } 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/live_poll_controller.js b/assets/controllers/live_poll_controller.js new file mode 100644 index 00000000..06ca1d0b --- /dev/null +++ b/assets/controllers/live_poll_controller.js @@ -0,0 +1,65 @@ +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); + } + + 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..c66a6ece 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,28 @@ 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.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(); + } + async submit(event) { if (!this.enabledValue) { return; @@ -28,7 +42,7 @@ export default class extends Controller { const stored = this.storedOperation(); if (stored?.statusUrl) { - this.open(); + this.prepareOverlay(); this.reset(); await this.poll(stored.statusUrl, Number(stored.cursor || 0)); @@ -44,8 +58,12 @@ export default class extends Controller { } this.starting = true; - this.open(); + this.prepareOverlay(); this.reset(); + this.updateOperationAlert({ + status: 'queued', + progress: null, + }); const formData = new FormData(this.element); if (submitter?.name) { @@ -77,7 +95,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 +107,37 @@ 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, + onPayload: (payload, nextCursor) => { + this.storeOperation(statusUrl, nextCursor, payload.continue_url || null, payload.status || null, payload.progress || 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 +200,17 @@ export default class extends Controller { open() { this.rootElement.hidden = false; + this.wireControls(); + } + + 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; @@ -248,7 +259,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.fail(error instanceof Error ? error.message : this.label('requestError')); @@ -280,6 +291,7 @@ export default class extends Controller { close = () => { this.polling = false; + this.livePoller?.stop(); this.rootElement.hidden = true; }; @@ -297,6 +309,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') @@ -327,6 +340,12 @@ 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(); @@ -355,10 +374,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 +430,14 @@ export default class extends Controller { } } - storeOperation(statusUrl, cursor, continueUrl = null, status = null) { + storeOperation(statusUrl, cursor, continueUrl = null, status = null, progress = null) { try { window.sessionStorage.setItem(this.storageKey(), JSON.stringify({ statusUrl, cursor, continueUrl, status, + progress, updatedAt: new Date().toISOString(), })); } catch { @@ -447,6 +463,89 @@ 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 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(), + 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(), + }, + }], + }); + } + + 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()}`; + } + get rootElement() { return document.querySelector('[data-operation-overlay-root]'); } diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js new file mode 100644 index 00000000..149c64f5 --- /dev/null +++ b/assets/controllers/ui_alert_stream_controller.js @@ -0,0 +1,39 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + url: String, + }; + + connect() { + if (!this.hasUrlValue || !this.urlValue || typeof window.EventSource !== 'function') { + return; + } + + this.source = new EventSource(this.urlValue, { withCredentials: true }); + this.source.addEventListener('message', this.receive); + this.source.addEventListener('ui-alert', this.receive); + } + + disconnect() { + if (!this.source) { + return; + } + + this.source.removeEventListener('message', this.receive); + this.source.removeEventListener('ui-alert', this.receive); + this.source.close(); + } + + 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. + } + }; +} diff --git a/assets/js/alerts/alert_element.js b/assets/js/alerts/alert_element.js new file mode 100644 index 00000000..fa88156b --- /dev/null +++ b/assets/js/alerts/alert_element.js @@ -0,0 +1,94 @@ +import { alertId, alertMode, normalizeAlertLevel } from './alert_payload.js'; + +export function createAlertElement(payload, closeLabel) { + const level = normalizeAlertLevel(payload.level || 'info'); + const mode = alertMode(payload); + const alert = document.createElement('section'); + 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, + }); + + const content = document.createElement('div'); + content.className = 'system-alert-content'; + + if (payload.loading) { + const spinner = document.createElement('span'); + spinner.className = 'system-alert-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + content.append(spinner); + } + + content.append(document.createTextNode(String(payload.message || '').trim())); + appendActions(content, Array.isArray(payload.actions) ? payload.actions : []); + alert.append(content); + alert.append(closeButton(closeLabel)); + + return alert; +} + +function appendActions(content, actions) { + const validActions = actions.filter((action) => action && String(action.label || '').trim()); + + if (validActions.length === 0) { + return; + } + + const actionList = document.createElement('div'); + actionList.className = 'system-alert-actions'; + + for (const action of validActions) { + actionList.append(actionElement(action)); + } + + content.append(actionList); +} + +function actionElement(action) { + const element = action.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 (action.href) { + element.href = String(action.href); + if (action.target) { + element.target = String(action.target); + } + } 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 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..57e74df5 --- /dev/null +++ b/assets/js/alerts/alert_payload.js @@ -0,0 +1,82 @@ +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, + 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 || '', + message: 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..9151cf2f --- /dev/null +++ b/assets/js/live/live_poll.js @@ -0,0 +1,91 @@ +export class LivePoller { + constructor({ + interval = 750, + onPayload = () => {}, + onError = () => {}, + onDone = () => {}, + fetcher = window.fetch.bind(window), + } = {}) { + this.interval = Number(interval || 0); + this.onPayload = onPayload; + this.onError = onError; + this.onDone = onDone; + this.fetcher = fetcher; + this.active = false; + } + + async poll(url, cursor = 0) { + this.active = true; + let nextCursor = Number(cursor || 0); + + try { + while (this.active) { + const response = await this.fetcher(this.urlWithCursor(url, nextCursor), { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!response.ok) { + this.onError(response, null); + + return null; + } + + const payload = await this.readJson(response); + nextCursor = Number(payload.cursor || nextCursor); + this.onPayload(payload, nextCursor); + + if (this.isTerminal(payload) || 0 === this.interval) { + this.active = false; + this.onDone(payload); + + return payload; + } + + await this.sleep(Number(payload.next_poll_ms || this.interval)); + } + } catch (error) { + this.active = false; + this.onError(null, error); + } + + return null; + } + + stop() { + this.active = false; + } + + 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('Expected a JSON response from the live endpoint.'); + } + + 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..3a15c980 --- /dev/null +++ b/assets/styles/system/alerts.css @@ -0,0 +1,94 @@ +.system-alert-stack { + justify-items: end; +} + +.system-alert-bell { + position: relative; + display: inline-grid; + width: 2.35rem; + height: 2.35rem; + place-items: center; + border: var(--system-border); + border-radius: 999px; + background: var(--color-paper); + color: var(--color-ink); + box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--color-brand-canvas) 18%, transparent); + cursor: pointer; + pointer-events: auto; +} + +.system-alert-bell:hover, +.system-alert-bell:focus-visible { + border-color: color-mix(in srgb, var(--color-primary) 46%, transparent); +} + +.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; + background: var(--color-error); + color: var(--color-paper); + font-size: 0.68rem; + font-weight: 800; + line-height: 1; +} + +.system-alert-panel { + display: grid; + width: 100%; + pointer-events: auto; +} + +.system-alert-list { + display: grid; + gap: 0.45rem; +} + +.system-alert-spinner { + display: inline-block; + width: 0.9rem; + height: 0.9rem; + margin-right: 0.45rem; + border: 2px solid color-mix(in srgb, currentColor 28%, transparent); + border-top-color: currentColor; + border-radius: 999px; + vertical-align: -0.1rem; + animation: system-alert-spin 850ms linear infinite; +} + +.system-alert-actions { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + margin-top: 0.35rem; +} + +.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; +} + +.system-alert-action:hover, +.system-alert-action:focus-visible { + color: var(--color-primary); +} + +@keyframes system-alert-spin { + to { + transform: rotate(360deg); + } +} diff --git a/bin/lint b/bin/lint index 1d6305c1..29228583 100755 --- a/bin/lint +++ b/bin/lint @@ -293,6 +293,76 @@ 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 (isTailwindDirectiveCssIssue($contents, $issue->line())) { + fwrite(STDOUT, sprintf( + "%s:%s:%s strict CSS parser skipped a Tailwind directive; 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 isTailwindDirectiveCssIssue(string $contents, ?int $line): bool +{ + if (null === $line) { + return false; + } + + $lines = preg_split('/\R/', $contents) ?: []; + $texts = [ + trim((string) ($lines[$line - 2] ?? '')), + trim((string) ($lines[$line - 1] ?? '')), + trim((string) ($lines[$line] ?? '')), + ]; + + foreach ($texts as $text) { + if (str_starts_with($text, '@apply ') + || str_starts_with($text, '@theme ') + || str_starts_with($text, '@custom-variant ') + || str_starts_with($text, '@source ') + ) { + return true; + } + } + + return false; +} + /** * @param list $files */ @@ -964,7 +1034,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/config/services.yaml b/config/services.yaml index e1e3d4ee..1053f998 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -441,6 +441,14 @@ services: arguments: $projectDir: '%kernel.project_dir%' + App\View\Alert\UiAlertTopicFactory: + arguments: + $defaultUri: '%env(DEFAULT_URI)%' + $secret: '%kernel.secret%' + + App\View\Alert\UiAlertPublisherInterface: + alias: App\View\Alert\MercureUiAlertPublisher + App\View\Http\HttpErrorRenderer: arguments: $debug: '%kernel.debug%' diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 3f758981..184bc2e7 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -294,6 +294,8 @@ | 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\UiAlertPublisherInterface`, `App\View\Alert\MercureUiAlertPublisher` | Defines the stable UI-alert payload with level, mode, actions, loading state, private user/session Mercure topic syntax, 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 for AlertStack components without starting new anonymous sessions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Alert/UiAlertTopicFactoryTest.php` | ## 8. Controllers @@ -315,7 +317,6 @@ | 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` | | 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 | ## 9. Console Commands @@ -323,7 +324,7 @@ | 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 known Tailwind directives as informational while `tailwind:build` remains authoritative. | `dev/draft/0.1.x-SetupTestAutomation.md` | N/A | | `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` | @@ -343,6 +344,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, and empty states, resolved through the same Twig namespace path order used by active themes and packages. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `php bin/console debug:twig-component root:AlertStack`, `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 +354,8 @@ | 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 while operation forms now surface progress through notification-center runner alerts and open the ActionLog overlay only on demand for details, continuation, retry, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | +| UI alert stream controller | `assets/controllers/alert_stack_controller.js`, `assets/controllers/ui_alert_stream_controller.js`, `assets/js/alerts/*.js` | Renders static, client-created, or Mercure-delivered UI alerts through a sessionStorage-backed notification center with badge counts, hide-vs-close behavior, quiet text actions, and optional EventSource updates. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | N/A | | 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 c0ebf85e..ac38efe0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -75,6 +75,17 @@ ## 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. +### 2026-06-13 feat-symfony-ux-integration +- Added namespace-aware Twig component primitives for root, frontend, and backend alert stacks, buttons, page headers, and empty states, while keeping the existing override-friendly partial entry points as thin wrappers. +- Added a targeted UI-alert Mercure foundation with stable private user/session topics, a `UiAlert` payload object, `UiAlertPublisherInterface`, Mercure publisher, Twig stream-topic helper, and a Stimulus stream subscriber that feeds the existing alert stack. +- Extracted reusable live JSON polling into `assets/js/live/live_poll.js` and `live-poll` Stimulus controller, then switched the operation overlay to consume that shared polling layer for `/api/live/**` status flows. +- Reworked the alert stack into a sessionStorage-backed notification center with a bell badge, `auto`/`hidden`/`persistent` display modes, quiet text actions that close their alert, Mercure/client-created alert parity, extracted alert JS helpers, and live-operation runner alerts that replace the default full-screen overlay until details are requested. +- Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. +- Removed the unused Alpine and ApexCharts application wiring, dropped the stale custom Apex chart Stimulus controller, and switched the package asset rewriter fixture to a neutral external import name. +- Updated the design-system draft and class map for scoped Twig components, targeted UI alerts, notification-center behavior, reusable live polling, and the removed chart controller. +- Verification: `php -l` for new alert/Twig-extension classes and `bin/lint`, `node --check` for alert/operation Stimulus controllers, `bin/lint --diff`, full `bin/lint`, `php bin/console lint:twig templates`, `php bin/console lint:container`, `php bin/console tailwind:build`, `php bin/console asset-map:compile`, `php bin/console assets:rebuild --trigger=codex-alert-center`, `php bin/console debug:asset-map | rg "alpine|apex|live_poll|ui_alert_stream|live-poll"`, `php bin/console render:route --include-status --role=public /user/login`, `php bin/console render:route --include-status --role=admin /admin`, `php bin/console render:route --include-status --setup-completed=0 /setup`, targeted PHPUnit runs, and full `php bin/phpunit` with 1153 tests and 7908 assertions. +- 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. - Extended `bin/lint` into the all-in-one diff linting entry point: it now supports `--diff`, `--diff=`, and `--diff:`, collects staged/unstaged or explicit Git diff files when Git is available, lints extensionless PHP scripts such as `bin/lint`, and runs a non-Markdown Git whitespace check that preserves intentional Markdown hard line breaks. diff --git a/dev/draft/0.1.x-SystemThemeDesignSystem.md b/dev/draft/0.1.x-SystemThemeDesignSystem.md index aaf2861f..63abf70f 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. @@ -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/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/src/View/Alert/MercureUiAlertPublisher.php b/src/View/Alert/MercureUiAlertPublisher.php new file mode 100644 index 00000000..a57eb610 --- /dev/null +++ b/src/View/Alert/MercureUiAlertPublisher.php @@ -0,0 +1,60 @@ +fromMessage($alert, $locale)->toArray() + : $alert->toArray(); + + try { + $data = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + return $this->hub->publish(new Update($topic, $data, private: $private, type: 'ui-alert')); + } + + public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message $alert, ?string $locale = null): ?string + { + return $this->publish($this->topicFactory->userTopic($user), $alert, $locale); + } + + public function publishToSession(SessionInterface|string $session, UiAlert|Message $alert, ?string $locale = null): ?string + { + return $this->publish($this->topicFactory->sessionTopic($session), $alert, $locale); + } + + private function fromMessage(Message $message, ?string $locale): UiAlert + { + return UiAlert::translated( + $this->translator->trans($message->translationKey(), $message->parameters(), locale: $locale), + $message->level(), + $message->code(), + $message->translationKey(), + $message->context(), + ); + } +} diff --git a/src/View/Alert/UiAlert.php b/src/View/Alert/UiAlert.php new file mode 100644 index 00000000..f6b04ffa --- /dev/null +++ b/src/View/Alert/UiAlert.php @@ -0,0 +1,135 @@ + $context + * @param list> $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, + ) { + 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, + ): self + { + return new self($message, $level, $persistent, mode: $mode, id: $id, actions: $actions, loading: $loading); + } + + /** + * @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, + ): self { + return new self( + $message, + $level instanceof MessageLevel ? $level->value : $level, + $persistent, + $code, + $translationKey, + $context, + $mode, + $id, + $actions, + $loading, + ); + } + + /** + * @return array{message: string, level: string, persistent: bool, mode: string, loading: bool, id?: string, actions?: list>, code?: string, translation_key?: string, context?: array} + */ + public function toArray(): array + { + $payload = [ + 'message' => $this->message, + 'level' => $this->normalizedLevel(), + 'persistent' => $this->persistent, + 'mode' => $this->normalizedMode(), + 'loading' => $this->loading, + ]; + + if (null !== $this->id && '' !== trim($this->id)) { + $payload['id'] = $this->id; + } + + if ([] !== $this->actions) { + $payload['actions'] = $this->actions; + } + + if (null !== $this->code) { + $payload['code'] = $this->code; + } + + if (null !== $this->translationKey) { + $payload['translation_key'] = $this->translationKey; + } + + if ([] !== $this->context) { + $payload['context'] = $this->context; + } + + 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', + }; + } +} diff --git a/src/View/Alert/UiAlertPublisherInterface.php b/src/View/Alert/UiAlertPublisherInterface.php new file mode 100644 index 00000000..addf9e57 --- /dev/null +++ b/src/View/Alert/UiAlertPublisherInterface.php @@ -0,0 +1,19 @@ +uid() + : ($user instanceof UserInterface ? $user->getUserIdentifier() : $user); + + return $this->topic('user', $identity); + } + + 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(); + if ($session->isStarted()) { + $topics[] = $this->sessionTopic($session); + } + } + + return array_values(array_unique($topics)); + } + + private function topic(string $scope, string $identity): string + { + return rtrim($this->baseUri(), '/').'/ui-alerts/'.$scope.'/'.$this->hash($scope, $identity); + } + + private function baseUri(): string + { + $uri = rtrim($this->defaultUri, '/'); + + return '' !== $uri ? $uri : 'https://localhost'; + } + + private function hash(string $scope, string $identity): string + { + return hash_hmac('sha256', $scope.':'.$identity, $this->secret); + } +} diff --git a/src/View/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php new file mode 100644 index 00000000..d8bb7409 --- /dev/null +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -0,0 +1,64 @@ +streamTopics(...)), + new TwigFunction('ui_alert_stream_url', $this->streamUrl(...)), + ]; + } + + /** + * @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) { + return null; + } + + try { + return $this->mercure->mercure($topics, ['subscribe' => $topics]); + } catch (Throwable) { + return null; + } + } +} 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/components/Button.html.twig b/templates/backend/components/Button.html.twig new file mode 100644 index 00000000..e775207a --- /dev/null +++ b/templates/backend/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-backend-button', 'system-button-' ~ variant, class]|filter(item => item is not empty)|join(' ') %} + +{% if href %} + {{ label }} +{% else %} + +{% endif %} diff --git a/templates/backend/components/ButtonGroup.html.twig b/templates/backend/components/ButtonGroup.html.twig new file mode 100644 index 00000000..8f50e74f --- /dev/null +++ b/templates/backend/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/backend/components/EmptyState.html.twig b/templates/backend/components/EmptyState.html.twig new file mode 100644 index 00000000..2fbb9a8e --- /dev/null +++ b/templates/backend/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/backend/components/PageHeader.html.twig b/templates/backend/components/PageHeader.html.twig new file mode 100644 index 00000000..d1741d14 --- /dev/null +++ b/templates/backend/components/PageHeader.html.twig @@ -0,0 +1,11 @@ +{% props title, eyebrow = 'ui.admin.area'|trans, actions = null %} + +
+
+
{{ eyebrow }}
+

{{ title }}

+
+ {% if actions is not empty %} +
{{ actions|raw }}
+ {% endif %} +
diff --git a/templates/backend/operations/action-log-overlay.html.twig b/templates/backend/operations/action-log-overlay.html.twig index f4f00af7..a83ff1ac 100644 --- a/templates/backend/operations/action-log-overlay.html.twig +++ b/templates/backend/operations/action-log-overlay.html.twig @@ -17,6 +17,7 @@ data-label-cancel="{{ 'ui.operations.action_log.actions.cancel'|trans }}" data-label-refresh="{{ 'ui.operations.action_log.actions.refresh'|trans }}" data-label-close="{{ 'ui.operations.action_log.actions.close'|trans }}" + data-label-show-details="{{ 'ui.operations.action_log.actions.show_details'|trans }}" data-label-status-queued="{{ 'ui.operations.action_log.statuses.queued'|trans }}" data-label-status-pending="{{ 'ui.operations.action_log.statuses.pending'|trans }}" data-label-status-running="{{ 'ui.operations.action_log.statuses.running'|trans }}" diff --git a/templates/backend/partials/actions/_button-group.html.twig b/templates/backend/partials/actions/_button-group.html.twig index 6bb92e46..10cf9e03 100644 --- a/templates/backend/partials/actions/_button-group.html.twig +++ b/templates/backend/partials/actions/_button-group.html.twig @@ -1,11 +1 @@ -
- {% for action in actions|default([]) %} - {% include '@backend/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/backend/partials/actions/_button.html.twig b/templates/backend/partials/actions/_button.html.twig index f8401e0f..6dc84965 100644 --- a/templates/backend/partials/actions/_button.html.twig +++ b/templates/backend/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-backend-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/backend/partials/feedback/_empty-state.html.twig b/templates/backend/partials/feedback/_empty-state.html.twig index a2acbf20..c38e1aee 100644 --- a/templates/backend/partials/feedback/_empty-state.html.twig +++ b/templates/backend/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/components/Alert.html.twig b/templates/components/Alert.html.twig new file mode 100644 index 00000000..43824bea --- /dev/null +++ b/templates/components/Alert.html.twig @@ -0,0 +1,76 @@ +{% props alert = {}, level = null, message = null, persistent = null, mode = null, actions = null, id = null, loading = null %} +{% set raw_level = level|default(alert.level|default('info')) %} +{% set normalized_level = { + notice: 'info', + danger: 'error', + warn: 'warning', + WARN: 'warning', + ERROR: 'error', + EXCEPTION: 'exception', + SUCCESS: 'success', + INFO: 'info', + DEBUG: 'debug', +}[raw_level]|default(raw_level|lower) %} +{% set normalized_level = normalized_level in ['debug', 'info', 'success', 'warning', 'error', 'exception'] ? normalized_level : 'info' %} +{% set alert_persistent = persistent is not null ? persistent : alert.persistent|default(false) %} +{% set alert_mode = mode|default(alert.mode|default(alert_persistent ? 'persistent' : 'auto')) %} +{% set alert_mode = alert_mode in ['auto', 'hidden', 'persistent'] ? alert_mode : 'auto' %} +{% set alert_message = message|default(alert.message|default('')) %} +{% set alert_actions = actions is not null ? actions : alert.actions|default([]) %} +{% set alert_loading = loading is not null ? loading : alert.loading|default(false) %} +{% set alert_id = id|default(alert.id|default('')) %} + +
+
+ {% if alert_loading %} + + {% endif %} + {{ alert_message }} + {% if alert_actions is not empty %} +
+ {% for action in alert_actions %} + {% set label = action.label|default('') %} + {% if label %} + {% if action.href|default('') %} + {{ label }} + {% else %} + + {% endif %} + {% endif %} + {% endfor %} +
+ {% endif %} +
+ +
diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig new file mode 100644 index 00000000..a43f1f71 --- /dev/null +++ b/templates/components/AlertStack.html.twig @@ -0,0 +1,31 @@ +{% props alerts = [], dismiss_delay = 8000, stream_topics = null %} +{% set stream_topics = stream_topics is same as(null) ? ui_alert_stream_topics() : stream_topics %} +{% set stream_url = ui_alert_stream_url(stream_topics) %} +{% set stack_attributes = { + class: 'system-alert-stack', + 'data-controller': 'alert-stack', + 'data-alert-stack-dismiss-delay-value': dismiss_delay, + 'data-alert-close-label': 'ui.alert.close'|trans, + 'data-alert-notifications-label': 'ui.alert.notifications'|trans, +} %} +{% if stream_url %} + {% set stack_attributes = stack_attributes|merge({ + 'data-controller': 'alert-stack ui-alert-stream', + 'data-action': 'ui-alert:received->alert-stack#append', + 'data-ui-alert-stream-url-value': stream_url, + }) %} +{% endif %} + +
+ +
+
+ {% for alert in alerts %} + + {% endfor %} +
+
+
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/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/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/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/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/View/Alert/MercureUiAlertPublisherTest.php b/tests/View/Alert/MercureUiAlertPublisherTest.php new file mode 100644 index 00000000..77857921 --- /dev/null +++ b/tests/View/Alert/MercureUiAlertPublisherTest.php @@ -0,0 +1,84 @@ +publish('https://example.test/ui-alerts/session/topic', UiAlert::fromLevel('danger', 'Saved')); + + self::assertSame('update-id', $id); + self::assertInstanceOf(Update::class, $hub->update); + self::assertSame(['https://example.test/ui-alerts/session/topic'], $hub->update->getTopics()); + self::assertTrue($hub->update->isPrivate()); + 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 testItTranslatesStructuredMessagesBeforePublishing(): void + { + $hub = new RecordingHub(); + $publisher = new MercureUiAlertPublisher( + $hub, + new UiAlertTopicFactory('https://example.test', 'secret'), + new IdentityTranslator(), + ); + + $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']); + } +} + +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/UiAlertTest.php b/tests/View/Alert/UiAlertTest.php new file mode 100644 index 00000000..c3a0e3c0 --- /dev/null +++ b/tests/View/Alert/UiAlertTest.php @@ -0,0 +1,30 @@ + 'Show details', 'event' => 'operation-overlay:show', 'detail' => ['operation' => 'demo']], + ], loading: true); + + self::assertSame([ + 'message' => 'Operation failed.', + 'level' => 'error', + 'persistent' => true, + 'mode' => 'persistent', + 'loading' => true, + 'id' => 'operation:demo', + 'actions' => [ + ['label' => 'Show details', 'event' => 'operation-overlay:show', 'detail' => ['operation' => 'demo']], + ], + ], $alert->toArray()); + } +} diff --git a/tests/View/Alert/UiAlertTopicFactoryTest.php b/tests/View/Alert/UiAlertTopicFactoryTest.php new file mode 100644 index 00000000..8d525d75 --- /dev/null +++ b/tests/View/Alert/UiAlertTopicFactoryTest.php @@ -0,0 +1,34 @@ +userTopic($user); + $sessionTopic = $factory->sessionTopic('session-id'); + + self::assertStringStartsWith('https://example.test/ui-alerts/user/', $userTopic); + self::assertStringStartsWith('https://example.test/ui-alerts/session/', $sessionTopic); + self::assertStringNotContainsString($user->uid(), $userTopic); + self::assertStringNotContainsString('session-id', $sessionTopic); + self::assertSame($sessionTopic, $factory->sessionTopic('session-id')); + } +} 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/ui.yaml b/translations/languages/de/ui.yaml index 2a562eaf..4b4032d2 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -3,6 +3,7 @@ ui: name: 'Studio' alert: close: 'Benachrichtigung schließen' + notifications: 'Benachrichtigungen' navigation: skip_to_content: 'Zum Inhalt springen' frontend: 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/ui.yaml b/translations/languages/en/ui.yaml index d101faf6..d8515341 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -3,6 +3,7 @@ ui: name: 'Studio' alert: close: 'Close notification' + notifications: 'Notifications' navigation: skip_to_content: 'Skip to content' frontend: From c9e516ee570608e401f2eec66e7248c8a45fe2c0 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 19:23:52 +0200 Subject: [PATCH 03/67] Polish notification center presentation --- assets/controllers/alert_stack_controller.js | 5 + assets/js/alerts/alert_element.js | 18 ++++ assets/styles/system/alerts.css | 108 +++++++++++++++++-- dev/WORKLOG.md | 1 + templates/components/Alert.html.twig | 9 ++ templates/components/AlertStack.html.twig | 8 ++ translations/languages/de/ui.yaml | 1 + translations/languages/en/ui.yaml | 1 + 8 files changed, 143 insertions(+), 8 deletions(-) diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index cca583de..5f95daee 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -41,6 +41,11 @@ export default class extends Controller { this.hidePanel(); } + hide(event) { + event.preventDefault(); + this.hidePanel(); + } + close(event) { event.preventDefault(); this.closeAlert(event.currentTarget.closest('[data-alert-stack-target="alert"]')); diff --git a/assets/js/alerts/alert_element.js b/assets/js/alerts/alert_element.js index fa88156b..1fe231f3 100644 --- a/assets/js/alerts/alert_element.js +++ b/assets/js/alerts/alert_element.js @@ -17,6 +17,11 @@ export function createAlertElement(payload, closeLabel) { mode, }); + 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'; @@ -35,6 +40,19 @@ export function createAlertElement(payload, 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) { const validActions = actions.filter((action) => action && String(action.label || '').trim()); diff --git a/assets/styles/system/alerts.css b/assets/styles/system/alerts.css index 3a15c980..e2041763 100644 --- a/assets/styles/system/alerts.css +++ b/assets/styles/system/alerts.css @@ -5,21 +5,25 @@ .system-alert-bell { position: relative; display: inline-grid; - width: 2.35rem; - height: 2.35rem; + width: 2.5rem; + height: 2.5rem; place-items: center; - border: var(--system-border); + border: 1px solid color-mix(in srgb, var(--color-gray-300) 78%, transparent); border-radius: 999px; - background: var(--color-paper); + background: color-mix(in srgb, var(--color-paper) 94%, transparent); color: var(--color-ink); - box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--color-brand-canvas) 18%, transparent); + 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 { @@ -32,6 +36,7 @@ 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; @@ -42,12 +47,98 @@ .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 1.35rem 3.25rem color-mix(in srgb, var(--color-brand-canvas) 28%, transparent); pointer-events: auto; + backdrop-filter: blur(18px); +} + +.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-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.45rem; + gap: 0.55rem; + max-height: min(25rem, calc(100vh - 9rem)); + overflow: auto; + padding: 0.65rem; + scrollbar-width: thin; +} + +.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-spinner { @@ -65,8 +156,8 @@ .system-alert-actions { display: flex; flex-wrap: wrap; - gap: 0.7rem; - margin-top: 0.35rem; + gap: 0.75rem; + margin-top: 0.45rem; } .system-alert-action { @@ -80,6 +171,7 @@ font-weight: 800; text-decoration: underline; text-underline-offset: 0.18em; + text-decoration-thickness: 1px; } .system-alert-action:hover, diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ac38efe0..b7e7cbe0 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -80,6 +80,7 @@ - Added a targeted UI-alert Mercure foundation with stable private user/session topics, a `UiAlert` payload object, `UiAlertPublisherInterface`, Mercure publisher, Twig stream-topic helper, and a Stimulus stream subscriber that feeds the existing alert stack. - Extracted reusable live JSON polling into `assets/js/live/live_poll.js` and `live-poll` Stimulus controller, then switched the operation overlay to consume that shared polling layer for `/api/live/**` status flows. - Reworked the alert stack into a sessionStorage-backed notification center with a bell badge, `auto`/`hidden`/`persistent` display modes, quiet text actions that close their alert, Mercure/client-created alert parity, extracted alert JS helpers, and live-operation runner alerts that replace the default full-screen overlay until details are requested. +- Polished the notification center presentation with a structured panel header, hide control, status icons, card spacing, bounded scroll area, and quieter market-ready alert action styling. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. - Removed the unused Alpine and ApexCharts application wiring, dropped the stale custom Apex chart Stimulus controller, and switched the package asset rewriter fixture to a neutral external import name. - Updated the design-system draft and class map for scoped Twig components, targeted UI alerts, notification-center behavior, reusable live polling, and the removed chart controller. diff --git a/templates/components/Alert.html.twig b/templates/components/Alert.html.twig index 43824bea..40a07ae6 100644 --- a/templates/components/Alert.html.twig +++ b/templates/components/Alert.html.twig @@ -19,6 +19,14 @@ {% set alert_actions = actions is not null ? actions : alert.actions|default([]) %} {% set alert_loading = loading is not null ? loading : alert.loading|default(false) %} {% set alert_id = id|default(alert.id|default('')) %} +{% set alert_icon = { + debug: 'ti-bug', + info: 'ti-info-circle', + success: 'ti-circle-check', + warning: 'ti-alert-triangle', + error: 'ti-alert-circle', + exception: 'ti-alert-circle', +}[normalized_level] %}
+
{% if alert_loading %} diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index a43f1f71..edac3a2a 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -22,6 +22,14 @@
+
+
+ {{ 'ui.alert.notifications'|trans }} +
+ +
{% for alert in alerts %} diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index 4b4032d2..6bdfb0b3 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -3,6 +3,7 @@ ui: name: 'Studio' alert: close: 'Benachrichtigung schließen' + hide: 'Benachrichtigungen ausblenden' notifications: 'Benachrichtigungen' navigation: skip_to_content: 'Zum Inhalt springen' diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index d8515341..c171e733 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -3,6 +3,7 @@ ui: name: 'Studio' alert: close: 'Close notification' + hide: 'Hide notifications' notifications: 'Notifications' navigation: skip_to_content: 'Skip to content' From 0532bae28cc394ca779f0fe30709029e6e4ff0bd Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 20:15:44 +0200 Subject: [PATCH 04/67] Refine notification center interactions --- assets/controllers/alert_stack_controller.js | 112 +++++++++++++++--- .../operation_overlay_controller.js | 28 ++++- assets/js/alerts/alert_element.js | 39 +++++- assets/js/alerts/alert_payload.js | 6 +- assets/styles/system/alerts.css | 98 ++++++++++++++- dev/WORKLOG.md | 4 + templates/components/Alert.html.twig | 19 ++- templates/components/AlertStack.html.twig | 46 ++++--- translations/languages/de/ui.yaml | 3 + translations/languages/en/ui.yaml | 3 + 10 files changed, 302 insertions(+), 56 deletions(-) diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index 5f95daee..b01a29a8 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -1,5 +1,5 @@ import { Controller } from '@hotwired/stimulus'; -import { createAlertElement } from '../js/alerts/alert_element.js'; +import { createAlertElement, updateAlertElement } from '../js/alerts/alert_element.js'; import { actionDetailFromElement, alertId, @@ -10,7 +10,7 @@ import { } from '../js/alerts/alert_payload.js'; export default class extends Controller { - static targets = ['alert', 'badge', 'list', 'panel', 'toggle']; + static targets = ['alert', 'badge', 'clearAll', 'empty', 'list', 'panel', 'toggle']; static values = { dismissDelay: { type: Number, default: 8000 }, }; @@ -18,14 +18,19 @@ export default class extends Controller { static memoryAlerts = []; static storageKey = 'system.alerts.active'; - connect() { + initialize() { this.alerts = new Map(); + } + + connect() { + this.ensureAlertState(); this.hydrateStoredAlerts(); this.hydrateServerAlerts(); this.renderState(); } alertTargetConnected(alert) { + this.ensureAlertState(); this.registerAlert(alert, true); } @@ -51,6 +56,19 @@ export default class extends Controller { this.closeAlert(event.currentTarget.closest('[data-alert-stack-target="alert"]')); } + closeAll(event) { + event.preventDefault(); + this.ensureAlertState(); + + const ids = [...this.alerts.keys()]; + for (const id of ids) { + this.removeAlertById(id); + } + + this.persist(); + this.renderState({ keepPanelOpen: true }); + } + action(event) { const action = event.currentTarget; const alert = action.closest('[data-alert-stack-target="alert"]'); @@ -71,6 +89,8 @@ export default class extends Controller { } upsertAlert(payload, store = true) { + this.ensureAlertState(); + for (const id of alertIds(payload.closes)) { this.closeAlertById(id, false); } @@ -85,15 +105,33 @@ export default class extends Controller { const id = alertId(payload); const existing = this.alerts.get(id); - const alert = createAlertElement({ ...payload, id }, this.closeLabel); + const normalizedPayload = { ...payload, id }; + const nextSignature = JSON.stringify(storableAlertPayload(normalizedPayload)); + const existingSignature = existing ? JSON.stringify(storableAlertPayload(existing.payload)) : ''; + + if (existing?.element?.isConnected && existingSignature === nextSignature) { + if (alertMode(payload) !== 'hidden') { + this.showPanel(); + } - if (existing?.element?.isConnected) { - existing.element.replaceWith(alert); - } else { + 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); + this.registerAlert(alert, false, true); if (store) { this.persist(); @@ -112,8 +150,10 @@ export default class extends Controller { return alert; } - registerAlert(alert, store = true) { - if (!alert || alert.dataset.alertRegistered === 'true') { + registerAlert(alert, store = true, refresh = false) { + this.ensureAlertState(); + + if (!alert || (alert.dataset.alertRegistered === 'true' && !refresh)) { return; } @@ -143,17 +183,12 @@ export default class extends Controller { } closeAlertById(id, store = true) { - if (!id || !this.alerts.has(id)) { + this.ensureAlertState(); + + if (!this.removeAlertById(id)) { return; } - const entry = this.alerts.get(id); - entry.element?.remove(); - this.alerts.delete(id); - document.dispatchEvent(new CustomEvent('ui-alert:closed', { - detail: { id }, - })); - if (store) { this.persist(); } @@ -202,18 +237,34 @@ export default class extends Controller { this.panelTarget.hidden = true; } - renderState() { + 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.panelTarget.hidden = false; + + return; + } + if (count === 0) { this.hidePanel(); } } persist() { + this.ensureAlertState(); + const payloads = [...this.alerts.values()].map((entry) => storableAlertPayload(entry.payload)); try { @@ -235,10 +286,33 @@ export default class extends Controller { } get activeCount() { + this.ensureAlertState(); + return this.alerts.size; } get closeLabel() { return this.element.dataset.alertCloseLabel || 'Close notification'; } + + ensureAlertState() { + if (!(this.alerts instanceof Map)) { + this.alerts = new Map(); + } + } + + removeAlertById(id) { + if (!id || !this.alerts.has(id)) { + return false; + } + + const entry = this.alerts.get(id); + entry.element?.remove(); + this.alerts.delete(id); + document.dispatchEvent(new CustomEvent('ui-alert:closed', { + detail: { id }, + })); + + return true; + } } diff --git a/assets/controllers/operation_overlay_controller.js b/assets/controllers/operation_overlay_controller.js index c66a6ece..51e77e4c 100644 --- a/assets/controllers/operation_overlay_controller.js +++ b/assets/controllers/operation_overlay_controller.js @@ -110,7 +110,14 @@ export default class extends Controller { this.livePoller = new LivePoller({ interval: 750, onPayload: (payload, nextCursor) => { - this.storeOperation(statusUrl, nextCursor, payload.continue_url || null, payload.status || null, payload.progress || null); + 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) => { @@ -259,7 +266,7 @@ export default class extends Controller { return; } - this.storeOperation(payload.value.status_url, 0, null, 'queued', null); + this.storeOperation(payload.value.status_url, 0, null, 'queued', null, payload.value.label || payload.value.operation || null); await this.poll(payload.value.status_url); } catch (error) { this.fail(error instanceof Error ? error.message : this.label('requestError')); @@ -430,7 +437,7 @@ export default class extends Controller { } } - storeOperation(statusUrl, cursor, continueUrl = null, status = null, progress = null) { + storeOperation(statusUrl, cursor, continueUrl = null, status = null, progress = null, label = null) { try { window.sessionStorage.setItem(this.storageKey(), JSON.stringify({ statusUrl, @@ -438,6 +445,7 @@ export default class extends Controller { continueUrl, status, progress, + label, updatedAt: new Date().toISOString(), })); } catch { @@ -493,6 +501,7 @@ export default class extends Controller { } const issue = payload.result?.issues?.[0] || null; + const title = this.operationTitle(payload); const message = terminal ? (status === 'success' ? this.label('completed') @@ -503,6 +512,7 @@ export default class extends Controller { this.dispatchAlert({ id: this.operationAlertId(), + title, level: status === 'success' ? 'success' : (status === 'requires_review' ? 'warning' : (status === 'failed' ? 'error' : 'info')), message, mode: terminal && status === 'success' ? 'auto' : 'persistent', @@ -517,6 +527,18 @@ export default class extends Controller { }); } + 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); diff --git a/assets/js/alerts/alert_element.js b/assets/js/alerts/alert_element.js index 1fe231f3..d2fb75c7 100644 --- a/assets/js/alerts/alert_element.js +++ b/assets/js/alerts/alert_element.js @@ -1,9 +1,12 @@ 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 alert = document.createElement('section'); alert.className = `system-alert system-alert-${level}`; alert.setAttribute('role', ['error', 'exception'].includes(level) ? 'alert' : 'status'); alert.dataset.alertStackTarget = 'alert'; @@ -16,6 +19,7 @@ export function createAlertElement(payload, closeLabel) { level, mode, }); + alert.replaceChildren(); const statusIcon = document.createElement('span'); statusIcon.className = `system-alert-icon ti ${alertIcon(level)}`; @@ -25,14 +29,37 @@ export function createAlertElement(payload, closeLabel) { const content = document.createElement('div'); content.className = 'system-alert-content'; - if (payload.loading) { + 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'); - spinner.className = 'system-alert-spinner'; - spinner.setAttribute('aria-hidden', 'true'); - content.append(spinner); + 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); } - content.append(document.createTextNode(String(payload.message || '').trim())); appendActions(content, Array.isArray(payload.actions) ? payload.actions : []); alert.append(content); alert.append(closeButton(closeLabel)); diff --git a/assets/js/alerts/alert_payload.js b/assets/js/alerts/alert_payload.js index 57e74df5..a7086b11 100644 --- a/assets/js/alerts/alert_payload.js +++ b/assets/js/alerts/alert_payload.js @@ -43,6 +43,7 @@ export function normalizeAlertLevel(level) { export function storableAlertPayload(payload) { return { id: payload.id, + title: payload.title, message: payload.message, level: normalizeAlertLevel(payload.level || 'info'), mode: alertMode(payload), @@ -64,7 +65,10 @@ export function payloadFromAlertElement(alert) { } catch { return { id: alert.dataset.alertId || '', - message: alert.querySelector('.system-alert-content')?.textContent || '', + 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', diff --git a/assets/styles/system/alerts.css b/assets/styles/system/alerts.css index e2041763..f6c8e32f 100644 --- a/assets/styles/system/alerts.css +++ b/assets/styles/system/alerts.css @@ -44,6 +44,18 @@ 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); + pointer-events: auto; + backdrop-filter: blur(18px); +} + .system-alert-panel { display: grid; width: 100%; @@ -52,9 +64,7 @@ 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 1.35rem 3.25rem color-mix(in srgb, var(--color-brand-canvas) 28%, transparent); - pointer-events: auto; - backdrop-filter: blur(18px); + box-shadow: 0 0.35rem 1rem color-mix(in srgb, var(--color-brand-canvas) 12%, transparent); } .system-alert-panel-header { @@ -69,6 +79,33 @@ 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; @@ -97,6 +134,32 @@ 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; @@ -141,18 +204,41 @@ 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-block; + display: inline-grid; width: 0.9rem; height: 0.9rem; - margin-right: 0.45rem; + flex: 0 0 auto; border: 2px solid color-mix(in srgb, currentColor 28%, transparent); border-top-color: currentColor; border-radius: 999px; - vertical-align: -0.1rem; 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; diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index b7e7cbe0..23f70be8 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -81,6 +81,10 @@ - Extracted reusable live JSON polling into `assets/js/live/live_poll.js` and `live-poll` Stimulus controller, then switched the operation overlay to consume that shared polling layer for `/api/live/**` status flows. - Reworked the alert stack into a sessionStorage-backed notification center with a bell badge, `auto`/`hidden`/`persistent` display modes, quiet text actions that close their alert, Mercure/client-created alert parity, extracted alert JS helpers, and live-operation runner alerts that replace the default full-screen overlay until details are requested. - Polished the notification center presentation with a structured panel header, hide control, status icons, card spacing, bounded scroll area, and quieter market-ready alert action styling. +- Stabilized operation runner notifications so repeated poll payloads update the same alert node without flashing, added operation labels as alert titles, and gave the notification-center panel a subtle shell wrapper for clearer overlay separation. +- Fixed request-time alert closing by initializing the alert stack state before Stimulus target callbacks can register server-rendered alert nodes. +- Added a quiet notification-center "close all" action and an empty-state fallback for panels without active alerts. +- Aligned alert heading markup so runner spinners render inline with operation titles. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. - Removed the unused Alpine and ApexCharts application wiring, dropped the stale custom Apex chart Stimulus controller, and switched the package asset rewriter fixture to a neutral external import name. - Updated the design-system draft and class map for scoped Twig components, targeted UI alerts, notification-center behavior, reusable live polling, and the removed chart controller. diff --git a/templates/components/Alert.html.twig b/templates/components/Alert.html.twig index 40a07ae6..20c07fdc 100644 --- a/templates/components/Alert.html.twig +++ b/templates/components/Alert.html.twig @@ -1,4 +1,4 @@ -{% props alert = {}, level = null, message = null, persistent = null, mode = null, actions = null, id = null, loading = null %} +{% props alert = {}, level = null, title = null, message = null, persistent = null, mode = null, actions = null, id = null, loading = null %} {% set raw_level = level|default(alert.level|default('info')) %} {% set normalized_level = { notice: 'info', @@ -16,6 +16,7 @@ {% set alert_mode = mode|default(alert.mode|default(alert_persistent ? 'persistent' : 'auto')) %} {% set alert_mode = alert_mode in ['auto', 'hidden', 'persistent'] ? alert_mode : 'auto' %} {% set alert_message = message|default(alert.message|default('')) %} +{% set alert_title = title|default(alert.title|default('')) %} {% set alert_actions = actions is not null ? actions : alert.actions|default([]) %} {% set alert_loading = loading is not null ? loading : alert.loading|default(false) %} {% set alert_id = id|default(alert.id|default('')) %} @@ -39,6 +40,7 @@ 'data-alert-payload': alert|merge({ id: alert_id, level: normalized_level, + title: alert_title, message: alert_message, mode: alert_mode, persistent: alert_persistent, @@ -49,10 +51,19 @@ >
- {% if alert_loading %} - + {% if alert_loading or alert_title %} +
+ {% if alert_loading %} + + {% endif %} + {% if alert_title %} + {{ alert_title }} + {% endif %} +
+ {% endif %} + {% if alert_message %} + {{ alert_message }} {% endif %} - {{ alert_message }} {% if alert_actions is not empty %}
{% for action in alert_actions %} diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index edac3a2a..98f30ed9 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -4,6 +4,7 @@ {% set stack_attributes = { class: 'system-alert-stack', 'data-controller': 'alert-stack', + 'data-action': 'ui-alert:received->alert-stack#append', 'data-alert-stack-dismiss-delay-value': dismiss_delay, 'data-alert-close-label': 'ui.alert.close'|trans, 'data-alert-notifications-label': 'ui.alert.notifications'|trans, @@ -11,7 +12,6 @@ {% if stream_url %} {% set stack_attributes = stack_attributes|merge({ 'data-controller': 'alert-stack ui-alert-stream', - 'data-action': 'ui-alert:received->alert-stack#append', 'data-ui-alert-stream-url-value': stream_url, }) %} {% endif %} @@ -21,19 +21,31 @@ -
-
-
- {{ 'ui.alert.notifications'|trans }} -
- -
-
- {% for alert in alerts %} - - {% endfor %} -
-
-
+
+
+
+
+ {{ 'ui.alert.notifications'|trans }} +
+
+ + +
+
+
+ {% for alert in alerts %} + + {% endfor %} +
+
+ + {{ 'ui.alert.empty_title'|trans }} + {{ 'ui.alert.empty_message'|trans }} +
+
+
+
diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index 6bdfb0b3..e594612f 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -3,6 +3,9 @@ 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' navigation: diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index c171e733..bc956e45 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -3,6 +3,9 @@ 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' navigation: From c418365029934922522cf25c8c7e4de2d1bb4b3c Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 20:16:10 +0200 Subject: [PATCH 05/67] Fix demo package CSS validation --- dev/CLASSMAP.md | 4 +-- dev/WORKLOG.md | 1 + .../assets/frontend/theme.css | 2 +- .../templates/frontend/preview.html.twig | 2 +- .../Package/PackageFileSyntaxValidator.php | 30 +++++++++++++++++++ tests/Core/Package/PackageValidatorTest.php | 16 ++++++++++ 6 files changed, 51 insertions(+), 4 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 184bc2e7..5881ea93 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -177,7 +177,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` | @@ -335,7 +335,7 @@ | `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 where the strict CSS parser cannot understand build-time directives, 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 diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 23f70be8..13688169 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -85,6 +85,7 @@ - Fixed request-time alert closing by initializing the alert stack state before Stimulus target callbacks can register server-rendered alert nodes. - Added a quiet notification-center "close all" action and an empty-state fallback for panels without active alerts. - Aligned alert heading markup so runner spinners render inline with operation titles. +- Repaired the demo frontend theme CSS namespace and aligned package CSS syntax validation with `bin/lint` Tailwind directive tolerance so demo packages can leave `faulty` state after a lifecycle reset; verified the demo module public routes render after activation. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. - Removed the unused Alpine and ApexCharts application wiring, dropped the stale custom Apex chart Stimulus controller, and switched the package asset rewriter fixture to a neutral external import name. - Updated the design-system draft and class map for scoped Twig components, targeted UI alerts, notification-center behavior, reusable live polling, and the removed chart controller. 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/Core/Package/PackageFileSyntaxValidator.php b/src/Core/Package/PackageFileSyntaxValidator.php index 2bc30b3e..e642d492 100644 --- a/src/Core/Package/PackageFileSyntaxValidator.php +++ b/src/Core/Package/PackageFileSyntaxValidator.php @@ -84,6 +84,10 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte $lintResult = $linter->lint($contents, $file); foreach ($lintResult->issues() as $lintIssue) { + if ($linter instanceof CssLinter && $this->isTailwindDirectiveCssIssue($contents, $lintIssue->line())) { + continue; + } + $issues[] = Message::create( $issueCode, $translationKey, @@ -96,4 +100,30 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte return $issues; } + + private function isTailwindDirectiveCssIssue(string $contents, ?int $line): bool + { + if (null === $line) { + return false; + } + + $lines = preg_split('/\R/', $contents) ?: []; + $texts = [ + trim((string) ($lines[$line - 2] ?? '')), + trim((string) ($lines[$line - 1] ?? '')), + trim((string) ($lines[$line] ?? '')), + ]; + + foreach ($texts as $text) { + if (str_starts_with($text, '@apply ') + || str_starts_with($text, '@theme ') + || str_starts_with($text, '@custom-variant ') + || str_starts_with($text, '@source ') + ) { + return true; + } + } + + return false; + } } diff --git a/tests/Core/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php index e887a64d..fa3538d7 100644 --- a/tests/Core/Package/PackageValidatorTest.php +++ b/tests/Core/Package/PackageValidatorTest.php @@ -808,6 +808,22 @@ 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 testItRejectsCssRulesTargetingClassesOutsidePackageNamespace(): void { $this->writeFile('assets/module.css', <<<'CSS' From 1c5563874e93ce03a8bcfeae050a34730e355d35 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sat, 13 Jun 2026 20:16:34 +0200 Subject: [PATCH 06/67] Clarify dev asset compile rules --- AGENTS.md | 4 ++-- dev/WORKLOG.md | 1 + dev/draft/0.1.x-SetupTestAutomation.md | 2 +- dev/draft/0.1.x-SystemThemeDesignSystem.md | 2 +- dev/draft/0.1.x-ThemeEngine.md | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0abbebf..f113363f 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ - `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. @@ -123,7 +123,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, 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/dev/WORKLOG.md b/dev/WORKLOG.md index 13688169..6de23310 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -86,6 +86,7 @@ - Added a quiet notification-center "close all" action and an empty-state fallback for panels without active alerts. - Aligned alert heading markup so runner spinners render inline with operation titles. - Repaired the demo frontend theme CSS namespace and aligned package CSS syntax validation with `bin/lint` Tailwind directive tolerance so demo packages can leave `faulty` state after a lifecycle reset; verified the demo module public routes render after activation. +- Removed locally generated `public/assets` and clarified that `asset-map:compile` is production/release-only rather than a local verification command. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. - Removed the unused Alpine and ApexCharts application wiring, dropped the stale custom Apex chart Stimulus controller, and switched the package asset rewriter fixture to a neutral external import name. - Updated the design-system draft and class map for scoped Twig components, targeted UI alerts, notification-center behavior, reusable live polling, and the removed chart controller. 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 63abf70f..3c71919d 100644 --- a/dev/draft/0.1.x-SystemThemeDesignSystem.md +++ b/dev/draft/0.1.x-SystemThemeDesignSystem.md @@ -77,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 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 From fd3b5a838ec7c7b17604061acaa31fb82bbea944 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 00:26:17 +0200 Subject: [PATCH 07/67] Add Mercure-backed UI alert infrastructure --- .env | 3 +- .../controllers/ui_alert_poll_controller.js | 37 ++ bin/init | 1 + bin/mercure | 18 + config/packages/messenger.yaml | 1 + config/packages/studio_mercure.yaml | 4 + config/services.yaml | 26 + dev/manual/web-server-configuration.md | 80 ++++ migrations/Version20260613000000.php | 60 +++ src/Command/MercureCheckCommand.php | 44 ++ src/Command/MercureHealthCommand.php | 56 +++ src/Command/MercureInstallCommand.php | 36 ++ src/Command/MercureStartCommand.php | 47 ++ src/Command/MercureStopCommand.php | 36 ++ src/Controller/LiveAlertController.php | 64 +++ src/Core/Mercure/MercureBinaryManager.php | 181 +++++++ src/Core/Mercure/MercureRuntime.php | 453 ++++++++++++++++++ src/Core/Process/DetachedProcessStarter.php | 4 +- src/Database/TablePrefix.php | 1 + src/Scheduler/CoreSchedulerTaskProvider.php | 7 + src/Setup/SetupDryRunPlanner.php | 4 + src/Setup/SetupRunner.php | 1 + src/Setup/SetupRuntimeCommandRunner.php | 25 + src/View/Alert/DispatchUiAlertMessage.php | 22 + .../Alert/DispatchUiAlertMessageHandler.php | 50 ++ src/View/Alert/MercureAvailability.php | 149 ++++++ src/View/Alert/MercureUiAlertPublisher.php | 23 +- src/View/Alert/RequestUiAlertFlasher.php | 32 ++ src/View/Alert/UiAlert.php | 39 +- src/View/Alert/UiAlertAction.php | 59 +++ src/View/Alert/UiAlertDelivery.php | 19 + src/View/Alert/UiAlertDeliveryOptions.php | 52 ++ src/View/Alert/UiAlertDispatcher.php | 148 ++++++ src/View/Alert/UiAlertDispatcherInterface.php | 40 ++ src/View/Alert/UiAlertInbox.php | 139 ++++++ src/View/Alert/UiAlertMessageFactory.php | 38 ++ src/View/Alert/UiAlertMode.php | 12 + src/View/Alert/UiAlertPresentation.php | 92 ++++ src/View/Alert/UiAlertPublisherInterface.php | 6 +- src/View/Alert/UiAlertTranslation.php | 68 +++ src/View/Twig/UiAlertTwigExtension.php | 4 +- .../partials/feedback/_flash-stack.html.twig | 12 +- templates/components/AlertStack.html.twig | 8 + templates/frontend/partials/_flash.html.twig | 12 +- tests/Command/AssetRebuildCommandTest.php | 2 +- tests/Controller/LiveAlertControllerTest.php | 71 +++ tests/Core/Mercure/MercureRuntimeTest.php | 56 +++ tests/Setup/SetupRunnerTest.php | 6 +- tests/View/Alert/UiAlertTest.php | 37 +- translations/languages/de/admin.yaml | 3 + translations/languages/en/admin.yaml | 3 + 51 files changed, 2343 insertions(+), 48 deletions(-) create mode 100644 assets/controllers/ui_alert_poll_controller.js create mode 100755 bin/mercure create mode 100644 config/packages/studio_mercure.yaml create mode 100644 migrations/Version20260613000000.php create mode 100644 src/Command/MercureCheckCommand.php create mode 100644 src/Command/MercureHealthCommand.php create mode 100644 src/Command/MercureInstallCommand.php create mode 100644 src/Command/MercureStartCommand.php create mode 100644 src/Command/MercureStopCommand.php create mode 100644 src/Controller/LiveAlertController.php create mode 100644 src/Core/Mercure/MercureBinaryManager.php create mode 100644 src/Core/Mercure/MercureRuntime.php create mode 100644 src/View/Alert/DispatchUiAlertMessage.php create mode 100644 src/View/Alert/DispatchUiAlertMessageHandler.php create mode 100644 src/View/Alert/MercureAvailability.php create mode 100644 src/View/Alert/RequestUiAlertFlasher.php create mode 100644 src/View/Alert/UiAlertAction.php create mode 100644 src/View/Alert/UiAlertDelivery.php create mode 100644 src/View/Alert/UiAlertDeliveryOptions.php create mode 100644 src/View/Alert/UiAlertDispatcher.php create mode 100644 src/View/Alert/UiAlertDispatcherInterface.php create mode 100644 src/View/Alert/UiAlertInbox.php create mode 100644 src/View/Alert/UiAlertMessageFactory.php create mode 100644 src/View/Alert/UiAlertMode.php create mode 100644 src/View/Alert/UiAlertPresentation.php create mode 100644 src/View/Alert/UiAlertTranslation.php create mode 100644 tests/Controller/LiveAlertControllerTest.php create mode 100644 tests/Core/Mercure/MercureRuntimeTest.php diff --git a/.env b/.env index faef650f..69d052f2 100755 --- a/.env +++ b/.env @@ -47,7 +47,8 @@ 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/assets/controllers/ui_alert_poll_controller.js b/assets/controllers/ui_alert_poll_controller.js new file mode 100644 index 00000000..e3185356 --- /dev/null +++ b/assets/controllers/ui_alert_poll_controller.js @@ -0,0 +1,37 @@ +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), + }); + 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/bin/init b/bin/init index 17be4067..e1a44386 100755 --- a/bin/init +++ b/bin/init @@ -183,6 +183,7 @@ 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:health'], 'Optional Mercure hub is available when supported.'); $this->runRequiredCommand([...$console, 'tailwind:build'], 'Tailwind CSS is built.'); $this->runRequiredCommand([...$console, 'cache:warmup'], 'Symfony cache is warmed.'); } 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/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/studio_mercure.yaml b/config/packages/studio_mercure.yaml new file mode 100644 index 00000000..36b38f02 --- /dev/null +++ b/config/packages/studio_mercure.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/services.yaml b/config/services.yaml index 1053f998..f1286fa8 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -109,6 +109,9 @@ services: App\Core\Config\ConfigDefaultProviderInterface: alias: App\Core\Config\Settings\CoreConfigDefaultProvider + App\View\Alert\UiAlertDispatcherInterface: + alias: App\View\Alert\UiAlertDispatcher + App\Api\Security\ApiAvailabilityCheckerInterface: alias: App\Api\Security\DatabaseApiAvailabilityChecker @@ -446,9 +449,32 @@ services: $defaultUri: '%env(DEFAULT_URI)%' $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/manual/web-server-configuration.md b/dev/manual/web-server-configuration.md index 6f931add..e4209519 100644 --- a/dev/manual/web-server-configuration.md +++ b/dev/manual/web-server-configuration.md @@ -35,6 +35,86 @@ 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 +``` + +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. + +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/migrations/Version20260613000000.php b/migrations/Version20260613000000.php new file mode 100644 index 00000000..bc77cd64 --- /dev/null +++ b/migrations/Version20260613000000.php @@ -0,0 +1,60 @@ +createTable('ui_alert_inbox'); + $inbox->addColumn('id', 'bigint', ['autoincrement' => true]); + $inbox->addColumn('topic', 'string', ['length' => 255]); + $inbox->addColumn('payload', 'json'); + $inbox->addColumn('created_at', 'datetime_immutable'); + $inbox->addColumn('expires_at', 'datetime_immutable', ['notnull' => false]); + $this->addPrimaryKey($inbox, 'id'); + $this->addIndex($inbox, ['topic', 'id'], 'idx_ui_alert_inbox_topic_cursor'); + $this->addIndex($inbox, ['expires_at'], 'idx_ui_alert_inbox_expires_at'); + } + + public function down(Schema $schema): void + { + $schema->dropTable('ui_alert_inbox'); + } + + private function schemaObjectName(string $name): string + { + return TablePrefix::apply($name); + } + + private function addPrimaryKey(Table $table, string ...$columns): void + { + $constraint = PrimaryKeyConstraint::editor() + ->setUnquotedName($this->schemaObjectName('pk_'.$table->getName())) + ->setUnquotedColumnNames(...$columns) + ->create(); + + $table->addPrimaryKeyConstraint($constraint); + } + + /** + * @param list $columns + */ + private function addIndex(Table $table, array $columns, string $name): void + { + $table->addIndex($columns, $this->schemaObjectName($name)); + } +} diff --git a/src/Command/MercureCheckCommand.php b/src/Command/MercureCheckCommand.php new file mode 100644 index 00000000..0a240f11 --- /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'], + ['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..2ea7cbe4 --- /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::FAILURE; + } + + 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..ff2d1801 --- /dev/null +++ b/src/Command/MercureStartCommand.php @@ -0,0 +1,47 @@ +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(), + ); + + $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/Controller/LiveAlertController.php b/src/Controller/LiveAlertController.php new file mode 100644 index 00000000..c89bd684 --- /dev/null +++ b/src/Controller/LiveAlertController.php @@ -0,0 +1,64 @@ +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'], + '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/Core/Mercure/MercureBinaryManager.php b/src/Core/Mercure/MercureBinaryManager.php new file mode 100644 index 00000000..ce31daf4 --- /dev/null +++ b/src/Core/Mercure/MercureBinaryManager.php @@ -0,0 +1,181 @@ +installDir().DIRECTORY_SEPARATOR.$this->binaryName(); + } + + public function isInstalled(): bool + { + $path = $this->binaryPath(); + + return is_file($path) && is_executable($path); + } + + public function install(): bool + { + if ($this->isInstalled()) { + return true; + } + + $asset = $this->assetName(); + if (null === $asset) { + return false; + } + + $archivePath = $this->cacheDir().DIRECTORY_SEPARATOR.$asset; + + try { + $this->ensureDirectory($this->cacheDir()); + $this->ensureDirectory($this->installDir()); + + if (!is_file($archivePath)) { + $response = HttpClient::create(['timeout' => 30])->request('GET', $this->downloadUrl($asset)); + file_put_contents($archivePath, $response->getContent(), LOCK_EX); + } + + $this->extract($archivePath); + $binary = $this->binaryPath(); + if (!is_file($binary)) { + return false; + } + + @chmod($binary, 0755); + $this->releaseMacQuarantine($binary); + + 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 + { + $os = match (PHP_OS_FAMILY) { + 'Darwin' => 'Darwin', + 'Linux' => 'Linux', + 'Windows' => 'Windows', + default => null, + }; + $arch = match (strtolower(php_uname('m'))) { + 'x86_64', 'amd64' => 'x86_64', + 'aarch64', 'arm64' => 'arm64', + 'armv6l' => 'armv6', + 'i386', 'i686' => 'i386', + default => null, + }; + + if (null === $os || null === $arch) { + return null; + } + + $extension = 'Windows' === $os ? 'zip' : 'tar.gz'; + + return sprintf('mercure-legacy_%s_%s.%s', $os, $arch, $extension); + } + + private function downloadUrl(string $asset): string + { + return sprintf('https://github.com/dunglas/mercure/releases/download/v%s/%s', $this->safeVersion(), $asset); + } + + 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; + } + + $tarPath = preg_replace('/\.gz$/', '', $archivePath) ?: $archivePath.'.tar'; + if (!is_file($tarPath)) { + $archive = new PharData($archivePath); + $archive->decompress(); + } + + (new PharData($tarPath))->extractTo($this->installDir(), null, true); + } + + 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..d8b781b9 --- /dev/null +++ b/src/Core/Mercure/MercureRuntime.php @@ -0,0 +1,453 @@ + + */ + public function startCommand(): array + { + $jwtSecret = $this->jwtSecret(); + + return [ + $this->binaryManager->binaryPath(), + '--addr', + $this->listenAddress(), + '--publisher-jwt-key', + $jwtSecret, + '--subscriber-jwt-key', + $jwtSecret, + '--cors-allowed-origins', + '*', + '--transport-url', + $this->transportUrl(), + ]; + } + + 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->isProcessRunning($pid) ? $pid : null; + } + + public function isRunning(): bool + { + $pid = $this->pid(); + + if (null !== $pid && $this->isProcessRunning($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 true; + } + + $this->removePidFile(); + + return [] === $this->binaryProcessIds(); + } + + if (!$this->isProcessRunning($pid)) { + if ($this->terminateBinaryProcesses()) { + $this->removePidFile(); + + return true; + } + + $this->removePidFile(); + + return [] === $this->binaryProcessIds(); + } + + if (!$this->terminate($pid)) { + if ($this->terminateBinaryProcesses()) { + $this->removePidFile(); + + return true; + } + + return false; + } + + for ($attempt = 0; $attempt < 10; ++$attempt) { + usleep(100000); + if (!$this->isProcessRunning($pid)) { + $this->removePidFile(); + + return true; + } + } + + if ($this->isProcessRunning($pid)) { + return false; + } + + if ([] !== $this->binaryProcessIds() && !$this->terminateBinaryProcesses()) { + 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->hubEndpointProbe($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, '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 ? $url : $this->localHubUrl(); + } + + public function publicHubUrl(): string + { + $url = trim((string) ($_SERVER['MERCURE_PUBLIC_URL'] ?? $_ENV['MERCURE_PUBLIC_URL'] ?? getenv('MERCURE_PUBLIC_URL') ?: '')); + + return '' !== $url ? $url : $this->localHubUrl(); + } + + private function publishDirectly(string $url): bool + { + try { + if (!method_exists($this->hub, 'getProvider')) { + return false; + } + + $response = HttpClient::create(['timeout' => 2.0])->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', + ]), + ]); + + $response->getContent(); + + return true; + } catch (Throwable) { + return false; + } + } + + private function hubEndpointProbe(string $url): bool + { + try { + $response = HttpClient::create([ + 'timeout' => 2.0, + 'max_duration' => 2.0, + ])->request('GET', $url); + $body = $response->getContent(false); + $status = $response->getStatusCode(); + + if (400 === $status) { + return str_contains($body, 'Missing "topic" parameter') + || str_contains($body, 'Missing topic parameter') + || str_contains($body, 'missing "topic" parameter') + || str_contains($body, 'missing topic parameter'); + } + + return 401 === $status + && ( + str_contains($body, 'Unauthorized') + || str_contains($body, 'unauthorized') + ); + } catch (Throwable) { + return false; + } + } + + 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 rtrim($this->defaultUri, '/').'/ui-alerts/health'; + } + + private function urlWithTopic(string $url): string + { + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url.$separator.QueryBuilder::build(['topic' => $this->healthTopic()]); + } + + private function transportUrl(): string + { + $directory = $this->projectDir.'/var/mercure'; + if (!is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + return $this->boltTransportUrl($directory.'/updates.db'); + } + + private function boltTransportUrl(string $path): string + { + $normalized = str_replace('\\', '/', $path); + if (1 === preg_match('/^[A-Za-z]:\//', $normalized)) { + $normalized = '/'.$normalized; + } + + return 'bolt://'.$normalized.'?size=1000&cleanup_frequency=0.3'; + } + + 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 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; + } + + /** + * @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/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/Scheduler/CoreSchedulerTaskProvider.php b/src/Scheduler/CoreSchedulerTaskProvider.php index 722c0ecb..7169a62a 100644 --- a/src/Scheduler/CoreSchedulerTaskProvider.php +++ b/src/Scheduler/CoreSchedulerTaskProvider.php @@ -40,6 +40,13 @@ public function schedulerTasks(): array 'cache:clear', '0 4 * * *', ), + SchedulerTaskDefinition::command( + 'system.mercure_health', + 'admin.scheduler.tasks.mercure_health.label', + 'admin.scheduler.tasks.mercure_health.description', + 'mercure:health', + '7 * * * *', + ), ]; } } diff --git a/src/Setup/SetupDryRunPlanner.php b/src/Setup/SetupDryRunPlanner.php index ccbe22df..2ef2cec6 100644 --- a/src/Setup/SetupDryRunPlanner.php +++ b/src/Setup/SetupDryRunPlanner.php @@ -83,6 +83,10 @@ 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, + '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/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..69b95657 100644 --- a/src/Setup/SetupRuntimeCommandRunner.php +++ b/src/Setup/SetupRuntimeCommandRunner.php @@ -144,6 +144,31 @@ public function runAssetRebuild( ]; } + /** + * @param array $environment + * + * @return array + */ + public function runMercureHealth( + string $projectDir, + SetupInput $input, + array $environment, + SetupCommandExecutorInterface $commandExecutor, + ): array { + $command = [ + ...$this->phpCliCommandPrefix($projectDir, $input, $environment, true), + $projectDir.'/bin/console', + 'mercure:health', + '--env='.$input->appEnv(), + ]; + $result = $commandExecutor->run($command, $projectDir, $this->databaseEnvironmentScope->commandEnvironment($environment)); + + return [ + 'command' => $command, + 'available' => $result->isSuccessful(), + ]; + } + /** * @return list */ diff --git a/src/View/Alert/DispatchUiAlertMessage.php b/src/View/Alert/DispatchUiAlertMessage.php new file mode 100644 index 00000000..1b733f58 --- /dev/null +++ b/src/View/Alert/DispatchUiAlertMessage.php @@ -0,0 +1,22 @@ + $payload + */ + public function __construct( + public string $topic, + public array $payload, + public bool $queue = true, + public bool $push = true, + public bool $private = true, + 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..c66be42e --- /dev/null +++ b/src/View/Alert/DispatchUiAlertMessageHandler.php @@ -0,0 +1,50 @@ +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, + is_array($message->payload['context'] ?? null) ? $message->payload['context'] : [], + (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->queue, + $message->push, + $message->private, + $message->ttlSeconds, + $message->locale, + )); + } +} diff --git a/src/View/Alert/MercureAvailability.php b/src/View/Alert/MercureAvailability.php new file mode 100644 index 00000000..9067f547 --- /dev/null +++ b/src/View/Alert/MercureAvailability.php @@ -0,0 +1,149 @@ +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(), + ); + } catch (Throwable) { + return false; + } + } +} diff --git a/src/View/Alert/MercureUiAlertPublisher.php b/src/View/Alert/MercureUiAlertPublisher.php index a57eb610..064adb47 100644 --- a/src/View/Alert/MercureUiAlertPublisher.php +++ b/src/View/Alert/MercureUiAlertPublisher.php @@ -11,22 +11,19 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Contracts\Translation\TranslatorInterface; final readonly class MercureUiAlertPublisher implements UiAlertPublisherInterface { public function __construct( private HubInterface $hub, private UiAlertTopicFactory $topicFactory, - private TranslatorInterface $translator, + private UiAlertMessageFactory $alertFactory, ) { } - public function publish(string $topic, UiAlert|Message $alert, ?string $locale = null, bool $private = true): ?string + public function publish(string $topic, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null, bool $private = true): ?string { - $payload = $alert instanceof Message - ? $this->fromMessage($alert, $locale)->toArray() - : $alert->toArray(); + $payload = $this->alertFactory->create($alert, $locale)->toArray(); try { $data = json_encode($payload, JSON_THROW_ON_ERROR); @@ -37,24 +34,14 @@ public function publish(string $topic, UiAlert|Message $alert, ?string $locale = return $this->hub->publish(new Update($topic, $data, private: $private, type: 'ui-alert')); } - public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message $alert, ?string $locale = null): ?string + public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string { return $this->publish($this->topicFactory->userTopic($user), $alert, $locale); } - public function publishToSession(SessionInterface|string $session, UiAlert|Message $alert, ?string $locale = null): ?string + public function publishToSession(SessionInterface|string $session, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string { return $this->publish($this->topicFactory->sessionTopic($session), $alert, $locale); } - private function fromMessage(Message $message, ?string $locale): UiAlert - { - return UiAlert::translated( - $this->translator->trans($message->translationKey(), $message->parameters(), locale: $locale), - $message->level(), - $message->code(), - $message->translationKey(), - $message->context(), - ); - } } 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 index f6b04ffa..e2b4154f 100644 --- a/src/View/Alert/UiAlert.php +++ b/src/View/Alert/UiAlert.php @@ -24,6 +24,7 @@ public function __construct( 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.'); @@ -41,9 +42,10 @@ public static function fromLevel( ?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); + return new self($message, $level, $persistent, mode: $mode, id: $id, actions: $actions, loading: $loading, title: $title); } /** @@ -61,7 +63,9 @@ public static function translated( ?string $id = null, array $actions = [], bool $loading = false, - ): self { + ?string $title = null, + ): self + { return new self( $message, $level instanceof MessageLevel ? $level->value : $level, @@ -73,11 +77,36 @@ public static function translated( $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, ); } /** - * @return array{message: string, level: string, persistent: bool, mode: string, loading: bool, id?: string, actions?: list>, code?: string, translation_key?: string, context?: array} + * @return array{message: string, level: string, persistent: bool, mode: string, loading: bool, title?: string, id?: string, actions?: list>, code?: string, translation_key?: string, context?: array} */ public function toArray(): array { @@ -89,6 +118,10 @@ public function toArray(): array '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; } diff --git a/src/View/Alert/UiAlertAction.php b/src/View/Alert/UiAlertAction.php new file mode 100644 index 00000000..6d8f29c1 --- /dev/null +++ b/src/View/Alert/UiAlertAction.php @@ -0,0 +1,59 @@ + $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); + } + + /** + * @return array{label: string, href?: string, target?: string, event?: string, detail?: array} + */ + public function toArray(): array + { + $action = ['label' => $this->label]; + + if (null !== $this->href && '' !== trim($this->href)) { + $action['href'] = $this->href; + } + + if (null !== $this->target && '' !== trim($this->target)) { + $action['target'] = $this->target; + } + + if (null !== $this->event && '' !== trim($this->event)) { + $action['event'] = $this->event; + } + + if ([] !== $this->detail) { + $action['detail'] = $this->detail; + } + + return $action; + } +} diff --git a/src/View/Alert/UiAlertDelivery.php b/src/View/Alert/UiAlertDelivery.php new file mode 100644 index 00000000..65e7fa8c --- /dev/null +++ b/src/View/Alert/UiAlertDelivery.php @@ -0,0 +1,19 @@ + UiAlertDeliveryOptions::direct(), + self::Queue => UiAlertDeliveryOptions::queued(), + }; + } +} diff --git a/src/View/Alert/UiAlertDeliveryOptions.php b/src/View/Alert/UiAlertDeliveryOptions.php new file mode 100644 index 00000000..8b8b6630 --- /dev/null +++ b/src/View/Alert/UiAlertDeliveryOptions.php @@ -0,0 +1,52 @@ +queue; + } + + public function push(): bool + { + return $this->push; + } + + 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..9b1c7231 --- /dev/null +++ b/src/View/Alert/UiAlertDispatcher.php @@ -0,0 +1,148 @@ +options($delivery); + $uiAlert = $this->alertFactory->create($alert, $options->locale(), $presentation); + + if (!$options->queue()) { + $flashed = $this->flasher->flash($uiAlert); + $topics = $this->currentTopics(); + if ($options->push() && [] !== $topics) { + $this->pushTopics($topics, $uiAlert, $options); + } + + return $flashed; + } + + $topics = $this->currentTopics(); + if ([] === $topics) { + return $this->flasher->flash($uiAlert); + } + + $queued = null !== $this->inbox->append($topics, $uiAlert, $options->ttlSeconds()); + $pushed = $options->push() && $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 + { + $options = $this->options($delivery); + $uiAlert = $this->alertFactory->create($alert, $options->locale(), $presentation); + $queued = false; + $pushed = false; + $flashed = false; + + if ($options->queue()) { + $queued = null !== $this->inbox->append([$topic], $uiAlert, $options->ttlSeconds()); + } else { + $flashed = $this->flasher->flash($uiAlert); + } + + if ($options->push()) { + try { + $pushed = null !== $this->publisher->publish($topic, $uiAlert, $options->locale(), $options->private()); + } catch (Throwable) { + $pushed = false; + } + } + + return $queued || $pushed || $flashed; + } + + public function addAlertToUser( + UserAccount|UserInterface|string $user, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool + { + return $this->addAlertToTopic($this->topicFactory->userTopic($user), $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(); + } + + /** + * @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 { + $cursor = null; + + 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, + ]); + $cursor = max($cursor ?? 0, (int) $this->connection->lastInsertId()); + } + + return $cursor; + } catch (Throwable) { + return null; + } + } + + /** + * @param list $topics + * + * @return array{cursor: int, alerts: list>} + */ + 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' => []]; + } + + try { + $now = new DateTimeImmutable(); + $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, max(1, min(250, $limit))], + [ArrayParameterType::STRING, ParameterType::INTEGER, Types::DATETIME_IMMUTABLE, ParameterType::INTEGER], + ); + } catch (Throwable) { + return ['cursor' => max(0, $cursor), 'alerts' => []]; + } + + $alerts = []; + $nextCursor = max(0, $cursor); + + 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]; + } + + public function cleanupExpired(): int + { + try { + return $this->connection->executeStatement( + 'DELETE FROM ui_alert_inbox WHERE expires_at IS NOT NULL AND expires_at <= ?', + [new DateTimeImmutable()], + [Types::DATETIME_IMMUTABLE], + ); + } catch (Throwable) { + return 0; + } + } + + /** + * @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 => trim((string) $topic), $topics), + static fn (string $topic): bool => '' !== $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..f20f31fd --- /dev/null +++ b/src/View/Alert/UiAlertMessageFactory.php @@ -0,0 +1,38 @@ +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(), + $alert->context(), + )->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 ? $action->toArray() : $action, + $this->actions, + ), static fn (array $action): bool => is_string($action['label'] ?? null) && '' !== trim($action['label']))); + } + + public function isLoading(): ?bool + { + return $this->loading; + } +} diff --git a/src/View/Alert/UiAlertPublisherInterface.php b/src/View/Alert/UiAlertPublisherInterface.php index addf9e57..1931c3ef 100644 --- a/src/View/Alert/UiAlertPublisherInterface.php +++ b/src/View/Alert/UiAlertPublisherInterface.php @@ -11,9 +11,9 @@ interface UiAlertPublisherInterface { - public function publish(string $topic, UiAlert|Message $alert, ?string $locale = null, bool $private = true): ?string; + public function publish(string $topic, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null, bool $private = true): ?string; - public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message $alert, ?string $locale = null): ?string; + public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string; - public function publishToSession(SessionInterface|string $session, UiAlert|Message $alert, ?string $locale = null): ?string; + public function publishToSession(SessionInterface|string $session, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string; } 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/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php index d8bb7409..e05967a3 100644 --- a/src/View/Twig/UiAlertTwigExtension.php +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -5,6 +5,7 @@ namespace App\View\Twig; use App\View\Alert\UiAlertTopicFactory; +use App\View\Alert\MercureAvailability; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Mercure\Twig\MercureExtension; @@ -19,6 +20,7 @@ public function __construct( private readonly RequestStack $requestStack, private readonly Security $security, private readonly UiAlertTopicFactory $topicFactory, + private readonly MercureAvailability $mercureAvailability, private readonly ?MercureExtension $mercure = null, ) { } @@ -51,7 +53,7 @@ public function streamUrl(?array $topics = null): ?string { $topics ??= $this->streamTopics(); - if ([] === $topics || null === $this->mercure) { + if ([] === $topics || null === $this->mercure || !$this->mercureAvailability->available()) { return null; } diff --git a/templates/backend/partials/feedback/_flash-stack.html.twig b/templates/backend/partials/feedback/_flash-stack.html.twig index e4651700..85ee170e 100644 --- a/templates/backend/partials/feedback/_flash-stack.html.twig +++ b/templates/backend/partials/feedback/_flash-stack.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/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 98f30ed9..a832a0e7 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -1,6 +1,7 @@ {% props alerts = [], dismiss_delay = 8000, stream_topics = null %} {% set stream_topics = stream_topics is same as(null) ? ui_alert_stream_topics() : stream_topics %} {% set stream_url = ui_alert_stream_url(stream_topics) %} +{% set poll_url = stream_topics is not empty ? path('api_live_alerts') : null %} {% set stack_attributes = { class: 'system-alert-stack', 'data-controller': 'alert-stack', @@ -15,6 +16,13 @@ 'data-ui-alert-stream-url-value': stream_url, }) %} {% endif %} +{% if poll_url %} + {% set stack_attributes = stack_attributes|merge({ + 'data-controller': stack_attributes['data-controller'] ~ ' ui-alert-poll', + 'data-ui-alert-poll-url-value': poll_url, + 'data-ui-alert-poll-interval-value': 15000, + }) %} +{% endif %}
+ + + {% endif %} + {% if not consent_prompt_required %} + + {% endif %} +
+ + +
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/user/profile.html.twig b/templates/frontend/user/profile.html.twig index f961b0ca..3196a849 100644 --- a/templates/frontend/user/profile.html.twig +++ b/templates/frontend/user/profile.html.twig @@ -29,7 +29,13 @@
{{ ('ui.user.roles.' ~ user_account.role.value)|trans }}
-
+ {% if username_change_enabled|default(false) %} {% include '@frontend/partials/forms/fields/input.html.twig' with { @@ -67,6 +73,7 @@ label: 'ui.user.profile.native_notifications.label'|trans, help: 'ui.user.profile.native_notifications.help'|trans, checked: user_account.settings.native_notifications|default(false), + attr: {'data-native-notification-preference-target': 'input'}, } only %} {% endif %} {% include '@frontend/partials/forms/fields/submit.html.twig' with {label: 'ui.user.profile.submit'|trans} only %} diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index f013a407..61348cc6 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -8,6 +8,17 @@ ui: 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' + required_title: 'Notwendige Cookies' + required_message: 'Notwendige Cookies halten Sessions, Sicherheitsprüfungen und grundlegende Einstellungen funktionsfähig und können hier nicht deaktiviert werden.' + no_optional: 'Aktuell sind keine optionalen Cookies registriert.' + privacy_link: 'Datenschutzhinweise' navigation: skip_to_content: 'Zum Inhalt springen' frontend: @@ -88,6 +99,7 @@ ui: native_notifications: label: 'Native Browser-Benachrichtigungen' help: 'Zeige Browser-Benachrichtigungen, wenn Echtzeit-Zustellung verfügbar ist.' + denied: 'Browser-Benachrichtigungen wurden nicht aktiviert, weil die Browser-Berechtigung nicht erteilt wurde.' submit: 'Profil speichern' success: 'Profil gespeichert.' close: diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 4a2921b0..742a922c 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -8,6 +8,17 @@ ui: 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' + required_title: 'Required cookies' + required_message: 'Required cookies keep sessions, security checks, and basic preferences working and cannot be disabled here.' + no_optional: 'No optional cookies are registered right now.' + privacy_link: 'Privacy information' navigation: skip_to_content: 'Skip to content' frontend: @@ -88,6 +99,7 @@ ui: native_notifications: label: 'Native browser notifications' help: 'Show browser-level notifications when real-time delivery is available.' + denied: 'Browser notifications were not enabled because the browser permission was not granted.' submit: 'Save profile' success: 'Profile saved.' close: From 7ad53e3492693cfa163701d219bcbb48a9fc3d72 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 03:08:42 +0200 Subject: [PATCH 13/67] Stabilize backend checks and update project notes --- dev/CLASSMAP.md | 10 +++++--- dev/WORKLOG.md | 12 +++++++++ src/Backend/BackendActionResponder.php | 17 ++++++++++++- tests/Command/AssetRebuildCommandTest.php | 2 +- tests/Operations/SqliteMigrationTest.php | 31 ++++++++++++++++++++++- 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5fef5b35..ed2223a5 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\Controller\LiveEndpointController` | Provides package-owned `/api/live/{package_slug}/...` endpoint registration for lightweight polling/manual live interactions, validates package live paths and handler keys below package-owned namespaces, reserves system live slugs such as `alerts` and `operations`, dispatches matching package handlers, 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` | | 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` | @@ -59,6 +60,7 @@ | 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, downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, legacy hub Bolt transport at `var/mercure/updates.db`, internal publish plus Mercure-fingerprinted public 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, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.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/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` | Provides a package-extendable cookie consent registry, central safe cookie get/set gate, response-time removal of registered optional cookies without stored consent, DNT/GPC-aware defaults, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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/Privacy/Cookie/CookieConsentManagerTest.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 | `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` | @@ -169,7 +171,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 and live endpoint namespaces, 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` | @@ -317,6 +319,8 @@ | 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. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php` | +| Route `privacy_cookie_consent` | `App\Controller\CookieConsentController` | Stores selected optional cookie consent in a long-lived required cookie and redirects back to the originating path. | `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 `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 | @@ -356,7 +360,7 @@ | 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 | -| 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 while operation forms now surface progress through notification-center runner alerts and open the ActionLog overlay only on demand for details, continuation, retry, and close controls. | `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, `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 and open the ActionLog overlay only on demand for details, continuation, retry, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | | UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `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, DB-backed user/session topic inbox with setup-completion gating, Mercure health-gated stream/push delivery, 15-second lazy polling fallback, sessionStorage-backed notification center with badge counts, hide-vs-close behavior, quiet text actions, presentation modes, optional titles/actions/loading state, and optional EventSource updates. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/Controller/LiveAlertControllerTest.php` | | 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 489aa3d9..96e8b29f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -111,6 +111,11 @@ - Made the Mercure EventSource subscriber recover from closed streams with bounded reconnect backoff and active/online wakeups while polling remains the reliable delivery fallback. - Simplified UI-alert delivery semantics to `Direct`, `Queue`, and low-level `Push`: direct alerts only flash into the current request, queued alerts write the inbox and best-effort push through Mercure, and push-only alerts remain reserved for volatile/debug notifications. - Added server-side fallback IDs for queued or pushed UI alerts so Mercure live delivery and polling fallback share the same dedupe key even when producers do not provide an explicit alert ID. +- Added package-owned live endpoint registration below `/api/live/{package_slug}/...` with provider/handler registries, runtime package contributions, reserved system live slug validation, and a shared dispatch controller for future package live interactions such as captcha seed reloads. +- Updated the shared frontend live poller so an explicit `next_poll_ms: 0` response disables automatic follow-up polling for manual one-shot live endpoints. +- Added a native browser-notification bridge for UI alerts that only displays OS notifications when the saved user setting is enabled and browser permission is already granted; the permission prompt is now requested only when the profile form is saved with the setting checked. +- Added a package-extendable cookie consent foundation with necessary/optional cookie definitions, central consent-aware cookie get/set helpers, response-time optional cookie filtering, DNT/GPC-aware defaults, long-lived consent-cookie storage, and a banner/overlay that stays hidden until optional cookies are registered. +- Adjusted synchronous backend action alert selection so successful workflows flash the first success-level message instead of an incidental debug message, keeping admin feedback aligned with result status. - Repaired the demo frontend theme CSS namespace and aligned package CSS syntax validation with `bin/lint` Tailwind directive tolerance so demo packages can leave `faulty` state after a lifecycle reset; verified the demo module public routes render after activation. - Removed locally generated `public/assets` and clarified that `asset-map:compile` is production/release-only rather than a local verification command. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. @@ -121,6 +126,13 @@ - Verification: `php -l src/View/Alert/UiAlertDispatcherInterface.php src/View/Alert/UiAlertDispatcher.php src/View/Alert/UiAlertMessageFactory.php src/Core/Mercure/MercureBinaryManager.php`, `php bin/console lint:container`, `php bin/phpunit tests/Controller/LiveAlertControllerTest.php tests/Core/Asset/AssetRebuildQueueFactoryTest.php tests/Command/AssetRebuildCommandTest.php tests/Scheduler/SchedulerRunnerTest.php tests/Controller/AdminSchedulerControllerTest.php tests/Controller/SchedulerControllerTest.php tests/View/Alert/UiAlertTest.php`, `bin/lint --diff`, and unsandboxed `php bin/console mercure:health`. - Verification: `php -l src/Command/MercureStopCommand.php src/Core/Mercure/MercureRuntime.php src/Setup/SetupRuntimeCommandRunner.php`, `php bin/console list mercure --raw`, `php bin/console lint:container`, and `php bin/phpunit tests/Setup/SetupRunnerTest.php tests/Controller/LiveAlertControllerTest.php tests/Core/Asset/AssetRebuildQueueFactoryTest.php tests/Command/AssetRebuildCommandTest.php tests/Scheduler/SchedulerRunnerTest.php tests/Controller/AdminSchedulerControllerTest.php tests/Controller/SchedulerControllerTest.php tests/View/Alert/UiAlertTest.php`. - Verification: `php -l src/Core/Mercure/MercureRuntime.php src/Core/Process/DetachedProcessStarter.php src/Core/Asset/AssetRebuildQueueFactory.php tests/Core/Mercure/MercureRuntimeTest.php`, `php bin/phpunit tests/Core/Mercure/MercureRuntimeTest.php tests/Core/Process/DetachedProcessStarterTest.php tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `php bin/console lint:container`, `bin/lint --diff`, confirmed `public/assets` is absent, and manually verified `mercure:health` plus `mercure:check` with matching publish/public URLs on `127.0.0.1:3000`. +- Verification: `php -l` for new Live/Cookie controller, registry, guard, Twig extension, and tests; `bin/lint src/Live src/Privacy src/Controller/CookieConsentController.php src/Controller/LiveEndpointController.php src/Core/Package/PackageRuntimeContributionRegistry.php src/Core/Package/PackageContributions.php src/Core/Package/PackageLiveContributionGuard.php tests/Core/Package/PackageLiveContributionGuardTest.php tests/Privacy/Cookie/CookieConsentManagerTest.php assets/controllers assets/js/live templates/base.html.twig templates/components/CookieConsent.html.twig templates/components/AlertStack.html.twig templates/frontend/partials/forms/fields/toggle.html.twig templates/frontend/user/profile.html.twig translations/languages/en/message.yaml translations/languages/de/message.yaml translations/languages/en/ui.yaml translations/languages/de/ui.yaml config/services.yaml`; `php bin/console lint:container`; `php bin/phpunit tests/Core/Package/PackageLiveContributionGuardTest.php tests/Privacy/Cookie/CookieConsentManagerTest.php tests/Core/Package/PackageApiContributionGuardTest.php`; full `bin/lint`; full `php bin/phpunit` with 1172 tests and 7977 assertions; Browser verification on `/admin` and `/user/profile` with no console errors and no cookie banner while only necessary cookies are registered. +- Added reusable cookie-consent reopening hooks through `cookie_consent_trigger_attributes()`, kept the consent overlay in the DOM for later privacy/footer links and styling checks even when only required cookies exist, and made reopened consent settings preserve stored optional-cookie selections. +- Scoped the cookie-consent redirect field to `_cookie_consent_target_path` so auth forms remain the only producers of Symfony's `_target_path`, and rejected protocol-relative consent redirect targets. +- Replaced the missing Tabler font cookie glyph with the locally imported Symfony UX Icons `tabler:cookie` SVG in the consent overlay. +- Added explicit one-shot live polling actions (`live-poll#poll`/`live-poll#refresh`) on top of `next_poll_ms: 0` manual-mode responses for package-driven interactions such as future captcha refresh flows. +- Verification: `php -l src/Privacy/Cookie/CookieConsentManager.php src/Privacy/Cookie/CookieConsentTwigExtension.php src/Controller/CookieConsentController.php tests/Privacy/Cookie/CookieConsentManagerTest.php`; `node --check assets/controllers/cookie_consent_controller.js assets/controllers/live_poll_controller.js assets/js/live/live_poll.js`; `bin/lint assets/controllers/cookie_consent_controller.js assets/controllers/live_poll_controller.js assets/js/live/live_poll.js templates/components/CookieConsent.html.twig src/Privacy/Cookie src/Controller/CookieConsentController.php tests/Privacy/Cookie translations/languages/en/ui.yaml translations/languages/de/ui.yaml`; `php bin/phpunit tests/Privacy/Cookie/CookieConsentManagerTest.php`; `php bin/phpunit tests/Controller/SecurityControllerTest.php --filter testLoginRouteAllowsOnlyLocalReturnTargets`; `php bin/console ux:icons:warm-cache`; Browser check on `/user/profile` confirmed the hidden consent overlay remains in the DOM with its controller, empty-state copy, and rendered UX icon while no console errors were logged. +- 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: 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 diff --git a/src/Backend/BackendActionResponder.php b/src/Backend/BackendActionResponder.php index 1e33b5c2..2394af9b 100644 --- a/src/Backend/BackendActionResponder.php +++ b/src/Backend/BackendActionResponder.php @@ -7,6 +7,7 @@ use App\Backend\BackendMessageKey; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; +use App\Core\Message\MessageLevel; use App\Core\Operation\Live\LiveOperationHttpResponder; use App\Core\Operation\OperationMessageKey; use App\Core\Workflow\WorkflowResult; @@ -88,12 +89,26 @@ private function audit(mixed $user, string $action, WorkflowResult $result, stri private function flashResult(WorkflowResult $result): void { $message = $result->isSuccess() - ? ($result->messages()[0] ?? Message::success(BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED)) + ? ($this->firstMessageWithLevel($result, MessageLevel::Success) ?? Message::success(BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED)) : ($result->firstIssue() ?? Message::error(CommonMessageCode::E_OPERATION_FAILED, OperationMessageKey::OPERATION_EXCEPTION)); $this->alerts->addAlert($message, UiAlertDelivery::Direct); } + /** + * @param WorkflowResult $result + */ + private function firstMessageWithLevel(WorkflowResult $result, MessageLevel $level): ?Message + { + foreach ($result->messages() as $message) { + if ($message->level() === $level) { + return $message; + } + } + + return null; + } + private function stringField(Request $request, string $name): string { $value = $request->request->get($name); diff --git a/tests/Command/AssetRebuildCommandTest.php b/tests/Command/AssetRebuildCommandTest.php index 8153d2d2..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(9, $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/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 4d77389f..88a917d3 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -132,7 +132,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 +178,35 @@ public function testPrefixedMigrationsUsePrefixedNamesWhenReverting(): void } } + /** + * @return list + */ + private function initialMigrationTables(): array + { + return [ + 'messenger_messages', + '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' From 6bb863c15aa362f87c7c14f91f09bf53dd2b78d9 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 03:54:33 +0200 Subject: [PATCH 14/67] Refine consent preview and operation alerts --- assets/controllers/alert_stack_controller.js | 7 ++- .../operation_overlay_controller.js | 3 ++ assets/styles/system/alerts.css | 20 +++++-- assets/styles/system/base.css | 2 +- .../Cookie/CoreCookieConsentProvider.php | 6 +++ templates/components/CookieConsent.html.twig | 53 ++++++++++--------- translations/languages/de/ui.yaml | 3 +- translations/languages/en/ui.yaml | 3 +- 8 files changed, 61 insertions(+), 36 deletions(-) diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index 51b7ec8a..98a85104 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -110,7 +110,12 @@ export default class extends Controller { const id = alertId(payload); if (this.closedAlertIds.has(id)) { - return null; + if (!payload.reopen) { + return null; + } + + this.closedAlertIds.delete(id); + this.persistClosedAlerts(); } const existing = this.alerts.get(id); diff --git a/assets/controllers/operation_overlay_controller.js b/assets/controllers/operation_overlay_controller.js index 51e77e4c..7d987b20 100644 --- a/assets/controllers/operation_overlay_controller.js +++ b/assets/controllers/operation_overlay_controller.js @@ -58,6 +58,7 @@ export default class extends Controller { } this.starting = true; + this.suppressRunningAlert = false; this.prepareOverlay(); this.reset(); this.updateOperationAlert({ @@ -267,6 +268,7 @@ export default class extends Controller { } 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')); @@ -512,6 +514,7 @@ export default class extends Controller { this.dispatchAlert({ id: this.operationAlertId(), + reopen: true, title, level: status === 'success' ? 'success' : (status === 'requires_review' ? 'warning' : (status === 'failed' ? 'error' : 'info')), message, diff --git a/assets/styles/system/alerts.css b/assets/styles/system/alerts.css index a667fa4f..1fb0f76d 100644 --- a/assets/styles/system/alerts.css +++ b/assets/styles/system/alerts.css @@ -315,14 +315,18 @@ height: 1.2rem; } -.system-cookie-consent-summary p, -.system-cookie-consent-required p { +.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); @@ -340,12 +344,20 @@ .system-cookie-consent-details { display: grid; gap: 0.75rem; - max-height: min(18rem, calc(100vh - 18rem)); - overflow: auto; + 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); diff --git a/assets/styles/system/base.css b/assets/styles/system/base.css index 82a394c1..4b257f6e 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -343,7 +343,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; diff --git a/src/Privacy/Cookie/CoreCookieConsentProvider.php b/src/Privacy/Cookie/CoreCookieConsentProvider.php index 069aff47..6fcd3e1a 100644 --- a/src/Privacy/Cookie/CoreCookieConsentProvider.php +++ b/src/Privacy/Cookie/CoreCookieConsentProvider.php @@ -15,6 +15,12 @@ public function cookieConsentDefinitions(): array CookieConsentDefinition::necessary(Cookie::create(CookieConsentManager::CONSENT_COOKIE_NAME)), CookieConsentDefinition::necessary(Cookie::create('PHPSESSID')), CookieConsentDefinition::necessary(Cookie::create(VisitorIdGenerator::COOKIE_NAME)), + CookieConsentDefinition::optional( + Cookie::create('_ga'), + 'Google Analytics', + 'Measures visits and interaction patterns to improve the site experience.', + 'https://policies.google.com/privacy', + ), ]; } } diff --git a/templates/components/CookieConsent.html.twig b/templates/components/CookieConsent.html.twig index 9ace3467..1fa10bd4 100644 --- a/templates/components/CookieConsent.html.twig +++ b/templates/components/CookieConsent.html.twig @@ -22,9 +22,9 @@ - + {% endif %} {% if not consent_prompt_required %}
diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index 61348cc6..42b8b126 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -15,8 +15,7 @@ ui: reject_optional: 'Optionale ablehnen' accept_selected: 'Auswahl speichern' close: 'Schließen' - required_title: 'Notwendige Cookies' - required_message: 'Notwendige Cookies halten Sessions, Sicherheitsprüfungen und grundlegende Einstellungen funktionsfähig und können hier nicht deaktiviert werden.' + optional_title: 'Optionale Cookies' no_optional: 'Aktuell sind keine optionalen Cookies registriert.' privacy_link: 'Datenschutzhinweise' navigation: diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 742a922c..a7d727c8 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -15,8 +15,7 @@ ui: reject_optional: 'Reject optional' accept_selected: 'Save selection' close: 'Close' - required_title: 'Required cookies' - required_message: 'Required cookies keep sessions, security checks, and basic preferences working and cannot be disabled here.' + optional_title: 'Optional cookies' no_optional: 'No optional cookies are registered right now.' privacy_link: 'Privacy information' navigation: From 8a132aed153efac55ef2bd2e7b82a17edcf1dbfa Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 03:54:57 +0200 Subject: [PATCH 15/67] Tail large log files efficiently --- dev/WORKLOG.md | 5 +++ src/Core/Log/LogLineReader.php | 52 +++++++++++++++++++------ tests/Core/Log/LogLineReaderTest.php | 58 ++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 tests/Core/Log/LogLineReaderTest.php diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 96e8b29f..1da238ef 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -131,7 +131,12 @@ - Scoped the cookie-consent redirect field to `_cookie_consent_target_path` so auth forms remain the only producers of Symfony's `_target_path`, and rejected protocol-relative consent redirect targets. - Replaced the missing Tabler font cookie glyph with the locally imported Symfony UX Icons `tabler:cookie` SVG in the consent overlay. - Added explicit one-shot live polling actions (`live-poll#poll`/`live-poll#refresh`) on top of `next_poll_ms: 0` manual-mode responses for package-driven interactions such as future captcha refresh flows. +- Fixed repeatable operation runner alerts so direct operation status alerts can reopen a previously closed operation alert ID without weakening closed-alert dedupe for polling-delivered inbox alerts. +- Simplified cookie-consent details to optional-cookie choices only, added a temporary Google Analytics-style optional cookie stub for UI preview, made optional-cookie choices scroll independently, and aligned the save action with the primary button style. +- Reworked the log line reader to tail large log files from the end in bounded byte chunks instead of seeking to `PHP_INT_MAX`, preventing application-log views from timing out on large `var/log/{env}.log` files. - Verification: `php -l src/Privacy/Cookie/CookieConsentManager.php src/Privacy/Cookie/CookieConsentTwigExtension.php src/Controller/CookieConsentController.php tests/Privacy/Cookie/CookieConsentManagerTest.php`; `node --check assets/controllers/cookie_consent_controller.js assets/controllers/live_poll_controller.js assets/js/live/live_poll.js`; `bin/lint assets/controllers/cookie_consent_controller.js assets/controllers/live_poll_controller.js assets/js/live/live_poll.js templates/components/CookieConsent.html.twig src/Privacy/Cookie src/Controller/CookieConsentController.php tests/Privacy/Cookie translations/languages/en/ui.yaml translations/languages/de/ui.yaml`; `php bin/phpunit tests/Privacy/Cookie/CookieConsentManagerTest.php`; `php bin/phpunit tests/Controller/SecurityControllerTest.php --filter testLoginRouteAllowsOnlyLocalReturnTargets`; `php bin/console ux:icons:warm-cache`; Browser check on `/user/profile` confirmed the hidden consent overlay remains in the DOM with its controller, empty-state copy, and rendered UX icon while no console errors were logged. +- Verification: `node --check assets/controllers/alert_stack_controller.js assets/controllers/operation_overlay_controller.js`; focused `bin/lint` for alert/operation controllers, consent template/styles/translations, and cookie provider; `php bin/console tailwind:build`; Browser check on `/user/profile` confirmed primary consent save button styling, alert stack z-index, optional-cookie scroll wrapper, and no console errors. +- Verification: `php bin/phpunit tests/Core/Log/LogLineReaderTest.php tests/Core/Log/LogFileBrowserTest.php`; `bin/lint src/Core/Log/LogLineReader.php tests/Core/Log/LogLineReaderTest.php`; `php bin/console render:route --include-status --role=admin '/admin/logs?source=application&time_window=24h&level=&q=&match=contains&audit_action=&per_page=50&page=1'` returned `HTTP 200`. - 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: 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. 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/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]); + } +} From 931d36c1df0dde031847692fa6d319cd872ccd49 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 04:32:15 +0200 Subject: [PATCH 16/67] Add reusable admin filter foundations --- assets/controllers/filter_form_controller.js | 142 ++++++++++++++++++ assets/styles/system/base.css | 16 ++ templates/backend/admin/logs.html.twig | 2 +- templates/backend/admin/statistics.html.twig | 2 +- .../backend/admin/users/groups.html.twig | 2 +- templates/backend/admin/users/index.html.twig | 2 +- .../backend/admin/users/reviews.html.twig | 2 +- templates/components/ChartPanel.html.twig | 13 ++ templates/components/MapView.html.twig | 10 ++ 9 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 assets/controllers/filter_form_controller.js create mode 100644 templates/components/ChartPanel.html.twig create mode 100644 templates/components/MapView.html.twig diff --git a/assets/controllers/filter_form_controller.js b/assets/controllers/filter_form_controller.js new file mode 100644 index 00000000..bdf6bc69 --- /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 = 'studio.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/styles/system/base.css b/assets/styles/system/base.css index 4b257f6e..ce9dc1f8 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -352,6 +352,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/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 }}

-
+
diff --git a/tests/Controller/LiveEndpointControllerTest.php b/tests/Controller/LiveEndpointControllerTest.php new file mode 100644 index 00000000..9409d95d --- /dev/null +++ b/tests/Controller/LiveEndpointControllerTest.php @@ -0,0 +1,123 @@ +controller($this->endpoint(AccessLevel::ADMIN), $this->user(AccessLevel::USER)); + + $response = $controller->dispatch(Request::create('/api/live/demo-pack/admin-action', Request::METHOD_POST)); + + 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_POST)); + + 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', + allowPublic: true, + 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()); + } + + private function controller(LiveEndpointDefinition $endpoint, ?UserAccount $user): 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($this->once())->method('getUser')->willReturn($user); + + return new LiveEndpointController( + new LiveEndpointRegistry([$provider]), + new LiveEndpointHandlerRegistry([$handler]), + new JsonOutputRenderer(), + $security, + ); + } + + private function endpoint(int $minimumAccessLevel): LiveEndpointDefinition + { + return new LiveEndpointDefinition( + 'package', + Request::METHOD_POST, + '/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), + ); + } +} diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 88a917d3..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 { @@ -185,6 +193,7 @@ private function initialMigrationTables(): array { return [ 'messenger_messages', + 'ui_alert_inbox', 'config_entry', 'package_setting_entry', 'scheduler_task', diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 342abfbc..ed85e35f 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -11,6 +11,7 @@ use App\Privacy\Cookie\CookieConsentRegistry; use App\Privacy\Cookie\CookieConsentTwigExtension; use App\Privacy\Cookie\CoreCookieConsentProvider; +use LogicException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; @@ -65,6 +66,52 @@ public function testItAllowsOptionalCookiesAfterConsentCookieWasStored(): void self::assertFalse($manager->bannerRequired($nextRequest)); } + 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 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(); + } + public function testItReturnsSelectedOptionalNamesFromStoredConsentOrDefaults(): void { $definition = CookieConsentDefinition::optional( @@ -136,7 +183,7 @@ public function testCoreProviderRegistersOnlyNecessaryCookies(): void */ private function manager(iterable $providers = []): CookieConsentManager { - return new CookieConsentManager(new CookieConsentRegistry($providers)); + return new CookieConsentManager(new CookieConsentRegistry($providers), 'test-secret'); } /** From 4d907f1b70732c06bef44966197358a58d2ed9c5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 06:58:37 +0200 Subject: [PATCH 28/67] Address second review hardening findings --- assets/controllers/alert_stack_controller.js | 33 ++++++++---- .../operation_overlay_controller.js | 1 + assets/js/live/live_poll.js | 4 +- config/services.yaml | 4 ++ dev/CLASSMAP.md | 8 +-- dev/WORKLOG.md | 3 +- src/Command/MercureCheckCommand.php | 2 +- src/Command/MercureStartCommand.php | 1 + src/Command/UiAlertInboxCleanupCommand.php | 31 +++++++++++ src/Controller/CookieConsentController.php | 11 ++-- src/Controller/UserController.php | 14 ++--- src/Core/Mercure/MercureRuntime.php | 30 +++++++---- src/Scheduler/CoreSchedulerTaskProvider.php | 7 +++ src/View/Alert/MercureAvailability.php | 1 + src/View/Twig/UiAlertTwigExtension.php | 25 +++++++++ templates/components/AlertStack.html.twig | 1 + templates/components/CookieConsent.html.twig | 8 +-- tests/Core/Mercure/MercureRuntimeTest.php | 52 +++++++++++++++++-- .../Cookie/CookieConsentManagerTest.php | 28 ++++++++++ translations/languages/de/admin.yaml | 3 ++ translations/languages/en/admin.yaml | 3 ++ 21 files changed, 224 insertions(+), 46 deletions(-) create mode 100644 src/Command/UiAlertInboxCleanupCommand.php diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index 08e1c0ae..658cef20 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -13,10 +13,11 @@ export default class extends Controller { static targets = ['alert', 'badge', 'clearAll', 'empty', 'list', 'panel', 'toggle']; static values = { dismissDelay: { type: Number, default: 8000 }, + storageScope: { type: String, default: 'public' }, }; - static memoryAlerts = []; - static memoryClosedAlerts = []; + static memoryAlerts = new Map(); + static memoryClosedAlerts = new Map(); static storageKey = 'system.alerts.active'; static closedStorageKey = 'system.alerts.closed'; @@ -328,20 +329,20 @@ export default class extends Controller { const payloads = [...this.alerts.values()].map((entry) => storableAlertPayload(entry.payload)); try { - window.sessionStorage.setItem(this.constructor.storageKey, JSON.stringify(payloads)); + window.sessionStorage.setItem(this.storageKey, JSON.stringify(payloads)); } catch { - this.constructor.memoryAlerts = payloads; + this.constructor.memoryAlerts.set(this.storageKey, payloads); } } readStoredAlerts() { try { - const raw = window.sessionStorage.getItem(this.constructor.storageKey); + 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; + return this.constructor.memoryAlerts.get(this.storageKey) || []; } } @@ -354,20 +355,20 @@ export default class extends Controller { this.closedAlertIds = new Set(ids); try { - window.sessionStorage.setItem(this.constructor.closedStorageKey, JSON.stringify(ids)); + window.sessionStorage.setItem(this.closedStorageKey, JSON.stringify(ids)); } catch { - this.constructor.memoryClosedAlerts = ids; + this.constructor.memoryClosedAlerts.set(this.closedStorageKey, ids); } } readClosedAlertIds() { try { - const raw = window.sessionStorage.getItem(this.constructor.closedStorageKey); + 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; + return this.constructor.memoryClosedAlerts.get(this.closedStorageKey) || []; } } @@ -381,6 +382,18 @@ export default class extends Controller { 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(); diff --git a/assets/controllers/operation_overlay_controller.js b/assets/controllers/operation_overlay_controller.js index c12114db..5f3df779 100644 --- a/assets/controllers/operation_overlay_controller.js +++ b/assets/controllers/operation_overlay_controller.js @@ -118,6 +118,7 @@ export default class extends Controller { this.polling = true; this.livePoller = new LivePoller({ interval: 750, + invalidJsonMessage: this.label('statusError'), onPayload: (payload, nextCursor) => { this.storeOperation( statusUrl, diff --git a/assets/js/live/live_poll.js b/assets/js/live/live_poll.js index 7a1e1c33..fa71947f 100644 --- a/assets/js/live/live_poll.js +++ b/assets/js/live/live_poll.js @@ -5,12 +5,14 @@ export class LivePoller { onError = () => {}, onDone = () => {}, fetcher = window.fetch.bind(window), + invalidJsonMessage = 'The live endpoint returned an invalid response.', } = {}) { this.interval = Number(interval || 0); this.onPayload = onPayload; this.onError = onError; this.onDone = onDone; this.fetcher = fetcher; + this.invalidJsonMessage = invalidJsonMessage; this.active = false; } @@ -109,7 +111,7 @@ export class LivePoller { const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { - throw new Error('Expected a JSON response from the live endpoint.'); + throw new Error(this.invalidJsonMessage); } return response.json(); diff --git a/config/services.yaml b/config/services.yaml index 3cd38035..af8fea7b 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -151,6 +151,10 @@ services: arguments: $secret: '%kernel.secret%' + App\View\Twig\UiAlertTwigExtension: + arguments: + $secret: '%kernel.secret%' + App\Content\Api\ContentApiPath: ~ App\Core\Package\PackageTranslationNamespaceValidator: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index db7004d6..2da77385 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -58,7 +58,7 @@ | 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, 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, downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, legacy hub Bolt transport at `var/mercure/updates.db`, relaxed publish/hub reachability probes with GET fallback for `2xx`, `400`, and `401` responses plus 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, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.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, downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, legacy hub Bolt transport at `var/mercure/updates.db`, JWT secrets passed through the detached-process environment instead of command arguments, relaxed read-only hub reachability diagnostics for `2xx`, `400`, and `401` responses, strict publish-health probes that require a successful authenticated POST, 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, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.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/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` | Provides a package-extendable cookie consent registry with duplicate-name rejection, central safe cookie get/set gate, response-time removal of registered optional cookies without stored consent, explicit expiration of withdrawn optional cookies, DNT/GPC-aware defaults, stateless HMAC CSRF protection that does not create anonymous sessions, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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/Privacy/Cookie/CookieConsentManagerTest.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` | @@ -299,7 +299,7 @@ | 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\UiAlertPublisherInterface`, `App\View\Alert\MercureUiAlertPublisher` | Defines the stable UI-alert payload with level, mode, actions, loading state, HMAC-bound user/session Mercure topic syntax, 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 for AlertStack components without starting new anonymous sessions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Alert/UiAlertTopicFactoryTest.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 without starting new anonymous sessions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Alert/UiAlertTopicFactoryTest.php` | ## 8. Controllers @@ -338,7 +338,7 @@ | `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, 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 and enables anonymous subscribers for HMAC-bound public alert topics, health retries configured publish endpoint recovery, treats publish GET `2xx`, `400`, and `401` responses as reachable to avoid auth-probe false negatives, requires 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` | +| `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, passes JWT secrets through environment variables, and enables anonymous subscribers for HMAC-bound public alert topics, health retries configured publish endpoint recovery, 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` | | `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` | @@ -365,7 +365,7 @@ | 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 | | 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, `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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | -| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `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, DB-backed user/session topic inbox with setup-completion gating, Mercure health-gated stream/push delivery, 15-second lazy polling fallback, sessionStorage-backed notification center with badge counts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, quiet text actions, presentation modes, optional titles/actions/loading state, and optional EventSource updates. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/Controller/LiveAlertControllerTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, DB-backed user/session topic inbox with setup-completion gating and scheduled expired-row cleanup, Mercure health-gated stream/push delivery, 15-second lazy polling fallback, session-scoped sessionStorage-backed notification center with badge counts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, quiet text actions, presentation modes, optional titles/actions/loading state, and optional EventSource updates. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/Controller/LiveAlertControllerTest.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` | `node --check assets/controllers/filter_form_controller.js` | | 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 3e5f082a..d1def5db 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -103,7 +103,7 @@ - Expanded `mercure:health` output so recovery attempts, public endpoint failures, and automatic hub shutdown are visible instead of being reported as a generic publish failure. - Added `mercure:health` to the setup ActionQueue after database initialization so the Mercure availability config key can be seeded during setup while keeping hub failures non-blocking. - Removed `mercure:health` from the package-aware asset rebuild queue because Mercure availability is runtime infrastructure, not an asset dependency. -- Relaxed Mercure publish and hub reachability probes to accept `2xx`, `400`, and `401` responses, including GET reachability fallback on the configured publish URL, so authenticated or missing-topic probe responses do not create false negatives after fresh local hub rebuilds; public subscribe checks remain tied to a real anonymous `text/event-stream` response. +- Relaxed read-only Mercure hub reachability diagnostics to accept `2xx`, `400`, and `401` responses so authenticated or missing-topic probe responses do not create false negatives after fresh local hub rebuilds; publish health now requires a successful authenticated POST, and public subscribe checks remain tied to a real anonymous `text/event-stream` response. - Gated `/api/live/alerts` behind setup completion so setup pages return an empty polling payload without touching Doctrine or the alert inbox before the database is ready. - Migrated existing controller/admin responder request alerts to the unified alert interface instead of direct `addFlash()` calls. - Routed translated UI keys through the same `addAlert()` dispatcher path via `UiAlertTranslation` instead of a separate translated-alert method. @@ -126,6 +126,7 @@ - Added smooth notification-center panel open/close transitions with outside-click and Escape hide behavior while keeping active alerts available behind the bell. - Removed the temporary analytics-style optional-cookie preview definition from the core consent provider so only packages or explicit providers can make optional consent choices appear. - Adjusted synchronous backend action alert selection so successful workflows flash the first success-level message instead of an incidental debug message, keeping admin feedback aligned with result status. +- Addressed second-round review findings by making cookie consent rejection server-visible without JavaScript, keeping profile pages on cached Mercure availability only, preserving native-notification preferences while the toggle is hidden, passing local Mercure JWT secrets through process environment variables instead of argv, requiring authenticated publish POST success for Mercure health, scheduling expired UI-alert inbox cleanup, routing live-poll invalid-response text through existing translated operation labels, and scoping notification-center storage by user/session/surface. - Repaired the demo frontend theme CSS namespace and aligned package CSS syntax validation with `bin/lint` Tailwind directive tolerance so demo packages can leave `faulty` state after a lifecycle reset; verified the demo module public routes render after activation. - Removed locally generated `public/assets` and clarified that `asset-map:compile` is production/release-only rather than a local verification command. - Updated `bin/lint --diff` focused CSS handling so known Tailwind directives are informational parser skips while `tailwind:build` remains the authoritative CSS validation step. diff --git a/src/Command/MercureCheckCommand.php b/src/Command/MercureCheckCommand.php index 648bf3db..0a240f11 100644 --- a/src/Command/MercureCheckCommand.php +++ b/src/Command/MercureCheckCommand.php @@ -34,7 +34,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ['Listen address' => $this->runtime->listenAddress()], ['Hub endpoint' => $this->runtime->hubReachable() ? 'reachable' : 'not reachable'], ['Publish endpoint' => $this->runtime->publishHubUrl()], - ['Publish endpoint status' => $this->runtime->publishHealthProbe() ? 'reachable' : 'not reachable'], + ['Publish endpoint status' => $this->runtime->publishHealthProbe() ? 'functional' : 'not functional'], ['Public endpoint' => $this->runtime->publicHubUrl()], ['Public endpoint status' => $this->runtime->publicSubscribeProbe() ? 'reachable' : 'not reachable'], ); diff --git a/src/Command/MercureStartCommand.php b/src/Command/MercureStartCommand.php index ff2d1801..c0d9aa16 100644 --- a/src/Command/MercureStartCommand.php +++ b/src/Command/MercureStartCommand.php @@ -38,6 +38,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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.'); diff --git a/src/Command/UiAlertInboxCleanupCommand.php b/src/Command/UiAlertInboxCleanupCommand.php new file mode 100644 index 00000000..bec788f8 --- /dev/null +++ b/src/Command/UiAlertInboxCleanupCommand.php @@ -0,0 +1,31 @@ +inbox->cleanupExpired(); + $output->writeln(sprintf('UI alert inbox cleanup removed %d expired row(s).', $removed)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/CookieConsentController.php b/src/Controller/CookieConsentController.php index 3f4aa459..dafbfde1 100644 --- a/src/Controller/CookieConsentController.php +++ b/src/Controller/CookieConsentController.php @@ -24,10 +24,13 @@ public function store(Request $request): Response return $this->redirectBack($request); } - $accepted = $request->request->all('cookies'); - $accepted = is_array($accepted) - ? array_values(array_filter(array_map('strval', $accepted), 'strlen')) - : []; + $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); diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 4fee0b7e..9880a89e 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -76,7 +76,7 @@ public function profile(Request $request): Response $success = false; $errors = []; $usernameChangeEnabled = $this->userFlowConfig->usernameChangeEnabled(); - $nativeNotificationsAvailable = $this->mercureAvailability->available(refreshIfStale: true); + $nativeNotificationsAvailable = $this->mercureAvailability->available(); if ($request->isMethod('POST')) { if (!$this->isCsrfTokenValid('user_profile', $this->stringField($request, '_csrf_token'))) { @@ -134,14 +134,16 @@ public function profile(Request $request): Response } if ([] === $errors) { + $settings = $user->settings(); + $settings['language'] = $language; + if ($nativeNotificationsAvailable) { + $settings['native_notifications'] = '1' === $this->stringField($request, 'native_notifications'); + } + $user->updateProfile([ 'display_name' => $this->stringField($request, 'display_name'), ]); - $user->updateSettings([ - ...$user->settings(), - 'language' => $language, - 'native_notifications' => $nativeNotificationsAvailable && '1' === $this->stringField($request, 'native_notifications'), - ]); + $user->updateSettings($settings); try { $this->stateMarkers->record(StateSubjectType::USER_ACCOUNT, $user->uid(), StateMarkerKey::MODIFIED, $user->username(), 'profile'); $this->entityManager->flush(); diff --git a/src/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php index 27e904df..66c76727 100644 --- a/src/Core/Mercure/MercureRuntime.php +++ b/src/Core/Mercure/MercureRuntime.php @@ -29,16 +29,10 @@ public function __construct( */ public function startCommand(): array { - $jwtSecret = $this->jwtSecret(); - return [ $this->binaryManager->binaryPath(), '--addr', $this->listenAddress(), - '--publisher-jwt-key', - $jwtSecret, - '--subscriber-jwt-key', - $jwtSecret, '--allow-anonymous', '--cors-allowed-origins', '*', @@ -47,6 +41,19 @@ public function startCommand(): array ]; } + /** + * @return array + */ + public function startEnvironment(): array + { + $jwtSecret = $this->jwtSecret(); + + return [ + 'MERCURE_PUBLISHER_JWT_KEY' => $jwtSecret, + 'MERCURE_SUBSCRIBER_JWT_KEY' => $jwtSecret, + ]; + } + public function logPath(): string { return $this->projectDir.'/var/log/mercure.log'; @@ -158,9 +165,7 @@ public function stop(): bool public function publishHealthProbe(): bool { - $url = $this->publishHubUrl(); - - return $this->hubEndpointProbe($url) || $this->publishDirectly($url); + return $this->publishDirectly($this->publishHubUrl()); } public function publicSubscribeProbe(): bool @@ -238,7 +243,7 @@ private function publishDirectly(string $url): bool ]), ]); - return self::probeStatusAccepted($response->getStatusCode()); + return self::publishStatusAccepted($response->getStatusCode()); } catch (Throwable) { return false; } @@ -260,6 +265,11 @@ 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 { diff --git a/src/Scheduler/CoreSchedulerTaskProvider.php b/src/Scheduler/CoreSchedulerTaskProvider.php index 7169a62a..6ee09079 100644 --- a/src/Scheduler/CoreSchedulerTaskProvider.php +++ b/src/Scheduler/CoreSchedulerTaskProvider.php @@ -19,6 +19,13 @@ public function schedulerTasks(): array 'operations:cleanup', '*/15 * * * *', ), + SchedulerTaskDefinition::command( + 'system.ui_alert_inbox_cleanup', + 'admin.scheduler.tasks.ui_alert_inbox_cleanup.label', + 'admin.scheduler.tasks.ui_alert_inbox_cleanup.description', + 'ui-alerts:cleanup-inbox', + '23 * * * *', + ), SchedulerTaskDefinition::command( 'system.package_discovery', 'admin.scheduler.tasks.package_discovery.label', diff --git a/src/View/Alert/MercureAvailability.php b/src/View/Alert/MercureAvailability.php index 9067f547..c506b9db 100644 --- a/src/View/Alert/MercureAvailability.php +++ b/src/View/Alert/MercureAvailability.php @@ -141,6 +141,7 @@ private function startHub(): bool $this->projectDir, $this->runtime->logPath(), $this->runtime->pidPath(), + $this->runtime->startEnvironment(), ); } catch (Throwable) { return false; diff --git a/src/View/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php index e4f5e1a5..b9982811 100644 --- a/src/View/Twig/UiAlertTwigExtension.php +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -8,6 +8,7 @@ use App\View\Alert\MercureAvailability; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Mercure\Twig\MercureExtension; use Symfony\Component\Security\Core\User\UserInterface; use Throwable; @@ -21,6 +22,7 @@ public function __construct( private readonly Security $security, private readonly UiAlertTopicFactory $topicFactory, private readonly MercureAvailability $mercureAvailability, + private readonly string $secret, private readonly ?MercureExtension $mercure = null, ) { } @@ -30,6 +32,7 @@ public function getFunctions(): array return [ new TwigFunction('ui_alert_stream_topics', $this->streamTopics(...)), new TwigFunction('ui_alert_stream_url', $this->streamUrl(...)), + new TwigFunction('ui_alert_storage_scope', $this->storageScope(...)), ]; } @@ -63,4 +66,26 @@ public function streamUrl(?array $topics = null): ?string 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(); + } + } catch (Throwable) { + $sessionScope = 'no-session'; + } + } + + return $surface.'.'.substr(hash_hmac('sha256', $surface.'|'.$userScope.'|'.$sessionScope, $this->secret), 0, 32); + } } diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 9afcfde6..42ec1409 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -7,6 +7,7 @@ 'data-controller': 'alert-stack native-notifications', 'data-action': 'ui-alert:received->alert-stack#append', 'data-alert-stack-dismiss-delay-value': dismiss_delay, + 'data-alert-stack-storage-scope-value': ui_alert_storage_scope(), 'data-alert-close-label': 'ui.alert.close'|trans, 'data-alert-notifications-label': 'ui.alert.notifications'|trans, 'data-native-notifications-enabled-value': app.user and app.user.settings.native_notifications|default(false) ? 'true' : 'false', diff --git a/templates/components/CookieConsent.html.twig b/templates/components/CookieConsent.html.twig index 53db580c..ee5ebe87 100644 --- a/templates/components/CookieConsent.html.twig +++ b/templates/components/CookieConsent.html.twig @@ -21,12 +21,12 @@ - - + {% endif %} {% if not consent_prompt_required %} -
-
-
-
- {{ 'ui.alert.notifications'|trans }} -
-
- - -
-
-
- {% for alert in alerts %} - - {% endfor %} -
-
- - {{ 'ui.alert.empty_title'|trans }} - {{ 'ui.alert.empty_message'|trans }} -
-
-
- +
+
+
+
+ {{ 'ui.alert.notifications'|trans }} +
+
+ + +
+
+
+ {% for alert in alerts %} + + {% endfor %} +
+
+ + {{ 'ui.alert.empty_title'|trans }} + {{ 'ui.alert.empty_message'|trans }} +
+
+
+ diff --git a/tests/Core/Mercure/MercureRuntimeTest.php b/tests/Core/Mercure/MercureRuntimeTest.php index 8107498e..7b920ace 100644 --- a/tests/Core/Mercure/MercureRuntimeTest.php +++ b/tests/Core/Mercure/MercureRuntimeTest.php @@ -115,6 +115,24 @@ public function testItAcceptsReachabilityProbeStatusCodes(): void } } + 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 testPublishHealthProbeRequiresSuccessfulPublishResponse(): void { foreach ([200, 201, 204] as $status) { @@ -209,4 +227,44 @@ private function removeDirectory(string $path): void @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/View/Alert/UiAlertInboxTest.php b/tests/View/Alert/UiAlertInboxTest.php new file mode 100644 index 00000000..d185f40f --- /dev/null +++ b/tests/View/Alert/UiAlertInboxTest.php @@ -0,0 +1,42 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE ui_alert_inbox (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, topic VARCHAR(255) 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, + ]], + ], $inbox->poll(['topic.one'])); + } + + 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/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index 54acd5d8..aab1aa14 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -117,6 +117,94 @@ test('alert stack stores new alerts, deduplicates updates, and closes all active assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.closedStorageKey)), ['alert-1']); }); +test('alert stack auto-dismiss removes transient alerts without closing future duplicates', () => { + 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.equal(sessionStorage.getItem(controller.closedStorageKey), null); + assert.deepEqual(closed, []); + + controller.upsertAlert({ id: 'auto-alert', level: 'success', message: 'Saved again', mode: 'auto' }); + + assert.equal(controller.activeCount, 1); + assert.equal(list.children.length, 1); +}); + +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('UI alert stream opens EventSource with credentials and forwards valid alert events', () => { installDom(); From 7c918cb4cc29be665aa08862f3e10fa5f3876b3d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 17:46:02 +0200 Subject: [PATCH 46/67] Normalize configured Mercure hub URLs --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Core/Mercure/MercureRuntime.php | 13 +++++++++++-- tests/Core/Mercure/MercureRuntimeTest.php | 20 ++++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 7c81a81c..f3761fc2 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -58,7 +58,7 @@ | 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, 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, 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 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/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, 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/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, central safe cookie get/set gate, response-time removal of registered optional cookies without stored consent, explicit expiration of withdrawn optional cookies, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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` | | 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 364d9620..86587d6b 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -104,6 +104,7 @@ - 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 and native browser notification preferences by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions and clearing unsupported or denied native-notification opt-ins before profile form submission. - 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. - 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, native notifications, alert stack behavior, alert polling, and Mercure stream reconnect handling. - 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. diff --git a/src/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php index 2afd262c..a129217d 100644 --- a/src/Core/Mercure/MercureRuntime.php +++ b/src/Core/Mercure/MercureRuntime.php @@ -225,14 +225,23 @@ public function publishHubUrl(): string { $url = trim((string) ($_SERVER['MERCURE_URL'] ?? $_ENV['MERCURE_URL'] ?? getenv('MERCURE_URL') ?: '')); - return '' !== $url ? $url : $this->localHubUrl(); + 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 ? $url : $this->localHubUrl(); + 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 diff --git a/tests/Core/Mercure/MercureRuntimeTest.php b/tests/Core/Mercure/MercureRuntimeTest.php index 7b920ace..8439a28c 100644 --- a/tests/Core/Mercure/MercureRuntimeTest.php +++ b/tests/Core/Mercure/MercureRuntimeTest.php @@ -133,6 +133,26 @@ public function testItNormalizesColonOnlyListenAddressForLocalHubUrls(): void } } + 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) { From 7b60282a6ab59c44c4263cff67343878d0f012cd Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 18:44:52 +0200 Subject: [PATCH 47/67] Harden consent cookies and alert dispatch --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + src/Privacy/Cookie/ConsentCookieJar.php | 14 +- src/Privacy/Cookie/CookieConsentManager.php | 4 +- .../CookieConsentResponseSubscriber.php | 4 + src/View/Alert/UiAlertDispatcher.php | 8 +- .../Cookie/CookieConsentManagerTest.php | 133 ++++++++++++++++++ tests/View/Alert/UiAlertDispatcherTest.php | 119 ++++++++++++++++ .../View/Twig/TwigComponentNamespaceTest.php | 26 ++++ 9 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 tests/View/Alert/UiAlertDispatcherTest.php create mode 100644 tests/View/Twig/TwigComponentNamespaceTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index f3761fc2..0ed4b1c2 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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, 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/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, central safe cookie get/set gate, response-time removal of registered optional cookies without stored consent, explicit expiration of withdrawn optional cookies, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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` | +| 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, central safe cookie get/set gate with registered cookie identity enforcement, 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, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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` | | 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` | @@ -359,7 +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` | `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` | +| 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` | @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, DB-backed user/session topic inbox with setup-completion gating, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery, 15-second lazy polling fallback that includes existing session-cookie topics and retries transient failures, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts without closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, user-initiated native-notification opt-in with denied/unsupported permission blocking, and optional EventSource updates. | `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/UiAlertInboxTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, DB-backed user/session topic inbox with setup-completion gating, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery, 15-second lazy polling fallback that includes existing session-cookie topics and retries transient failures, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts without closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, user-initiated native-notification opt-in with denied/unsupported permission blocking, and optional EventSource updates. | `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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 86587d6b..e44b72b1 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -105,6 +105,7 @@ - Hardened queued alert fallback and native browser notification preferences by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions and clearing unsupported or denied native-notification opt-ins before profile form submission. - 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. - 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, native notifications, alert stack behavior, alert polling, and Mercure stream reconnect handling. - 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. diff --git a/src/Privacy/Cookie/ConsentCookieJar.php b/src/Privacy/Cookie/ConsentCookieJar.php index e881b500..106ae21e 100644 --- a/src/Privacy/Cookie/ConsentCookieJar.php +++ b/src/Privacy/Cookie/ConsentCookieJar.php @@ -32,8 +32,20 @@ public function set(Request $request, Response $response, CookieConsentDefinitio return false; } - $response->headers->setCookie($cookie ?? $definition->cookie()); + $cookie ??= $definition->cookie(); + if (!$this->sameCookieIdentity($definition->cookie(), $cookie)) { + return false; + } + + $response->headers->setCookie($cookie); return true; } + + private function sameCookieIdentity(Cookie $expected, Cookie $actual): bool + { + return $expected->getName() === $actual->getName() + && $expected->getPath() === $actual->getPath() + && $expected->getDomain() === $actual->getDomain(); + } } diff --git a/src/Privacy/Cookie/CookieConsentManager.php b/src/Privacy/Cookie/CookieConsentManager.php index 56528de5..89d727ce 100644 --- a/src/Privacy/Cookie/CookieConsentManager.php +++ b/src/Privacy/Cookie/CookieConsentManager.php @@ -69,7 +69,7 @@ public function attachConsentCookie(Request $request, Response $response, array $this->registry->optionalDefinitions(), ); $accepted = array_values(array_intersect($allowedNames, array_unique($acceptedOptionalNames))); - $withdrawn = array_values(array_diff($this->acceptedOptionalNames($request), $accepted)); + $rejected = array_values(array_diff($allowedNames, $accepted)); $response->headers->setCookie(Cookie::create( self::CONSENT_COOKIE_NAME, @@ -88,7 +88,7 @@ public function attachConsentCookie(Request $request, Response $response, array )); foreach ($this->registry->optionalDefinitions() as $definition) { - if (!in_array($definition->name(), $withdrawn, true)) { + if (!in_array($definition->name(), $rejected, true)) { continue; } diff --git a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php index 2276972d..cf7d74fb 100644 --- a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php +++ b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php @@ -29,6 +29,10 @@ public function filterCookies(ResponseEvent $event): void $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 || $this->consent->allowed($request, $definition)) { continue; diff --git a/src/View/Alert/UiAlertDispatcher.php b/src/View/Alert/UiAlertDispatcher.php index 1924cd74..e992f193 100644 --- a/src/View/Alert/UiAlertDispatcher.php +++ b/src/View/Alert/UiAlertDispatcher.php @@ -75,13 +75,7 @@ public function addAlertToTopic( $flashed = $this->flasher->flash($uiAlert); } - if ($options->pushes()) { - try { - $pushed = null !== $this->publisher->publish($topic, $uiAlert, $options->locale(), $options->private()); - } catch (Throwable) { - $pushed = false; - } - } + $pushed = $options->pushes() && $this->pushTopics([$topic], $uiAlert, $options); return $queued || $pushed || $flashed; } diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 507db668..ce65339f 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -11,6 +11,7 @@ use App\Privacy\Cookie\CookieConsentManager; use App\Privacy\Cookie\CookieConsentProviderInterface; use App\Privacy\Cookie\CookieConsentRegistry; +use App\Privacy\Cookie\CookieConsentResponseSubscriber; use App\Privacy\Cookie\CookieConsentTwigExtension; use App\Privacy\Cookie\CoreCookieConsentProvider; use App\Tests\Support\FilesystemTestHelper; @@ -21,6 +22,8 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; final class CookieConsentManagerTest extends TestCase { @@ -114,6 +117,88 @@ public function testItExpiresWithdrawnOptionalCookies(): void 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 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 testItRejectsDuplicateCookieDefinitions(): void { $registry = new CookieConsentRegistry([ @@ -220,6 +305,46 @@ public function testConsentCookieJarBlocksOptionalCookiesWithoutConsent(): void 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'), + ] 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()); @@ -270,3 +395,11 @@ public function cookieConsentDefinitions(): array }; } } + +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/View/Alert/UiAlertDispatcherTest.php b/tests/View/Alert/UiAlertDispatcherTest.php new file mode 100644 index 00000000..813fbbf7 --- /dev/null +++ b/tests/View/Alert/UiAlertDispatcherTest.php @@ -0,0 +1,119 @@ + '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(255) 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('https://studio.example.test', '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( + 'https://studio.example.test/ui-alerts/user/test', + UiAlert::fromLevel('success', 'Queued alert'), + UiAlertDelivery::Queue, + )); + + self::assertSame([], $publisher->publishedTopics); + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); + } +} + +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/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php new file mode 100644 index 00000000..b84f42d1 --- /dev/null +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -0,0 +1,26 @@ +get(Environment::class); + + self::assertStringContainsString( + 'system-alert-stack', + $twig->createTemplate('')->render(), + ); + self::assertStringContainsString( + 'system-cookie-consent', + $twig->createTemplate('')->render(), + ); + } +} From f6188af48e54848d475e886ba927c945d2efe1e4 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 19:06:49 +0200 Subject: [PATCH 48/67] Harden Mercure downloads and consent cookies --- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + src/Core/Mercure/MercureBinaryManager.php | 46 ++++++++++- src/Privacy/Cookie/ConsentCookieJar.php | 5 +- src/Privacy/Cookie/CookieConsentManager.php | 73 +++++++++++++++-- tests/Core/Mercure/MercureRuntimeTest.php | 30 +++++++ .../Cookie/CookieConsentManagerTest.php | 80 +++++++++++++++++++ 7 files changed, 229 insertions(+), 12 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 0ed4b1c2..8db0778a 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -58,9 +58,9 @@ | 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, 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, 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/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/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, central safe cookie get/set gate with registered cookie identity enforcement, 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, long-lived consent-cookie persistence, selected optional-cookie state for later edits, 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` | +| 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, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, 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 consent-cookie persistence, selected optional-cookie state for later edits, 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` | | 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` | @@ -325,7 +325,7 @@ | 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 long-lived required cookie, expires optional cookies whose consent was withdrawn, and redirects back to the originating path. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Privacy/Cookie/CookieConsentManagerTest.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 `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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e44b72b1..d899077c 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -106,6 +106,7 @@ - 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, native notifications, alert stack behavior, alert polling, and Mercure stream reconnect handling. - 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. diff --git a/src/Core/Mercure/MercureBinaryManager.php b/src/Core/Mercure/MercureBinaryManager.php index aaa8bfe7..c560c752 100644 --- a/src/Core/Mercure/MercureBinaryManager.php +++ b/src/Core/Mercure/MercureBinaryManager.php @@ -7,16 +7,33 @@ use RuntimeException; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Process\Process; +use Symfony\Contracts\HttpClient\HttpClientInterface; use Throwable; use ZipArchive; final readonly class MercureBinaryManager { public const DEFAULT_VERSION = '0.24.2'; + private const SHA256_BY_VERSION_AND_ASSET = [ + '0.24.2' => [ + '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, ) { } @@ -47,7 +64,8 @@ public function install(): bool } $asset = $this->assetName(); - if (null === $asset) { + $checksum = null === $asset ? null : $this->assetChecksum($asset); + if (null === $asset || null === $checksum) { return false; } @@ -57,11 +75,18 @@ public function install(): bool $this->ensureDirectory($this->cacheDir()); $this->ensureDirectory($this->installDir()); - if (!is_file($archivePath)) { - $response = HttpClient::create(['timeout' => 30])->request('GET', $this->downloadUrl($asset)); + 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)) { @@ -141,6 +166,21 @@ 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); diff --git a/src/Privacy/Cookie/ConsentCookieJar.php b/src/Privacy/Cookie/ConsentCookieJar.php index 106ae21e..4013641d 100644 --- a/src/Privacy/Cookie/ConsentCookieJar.php +++ b/src/Privacy/Cookie/ConsentCookieJar.php @@ -46,6 +46,9 @@ private function sameCookieIdentity(Cookie $expected, Cookie $actual): bool { return $expected->getName() === $actual->getName() && $expected->getPath() === $actual->getPath() - && $expected->getDomain() === $actual->getDomain(); + && $expected->getDomain() === $actual->getDomain() + && $expected->isSecure() === $actual->isSecure() + && $expected->isHttpOnly() === $actual->isHttpOnly() + && $expected->getSameSite() === $actual->getSameSite(); } } diff --git a/src/Privacy/Cookie/CookieConsentManager.php b/src/Privacy/Cookie/CookieConsentManager.php index 89d727ce..f6b8b64a 100644 --- a/src/Privacy/Cookie/CookieConsentManager.php +++ b/src/Privacy/Cookie/CookieConsentManager.php @@ -13,6 +13,7 @@ { public const CONSENT_COOKIE_NAME = 'studio_cookie_consent'; private const TTL_SECONDS = 31_536_000; + private const CLOCK_SKEW_SECONDS = 300; public function __construct( private CookieConsentRegistry $registry, @@ -73,7 +74,7 @@ public function attachConsentCookie(Request $request, Response $response, array $response->headers->setCookie(Cookie::create( self::CONSENT_COOKIE_NAME, - $this->encode([ + $this->encodeConsent([ 'accepted' => $accepted, 'created_at' => time(), 'version' => 1, @@ -157,16 +158,78 @@ private function storedConsent(Request $request): ?array return null; } - $decoded = json_decode(base64_decode($value, true) ?: '', true); + $payload = $this->decodeConsent($value); + if (null === $payload) { + return null; + } + + $createdAt = $payload['created_at'] ?? null; + if (($payload['version'] ?? null) !== 1 || !is_int($createdAt)) { + return null; + } - return is_array($decoded) ? $decoded : null; + $now = time(); + if ($createdAt > $now + self::CLOCK_SKEW_SECONDS || $createdAt < $now - self::TTL_SECONDS) { + return null; + } + + return $payload; } /** * @param array $payload */ - private function encode(array $payload): string + private function encodeConsent(array $payload): string { - return base64_encode(json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + $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/tests/Core/Mercure/MercureRuntimeTest.php b/tests/Core/Mercure/MercureRuntimeTest.php index 8439a28c..e3cc1f4f 100644 --- a/tests/Core/Mercure/MercureRuntimeTest.php +++ b/tests/Core/Mercure/MercureRuntimeTest.php @@ -102,6 +102,36 @@ public function testItMapsSupportedHostPlatformsToReleaseAssetNames(): void 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 testItAcceptsReachabilityProbeStatusCodes(): void { $method = new ReflectionMethod(MercureRuntime::class, 'probeStatusAccepted'); diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index ce65339f..77827f22 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -87,6 +87,49 @@ public function testItAllowsOptionalCookiesAfterConsentCookieWasStored(): void 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( @@ -271,6 +314,30 @@ public function testCookieConsentRejectActionIgnoresPostedOptionalCookies(): voi 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', '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 testCookieConsentCsrfTokenIsVisitorBound(): void { $manager = $this->manager(); @@ -328,6 +395,9 @@ public function testConsentCookieJarRejectsCustomCookiesWithDifferentIdentity(): 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(); @@ -394,6 +464,16 @@ public function cookieConsentDefinitions(): array } }; } + + /** + * @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 From ab407f06e058e0857ad0b7f276f46fa4775bf83f Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 20:50:43 +0200 Subject: [PATCH 49/67] Harden Mercure alert delivery edges --- assets/controllers/alert_stack_controller.js | 2 +- .../controllers/ui_alert_stream_controller.js | 36 ++++++++ dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + src/Core/Mercure/MercureRuntime.php | 11 ++- .../PackageRuntimeContributionRegistry.php | 52 +++++++++++- src/Security/AppSecretRotationGuard.php | 12 +++ src/View/Alert/MercureUiAlertPublisher.php | 10 ++- templates/components/AlertStack.html.twig | 3 +- tests/Core/Mercure/MercureRuntimeTest.php | 31 +++++++ .../Package/PackageLifecycleBoundaryTest.php | 85 +++++++++++++++++++ tests/Security/AppSecretRotationGuardTest.php | 21 +++++ .../Alert/MercureUiAlertPublisherTest.php | 11 +++ .../View/Twig/TwigComponentNamespaceTest.php | 42 +++++++++ tests/assets/live_alert_controllers.test.mjs | 74 +++++++++++++++- 15 files changed, 383 insertions(+), 14 deletions(-) diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index a30c6bed..709bd47a 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -285,7 +285,7 @@ export default class extends Controller { continue; } - changed = this.removeAlertById(id, false, false) || changed; + changed = this.removeAlertById(id, true, false) || changed; } if (!changed) { diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js index 6ae199c4..0e027431 100644 --- a/assets/controllers/ui_alert_stream_controller.js +++ b/assets/controllers/ui_alert_stream_controller.js @@ -6,6 +6,8 @@ export default class extends Controller { static values = { url: String, + catchUpUrl: String, + catchUpCursor: { type: Number, default: 0 }, credentials: { type: Boolean, default: false }, }; @@ -43,6 +45,7 @@ export default class extends Controller { open = () => { this.reconnectAttempts = 0; + this.catchUp(); }; error = () => { @@ -75,6 +78,39 @@ export default class extends Controller { } }; + async catchUp() { + if (!this.hasCatchUpUrlValue || !this.catchUpUrlValue || typeof window.fetch !== 'function') { + return; + } + + try { + const url = new URL(this.catchUpUrlValue, window.location.origin); + url.searchParams.set('cursor', String(Math.max(0, this.catchUpCursorValue || 0))); + const response = await window.fetch(url.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return; + } + + const payload = await response.json(); + const cursor = Number(payload.cursor); + if (Number.isFinite(cursor)) { + this.catchUpCursorValue = Math.max(0, this.catchUpCursorValue || 0, cursor); + } + + for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { + this.element.dispatchEvent(new CustomEvent('ui-alert:received', { + bubbles: true, + detail: alert, + })); + } + } catch { + // Stream delivery remains active; the next open/reconnect can catch up again. + } + } + scheduleReconnect(delay = null) { if (!this.shouldReconnect || this.reconnectTimer) { return; diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 8db0778a..3d028852 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -92,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` | 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. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/AppSecretRotationGuardTest.php`, `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` | @@ -175,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/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 and live endpoint namespaces, 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/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` | @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, DB-backed user/session topic inbox with setup-completion gating, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery, 15-second lazy polling fallback that includes existing session-cookie topics and retries transient failures, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts without closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, user-initiated native-notification opt-in with denied/unsupported permission blocking, and optional EventSource updates. | `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/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, DB-backed user/session topic inbox with setup-completion gating, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated native-notification opt-in with denied/unsupported permission blocking. | `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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index d899077c..acc141a7 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -109,6 +109,7 @@ - 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, native notifications, 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. - 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: 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. diff --git a/src/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php index a129217d..377d1e5e 100644 --- a/src/Core/Mercure/MercureRuntime.php +++ b/src/Core/Mercure/MercureRuntime.php @@ -86,14 +86,14 @@ public function processId(): ?int { $pid = $this->pid(); - return null !== $pid && $this->isProcessRunning($pid) ? $pid : null; + return null !== $pid && $this->pidBelongsToBinary($pid) ? $pid : null; } public function isRunning(): bool { $pid = $this->pid(); - if (null !== $pid && $this->isProcessRunning($pid)) { + if (null !== $pid && $this->pidBelongsToBinary($pid)) { return true; } @@ -127,7 +127,7 @@ public function stop(): bool return [] === $this->binaryProcessIds(); } - if (!$this->isProcessRunning($pid)) { + if (!$this->pidBelongsToBinary($pid)) { if ($this->terminateBinaryProcesses()) { $this->removePidFile(); @@ -440,6 +440,11 @@ private function isProcessRunning(int $pid): bool return false; } + private function pidBelongsToBinary(int $pid): bool + { + return in_array($pid, $this->binaryProcessIds(), true); + } + private function terminate(int $pid): bool { try { diff --git a/src/Core/Package/PackageRuntimeContributionRegistry.php b/src/Core/Package/PackageRuntimeContributionRegistry.php index 53502eb4..168c816a 100644 --- a/src/Core/Package/PackageRuntimeContributionRegistry.php +++ b/src/Core/Package/PackageRuntimeContributionRegistry.php @@ -29,6 +29,7 @@ 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, LiveEndpointProviderInterface, LiveEndpointHandlerProviderInterface, CookieConsentProviderInterface, SchedulerTaskProviderInterface, SchedulerCallableProviderInterface, SchedulerActionQueueProviderInterface { @@ -128,7 +129,7 @@ private function addToRegistry(ExtensionPackage $package, mixed $contribution): } if ($contribution instanceof CookieConsentDefinition) { - $this->cookieConsentDefinitions[] = $contribution; + $this->addCookieConsentDefinition($package, $contribution); return; } @@ -295,6 +296,55 @@ private function addLiveEndpointHandler(ExtensionPackage $package, LiveEndpointH $this->liveEndpointHandlers[] = $handler; } + private function addCookieConsentDefinition(ExtensionPackage $package, CookieConsentDefinition $definition): void + { + 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; + } + + 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; diff --git a/src/Security/AppSecretRotationGuard.php b/src/Security/AppSecretRotationGuard.php index 33f78595..0ff3f6c7 100644 --- a/src/Security/AppSecretRotationGuard.php +++ b/src/Security/AppSecretRotationGuard.php @@ -94,12 +94,16 @@ public function handle(): void } $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'], @@ -149,6 +153,14 @@ private function stopMercureBeforeSecretRotation(): bool } } + private function markMercureUnavailableAfterFailedStop(): void + { + try { + $this->config->set(MercureAvailability::AVAILABLE_KEY, false, ConfigValueType::Boolean); + } catch (Throwable) { + } + } + private function refreshMercureAfterSecretRotation(): void { try { diff --git a/src/View/Alert/MercureUiAlertPublisher.php b/src/View/Alert/MercureUiAlertPublisher.php index 5bca3fde..3df95344 100644 --- a/src/View/Alert/MercureUiAlertPublisher.php +++ b/src/View/Alert/MercureUiAlertPublisher.php @@ -31,7 +31,15 @@ public function publish(string $topic, UiAlert|Message|UiAlertTranslation $alert return null; } - return $this->hub->publish(new Update($topic, $data, private: $private, type: 'ui-alert')); + $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 diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 2a876828..45cfdcd4 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -1,7 +1,7 @@ {% props alerts = [], dismiss_delay = 8000, stream_topics = null %} {% set stream_topics = stream_topics is same as(null) ? ui_alert_stream_topics() : stream_topics %} {% set stream_url = ui_alert_stream_url(stream_topics) %} -{% set poll_url = stream_topics is not empty ? path('api_live_alerts') : null %} +{% set poll_url = stream_url ? null : (stream_topics is not empty ? path('api_live_alerts') : null) %} {% set stack_attributes = { class: 'system-alert-stack', 'data-controller': 'alert-stack native-notifications', @@ -17,6 +17,7 @@ {% set stack_attributes = stack_attributes|merge({ 'data-controller': stack_attributes['data-controller'] ~ ' ui-alert-stream', 'data-ui-alert-stream-url-value': stream_url, + 'data-ui-alert-stream-catch-up-url-value': stream_topics is not empty ? path('api_live_alerts') : null, }) %} {% endif %} {% if poll_url %} diff --git a/tests/Core/Mercure/MercureRuntimeTest.php b/tests/Core/Mercure/MercureRuntimeTest.php index e3cc1f4f..6d4540c9 100644 --- a/tests/Core/Mercure/MercureRuntimeTest.php +++ b/tests/Core/Mercure/MercureRuntimeTest.php @@ -14,6 +14,7 @@ use Symfony\Component\Mercure\Jwt\StaticTokenProvider; use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; use Symfony\Component\Mercure\Update; +use Symfony\Component\Process\Process; final class MercureRuntimeTest extends TestCase { @@ -132,6 +133,36 @@ public function testItRejectsDownloadedArchivesWithUnexpectedChecksum(): void } } + 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'); diff --git a/tests/Core/Package/PackageLifecycleBoundaryTest.php b/tests/Core/Package/PackageLifecycleBoundaryTest.php index 15494ef3..0709f962 100644 --- a/tests/Core/Package/PackageLifecycleBoundaryTest.php +++ b/tests/Core/Package/PackageLifecycleBoundaryTest.php @@ -299,6 +299,91 @@ 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 testPackagePhpLoaderRejectsElevatedSchedulerContributions(): void { $this->insertPackage('scheduler-module', ['module'], 'active'); diff --git a/tests/Security/AppSecretRotationGuardTest.php b/tests/Security/AppSecretRotationGuardTest.php index 8056dff7..fd856e39 100644 --- a/tests/Security/AppSecretRotationGuardTest.php +++ b/tests/Security/AppSecretRotationGuardTest.php @@ -4,8 +4,12 @@ namespace App\Tests\Security; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Security\AppSecretRotationGuard; use App\Setup\SetupInputValidator; +use App\View\Alert\MercureAvailability; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use ReflectionClass; use RuntimeException; @@ -25,6 +29,23 @@ public function testItRejectsUnsupportedShortAppSecretBeforeRecovery(): void $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); diff --git a/tests/View/Alert/MercureUiAlertPublisherTest.php b/tests/View/Alert/MercureUiAlertPublisherTest.php index 18257f61..a7572d2f 100644 --- a/tests/View/Alert/MercureUiAlertPublisherTest.php +++ b/tests/View/Alert/MercureUiAlertPublisherTest.php @@ -29,6 +29,7 @@ public function testItPublishesUiAlertPayloadsAsPublicMercureUpdatesByDefault(): self::assertInstanceOf(Update::class, $hub->update); self::assertSame(['https://example.test/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', @@ -49,6 +50,16 @@ public function testItCanPublishPrivateMercureUpdatesExplicitly(): void self::assertTrue($hub->update?->isPrivate()); } + public function testItUsesStableAlertIdsAsMercureEventIds(): void + { + $hub = new RecordingHub(); + $publisher = $this->publisher($hub); + + $publisher->publish('https://example.test/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(); diff --git a/tests/View/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php index b84f42d1..2143aa08 100644 --- a/tests/View/Twig/TwigComponentNamespaceTest.php +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -4,6 +4,9 @@ namespace App\Tests\View\Twig; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; +use App\View\Alert\MercureAvailability; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Twig\Environment; @@ -23,4 +26,43 @@ public function testRootAnonymousComponentsRenderFromTwigNamespace(): void $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-catch-up-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/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index aab1aa14..e80ad190 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -117,7 +117,7 @@ test('alert stack stores new alerts, deduplicates updates, and closes all active assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.closedStorageKey)), ['alert-1']); }); -test('alert stack auto-dismiss removes transient alerts without closing future duplicates', () => { +test('alert stack auto-dismiss removes transient alerts and remembers delivered ids', () => { const { sessionStorage, window } = installDom(); let scheduled = null; window.setTimeout = (callback) => { @@ -156,13 +156,13 @@ test('alert stack auto-dismiss removes transient alerts without closing future d assert.equal(controller.activeCount, 0); assert.equal(list.children.length, 0); assert.deepEqual(JSON.parse(sessionStorage.getItem(controller.storageKey)), []); - assert.equal(sessionStorage.getItem(controller.closedStorageKey), null); + 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, 1); - assert.equal(list.children.length, 1); + assert.equal(controller.activeCount, 0); + assert.equal(list.children.length, 0); }); test('alert stack auto-dismiss keeps persistent alerts active', () => { @@ -261,6 +261,72 @@ test('UI alert stream opens EventSource with credentials and forwards valid aler assert.equal(sources[0].closed, true); }); +test('UI alert stream performs a one-time queue catch-up when 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: 42, + alerts: [{ id: 'queued', message: 'Queued fallback' }], + }; + }, + }; + }; + + 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, 1); + assert.equal(fetches[0].url, 'http://127.0.0.1:8000/api/live/alerts?cursor=7'); + assert.deepEqual(fetches[0].options, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + assert.equal(controller.catchUpCursorValue, 42); + assert.deepEqual(received, [{ id: 'queued', message: 'Queued fallback' }]); +}); + test('UI alert stream schedules reconnect when the stream closes', () => { const { window } = installDom(); From f2c1a05c3fdbc8a9f9de03041dcb601178f44582 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 21:25:54 +0200 Subject: [PATCH 50/67] Harden cookie consent package validation --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + .../PackageRuntimeContributionRegistry.php | 31 ++++- .../Cookie/CookieConsentDefinition.php | 24 ++++ .../Package/PackageLifecycleBoundaryTest.php | 108 ++++++++++++++++++ .../Cookie/CookieConsentManagerTest.php | 39 +++++++ 6 files changed, 203 insertions(+), 2 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 3d028852..27f63815 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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/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, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, 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 consent-cookie persistence, selected optional-cookie state for later edits, 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` | +| 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, 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 consent-cookie persistence, selected optional-cookie state for later edits, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index acc141a7..e278729f 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -110,6 +110,7 @@ - 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, native notifications, 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. - 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: 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. diff --git a/src/Core/Package/PackageRuntimeContributionRegistry.php b/src/Core/Package/PackageRuntimeContributionRegistry.php index 168c816a..17c384cc 100644 --- a/src/Core/Package/PackageRuntimeContributionRegistry.php +++ b/src/Core/Package/PackageRuntimeContributionRegistry.php @@ -9,16 +9,18 @@ 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; @@ -33,6 +35,12 @@ 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) { } @@ -298,6 +306,13 @@ private function addLiveEndpointHandler(ExtensionPackage $package, LiveEndpointH 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(), @@ -308,6 +323,20 @@ private function addCookieConsentDefinition(ExtensionPackage $package, CookieCon $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); diff --git a/src/Privacy/Cookie/CookieConsentDefinition.php b/src/Privacy/Cookie/CookieConsentDefinition.php index 4eaeff75..3a059ffe 100644 --- a/src/Privacy/Cookie/CookieConsentDefinition.php +++ b/src/Privacy/Cookie/CookieConsentDefinition.php @@ -23,6 +23,10 @@ public function __construct( 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 @@ -64,4 +68,24 @@ public function privacyUrl(): string { return $this->privacyUrl; } + + private function privacyUrlAllowed(string $url): bool + { + $url = trim($url); + if ('' === $url || str_starts_with($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/tests/Core/Package/PackageLifecycleBoundaryTest.php b/tests/Core/Package/PackageLifecycleBoundaryTest.php index 0709f962..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; @@ -384,6 +385,113 @@ public function testPackagePhpLoaderRejectsCrossSiteNecessaryCookieConsentContri 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/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 77827f22..b5edb074 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -16,7 +16,9 @@ use App\Privacy\Cookie\CoreCookieConsentProvider; use App\Tests\Support\FilesystemTestHelper; use App\Controller\CookieConsentController; +use InvalidArgumentException; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; @@ -258,6 +260,31 @@ public function testItRejectsDuplicateCookieDefinitions(): void $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( @@ -465,6 +492,18 @@ public function cookieConsentDefinitions(): array }; } + /** + * @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 'http without host' => ['http:/privacy']; + yield 'control character' => ["https://example.test/privacy\njavascript:alert(1)"]; + } + /** * @param array $payload */ From a70977a5f66750f55f0396ce89300c89ca5504a2 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 23:11:52 +0200 Subject: [PATCH 51/67] Harden cookie consent redirect boundaries --- src/Controller/CookieConsentController.php | 11 ++++++++++- src/Controller/SecurityController.php | 11 ++++++++++- src/Navigation/NavigationUrlResolver.php | 4 ++-- src/Privacy/Cookie/CookieConsentDefinition.php | 2 +- src/Privacy/Cookie/CookieConsentManager.php | 2 +- src/View/Http/HttpErrorRenderer.php | 11 ++++++++++- tests/Controller/SecurityControllerTest.php | 6 ++++-- tests/Navigation/NavigationBuilderTest.php | 8 ++++++++ tests/Privacy/Cookie/CookieConsentManagerTest.php | 8 +++++++- 9 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/Controller/CookieConsentController.php b/src/Controller/CookieConsentController.php index 0fa10c1d..1e4c0751 100644 --- a/src/Controller/CookieConsentController.php +++ b/src/Controller/CookieConsentController.php @@ -41,10 +41,19 @@ public function store(Request $request): Response private function redirectBack(Request $request): RedirectResponse { $target = (string) $request->request->get('_cookie_consent_target_path', ''); - if ('' === $target || !str_starts_with($target, '/') || str_starts_with($target, '//')) { + 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/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/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/CookieConsentDefinition.php b/src/Privacy/Cookie/CookieConsentDefinition.php index 3a059ffe..e437c382 100644 --- a/src/Privacy/Cookie/CookieConsentDefinition.php +++ b/src/Privacy/Cookie/CookieConsentDefinition.php @@ -72,7 +72,7 @@ public function privacyUrl(): string private function privacyUrlAllowed(string $url): bool { $url = trim($url); - if ('' === $url || str_starts_with($url, '//') || preg_match('/[\x00-\x1F\x7F]/', $url)) { + if ('' === $url || str_starts_with($url, '//') || str_contains($url, '\\') || preg_match('/[\x00-\x1F\x7F]/', $url)) { return false; } diff --git a/src/Privacy/Cookie/CookieConsentManager.php b/src/Privacy/Cookie/CookieConsentManager.php index f6b8b64a..09f6249d 100644 --- a/src/Privacy/Cookie/CookieConsentManager.php +++ b/src/Privacy/Cookie/CookieConsentManager.php @@ -11,7 +11,7 @@ final readonly class CookieConsentManager { - public const CONSENT_COOKIE_NAME = 'studio_cookie_consent'; + public const CONSENT_COOKIE_NAME = 'system_cookie_consent'; private const TTL_SECONDS = 31_536_000; private const CLOCK_SKEW_SECONDS = 300; 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/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/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/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index b5edb074..400dee70 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -346,7 +346,7 @@ public function testCookieConsentRedirectsOnlyToSafeRelativeTargets(): void $manager = $this->manager(); $controller = new CookieConsentController($manager); - foreach (['https://evil.example.test', '//evil.example.test/path', 'relative/path', ''] as $target) { + 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', @@ -365,6 +365,11 @@ public function testCookieConsentRedirectsOnlyToSafeRelativeTargets(): void 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(); @@ -500,6 +505,7 @@ 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)"]; } From 2acab888f8a4832131345d9e82dc57b55e73f961 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Sun, 14 Jun 2026 23:12:02 +0200 Subject: [PATCH 52/67] Harden Mercure alert transport boundaries --- ...studio_mercure.yaml => mercure_local.yaml} | 0 config/services.yaml | 1 - dev/CLASSMAP.md | 8 +- dev/WORKLOG.md | 2 + dev/manual/web-server-configuration.md | 2 +- migrations/Version20260531000000.php | 2 +- src/Core/Mercure/MercureRuntime.php | 2 +- src/Setup/SetupRuntimeCommandRunner.php | 19 +++- src/View/Alert/UiAlertDispatcher.php | 4 + src/View/Alert/UiAlertInbox.php | 7 +- src/View/Alert/UiAlertTopicFactory.php | 15 +-- src/View/Twig/UiAlertTwigExtension.php | 5 + .../UiAlertInboxCleanupCommandTest.php | 2 +- tests/Setup/SetupRunnerTest.php | 8 ++ .../Alert/MercureUiAlertPublisherTest.php | 10 +- tests/View/Alert/UiAlertDispatcherTest.php | 52 ++++++++- tests/View/Alert/UiAlertInboxTest.php | 17 ++- tests/View/Alert/UiAlertTopicFactoryTest.php | 20 +++- .../View/Twig/TwigComponentNamespaceTest.php | 2 +- tests/View/Twig/UiAlertTwigExtensionTest.php | 102 ++++++++++++++++++ 20 files changed, 248 insertions(+), 32 deletions(-) rename config/packages/{studio_mercure.yaml => mercure_local.yaml} (100%) create mode 100644 tests/View/Twig/UiAlertTwigExtensionTest.php diff --git a/config/packages/studio_mercure.yaml b/config/packages/mercure_local.yaml similarity index 100% rename from config/packages/studio_mercure.yaml rename to config/packages/mercure_local.yaml diff --git a/config/services.yaml b/config/services.yaml index af8fea7b..6ec5e244 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -478,7 +478,6 @@ services: App\View\Alert\UiAlertTopicFactory: arguments: - $defaultUri: '%env(DEFAULT_URI)%' $secret: '%kernel.secret%' App\View\Alert\MercureAvailability: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 27f63815..46efcb62 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -60,7 +60,7 @@ | 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/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, 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 consent-cookie persistence, selected optional-cookie state for later edits, 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` | +| 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, 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` | @@ -302,8 +302,8 @@ | 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\UiAlertPublisherInterface`, `App\View\Alert\MercureUiAlertPublisher` | Defines the stable UI-alert payload with level, mode, actions, loading state, HMAC-bound user/session Mercure topic syntax derived from authenticated users 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 without starting new anonymous sessions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Alert/UiAlertTopicFactoryTest.php` | +| Services | `App\View\Alert\UiAlert`, `App\View\Alert\UiAlertTopicFactory`, `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 authenticated users 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 @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, DB-backed user/session topic inbox with setup-completion gating, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated native-notification opt-in with denied/unsupported permission blocking. | `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/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, 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, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated native-notification opt-in with denied/unsupported permission blocking. | `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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index e278729f..deeced83 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -111,6 +111,8 @@ - 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, native notifications, 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. - 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: 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. diff --git a/dev/manual/web-server-configuration.md b/dev/manual/web-server-configuration.md index b13b7d9d..ddf5c9db 100644 --- a/dev/manual/web-server-configuration.md +++ b/dev/manual/web-server-configuration.md @@ -61,7 +61,7 @@ Override `MERCURE_PUBLIC_URL` only when the browser-facing URL differs from the 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 topics. 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. +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. diff --git a/migrations/Version20260531000000.php b/migrations/Version20260531000000.php index 7c29d223..45e309be 100644 --- a/migrations/Version20260531000000.php +++ b/migrations/Version20260531000000.php @@ -32,7 +32,7 @@ public function up(Schema $schema): void $uiAlertInbox = $schema->createTable('ui_alert_inbox'); $uiAlertInbox->addColumn('id', 'bigint', ['autoincrement' => true]); - $uiAlertInbox->addColumn('topic', 'string', ['length' => 255]); + $uiAlertInbox->addColumn('topic', 'string', ['length' => 80]); $uiAlertInbox->addColumn('payload', 'json'); $uiAlertInbox->addColumn('created_at', 'datetime_immutable'); $uiAlertInbox->addColumn('expires_at', 'datetime_immutable', ['notnull' => false]); diff --git a/src/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php index 377d1e5e..68eeb68a 100644 --- a/src/Core/Mercure/MercureRuntime.php +++ b/src/Core/Mercure/MercureRuntime.php @@ -333,7 +333,7 @@ private function jwtSecret(): string private function healthTopic(): string { - return rtrim($this->defaultUri, '/').'/ui-alerts/health'; + return 'urn:system:ui-alerts:health'; } private function urlWithTopic(string $url): string diff --git a/src/Setup/SetupRuntimeCommandRunner.php b/src/Setup/SetupRuntimeCommandRunner.php index 75745a5d..3fe16836 100644 --- a/src/Setup/SetupRuntimeCommandRunner.php +++ b/src/Setup/SetupRuntimeCommandRunner.php @@ -155,7 +155,7 @@ public function runMercureHealth( array $environment, SetupCommandExecutorInterface $commandExecutor, ): array { - $commandEnvironment = $this->databaseEnvironmentScope->commandEnvironment($environment); + $commandEnvironment = $this->mercureHealthCommandEnvironment($environment); $phpCommand = $this->phpCliCommandPrefix($projectDir, $input, $environment, true); $stopCommand = [ ...$phpCommand, @@ -296,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/UiAlertDispatcher.php b/src/View/Alert/UiAlertDispatcher.php index e992f193..d854f565 100644 --- a/src/View/Alert/UiAlertDispatcher.php +++ b/src/View/Alert/UiAlertDispatcher.php @@ -60,6 +60,10 @@ public function addAlertToTopic( ?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()) { diff --git a/src/View/Alert/UiAlertInbox.php b/src/View/Alert/UiAlertInbox.php index 5815efa1..003feb29 100644 --- a/src/View/Alert/UiAlertInbox.php +++ b/src/View/Alert/UiAlertInbox.php @@ -114,11 +114,16 @@ public function cleanupExpired(): int private function normalizeTopics(array $topics): array { return array_values(array_unique(array_filter( - array_map(static fn (mixed $topic): string => trim((string) $topic), $topics), + 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 */ diff --git a/src/View/Alert/UiAlertTopicFactory.php b/src/View/Alert/UiAlertTopicFactory.php index 6d95758c..172e23af 100644 --- a/src/View/Alert/UiAlertTopicFactory.php +++ b/src/View/Alert/UiAlertTopicFactory.php @@ -11,8 +11,9 @@ final readonly class UiAlertTopicFactory { + public const PREFIX = 'urn:system:ui-alerts:'; + public function __construct( - private string $defaultUri, private string $secret, ) { } @@ -55,16 +56,16 @@ public function topicsFor(?Request $request, ?UserInterface $user): array return array_values(array_unique($topics)); } - private function topic(string $scope, string $identity): string + public function isUiAlertTopic(string $topic): bool { - return rtrim($this->baseUri(), '/').'/ui-alerts/'.$scope.'/'.$this->hash($scope, $identity); + $matches = preg_match('/^'.preg_quote(self::PREFIX, '/').'(user|session):[a-f0-9]{64}$/', $topic); + + return 1 === $matches; } - private function baseUri(): string + private function topic(string $scope, string $identity): string { - $uri = rtrim($this->defaultUri, '/'); - - return '' !== $uri ? $uri : 'https://localhost'; + return self::PREFIX.$scope.':'.$this->hash($scope, $identity); } private function hash(string $scope, string $identity): string diff --git a/src/View/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php index b9982811..b6e0a483 100644 --- a/src/View/Twig/UiAlertTwigExtension.php +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -80,6 +80,11 @@ public function storageScope(): string $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'; diff --git a/tests/Command/UiAlertInboxCleanupCommandTest.php b/tests/Command/UiAlertInboxCleanupCommandTest.php index a226e761..a352c378 100644 --- a/tests/Command/UiAlertInboxCleanupCommandTest.php +++ b/tests/Command/UiAlertInboxCleanupCommandTest.php @@ -16,7 +16,7 @@ final class UiAlertInboxCleanupCommandTest extends TestCase public function testItReportsRemovedExpiredRows(): 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(255) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME 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)'); $connection->insert('ui_alert_inbox', [ 'topic' => 'test.expired', 'payload' => '{}', diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php index 982b8119..dc28d08f 100644 --- a/tests/Setup/SetupRunnerTest.php +++ b/tests/Setup/SetupRunnerTest.php @@ -106,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'], @@ -878,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, @@ -889,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 index a7572d2f..eb1fcebb 100644 --- a/tests/View/Alert/MercureUiAlertPublisherTest.php +++ b/tests/View/Alert/MercureUiAlertPublisherTest.php @@ -23,11 +23,11 @@ public function testItPublishesUiAlertPayloadsAsPublicMercureUpdatesByDefault(): $hub = new RecordingHub(); $publisher = $this->publisher($hub); - $id = $publisher->publish('https://example.test/ui-alerts/session/topic', UiAlert::fromLevel('danger', 'Saved')); + $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(['https://example.test/ui-alerts/session/topic'], $hub->update->getTopics()); + 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()); @@ -45,7 +45,7 @@ public function testItCanPublishPrivateMercureUpdatesExplicitly(): void $hub = new RecordingHub(); $publisher = $this->publisher($hub); - $publisher->publish('https://example.test/ui-alerts/session/topic', UiAlert::fromLevel('success', 'Saved'), private: true); + $publisher->publish('urn:system:ui-alerts:session:topic', UiAlert::fromLevel('success', 'Saved'), private: true); self::assertTrue($hub->update?->isPrivate()); } @@ -55,7 +55,7 @@ public function testItUsesStableAlertIdsAsMercureEventIds(): void $hub = new RecordingHub(); $publisher = $this->publisher($hub); - $publisher->publish('https://example.test/ui-alerts/session/topic', UiAlert::fromLevel('success', 'Saved', id: 'ui-alert-stable')); + $publisher->publish('urn:system:ui-alerts:session:topic', UiAlert::fromLevel('success', 'Saved', id: 'ui-alert-stable')); self::assertSame('ui-alert-stable', $hub->update?->getId()); } @@ -79,7 +79,7 @@ private function publisher(RecordingHub $hub): MercureUiAlertPublisher { return new MercureUiAlertPublisher( $hub, - new UiAlertTopicFactory('https://example.test', 'secret'), + new UiAlertTopicFactory('secret'), new UiAlertMessageFactory(new IdentityTranslator()), ); } diff --git a/tests/View/Alert/UiAlertDispatcherTest.php b/tests/View/Alert/UiAlertDispatcherTest.php index 813fbbf7..997158b4 100644 --- a/tests/View/Alert/UiAlertDispatcherTest.php +++ b/tests/View/Alert/UiAlertDispatcherTest.php @@ -20,6 +20,7 @@ use App\View\Alert\UiAlertPublisherInterface; use App\View\Alert\UiAlertTopicFactory; use App\View\Alert\UiAlertTranslation; +use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\Security; @@ -38,11 +39,11 @@ public function testTopicQueueDeliverySkipsMercurePublishWhenUnavailable(): 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(255) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME 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('https://studio.example.test', 'test-secret'); + $topicFactory = new UiAlertTopicFactory('test-secret'); $dispatcher = new UiAlertDispatcher( $topicFactory, new UiAlertMessageFactory(new IdentityTranslator()), @@ -65,7 +66,7 @@ public function testTopicQueueDeliverySkipsMercurePublishWhenUnavailable(): void ); self::assertTrue($dispatcher->addAlertToTopic( - 'https://studio.example.test/ui-alerts/user/test', + $topicFactory->userTopic('test-user'), UiAlert::fromLevel('success', 'Queued alert'), UiAlertDelivery::Queue, )); @@ -73,6 +74,51 @@ public function testTopicQueueDeliverySkipsMercurePublishWhenUnavailable(): void 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')); + } + + private function dispatcher(Connection $connection, RecordingPublisher $publisher): UiAlertDispatcher + { + $config = new Config($connection); + self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false, ConfigValueType::Boolean)); + + return new UiAlertDispatcher( + new UiAlertTopicFactory('test-secret'), + 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 class RecordingPublisher implements UiAlertPublisherInterface diff --git a/tests/View/Alert/UiAlertInboxTest.php b/tests/View/Alert/UiAlertInboxTest.php index d185f40f..b5d7226e 100644 --- a/tests/View/Alert/UiAlertInboxTest.php +++ b/tests/View/Alert/UiAlertInboxTest.php @@ -14,7 +14,7 @@ final class UiAlertInboxTest extends TestCase public function testItAppendsAndPollsQueuedAlertsWithoutRequiringInsertIds(): 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(255) NOT NULL, payload CLOB NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME 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)'); $inbox = new UiAlertInbox($connection); $result = $inbox->append(['topic.one', 'topic.two'], UiAlert::fromLevel('success', 'Queued')); @@ -32,6 +32,21 @@ public function testItAppendsAndPollsQueuedAlertsWithoutRequiringInsertIds(): vo ], $inbox->poll(['topic.one'])); } + 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]); diff --git a/tests/View/Alert/UiAlertTopicFactoryTest.php b/tests/View/Alert/UiAlertTopicFactoryTest.php index d8321355..dfcdd2af 100644 --- a/tests/View/Alert/UiAlertTopicFactoryTest.php +++ b/tests/View/Alert/UiAlertTopicFactoryTest.php @@ -16,7 +16,7 @@ final class UiAlertTopicFactoryTest extends TestCase { public function testItBuildsStableHashedUserAndSessionTopics(): void { - $factory = new UiAlertTopicFactory('https://example.test', 'secret'); + $factory = new UiAlertTopicFactory('secret'); $user = new UserAccount( '71000000-0000-7000-8000-000000000001', 'admin', @@ -28,16 +28,28 @@ public function testItBuildsStableHashedUserAndSessionTopics(): void $userTopic = $factory->userTopic($user); $sessionTopic = $factory->sessionTopic('session-id'); - self::assertStringStartsWith('https://example.test/ui-alerts/user/', $userTopic); - self::assertStringStartsWith('https://example.test/ui-alerts/session/', $sessionTopic); + 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::assertTrue($factory->isUiAlertTopic($userTopic)); + self::assertTrue($factory->isUiAlertTopic($sessionTopic)); + } + + 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('https://example.test', 'secret'); + $factory = new UiAlertTopicFactory('secret'); $request = Request::create('/api/live/alerts'); $session = new Session(new MockArraySessionStorage()); $session->setName('PHPSESSID'); diff --git a/tests/View/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php index 2143aa08..cceab976 100644 --- a/tests/View/Twig/TwigComponentNamespaceTest.php +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -62,7 +62,7 @@ public function testAlertStackUsesPollingOnlyWhenNoMercureStreamIsRendered(): vo private function renderAlertStack(Environment $twig): string { return $twig - ->createTemplate('') + ->createTemplate('') ->render(); } } diff --git a/tests/View/Twig/UiAlertTwigExtensionTest.php b/tests/View/Twig/UiAlertTwigExtensionTest.php new file mode 100644 index 00000000..c3e0c1fa --- /dev/null +++ b/tests/View/Twig/UiAlertTwigExtensionTest.php @@ -0,0 +1,102 @@ +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()); + } + + private function extension(Request $request): UiAlertTwigExtension + { + $requestStack = new RequestStack(); + $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)'); + + return new UiAlertTwigExtension( + $requestStack, + new Security($this->securityContainer()), + new UiAlertTopicFactory('topic-secret'), + new MercureAvailability( + new Config($connection), + new MercureRuntime( + new MercureBinaryManager('/tmp/studio'), + new UiAlertTwigSilentHub(), + 'https://studio.example.test', + '/tmp/studio', + ), + new DetachedProcessStarter(), + '/tmp/studio', + ), + 'storage-secret', + ); + } + + private function securityContainer(): Container + { + $container = new Container(); + $container->set('security.token_storage', new TokenStorage()); + + return $container; + } +} + +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'; + } +} From 25eb4cf3f20f0043040bcf067f8675e4a86fbcd8 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 00:06:01 +0200 Subject: [PATCH 53/67] Run cookie consent filtering after response writers --- src/Privacy/Cookie/CookieConsentResponseSubscriber.php | 4 +++- tests/Privacy/Cookie/CookieConsentManagerTest.php | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php index cf7d74fb..aad2e173 100644 --- a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php +++ b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php @@ -10,6 +10,8 @@ final readonly class CookieConsentResponseSubscriber implements EventSubscriberInterface { + private const RESPONSE_PRIORITY = -4096; + public function __construct( private CookieConsentRegistry $registry, private CookieConsentManager $consent, @@ -19,7 +21,7 @@ public function __construct( public static function getSubscribedEvents(): array { return [ - KernelEvents::RESPONSE => ['filterCookies', -64], + KernelEvents::RESPONSE => ['filterCookies', self::RESPONSE_PRIORITY], ]; } diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 400dee70..1c51d374 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -217,6 +217,15 @@ public function testResponseSubscriberKeepsOptionalCookieClearHeaders(): void 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( From d1b371dee8779ff15ad2efc0684212c0a3700353 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 00:06:07 +0200 Subject: [PATCH 54/67] Normalize user alert topics through account UIDs --- config/services.yaml | 3 + .../DoctrineUiAlertUserIdentityResolver.php | 32 ++++++++++ src/View/Alert/MercureUiAlertPublisher.php | 9 ++- src/View/Alert/UiAlertDispatcher.php | 9 ++- src/View/Alert/UiAlertTopicFactory.php | 27 +++++++-- .../UiAlertUserIdentityResolverInterface.php | 10 ++++ .../Alert/MercureUiAlertPublisherTest.php | 40 +++++++++++++ tests/View/Alert/UiAlertDispatcherTest.php | 60 ++++++++++++++++++- tests/View/Alert/UiAlertTopicFactoryTest.php | 58 ++++++++++++++++++ 9 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 src/View/Alert/DoctrineUiAlertUserIdentityResolver.php create mode 100644 src/View/Alert/UiAlertUserIdentityResolverInterface.php diff --git a/config/services.yaml b/config/services.yaml index 6ec5e244..da508fcb 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -124,6 +124,9 @@ services: 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 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/MercureUiAlertPublisher.php b/src/View/Alert/MercureUiAlertPublisher.php index 3df95344..0b997950 100644 --- a/src/View/Alert/MercureUiAlertPublisher.php +++ b/src/View/Alert/MercureUiAlertPublisher.php @@ -6,6 +6,7 @@ use App\Core\Message\Message; use App\Entity\UserAccount; +use InvalidArgumentException; use JsonException; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Mercure\HubInterface; @@ -44,7 +45,13 @@ public function publish(string $topic, UiAlert|Message|UiAlertTranslation $alert public function publishToUser(UserAccount|UserInterface|string $user, UiAlert|Message|UiAlertTranslation $alert, ?string $locale = null): ?string { - return $this->publish($this->topicFactory->userTopic($user), $alert, $locale); + 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 diff --git a/src/View/Alert/UiAlertDispatcher.php b/src/View/Alert/UiAlertDispatcher.php index d854f565..f004e5e2 100644 --- a/src/View/Alert/UiAlertDispatcher.php +++ b/src/View/Alert/UiAlertDispatcher.php @@ -7,6 +7,7 @@ use App\Core\Message\Message; use App\Core\Id\UuidFactory; use App\Entity\UserAccount; +use InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -91,7 +92,13 @@ public function addAlertToUser( ?UiAlertPresentation $presentation = null, ): bool { - return $this->addAlertToTopic($this->topicFactory->userTopic($user), $alert, $delivery, $presentation); + try { + $topic = $this->topicFactory->userTopic($user); + } catch (InvalidArgumentException) { + return false; + } + + return $this->addAlertToTopic($topic, $alert, $delivery, $presentation); } public function addAlertToSession( diff --git a/src/View/Alert/UiAlertTopicFactory.php b/src/View/Alert/UiAlertTopicFactory.php index 172e23af..63bde10b 100644 --- a/src/View/Alert/UiAlertTopicFactory.php +++ b/src/View/Alert/UiAlertTopicFactory.php @@ -5,6 +5,7 @@ namespace App\View\Alert; use App\Entity\UserAccount; +use InvalidArgumentException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -15,16 +16,13 @@ public function __construct( private string $secret, + private ?UiAlertUserIdentityResolverInterface $userIdentityResolver = null, ) { } public function userTopic(UserAccount|UserInterface|string $user): string { - $identity = $user instanceof UserAccount - ? $user->uid() - : ($user instanceof UserInterface ? $user->getUserIdentifier() : $user); - - return $this->topic('user', $identity); + return $this->topic('user', $this->userIdentity($user)); } public function sessionTopic(SessionInterface|string $session): string @@ -73,6 +71,25 @@ 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 = strtolower(trim($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}$/', $identity)) { + return $identity; + } + + $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()) { 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 @@ +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( @@ -85,6 +110,21 @@ private function publisher(RecordingHub $hub): MercureUiAlertPublisher } } +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; diff --git a/tests/View/Alert/UiAlertDispatcherTest.php b/tests/View/Alert/UiAlertDispatcherTest.php index 997158b4..5db713c9 100644 --- a/tests/View/Alert/UiAlertDispatcherTest.php +++ b/tests/View/Alert/UiAlertDispatcherTest.php @@ -20,6 +20,7 @@ use App\View\Alert\UiAlertPublisherInterface; use App\View\Alert\UiAlertTopicFactory; use App\View\Alert\UiAlertTranslation; +use App\View\Alert\UiAlertUserIdentityResolverInterface; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; @@ -66,7 +67,7 @@ public function testTopicQueueDeliverySkipsMercurePublishWhenUnavailable(): void ); self::assertTrue($dispatcher->addAlertToTopic( - $topicFactory->userTopic('test-user'), + $topicFactory->userTopic('71000000-0000-7000-8000-000000000001'), UiAlert::fromLevel('success', 'Queued alert'), UiAlertDelivery::Queue, )); @@ -93,13 +94,51 @@ public function testTopicDeliveryRejectsNonUiAlertTopics(): void self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM ui_alert_inbox')); } - private function dispatcher(Connection $connection, RecordingPublisher $publisher): UiAlertDispatcher + 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'), + new UiAlertTopicFactory('test-secret', $resolver), new UiAlertMessageFactory(new IdentityTranslator()), new UiAlertInbox($connection), $publisher, @@ -121,6 +160,21 @@ private function dispatcher(Connection $connection, RecordingPublisher $publishe } } +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 { /** diff --git a/tests/View/Alert/UiAlertTopicFactoryTest.php b/tests/View/Alert/UiAlertTopicFactoryTest.php index dfcdd2af..db5553ee 100644 --- a/tests/View/Alert/UiAlertTopicFactoryTest.php +++ b/tests/View/Alert/UiAlertTopicFactoryTest.php @@ -7,10 +7,13 @@ use App\Entity\UserAccount; use App\Security\UserRole; use App\View\Alert\UiAlertTopicFactory; +use App\View\Alert\UiAlertUserIdentityResolverInterface; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\Security\Core\User\UserInterface; final class UiAlertTopicFactoryTest extends TestCase { @@ -33,10 +36,50 @@ public function testItBuildsStableHashedUserAndSessionTopics(): void 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([ + 'admin' => '71000000-0000-7000-8000-000000000001', + ])); + + self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic('admin')); + } + + 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 testItRejectsNonUiAlertTopics(): void { $factory = new UiAlertTopicFactory('secret'); @@ -62,3 +105,18 @@ public function testItUsesExistingSessionCookieForRequestTopicsWithoutStartingSe 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; + } +} From 01822f4ccd799934d5ceaeb4cc14eb8dc81c0fa5 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 00:06:12 +0200 Subject: [PATCH 55/67] Reject unroutable package live roots --- dev/CLASSMAP.md | 6 ++--- dev/WORKLOG.md | 1 + .../Package/PackageLiveContributionGuard.php | 4 ++-- src/Live/PackageLiveEndpointPath.php | 14 ++++++++++- .../PackageLiveContributionGuardTest.php | 24 +++++++++++++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 46efcb62..020003df 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -49,7 +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 package live 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` | +| 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` | @@ -60,7 +60,7 @@ | 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/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, 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` | +| 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` | @@ -302,7 +302,7 @@ | 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\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 authenticated users 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` | +| 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index deeced83..ad5406fc 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -113,6 +113,7 @@ - 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}`. - 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: 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. diff --git a/src/Core/Package/PackageLiveContributionGuard.php b/src/Core/Package/PackageLiveContributionGuard.php index 7280acb5..b0e410b7 100644 --- a/src/Core/Package/PackageLiveContributionGuard.php +++ b/src/Core/Package/PackageLiveContributionGuard.php @@ -25,8 +25,8 @@ public static function assertEndpoint(ExtensionPackage $package, LiveEndpointDef ]); } - $expectedPrefix = PackageLiveEndpointPath::path($package->packageName(), ''); - if (!str_starts_with($definition->path(), $expectedPrefix)) { + $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(), diff --git a/src/Live/PackageLiveEndpointPath.php b/src/Live/PackageLiveEndpointPath.php index 8e4bf75d..bf921fa8 100644 --- a/src/Live/PackageLiveEndpointPath.php +++ b/src/Live/PackageLiveEndpointPath.php @@ -25,7 +25,19 @@ public static function slug(string $packageName): string public static function path(string $packageName, string $path): string { - return '/api/live/'.self::slug($packageName).('/' === $path ? '' : '/'.ltrim($path, '/')); + $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/tests/Core/Package/PackageLiveContributionGuardTest.php b/tests/Core/Package/PackageLiveContributionGuardTest.php index 97c00d75..774ed13b 100644 --- a/tests/Core/Package/PackageLiveContributionGuardTest.php +++ b/tests/Core/Package/PackageLiveContributionGuardTest.php @@ -74,6 +74,30 @@ public function testItRejectsPathsOutsideOwnedLiveNamespace(): void )); } + 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'); From 013cdb3f60eee7586f060f29f970d2547891eeab Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 01:05:36 +0200 Subject: [PATCH 56/67] Harden alert topic and notification preferences --- dev/CLASSMAP.md | 4 +-- dev/WORKLOG.md | 3 +- src/Controller/UserController.php | 4 +-- src/View/Alert/UiAlertTopicFactory.php | 7 ++-- .../Controller/UserProfileControllerTest.php | 33 +++++++++++++++++++ tests/View/Alert/UiAlertTopicFactoryTest.php | 28 ++++++++++++++-- 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 020003df..5180f42d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -316,7 +316,7 @@ | Routes `backend_admin_user_groups`, `backend_admin_user_group_detail`, `backend_admin_user_group_delete` | `App\Controller\AdminAclGroupController` | Admin ACL group routes with `details/` dynamic paths for searchable/paginated ACL groups, group detail member summaries, create/update/delete actions, hierarchy guardrails, impact review before updates/deletes, and LiveLog-backed group apply. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_reviews`, `backend_admin_user_review_reactivate`, `backend_admin_user_review_delete` | `App\Controller\AdminUserReviewController` | Admin review queue routes with `details/` disputed-account actions for contextual registration/invitation/dispute rows plus disputed-account reactivation and confirmed delete actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserReviewControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | -| Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | +| Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language and native-notification preference updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_api_keys`, `user_api_key_reveal`, `user_api_key_revoke` | `App\Controller\UserApiKeyController` | Authenticated user API-key routes for generation, revocation, and password-confirmed reveal of encrypted key material. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/UserApiKeyControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_register`, `user_invitation_accept` | `App\Controller\UserRegistrationController` | Public registration and invitation routes for disabled/admin-approval/auto-approval registration, existing-account notices, optional default registration groups, deleted-account reactivation with token role/group reset, and invitation/registration token acceptance through a Security-owned mutation service. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | | Routes `user_reset_password`, `user_password_reset_token`, `user_security_review` | `App\Controller\UserPasswordRecoveryController` | Public password recovery and security-review routes for non-enumerating reset requests, reset completion, password-change review links, and password-change dispute locking. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, 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, portable append success reporting without sequence-specific insert IDs, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated native-notification opt-in with denied/unsupported permission blocking. | `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/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated polling/stream-compatible native-notification opt-in with denied/unsupported permission blocking. | `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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index ad5406fc..40c64a5e 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -79,7 +79,7 @@ - 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 optional native browser notification support behind the profile setting and Mercure-health gate, while keeping permission prompts user-initiated. +- Added optional native browser notification support behind the profile setting and browser permission gate, while keeping permission prompts user-initiated and compatible with both Mercure streams and polling fallback alerts. - 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. @@ -114,6 +114,7 @@ - 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 and allowing native-notification opt-in for polling-only alert delivery. - 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: 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. diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 9880a89e..13876c89 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -23,7 +23,6 @@ use App\Security\UserFlowConfig; use App\Security\UserPasswordChangeService; use App\View\Http\HttpErrorRenderer; -use App\View\Alert\MercureAvailability; use App\View\Alert\UiAlertDelivery; use App\View\Alert\UiAlertDispatcherInterface; use App\View\Alert\UiAlertTranslation; @@ -49,7 +48,6 @@ public function __construct( private readonly UserAccountClosureService $accountClosureService, private readonly UserProfileLocaleService $profileLocales, private readonly ApiFeaturePolicy $apiFeaturePolicy, - private readonly MercureAvailability $mercureAvailability, private readonly UiAlertDispatcherInterface $alerts, ) { } @@ -76,7 +74,7 @@ public function profile(Request $request): Response $success = false; $errors = []; $usernameChangeEnabled = $this->userFlowConfig->usernameChangeEnabled(); - $nativeNotificationsAvailable = $this->mercureAvailability->available(); + $nativeNotificationsAvailable = true; if ($request->isMethod('POST')) { if (!$this->isCsrfTokenValid('user_profile', $this->stringField($request, '_csrf_token'))) { diff --git a/src/View/Alert/UiAlertTopicFactory.php b/src/View/Alert/UiAlertTopicFactory.php index 63bde10b..e1071a99 100644 --- a/src/View/Alert/UiAlertTopicFactory.php +++ b/src/View/Alert/UiAlertTopicFactory.php @@ -77,9 +77,10 @@ private function userIdentity(UserAccount|UserInterface|string $user): string ? $user->uid() : ($user instanceof UserInterface ? $user->getUserIdentifier() : $user); - $identity = strtolower(trim($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}$/', $identity)) { - return $identity; + $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); diff --git a/tests/Controller/UserProfileControllerTest.php b/tests/Controller/UserProfileControllerTest.php index ecbd0ad0..2f6f7979 100644 --- a/tests/Controller/UserProfileControllerTest.php +++ b/tests/Controller/UserProfileControllerTest.php @@ -10,6 +10,7 @@ use App\Security\AccountTokenIssuer; use App\Security\AccountTokenStatus; use App\Security\AccountTokenType; +use App\View\Alert\MercureAvailability; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -52,6 +53,38 @@ public function testProfileRouteRendersAccountSkeleton(): void self::assertSelectorNotExists('input[name="username"]'); } + public function testProfileCanEnableNativeNotificationsWhenMercureIsDisabled(): void + { + $client = self::createClient(); + $config = self::getContainer()->get(Config::class); + self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false)); + self::assertTrue($config->set(MercureAvailability::AVAILABLE_KEY, false)); + $user = $this->createUserWithLevel(1, 'pollnotify', 'profile-password'); + + $this->loginTestUser($client, $user); + $crawler = $client->request('GET', '/user/profile'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="native_notifications"]'); + + $client->request('POST', '/user/profile', [ + '_csrf_token' => (string) $crawler->filter('input[name="_csrf_token"]')->attr('value'), + 'email' => $user->email(), + 'display_name' => 'Polling Notifications', + 'language' => 'default', + 'native_notifications' => '1', + ]); + + self::assertResponseRedirects('/user/profile'); + + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + $entityManager->clear(); + $updatedUser = $entityManager->find(UserAccount::class, $user->uid()); + + self::assertInstanceOf(UserAccount::class, $updatedUser); + self::assertTrue($updatedUser->settings()['native_notifications'] ?? false); + } + public function testProfileUsernameChangeRequiresSetting(): void { $client = self::createClient(); diff --git a/tests/View/Alert/UiAlertTopicFactoryTest.php b/tests/View/Alert/UiAlertTopicFactoryTest.php index db5553ee..adc6058a 100644 --- a/tests/View/Alert/UiAlertTopicFactoryTest.php +++ b/tests/View/Alert/UiAlertTopicFactoryTest.php @@ -44,10 +44,10 @@ public function testItBuildsStableHashedUserAndSessionTopics(): void public function testItResolvesUsernameStringsToAccountUidTopics(): void { $factory = new UiAlertTopicFactory('secret', new FakeUserAlertIdentityResolver([ - 'admin' => '71000000-0000-7000-8000-000000000001', + 'AdminUser' => '71000000-0000-7000-8000-000000000001', ])); - self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic('admin')); + self::assertSame($factory->userTopic('71000000-0000-7000-8000-000000000001'), $factory->userTopic('AdminUser')); } public function testItRejectsUnresolvedUsernameTopics(): void @@ -80,6 +80,30 @@ public function getUserIdentifier(): string 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'); From b2ff10516d1c9fcf26b227a60a4defdf78b7e3ff Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 01:16:59 +0200 Subject: [PATCH 57/67] Remove native browser notifications --- .env | 4 - assets/controllers.json | 6 - ...tive_notification_preference_controller.js | 73 -------- .../native_notifications_controller.js | 40 ----- composer.json | 2 +- composer.lock | 160 +----------------- config/bundles.php | 1 - config/packages/notifier.yaml | 1 - config/reference.php | 7 - dev/CLASSMAP.md | 4 +- dev/WORKLOG.md | 12 +- src/Controller/UserController.php | 5 - symfony.lock | 12 -- templates/components/AlertStack.html.twig | 4 +- templates/frontend/user/profile.html.twig | 12 -- .../Controller/UserProfileControllerTest.php | 33 ---- tests/assets/controller_foundation.test.mjs | 128 -------------- translations/languages/de/ui.yaml | 4 - translations/languages/en/ui.yaml | 4 - 19 files changed, 11 insertions(+), 501 deletions(-) delete mode 100644 assets/controllers/native_notification_preference_controller.js delete mode 100644 assets/controllers/native_notifications_controller.js diff --git a/.env b/.env index 3f78d84d..a6881976 100755 --- a/.env +++ b/.env @@ -42,10 +42,6 @@ MAILER_DSN=null://null LOCK_DSN=flock ###< symfony/lock ### -###> symfony/mercure-notifier ### -MERCURE_DSN=mercure://default -###< symfony/mercure-notifier ### - ###> symfony/mercure-bundle ### MERCURE_HUB_LISTEN=127.0.0.1:3000 MERCURE_URL=http://${MERCURE_HUB_LISTEN}/.well-known/mercure 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/native_notification_preference_controller.js b/assets/controllers/native_notification_preference_controller.js deleted file mode 100644 index a2404b9d..00000000 --- a/assets/controllers/native_notification_preference_controller.js +++ /dev/null @@ -1,73 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ['input']; - static values = { - deniedMessage: String, - }; - - async submit(event) { - if (this.submitting || !this.hasInputTarget || !this.inputTarget.checked) { - return; - } - - if (!this.canRequestPermission) { - event.preventDefault(); - this.inputTarget.checked = false; - this.showDeniedMessage(); - this.submitWithCurrentState(event); - - return; - } - - if (!this.needsPermission) { - return; - } - - event.preventDefault(); - - const permission = await window.Notification.requestPermission(); - if (permission !== 'granted') { - this.inputTarget.checked = false; - this.showDeniedMessage(); - } - - this.submitWithCurrentState(event); - } - - submitWithCurrentState(event) { - this.submitting = true; - this.element.requestSubmit(event.submitter || undefined); - } - - showDeniedMessage() { - const message = String(this.deniedMessageValue || '').trim(); - if (!message) { - return; - } - - const stack = document.querySelector('[data-controller~="alert-stack"]'); - const target = stack || document; - - target.dispatchEvent(new CustomEvent('ui-alert:received', { - bubbles: true, - detail: { - level: 'warning', - message, - mode: 'auto', - }, - })); - } - - get needsPermission() { - return this.canRequestPermission && window.Notification.permission === 'default'; - } - - get canRequestPermission() { - const notification = window.Notification; - - return typeof notification === 'function' - && typeof notification.requestPermission === 'function' - && notification.permission !== 'denied'; - } -} diff --git a/assets/controllers/native_notifications_controller.js b/assets/controllers/native_notifications_controller.js deleted file mode 100644 index 1df07c7d..00000000 --- a/assets/controllers/native_notifications_controller.js +++ /dev/null @@ -1,40 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - static values = { - enabled: { type: Boolean, default: false }, - title: { type: String, default: 'Notifications' }, - }; - - connect() { - document.addEventListener('ui-alert:shown', this.notify); - } - - disconnect() { - document.removeEventListener('ui-alert:shown', this.notify); - } - - notify = (event) => { - if (!this.enabledValue || !this.canNotify) { - return; - } - - const payload = event.detail || {}; - const title = String(payload.title || this.titleValue || '').trim(); - const body = String(payload.message || '').trim(); - - if (!title && !body) { - return; - } - - try { - new Notification(title || this.titleValue, { body }); - } catch { - // Browser notification support is best-effort and must never break alerts. - } - }; - - get canNotify() { - return typeof window.Notification === 'function' && Notification.permission === 'granted'; - } -} 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/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/dev/CLASSMAP.md b/dev/CLASSMAP.md index 5180f42d..a4d0f3eb 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -316,7 +316,7 @@ | Routes `backend_admin_user_groups`, `backend_admin_user_group_detail`, `backend_admin_user_group_delete` | `App\Controller\AdminAclGroupController` | Admin ACL group routes with `details/` dynamic paths for searchable/paginated ACL groups, group detail member summaries, create/update/delete actions, hierarchy guardrails, impact review before updates/deletes, and LiveLog-backed group apply. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_reviews`, `backend_admin_user_review_reactivate`, `backend_admin_user_review_delete` | `App\Controller\AdminUserReviewController` | Admin review queue routes with `details/` disputed-account actions for contextual registration/invitation/dispute rows plus disputed-account reactivation and confirmed delete actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserReviewControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | -| Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language and native-notification preference updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | +| Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_api_keys`, `user_api_key_reveal`, `user_api_key_revoke` | `App\Controller\UserApiKeyController` | Authenticated user API-key routes for generation, revocation, and password-confirmed reveal of encrypted key material. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/UserApiKeyControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_register`, `user_invitation_accept` | `App\Controller\UserRegistrationController` | Public registration and invitation routes for disabled/admin-approval/auto-approval registration, existing-account notices, optional default registration groups, deleted-account reactivation with token role/group reset, and invitation/registration token acceptance through a Security-owned mutation service. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | | Routes `user_reset_password`, `user_password_reset_token`, `user_security_review` | `App\Controller\UserPasswordRecoveryController` | Public password recovery and security-review routes for non-enumerating reset requests, reset completion, password-change review links, and password-change dispute locking. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, browser notification events only for newly created alerts, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, quiet text actions, presentation modes, optional titles/actions/loading state, and user-initiated polling/stream-compatible native-notification opt-in with denied/unsupported permission blocking. | `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/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | +| UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, quiet text 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/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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 40c64a5e..fc461fb9 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -79,7 +79,6 @@ - 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 optional native browser notification support behind the profile setting and browser permission gate, while keeping permission prompts user-initiated and compatible with both Mercure streams and polling fallback alerts. - 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. @@ -92,8 +91,8 @@ - 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 suppressing native-notification events during stored alert hydration, 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, expiring withdrawn optional cookies, and preserving native-notification preferences while the toggle is hidden. +- 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. @@ -102,19 +101,20 @@ - 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 and native browser notification preferences by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions and clearing unsupported or denied native-notification opt-ins before profile form submission. +- 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, native notifications, alert stack behavior, alert polling, and Mercure stream reconnect handling. +- 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 and allowing native-notification opt-in for polling-only alert delivery. +- 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. - 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: 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. diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 13876c89..f682b986 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -74,7 +74,6 @@ public function profile(Request $request): Response $success = false; $errors = []; $usernameChangeEnabled = $this->userFlowConfig->usernameChangeEnabled(); - $nativeNotificationsAvailable = true; if ($request->isMethod('POST')) { if (!$this->isCsrfTokenValid('user_profile', $this->stringField($request, '_csrf_token'))) { @@ -134,9 +133,6 @@ public function profile(Request $request): Response if ([] === $errors) { $settings = $user->settings(); $settings['language'] = $language; - if ($nativeNotificationsAvailable) { - $settings['native_notifications'] = '1' === $this->stringField($request, 'native_notifications'); - } $user->updateProfile([ 'display_name' => $this->stringField($request, 'display_name'), @@ -162,7 +158,6 @@ public function profile(Request $request): Response 'username_change_enabled' => $usernameChangeEnabled, 'language_options' => $this->profileLocales->options(), 'api_key_management_enabled' => $this->apiFeaturePolicy->canManageKeys($user), - 'native_notifications_available' => $nativeNotificationsAvailable, 'success' => $success, 'errors' => $errors, ]); 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/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 45cfdcd4..81aa455d 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -4,14 +4,12 @@ {% set poll_url = stream_url ? null : (stream_topics is not empty ? path('api_live_alerts') : null) %} {% set stack_attributes = { class: 'system-alert-stack', - 'data-controller': 'alert-stack native-notifications', + 'data-controller': 'alert-stack', 'data-action': 'ui-alert:received->alert-stack#append', 'data-alert-stack-dismiss-delay-value': dismiss_delay, 'data-alert-stack-storage-scope-value': ui_alert_storage_scope(), 'data-alert-close-label': 'ui.alert.close'|trans, 'data-alert-notifications-label': 'ui.alert.notifications'|trans, - 'data-native-notifications-enabled-value': app.user and app.user.settings.native_notifications|default(false) ? 'true' : 'false', - 'data-native-notifications-title-value': 'ui.alert.notifications'|trans, } %} {% if stream_url %} {% set stack_attributes = stack_attributes|merge({ diff --git a/templates/frontend/user/profile.html.twig b/templates/frontend/user/profile.html.twig index 3196a849..6dcb9e75 100644 --- a/templates/frontend/user/profile.html.twig +++ b/templates/frontend/user/profile.html.twig @@ -32,9 +32,6 @@
{% if username_change_enabled|default(false) %} @@ -67,15 +64,6 @@ default: 'ui.user.profile.language_default'|trans, })|merge(language_options|default({})), } only %} - {% if native_notifications_available|default(false) %} - {% include '@frontend/partials/forms/fields/toggle.html.twig' with { - name: 'native_notifications', - label: 'ui.user.profile.native_notifications.label'|trans, - help: 'ui.user.profile.native_notifications.help'|trans, - checked: user_account.settings.native_notifications|default(false), - attr: {'data-native-notification-preference-target': 'input'}, - } only %} - {% endif %} {% include '@frontend/partials/forms/fields/submit.html.twig' with {label: 'ui.user.profile.submit'|trans} only %}
{% set profile_actions = [ diff --git a/tests/Controller/UserProfileControllerTest.php b/tests/Controller/UserProfileControllerTest.php index 2f6f7979..ecbd0ad0 100644 --- a/tests/Controller/UserProfileControllerTest.php +++ b/tests/Controller/UserProfileControllerTest.php @@ -10,7 +10,6 @@ use App\Security\AccountTokenIssuer; use App\Security\AccountTokenStatus; use App\Security\AccountTokenType; -use App\View\Alert\MercureAvailability; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -53,38 +52,6 @@ public function testProfileRouteRendersAccountSkeleton(): void self::assertSelectorNotExists('input[name="username"]'); } - public function testProfileCanEnableNativeNotificationsWhenMercureIsDisabled(): void - { - $client = self::createClient(); - $config = self::getContainer()->get(Config::class); - self::assertTrue($config->set(MercureAvailability::ENABLED_KEY, false)); - self::assertTrue($config->set(MercureAvailability::AVAILABLE_KEY, false)); - $user = $this->createUserWithLevel(1, 'pollnotify', 'profile-password'); - - $this->loginTestUser($client, $user); - $crawler = $client->request('GET', '/user/profile'); - - self::assertResponseIsSuccessful(); - self::assertSelectorExists('input[name="native_notifications"]'); - - $client->request('POST', '/user/profile', [ - '_csrf_token' => (string) $crawler->filter('input[name="_csrf_token"]')->attr('value'), - 'email' => $user->email(), - 'display_name' => 'Polling Notifications', - 'language' => 'default', - 'native_notifications' => '1', - ]); - - self::assertResponseRedirects('/user/profile'); - - $entityManager = self::getContainer()->get(EntityManagerInterface::class); - $entityManager->clear(); - $updatedUser = $entityManager->find(UserAccount::class, $user->uid()); - - self::assertInstanceOf(UserAccount::class, $updatedUser); - self::assertTrue($updatedUser->settings()['native_notifications'] ?? false); - } - public function testProfileUsernameChangeRequiresSetting(): void { $client = self::createClient(); diff --git a/tests/assets/controller_foundation.test.mjs b/tests/assets/controller_foundation.test.mjs index 3ebfb41b..79c98cde 100644 --- a/tests/assets/controller_foundation.test.mjs +++ b/tests/assets/controller_foundation.test.mjs @@ -16,8 +16,6 @@ const { default: CookieConsentController } = await loadStimulusController('asset 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: NativeNotificationPreferenceController } = await loadStimulusController('assets/controllers/native_notification_preference_controller.js'); -const { default: NativeNotificationsController } = await loadStimulusController('assets/controllers/native_notifications_controller.js'); const { default: TabsController } = await loadStimulusController('assets/controllers/tabs_controller.js'); test('disclosure updates panels and trigger state when the open value changes', () => { @@ -207,132 +205,6 @@ test('cookie consent opens from a trigger and can reject optional choices', () = assert.equal(option.checked, false); }); -test('native notifications are emitted only when enabled and browser permission is granted', () => { - installDom(); - - const notifications = []; - function Notification(title, options) { - notifications.push({ title, options }); - } - Notification.permission = 'granted'; - window.Notification = Notification; - globalThis.Notification = Notification; - - const controller = new NativeNotificationsController(); - controller.enabledValue = true; - controller.titleValue = 'Fallback title'; - controller.connect(); - - document.dispatchEvent(new CustomEvent('ui-alert:shown', { - detail: { title: 'Alert title', message: 'Alert body' }, - })); - - assert.deepEqual(notifications, [{ - title: 'Alert title', - options: { body: 'Alert body' }, - }]); -}); - -test('native notification preference requests permission before submitting', async () => { - installDom(); - - const alerts = []; - document.addEventListener('ui-alert:received', (receivedEvent) => alerts.push(receivedEvent.detail)); - - function Notification() {} - Notification.permission = 'default'; - Notification.requestPermission = async () => 'denied'; - window.Notification = Notification; - globalThis.Notification = Notification; - - const controller = new NativeNotificationPreferenceController(); - const form = new FakeFormElement(); - const input = new FakeInputElement('checkbox'); - const submitter = new FakeElement('button'); - input.checked = true; - controller.element = form; - controller.hasInputTarget = true; - controller.inputTarget = input; - controller.deniedMessageValue = 'Permission denied'; - - const submitEvent = event({ submitter }); - await controller.submit(submitEvent); - - assert.equal(submitEvent.defaultPrevented, true); - assert.equal(input.checked, false); - assert.equal(form.submitted, true); - assert.equal(form.submitter, submitter); - assert.deepEqual(alerts, [{ - level: 'warning', - message: 'Permission denied', - mode: 'auto', - }]); -}); - -test('native notification preference blocks unsupported opt-ins before submitting', async () => { - installDom(); - - const alerts = []; - document.addEventListener('ui-alert:received', (receivedEvent) => alerts.push(receivedEvent.detail)); - window.Notification = undefined; - delete globalThis.Notification; - - const controller = new NativeNotificationPreferenceController(); - const form = new FakeFormElement(); - const input = new FakeInputElement('checkbox'); - input.checked = true; - controller.element = form; - controller.hasInputTarget = true; - controller.inputTarget = input; - controller.deniedMessageValue = 'Permission denied'; - - const submitEvent = event(); - await controller.submit(submitEvent); - - assert.equal(submitEvent.defaultPrevented, true); - assert.equal(input.checked, false); - assert.equal(form.submitted, true); - assert.deepEqual(alerts, [{ - level: 'warning', - message: 'Permission denied', - mode: 'auto', - }]); -}); - -test('native notification preference blocks denied opt-ins before submitting', async () => { - installDom(); - - const alerts = []; - document.addEventListener('ui-alert:received', (receivedEvent) => alerts.push(receivedEvent.detail)); - - function Notification() {} - Notification.permission = 'denied'; - Notification.requestPermission = async () => 'granted'; - window.Notification = Notification; - globalThis.Notification = Notification; - - const controller = new NativeNotificationPreferenceController(); - const form = new FakeFormElement(); - const input = new FakeInputElement('checkbox'); - input.checked = true; - controller.element = form; - controller.hasInputTarget = true; - controller.inputTarget = input; - controller.deniedMessageValue = 'Permission denied'; - - const submitEvent = event(); - await controller.submit(submitEvent); - - assert.equal(submitEvent.defaultPrevented, true); - assert.equal(input.checked, false); - assert.equal(form.submitted, true); - assert.deepEqual(alerts, [{ - level: 'warning', - message: 'Permission denied', - mode: 'auto', - }]); -}); - function tab(id) { const element = new FakeElement('button'); element.dataset.tabsId = id; diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index 39f3801d..e35dc84b 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -99,10 +99,6 @@ ui: display_name: 'Anzeigename' language: 'Sprache' language_default: 'Standard' - native_notifications: - label: 'Native Browser-Benachrichtigungen' - help: 'Zeige Browser-Benachrichtigungen, wenn Echtzeit-Zustellung verfügbar ist.' - denied: 'Browser-Benachrichtigungen wurden nicht aktiviert, weil die Browser-Berechtigung nicht erteilt wurde.' submit: 'Profil speichern' success: 'Profil gespeichert.' close: diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 4eb56f32..864c372e 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -99,10 +99,6 @@ ui: display_name: 'Display name' language: 'Language' language_default: 'Default' - native_notifications: - label: 'Native browser notifications' - help: 'Show browser-level notifications when real-time delivery is available.' - denied: 'Browser notifications were not enabled because the browser permission was not granted.' submit: 'Save profile' success: 'Profile saved.' close: From f6c87b04bcfed191adac8ef35f8ebed421f54c9d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 02:03:26 +0200 Subject: [PATCH 58/67] Harden cookie filtering and package alerts --- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Backend/BackendActionResponder.php | 24 +------- src/Controller/AdminPackageController.php | 9 +-- src/Privacy/Cookie/ConsentCookieJar.php | 12 +--- .../Cookie/CookieConsentDefinition.php | 10 ++++ .../CookieConsentResponseSubscriber.php | 6 +- .../Alert/WorkflowResultAlertSelector.php | 39 +++++++++++++ .../Cookie/CookieConsentManagerTest.php | 55 +++++++++++++++++++ .../Alert/WorkflowResultAlertSelectorTest.php | 55 +++++++++++++++++++ 10 files changed, 173 insertions(+), 40 deletions(-) create mode 100644 src/View/Alert/WorkflowResultAlertSelector.php create mode 100644 tests/View/Alert/WorkflowResultAlertSelectorTest.php diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index a4d0f3eb..2a9150d4 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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\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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, quiet text 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/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, quiet text 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index fc461fb9..3b18cbe2 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -116,6 +116,7 @@ - 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. - 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. diff --git a/src/Backend/BackendActionResponder.php b/src/Backend/BackendActionResponder.php index 2394af9b..a989215c 100644 --- a/src/Backend/BackendActionResponder.php +++ b/src/Backend/BackendActionResponder.php @@ -7,13 +7,12 @@ use App\Backend\BackendMessageKey; use App\Core\Message\CommonMessageCode; use App\Core\Message\Message; -use App\Core\Message\MessageLevel; 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; @@ -26,6 +25,7 @@ public function __construct( private LiveOperationHttpResponder $liveOperationResponder, private FormTokenValidator $formTokenValidator, private UiAlertDispatcherInterface $alerts, + private WorkflowResultAlertSelector $alertSelector, ) { } @@ -88,25 +88,7 @@ private function audit(mixed $user, string $action, WorkflowResult $result, stri */ private function flashResult(WorkflowResult $result): void { - $message = $result->isSuccess() - ? ($this->firstMessageWithLevel($result, MessageLevel::Success) ?? Message::success(BackendMessageKey::BACKEND_ACTION_CACHE_CLEAR_COMPLETED)) - : ($result->firstIssue() ?? Message::error(CommonMessageCode::E_OPERATION_FAILED, OperationMessageKey::OPERATION_EXCEPTION)); - - $this->alerts->addAlert($message, UiAlertDelivery::Direct); - } - - /** - * @param WorkflowResult $result - */ - private function firstMessageWithLevel(WorkflowResult $result, MessageLevel $level): ?Message - { - foreach ($result->messages() as $message) { - if ($message->level() === $level) { - return $message; - } - } - - return null; + $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } private function stringField(Request $request, string $name): string diff --git a/src/Controller/AdminPackageController.php b/src/Controller/AdminPackageController.php index ae675afa..2846a519 100644 --- a/src/Controller/AdminPackageController.php +++ b/src/Controller/AdminPackageController.php @@ -14,12 +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; @@ -39,6 +39,7 @@ public function __construct( private readonly LiveOperationHttpResponder $liveOperationResponder, private readonly FormTokenValidator $formTokenValidator, private readonly UiAlertDispatcherInterface $alerts, + private readonly WorkflowResultAlertSelector $alertSelector, ) { } @@ -236,11 +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->alerts->addAlert($message, UiAlertDelivery::Direct); + $this->alerts->addAlert($this->alertSelector->fromResult($result), UiAlertDelivery::Direct); } private function stringField(Request $request, string $name): string diff --git a/src/Privacy/Cookie/ConsentCookieJar.php b/src/Privacy/Cookie/ConsentCookieJar.php index 4013641d..86cfc801 100644 --- a/src/Privacy/Cookie/ConsentCookieJar.php +++ b/src/Privacy/Cookie/ConsentCookieJar.php @@ -33,7 +33,7 @@ public function set(Request $request, Response $response, CookieConsentDefinitio } $cookie ??= $definition->cookie(); - if (!$this->sameCookieIdentity($definition->cookie(), $cookie)) { + if (!$definition->matchesCookieIdentity($cookie)) { return false; } @@ -41,14 +41,4 @@ public function set(Request $request, Response $response, CookieConsentDefinitio return true; } - - private function sameCookieIdentity(Cookie $expected, Cookie $actual): bool - { - return $expected->getName() === $actual->getName() - && $expected->getPath() === $actual->getPath() - && $expected->getDomain() === $actual->getDomain() - && $expected->isSecure() === $actual->isSecure() - && $expected->isHttpOnly() === $actual->isHttpOnly() - && $expected->getSameSite() === $actual->getSameSite(); - } } diff --git a/src/Privacy/Cookie/CookieConsentDefinition.php b/src/Privacy/Cookie/CookieConsentDefinition.php index e437c382..6022ce9c 100644 --- a/src/Privacy/Cookie/CookieConsentDefinition.php +++ b/src/Privacy/Cookie/CookieConsentDefinition.php @@ -69,6 +69,16 @@ public function privacyUrl(): string return $this->privacyUrl; } + public function matchesCookieIdentity(Cookie $cookie): bool + { + return $this->cookie->getName() === $cookie->getName() + && $this->cookie->getPath() === $cookie->getPath() + && $this->cookie->getDomain() === $cookie->getDomain() + && $this->cookie->isSecure() === $cookie->isSecure() + && $this->cookie->isHttpOnly() === $cookie->isHttpOnly() + && $this->cookie->getSameSite() === $cookie->getSameSite(); + } + private function privacyUrlAllowed(string $url): bool { $url = trim($url); diff --git a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php index aad2e173..8aca85dc 100644 --- a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php +++ b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php @@ -36,7 +36,11 @@ public function filterCookies(ResponseEvent $event): void } $definition = $this->registry->definition($cookie->getName()); - if (!$definition instanceof CookieConsentDefinition || $this->consent->allowed($request, $definition)) { + if (!$definition instanceof CookieConsentDefinition) { + continue; + } + + if ($definition->matchesCookieIdentity($cookie) && $this->consent->allowed($request, $definition)) { continue; } diff --git a/src/View/Alert/WorkflowResultAlertSelector.php b/src/View/Alert/WorkflowResultAlertSelector.php new file mode 100644 index 00000000..e5c6a875 --- /dev/null +++ b/src/View/Alert/WorkflowResultAlertSelector.php @@ -0,0 +1,39 @@ + $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/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 1c51d374..289db366 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -253,6 +253,61 @@ public function testResponseSubscriberRemovesActiveOptionalCookiesWithoutConsent ))); } + 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([ 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); + } +} From 9dce0f9b5713ea92a230918036a4d17b334596d8 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 02:26:34 +0200 Subject: [PATCH 59/67] Harden alert actions and URL guards --- assets/controllers/filter_form_controller.js | 2 +- assets/js/alerts/alert_element.js | 93 +++++++++++++++++-- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + src/Backend/PackageAdminLinkResolver.php | 2 +- .../Routing/ContentRedirectResolver.php | 10 +- src/Core/Mercure/MercureRuntime.php | 13 ++- src/View/Alert/UiAlert.php | 16 +++- src/View/Alert/UiAlertAction.php | 81 +++++++++++++++- src/View/Alert/UiAlertPresentation.php | 6 +- templates/components/Alert.html.twig | 1 + .../Backend/PackageAdminLinkResolverTest.php | 1 + .../Routing/ContentRedirectResolverTest.php | 23 +++++ tests/View/Alert/UiAlertTest.php | 33 +++++++ tests/assets/alert_payload.test.mjs | 32 +++++++ tests/assets/controller_foundation.test.mjs | 1 + 16 files changed, 294 insertions(+), 23 deletions(-) diff --git a/assets/controllers/filter_form_controller.js b/assets/controllers/filter_form_controller.js index bdf6bc69..ee2c48df 100644 --- a/assets/controllers/filter_form_controller.js +++ b/assets/controllers/filter_form_controller.js @@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static fallbackStorage = new Map(); - static storagePrefix = 'studio.filter-form.focus.'; + static storagePrefix = 'system.filter-form.focus.'; static values = { autoSubmit: { type: Boolean, default: true }, diff --git a/assets/js/alerts/alert_element.js b/assets/js/alerts/alert_element.js index d2fb75c7..ff5a0d65 100644 --- a/assets/js/alerts/alert_element.js +++ b/assets/js/alerts/alert_element.js @@ -7,6 +7,7 @@ export function createAlertElement(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'; @@ -18,6 +19,7 @@ export function updateAlertElement(alert, payload, closeLabel) { id: alert.dataset.alertId, level, mode, + actions, }); alert.replaceChildren(); @@ -60,7 +62,7 @@ export function updateAlertElement(alert, payload, closeLabel) { content.append(messageElement); } - appendActions(content, Array.isArray(payload.actions) ? payload.actions : []); + appendActions(content, actions); alert.append(content); alert.append(closeButton(closeLabel)); @@ -81,16 +83,14 @@ function alertIcon(level) { } function appendActions(content, actions) { - const validActions = actions.filter((action) => action && String(action.label || '').trim()); - - if (validActions.length === 0) { + if (actions.length === 0) { return; } const actionList = document.createElement('div'); actionList.className = 'system-alert-actions'; - for (const action of validActions) { + for (const action of actions) { actionList.append(actionElement(action)); } @@ -98,15 +98,19 @@ function appendActions(content, actions) { } function actionElement(action) { - const element = action.href ? document.createElement('a') : document.createElement('button'); + 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 (action.href) { - element.href = String(action.href); + 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'; @@ -123,6 +127,79 @@ function actionElement(action) { 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'; diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 2a9150d4..2782a451 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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, quiet text 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` | +| 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 3b18cbe2..aaf78c98 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -115,6 +115,7 @@ - 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. - 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. 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/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/Core/Mercure/MercureRuntime.php b/src/Core/Mercure/MercureRuntime.php index 68eeb68a..62f58bd9 100644 --- a/src/Core/Mercure/MercureRuntime.php +++ b/src/Core/Mercure/MercureRuntime.php @@ -367,7 +367,18 @@ private function envFilePath(): string ]); if (!is_file($path) || (string) @file_get_contents($path) !== $contents) { - @file_put_contents($path, $contents, LOCK_EX); + $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); diff --git a/src/View/Alert/UiAlert.php b/src/View/Alert/UiAlert.php index b91930f0..d8a6b2f6 100644 --- a/src/View/Alert/UiAlert.php +++ b/src/View/Alert/UiAlert.php @@ -152,8 +152,9 @@ public function toArray(): array $payload['id'] = $this->id; } - if ([] !== $this->actions) { - $payload['actions'] = $this->actions; + $actions = $this->normalizedActions(); + if ([] !== $actions) { + $payload['actions'] = $actions; } if (null !== $this->code) { @@ -187,4 +188,15 @@ private function normalizedMode(): string 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 index 6d8f29c1..2ed8510b 100644 --- a/src/View/Alert/UiAlertAction.php +++ b/src/View/Alert/UiAlertAction.php @@ -31,19 +31,60 @@ public static function event(string $label, string $event, array $detail = []): 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' => $this->label]; + $action = ['label' => trim($this->label)]; - if (null !== $this->href && '' !== trim($this->href)) { - $action['href'] = $this->href; + if (null !== $this->href && '' !== trim($this->href) && self::hrefAllowed($this->href)) { + $action['href'] = trim($this->href); } - if (null !== $this->target && '' !== trim($this->target)) { - $action['target'] = $this->target; + 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)) { @@ -56,4 +97,34 @@ public function toArray(): array 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/UiAlertPresentation.php b/src/View/Alert/UiAlertPresentation.php index 31395184..0d3ba6f8 100644 --- a/src/View/Alert/UiAlertPresentation.php +++ b/src/View/Alert/UiAlertPresentation.php @@ -80,9 +80,11 @@ public function id(): ?string public function actions(): array { return array_values(array_filter(array_map( - static fn (UiAlertAction|array $action): array => $action instanceof UiAlertAction ? $action->toArray() : $action, + static fn (UiAlertAction|array $action): ?array => $action instanceof UiAlertAction + ? UiAlertAction::normalize($action->toArray()) + : UiAlertAction::normalize($action), $this->actions, - ), static fn (array $action): bool => is_string($action['label'] ?? null) && '' !== trim($action['label']))); + ))); } public function isLoading(): ?bool diff --git a/templates/components/Alert.html.twig b/templates/components/Alert.html.twig index 3dcba4b8..b85c8a81 100644 --- a/templates/components/Alert.html.twig +++ b/templates/components/Alert.html.twig @@ -76,6 +76,7 @@ class="system-alert-action" href="{{ action.href }}" {% if action.target|default('') %}target="{{ action.target }}"{% endif %} + {% if action.target|default('') == '_blank' %}rel="noopener noreferrer"{% endif %} data-action="alert-stack#action" >{{ label }} {% else %} 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/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/View/Alert/UiAlertTest.php b/tests/View/Alert/UiAlertTest.php index c294f9e4..920cd454 100644 --- a/tests/View/Alert/UiAlertTest.php +++ b/tests/View/Alert/UiAlertTest.php @@ -66,4 +66,37 @@ public function testItDoesNotSerializeDiagnosticContext(): void 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/assets/alert_payload.test.mjs b/tests/assets/alert_payload.test.mjs index 654c4684..b8fed53d 100644 --- a/tests/assets/alert_payload.test.mjs +++ b/tests/assets/alert_payload.test.mjs @@ -9,6 +9,8 @@ import { 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']); @@ -122,3 +124,33 @@ test('actionDetailFromElement parses action details safely', () => { }, }), {}); }); + +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 index 79c98cde..33e24c62 100644 --- a/tests/assets/controller_foundation.test.mjs +++ b/tests/assets/controller_foundation.test.mjs @@ -148,6 +148,7 @@ test('filter form submit stores focus state, resets pagination, and submits the 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'); }); From 6e6e8dd00cc057777baef19981d0b3df07a5387d Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 07:56:23 +0200 Subject: [PATCH 60/67] Preserve auto-secure necessary cookies --- .../Cookie/CookieConsentDefinition.php | 12 ++++- .../CookieConsentResponseSubscriber.php | 2 +- .../Cookie/CookieConsentManagerTest.php | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Privacy/Cookie/CookieConsentDefinition.php b/src/Privacy/Cookie/CookieConsentDefinition.php index 6022ce9c..c5d6e45f 100644 --- a/src/Privacy/Cookie/CookieConsentDefinition.php +++ b/src/Privacy/Cookie/CookieConsentDefinition.php @@ -70,11 +70,21 @@ public function privacyUrl(): string } 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() - && $this->cookie->isSecure() === $cookie->isSecure() + && (!$compareSecure || $this->cookie->isSecure() === $cookie->isSecure()) && $this->cookie->isHttpOnly() === $cookie->isHttpOnly() && $this->cookie->getSameSite() === $cookie->getSameSite(); } diff --git a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php index 8aca85dc..bb36a1fd 100644 --- a/src/Privacy/Cookie/CookieConsentResponseSubscriber.php +++ b/src/Privacy/Cookie/CookieConsentResponseSubscriber.php @@ -40,7 +40,7 @@ public function filterCookies(ResponseEvent $event): void continue; } - if ($definition->matchesCookieIdentity($cookie) && $this->consent->allowed($request, $definition)) { + if ($definition->matchesResponseCookie($cookie) && $this->consent->allowed($request, $definition)) { continue; } diff --git a/tests/Privacy/Cookie/CookieConsentManagerTest.php b/tests/Privacy/Cookie/CookieConsentManagerTest.php index 289db366..c66c5afe 100644 --- a/tests/Privacy/Cookie/CookieConsentManagerTest.php +++ b/tests/Privacy/Cookie/CookieConsentManagerTest.php @@ -253,6 +253,53 @@ public function testResponseSubscriberRemovesActiveOptionalCookiesWithoutConsent ))); } + 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( From 228f50cd325b8ba455a2f19c010846e68223311a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 08:58:28 +0200 Subject: [PATCH 61/67] Fall back when alert streams cannot open --- .../controllers/ui_alert_stream_controller.js | 64 +++++++++- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + templates/components/AlertStack.html.twig | 2 + .../View/Twig/TwigComponentNamespaceTest.php | 1 + tests/assets/live_alert_controllers.test.mjs | 112 ++++++++++++++++++ 6 files changed, 175 insertions(+), 7 deletions(-) diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js index 0e027431..d7f674b1 100644 --- a/assets/controllers/ui_alert_stream_controller.js +++ b/assets/controllers/ui_alert_stream_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 reconnectBaseDelay = 1000; @@ -9,17 +10,27 @@ export default class extends Controller { 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 || typeof window.EventSource !== 'function') { + 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.openSource(); } @@ -28,6 +39,7 @@ export default class extends Controller { window.clearTimeout(this.reconnectTimer); document.removeEventListener('visibilitychange', this.reconnectWhenActive); window.removeEventListener('online', this.reconnectWhenActive); + this.fallbackPoller?.stop(); this.closeSource(); } @@ -45,12 +57,19 @@ export default class extends Controller { 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(); } }; @@ -97,20 +116,53 @@ export default class extends Controller { const payload = await response.json(); const cursor = Number(payload.cursor); if (Number.isFinite(cursor)) { - this.catchUpCursorValue = Math.max(0, this.catchUpCursorValue || 0, cursor); + this.updateCursor(cursor); } for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { - this.element.dispatchEvent(new CustomEvent('ui-alert:received', { - bubbles: true, - detail: alert, - })); + this.dispatchAlert(alert); } } catch { // Stream delivery remains active; the next open/reconnect can catch up again. } } + 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; diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 2782a451..211306b6 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -370,7 +370,7 @@ | 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 | | 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback only when no stream URL is rendered, 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` | +| 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback when no stream URL is rendered or the browser cannot open the stream, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index aaf78c98..64e94806 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -116,6 +116,7 @@ - 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. - 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. diff --git a/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 81aa455d..177c804a 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -16,6 +16,8 @@ 'data-controller': stack_attributes['data-controller'] ~ ' ui-alert-stream', 'data-ui-alert-stream-url-value': stream_url, 'data-ui-alert-stream-catch-up-url-value': stream_topics is not empty ? path('api_live_alerts') : null, + 'data-ui-alert-stream-fallback-url-value': stream_topics is not empty ? path('api_live_alerts') : null, + 'data-ui-alert-stream-fallback-interval-value': 15000, }) %} {% endif %} {% if poll_url %} diff --git a/tests/View/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php index cceab976..0d775371 100644 --- a/tests/View/Twig/TwigComponentNamespaceTest.php +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -51,6 +51,7 @@ public function testAlertStackUsesPollingOnlyWhenNoMercureStreamIsRendered(): vo self::assertStringContainsString('ui-alert-stream', $stream); self::assertStringContainsString('data-ui-alert-stream-url-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 { diff --git a/tests/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index e80ad190..23eab2e6 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -368,9 +368,121 @@ test('UI alert stream schedules reconnect when the stream closes', () => { 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' }]); +}); From 96ba7dee380535f2978196532a57c7e3b3b54e25 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 09:46:32 +0200 Subject: [PATCH 62/67] Keep operation details reopenable --- assets/controllers/alert_stack_controller.js | 7 +- .../operation_overlay_controller.js | 8 ++ dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 1 + tests/assets/controller_foundation.test.mjs | 90 +++++++++++++++++++ tests/assets/live_alert_controllers.test.mjs | 46 ++++++++++ 6 files changed, 152 insertions(+), 2 deletions(-) diff --git a/assets/controllers/alert_stack_controller.js b/assets/controllers/alert_stack_controller.js index 709bd47a..2da51b94 100644 --- a/assets/controllers/alert_stack_controller.js +++ b/assets/controllers/alert_stack_controller.js @@ -92,14 +92,19 @@ export default class extends Controller { 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: actionDetailFromElement(action), + detail, })); } + if (detail.keepAlert === true) { + return; + } + this.closeAlert(alert); } diff --git a/assets/controllers/operation_overlay_controller.js b/assets/controllers/operation_overlay_controller.js index 5f3df779..413bae2f 100644 --- a/assets/controllers/operation_overlay_controller.js +++ b/assets/controllers/operation_overlay_controller.js @@ -315,6 +315,12 @@ export default class extends Controller { }; close = () => { + if (this.polling || this.starting) { + this.rootElement.hidden = true; + + return; + } + this.polling = false; this.livePoller?.stop(); this.rootElement.hidden = true; @@ -327,6 +333,7 @@ export default class extends Controller { this.resultRendered = false; this.stepElements = new Map(); this.hideButtons(); + this.showCloseControls(); } finish(payload) { @@ -567,6 +574,7 @@ export default class extends Controller { event: 'operation-overlay:show', detail: { storageKey: this.storageKey(), + keepAlert: !terminal, }, }], }); diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 211306b6..2af8241c 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -369,7 +369,7 @@ | 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 | -| 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, 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, and close controls. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs`, `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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback when no stream URL is rendered or the browser cannot open the stream, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 64e94806..a030ef94 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -117,6 +117,7 @@ - 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. - 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. diff --git a/tests/assets/controller_foundation.test.mjs b/tests/assets/controller_foundation.test.mjs index 33e24c62..590cfb34 100644 --- a/tests/assets/controller_foundation.test.mjs +++ b/tests/assets/controller_foundation.test.mjs @@ -16,6 +16,7 @@ const { default: CookieConsentController } = await loadStimulusController('asset 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', () => { @@ -206,6 +207,68 @@ test('cookie consent opens from a trigger and can reject optional choices', () = 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; @@ -220,6 +283,33 @@ function panel(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`, { diff --git a/tests/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index 23eab2e6..c345285c 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -205,6 +205,52 @@ test('alert stack auto-dismiss keeps persistent alerts active', () => { 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(); From 429de8ae3e3a8d194cee4799bbe694353858d26b Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 12:21:00 +0200 Subject: [PATCH 63/67] Harden alert catch-up and CSS linting --- .../controllers/ui_alert_stream_controller.js | 52 ++++++---- bin/lint | 27 +----- dev/CLASSMAP.md | 6 +- dev/WORKLOG.md | 1 + src/Controller/LiveAlertController.php | 1 + src/Core/Lint/CssLinter.php | 33 +++++++ .../Package/PackageFileSyntaxValidator.php | 31 +----- src/View/Alert/UiAlertInbox.php | 13 ++- src/View/Twig/UiAlertTwigExtension.php | 17 +++- templates/components/AlertStack.html.twig | 1 + tests/Core/Package/PackageValidatorTest.php | 18 ++++ tests/View/Alert/UiAlertInboxTest.php | 19 ++++ .../View/Twig/TwigComponentNamespaceTest.php | 1 + tests/View/Twig/UiAlertTwigExtensionTest.php | 96 ++++++++++++++++++- tests/assets/live_alert_controllers.test.mjs | 21 ++-- 15 files changed, 251 insertions(+), 86 deletions(-) diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js index d7f674b1..e0b7ce5d 100644 --- a/assets/controllers/ui_alert_stream_controller.js +++ b/assets/controllers/ui_alert_stream_controller.js @@ -103,30 +103,48 @@ export default class extends Controller { } try { - const url = new URL(this.catchUpUrlValue, window.location.origin); - url.searchParams.set('cursor', String(Math.max(0, this.catchUpCursorValue || 0))); - const response = await window.fetch(url.toString(), { - credentials: 'same-origin', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - return; - } + let previousCursor = -1; - const payload = await response.json(); - const cursor = Number(payload.cursor); - if (Number.isFinite(cursor)) { - this.updateCursor(cursor); - } + do { + previousCursor = Math.max(0, this.catchUpCursorValue || 0); - for (const alert of Array.isArray(payload.alerts) ? payload.alerts : []) { - this.dispatchAlert(alert); - } + 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. } } + 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) { diff --git a/bin/lint b/bin/lint index 3def7f67..3344fc2c 100755 --- a/bin/lint +++ b/bin/lint @@ -308,7 +308,9 @@ function lintCssFiles(string $root, array $files): int $result = $linter->lint($contents, $relative); foreach ($result->issues() as $issue) { - if (isTailwindDirectiveCssIssue($contents, $issue->line())) { + if (isTailwindDirectiveCssIssue($contents, $issue->line()) + && $linter->lint(CssLinter::withoutTailwindDirectiveLines($contents), $relative)->isSuccess() + ) { fwrite(STDOUT, sprintf( "%s:%s:%s strict CSS parser skipped a Tailwind directive; tailwind:build remains authoritative for this file.\n", $relative, @@ -339,28 +341,7 @@ function lintCssFiles(string $root, array $files): int function isTailwindDirectiveCssIssue(string $contents, ?int $line): bool { - if (null === $line) { - return false; - } - - $lines = preg_split('/\R/', $contents) ?: []; - $texts = [ - trim((string) ($lines[$line - 2] ?? '')), - trim((string) ($lines[$line - 1] ?? '')), - trim((string) ($lines[$line] ?? '')), - ]; - - foreach ($texts as $text) { - if (str_starts_with($text, '@apply ') - || str_starts_with($text, '@theme ') - || str_starts_with($text, '@custom-variant ') - || str_starts_with($text, '@source ') - ) { - return true; - } - } - - return false; + return CssLinter::isTailwindDirectiveLine($contents, $line); } /** diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 2af8241c..e8ec385d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -337,7 +337,7 @@ | 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, with focused CSS lint treating known Tailwind directives as informational while `tailwind:build` remains authoritative. | `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 directive lines as informational only after reparsing without Tailwind directive lines 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` | @@ -350,7 +350,7 @@ | `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 with Tailwind directive tolerance where the strict CSS parser cannot understand build-time directives, 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 @@ -370,7 +370,7 @@ | 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 | | 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, 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, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, one-shot inbox catch-up on stream open/reconnect, 15-second lazy polling fallback when no stream URL is rendered or the browser cannot open the stream, 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` | +| 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 on stream open/reconnect, 15-second lazy polling fallback when no stream URL is rendered or the browser cannot open the stream, 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index a030ef94..6900bd78 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -118,6 +118,7 @@ - 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 Tailwind CSS directive parse errors only when a second parse without directive lines finds no adjacent syntax error. - 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. diff --git a/src/Controller/LiveAlertController.php b/src/Controller/LiveAlertController.php index c89bd684..577ffe70 100644 --- a/src/Controller/LiveAlertController.php +++ b/src/Controller/LiveAlertController.php @@ -50,6 +50,7 @@ public function poll(Request $request): Response return $this->json->render([ 'cursor' => $payload['cursor'], 'alerts' => $payload['alerts'], + 'has_more' => $payload['has_more'], 'next_poll_ms' => self::POLL_INTERVAL_MS, ]); } diff --git a/src/Core/Lint/CssLinter.php b/src/Core/Lint/CssLinter.php index 70270c61..649a2683 100644 --- a/src/Core/Lint/CssLinter.php +++ b/src/Core/Lint/CssLinter.php @@ -12,6 +12,39 @@ final class CssLinter implements LinterInterface { + public static function isTailwindDirectiveLine(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); + } + + public static function withoutTailwindDirectiveLines(string $contents): string + { + $lines = preg_split('/\R/', $contents) ?: []; + + foreach ($lines as $index => $line) { + if (self::isTailwindDirectiveText(trim((string) $line))) { + $lines[$index] = '/* Tailwind directive omitted for strict CSS parsing. */'; + } + } + + return 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 '); + } + public function lint(string $contents, ?string $path = null): LintResult { try { diff --git a/src/Core/Package/PackageFileSyntaxValidator.php b/src/Core/Package/PackageFileSyntaxValidator.php index e642d492..366c0c84 100644 --- a/src/Core/Package/PackageFileSyntaxValidator.php +++ b/src/Core/Package/PackageFileSyntaxValidator.php @@ -84,7 +84,10 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte $lintResult = $linter->lint($contents, $file); foreach ($lintResult->issues() as $lintIssue) { - if ($linter instanceof CssLinter && $this->isTailwindDirectiveCssIssue($contents, $lintIssue->line())) { + if ($linter instanceof CssLinter + && CssLinter::isTailwindDirectiveLine($contents, $lintIssue->line()) + && $linter->lint(CssLinter::withoutTailwindDirectiveLines($contents), $file)->isSuccess() + ) { continue; } @@ -100,30 +103,4 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte return $issues; } - - private function isTailwindDirectiveCssIssue(string $contents, ?int $line): bool - { - if (null === $line) { - return false; - } - - $lines = preg_split('/\R/', $contents) ?: []; - $texts = [ - trim((string) ($lines[$line - 2] ?? '')), - trim((string) ($lines[$line - 1] ?? '')), - trim((string) ($lines[$line] ?? '')), - ]; - - foreach ($texts as $text) { - if (str_starts_with($text, '@apply ') - || str_starts_with($text, '@theme ') - || str_starts_with($text, '@custom-variant ') - || str_starts_with($text, '@source ') - ) { - return true; - } - } - - return false; - } } diff --git a/src/View/Alert/UiAlertInbox.php b/src/View/Alert/UiAlertInbox.php index 003feb29..e1b9f6be 100644 --- a/src/View/Alert/UiAlertInbox.php +++ b/src/View/Alert/UiAlertInbox.php @@ -63,28 +63,31 @@ public function append(array $topics, UiAlert $alert, ?int $ttlSeconds = 86400): /** * @param list $topics * - * @return array{cursor: int, alerts: list>} + * @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' => []]; + 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, max(1, min(250, $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' => []]; + 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)); @@ -94,7 +97,7 @@ public function poll(array $topics, int $cursor = 0, int $limit = self::DEFAULT_ } } - return ['cursor' => $nextCursor, 'alerts' => $alerts]; + return ['cursor' => $nextCursor, 'alerts' => $alerts, 'has_more' => $hasMore]; } public function cleanupExpired(): int diff --git a/src/View/Twig/UiAlertTwigExtension.php b/src/View/Twig/UiAlertTwigExtension.php index b6e0a483..1f246517 100644 --- a/src/View/Twig/UiAlertTwigExtension.php +++ b/src/View/Twig/UiAlertTwigExtension.php @@ -61,7 +61,7 @@ public function streamUrl(?array $topics = null): ?string } try { - return $this->mercure->mercure($topics); + return $this->mercure->mercure($topics, $this->authorizationOptions($topics)); } catch (Throwable) { return null; } @@ -93,4 +93,19 @@ public function storageScope(): string 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/templates/components/AlertStack.html.twig b/templates/components/AlertStack.html.twig index 177c804a..d0bc3b96 100644 --- a/templates/components/AlertStack.html.twig +++ b/templates/components/AlertStack.html.twig @@ -15,6 +15,7 @@ {% set stack_attributes = stack_attributes|merge({ 'data-controller': stack_attributes['data-controller'] ~ ' ui-alert-stream', 'data-ui-alert-stream-url-value': stream_url, + 'data-ui-alert-stream-credentials-value': true, 'data-ui-alert-stream-catch-up-url-value': stream_topics is not empty ? path('api_live_alerts') : null, 'data-ui-alert-stream-fallback-url-value': stream_topics is not empty ? path('api_live_alerts') : null, 'data-ui-alert-stream-fallback-interval-value': 15000, diff --git a/tests/Core/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php index fa3538d7..97a6b183 100644 --- a/tests/Core/Package/PackageValidatorTest.php +++ b/tests/Core/Package/PackageValidatorTest.php @@ -824,6 +824,24 @@ public function testItAcceptsTailwindDirectivesInPackageCssSyntaxChecks(): void 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 testItRejectsCssRulesTargetingClassesOutsidePackageNamespace(): void { $this->writeFile('assets/module.css', <<<'CSS' diff --git a/tests/View/Alert/UiAlertInboxTest.php b/tests/View/Alert/UiAlertInboxTest.php index b5d7226e..ef00150a 100644 --- a/tests/View/Alert/UiAlertInboxTest.php +++ b/tests/View/Alert/UiAlertInboxTest.php @@ -29,9 +29,28 @@ public function testItAppendsAndPollsQueuedAlertsWithoutRequiringInsertIds(): vo '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]); diff --git a/tests/View/Twig/TwigComponentNamespaceTest.php b/tests/View/Twig/TwigComponentNamespaceTest.php index 0d775371..135041b6 100644 --- a/tests/View/Twig/TwigComponentNamespaceTest.php +++ b/tests/View/Twig/TwigComponentNamespaceTest.php @@ -50,6 +50,7 @@ public function testAlertStackUsesPollingOnlyWhenNoMercureStreamIsRendered(): vo 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); diff --git a/tests/View/Twig/UiAlertTwigExtensionTest.php b/tests/View/Twig/UiAlertTwigExtensionTest.php index c3e0c1fa..e63ea8c9 100644 --- a/tests/View/Twig/UiAlertTwigExtensionTest.php +++ b/tests/View/Twig/UiAlertTwigExtensionTest.php @@ -5,6 +5,7 @@ namespace App\Tests\View\Twig; use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Mercure\MercureBinaryManager; use App\Core\Mercure\MercureRuntime; use App\Core\Process\DetachedProcessStarter; @@ -19,8 +20,11 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\Mercure\Authorization; +use Symfony\Component\Mercure\HubRegistry; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Jwt\TokenFactoryInterface; +use Symfony\Component\Mercure\Twig\MercureExtension as MercureTwigExtension; use Symfony\Component\Mercure\Update; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; @@ -48,19 +52,63 @@ public function testStorageScopeUsesExistingSessionCookieWithoutStartingSession( self::assertFalse($secondSession->isStarted()); } - private function extension(Request $request): UiAlertTwigExtension + 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( - new Config($connection), + $config, new MercureRuntime( new MercureBinaryManager('/tmp/studio'), new UiAlertTwigSilentHub(), @@ -71,6 +119,7 @@ private function extension(Request $request): UiAlertTwigExtension '/tmp/studio', ), 'storage-secret', + $mercure, ); } @@ -83,6 +132,49 @@ private function securityContainer(): 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 diff --git a/tests/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index c345285c..85e15b57 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -307,21 +307,22 @@ test('UI alert stream opens EventSource with credentials and forwards valid aler assert.equal(sources[0].closed, true); }); -test('UI alert stream performs a one-time queue catch-up when the stream opens', async () => { +test('UI alert stream drains queued catch-up pages when the stream opens', async () => { const { window } = installDom(); const sources = []; const fetches = []; + const pages = [ + { cursor: 42, has_more: true, alerts: [{ id: 'queued-1', message: 'Queued one' }] }, + { cursor: 45, has_more: false, alerts: [{ id: 'queued-2', message: 'Queued two' }] }, + ]; window.fetch = async (url, options) => { fetches.push({ url, options }); return { ok: true, async json() { - return { - cursor: 42, - alerts: [{ id: 'queued', message: 'Queued fallback' }], - }; + return pages.shift(); }, }; }; @@ -363,14 +364,18 @@ test('UI alert stream performs a one-time queue catch-up when the stream opens', sources[0].listeners.get('open')(); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(fetches.length, 1); + assert.equal(fetches.length, 2); 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.deepEqual(fetches[0].options, { credentials: 'same-origin', headers: { Accept: 'application/json' }, }); - assert.equal(controller.catchUpCursorValue, 42); - assert.deepEqual(received, [{ id: 'queued', message: 'Queued fallback' }]); + assert.equal(controller.catchUpCursorValue, 45); + assert.deepEqual(received, [ + { id: 'queued-1', message: 'Queued one' }, + { id: 'queued-2', message: 'Queued two' }, + ]); }); test('UI alert stream schedules reconnect when the stream closes', () => { From 4e91ead564252d97c53177e50be2d52777f216cc Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 12:30:02 +0200 Subject: [PATCH 64/67] Normalize generated CSS lint inputs --- bin/lint | 10 ++-- dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 2 +- src/Core/Lint/CssLinter.php | 60 ++++++++++++++++++- .../Package/PackageFileSyntaxValidator.php | 4 +- tests/Core/Package/PackageValidatorTest.php | 41 +++++++++++++ 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/bin/lint b/bin/lint index 3344fc2c..dba18d8a 100755 --- a/bin/lint +++ b/bin/lint @@ -308,11 +308,11 @@ function lintCssFiles(string $root, array $files): int $result = $linter->lint($contents, $relative); foreach ($result->issues() as $issue) { - if (isTailwindDirectiveCssIssue($contents, $issue->line()) - && $linter->lint(CssLinter::withoutTailwindDirectiveLines($contents), $relative)->isSuccess() + if (isStrictParserUnsupportedCssIssue($contents, $issue->line()) + && $linter->lint(CssLinter::forStrictParser($contents), $relative)->isSuccess() ) { fwrite(STDOUT, sprintf( - "%s:%s:%s strict CSS parser skipped a Tailwind directive; tailwind:build remains authoritative for this file.\n", + "%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, @@ -339,9 +339,9 @@ function lintCssFiles(string $root, array $files): int return $failures; } -function isTailwindDirectiveCssIssue(string $contents, ?int $line): bool +function isStrictParserUnsupportedCssIssue(string $contents, ?int $line): bool { - return CssLinter::isTailwindDirectiveLine($contents, $line); + return CssLinter::isStrictParserUnsupportedLine($contents, $line); } /** diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index e8ec385d..b4239d6d 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -337,7 +337,7 @@ | 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, with focused CSS lint treating parser errors on known Tailwind directive lines as informational only after reparsing without Tailwind directive lines while `tailwind:build` remains authoritative. | `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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 6900bd78..2c013bb8 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -118,7 +118,7 @@ - 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 Tailwind CSS directive parse errors only when a second parse without directive lines finds no adjacent syntax error. +- 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: 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. diff --git a/src/Core/Lint/CssLinter.php b/src/Core/Lint/CssLinter.php index 649a2683..de9a9e50 100644 --- a/src/Core/Lint/CssLinter.php +++ b/src/Core/Lint/CssLinter.php @@ -13,6 +13,11 @@ final class CssLinter implements LinterInterface { public static function isTailwindDirectiveLine(string $contents, ?int $line): bool + { + return self::isStrictParserUnsupportedLine($contents, $line); + } + + public static function isStrictParserUnsupportedLine(string $contents, ?int $line): bool { if (null === $line) { return false; @@ -21,20 +26,51 @@ public static function isTailwindDirectiveLine(string $contents, ?int $line): bo $lines = preg_split('/\R/', $contents) ?: []; $text = trim((string) ($lines[$line - 1] ?? '')); - return self::isTailwindDirectiveText($text); + return self::isTailwindDirectiveText($text) + || self::isUnsupportedGroupAtRuleText($text) + || self::containsEmptyCustomPropertyFallback($text); } public static function withoutTailwindDirectiveLines(string $contents): string + { + return self::forStrictParser($contents); + } + + public static function forStrictParser(string $contents): string { $lines = preg_split('/\R/', $contents) ?: []; + $depth = 0; + $unwrapGroupClosingDepths = []; foreach ($lines as $index => $line) { - if (self::isTailwindDirectiveText(trim((string) $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 implode("\n", $lines); + return preg_replace('/var\((--[A-Za-z0-9_-]+),\)/', 'var($1, initial)', implode("\n", $lines)) + ?? implode("\n", $lines); } private static function isTailwindDirectiveText(string $text): bool @@ -45,6 +81,24 @@ private static function isTailwindDirectiveText(string $text): bool || 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 { try { diff --git a/src/Core/Package/PackageFileSyntaxValidator.php b/src/Core/Package/PackageFileSyntaxValidator.php index 366c0c84..52a1a25c 100644 --- a/src/Core/Package/PackageFileSyntaxValidator.php +++ b/src/Core/Package/PackageFileSyntaxValidator.php @@ -85,8 +85,8 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte foreach ($lintResult->issues() as $lintIssue) { if ($linter instanceof CssLinter - && CssLinter::isTailwindDirectiveLine($contents, $lintIssue->line()) - && $linter->lint(CssLinter::withoutTailwindDirectiveLines($contents), $file)->isSuccess() + && CssLinter::isStrictParserUnsupportedLine($contents, $lintIssue->line()) + && $linter->lint(CssLinter::forStrictParser($contents), $file)->isSuccess() ) { continue; } diff --git a/tests/Core/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php index 97a6b183..59ba405c 100644 --- a/tests/Core/Package/PackageValidatorTest.php +++ b/tests/Core/Package/PackageValidatorTest.php @@ -842,6 +842,47 @@ public function testItRejectsCssSyntaxErrorsNextToTailwindDirectives(): void 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,); + @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' From 55c19ee9b8d4c8f1022e536553742ee4f3776a9e Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 12:36:00 +0200 Subject: [PATCH 65/67] Handle CSS registry stubs in lint --- bin/lint | 2 +- src/Core/Lint/CssLinter.php | 22 +++++++++++++++++++ .../Package/PackageFileSyntaxValidator.php | 2 +- tests/Core/Lint/LinterTest.php | 10 +++++++++ tests/Core/Package/PackageValidatorTest.php | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/bin/lint b/bin/lint index dba18d8a..79e1f772 100755 --- a/bin/lint +++ b/bin/lint @@ -341,7 +341,7 @@ function lintCssFiles(string $root, array $files): int function isStrictParserUnsupportedCssIssue(string $contents, ?int $line): bool { - return CssLinter::isStrictParserUnsupportedLine($contents, $line); + return CssLinter::hasStrictParserUnsupportedContext($contents, $line); } /** diff --git a/src/Core/Lint/CssLinter.php b/src/Core/Lint/CssLinter.php index de9a9e50..c87e0705 100644 --- a/src/Core/Lint/CssLinter.php +++ b/src/Core/Lint/CssLinter.php @@ -12,6 +12,17 @@ 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 isTailwindDirectiveLine(string $contents, ?int $line): bool { return self::isStrictParserUnsupportedLine($contents, $line); @@ -101,6 +112,10 @@ private static function braceDelta(string $line): int 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) { @@ -117,4 +132,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/Package/PackageFileSyntaxValidator.php b/src/Core/Package/PackageFileSyntaxValidator.php index 52a1a25c..6a259261 100644 --- a/src/Core/Package/PackageFileSyntaxValidator.php +++ b/src/Core/Package/PackageFileSyntaxValidator.php @@ -85,7 +85,7 @@ private function lintFiles(PackageCandidate $candidate, array $files, LinterInte foreach ($lintResult->issues() as $lintIssue) { if ($linter instanceof CssLinter - && CssLinter::isStrictParserUnsupportedLine($contents, $lintIssue->line()) + && CssLinter::hasStrictParserUnsupportedContext($contents, $lintIssue->line()) && $linter->lint(CssLinter::forStrictParser($contents), $file)->isSuccess() ) { continue; 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/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php index 59ba405c..c1ddfcde 100644 --- a/tests/Core/Package/PackageValidatorTest.php +++ b/tests/Core/Package/PackageValidatorTest.php @@ -847,6 +847,7 @@ 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); } From d07952b700a9669bffd9212e8ad20051e709d371 Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 12:41:58 +0200 Subject: [PATCH 66/67] Drain paginated live poll payloads --- assets/js/live/live_poll.js | 10 +++++++--- dev/CLASSMAP.md | 4 ++-- dev/WORKLOG.md | 1 + src/Core/Lint/CssLinter.php | 10 ---------- tests/assets/live_poll.test.mjs | 29 +++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/assets/js/live/live_poll.js b/assets/js/live/live_poll.js index 21d998cd..d58615da 100644 --- a/assets/js/live/live_poll.js +++ b/assets/js/live/live_poll.js @@ -25,6 +25,7 @@ export class LivePoller { try { while (this.active) { let result = null; + const previousCursor = nextCursor; try { result = await this.fetchPayload(url, nextCursor); @@ -45,16 +46,19 @@ export class LivePoller { nextCursor = result.cursor; this.onPayload(payload, nextCursor); - const nextDelay = Number(payload.next_poll_ms ?? this.interval); + const hasMore = payload.has_more === true && nextCursor > previousCursor; + const nextDelay = hasMore ? 0 : Number(payload.next_poll_ms ?? this.interval); - if (this.isTerminal(payload) || nextDelay <= 0) { + if (this.isTerminal(payload) || (!hasMore && nextDelay <= 0)) { this.active = false; this.onDone(payload); return payload; } - await this.sleep(nextDelay); + if (nextDelay > 0) { + await this.sleep(nextDelay); + } } } catch (error) { this.active = false; diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index b4239d6d..603f7a6e 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -369,8 +369,8 @@ | 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 | -| 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, 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 on stream open/reconnect, 15-second lazy polling fallback when no stream URL is rendered or the browser cannot open the stream, 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` | +| 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 on stream open/reconnect and 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 2c013bb8..61b7f8cf 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -119,6 +119,7 @@ - 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, and made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream 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. diff --git a/src/Core/Lint/CssLinter.php b/src/Core/Lint/CssLinter.php index c87e0705..3555a8aa 100644 --- a/src/Core/Lint/CssLinter.php +++ b/src/Core/Lint/CssLinter.php @@ -23,11 +23,6 @@ public static function hasStrictParserUnsupportedContext(string $contents, ?int || self::isStrictParserUnsupportedLine($contents, $line + 1); } - public static function isTailwindDirectiveLine(string $contents, ?int $line): bool - { - return self::isStrictParserUnsupportedLine($contents, $line); - } - public static function isStrictParserUnsupportedLine(string $contents, ?int $line): bool { if (null === $line) { @@ -42,11 +37,6 @@ public static function isStrictParserUnsupportedLine(string $contents, ?int $lin || self::containsEmptyCustomPropertyFallback($text); } - public static function withoutTailwindDirectiveLines(string $contents): string - { - return self::forStrictParser($contents); - } - public static function forStrictParser(string $contents): string { $lines = preg_split('/\R/', $contents) ?: []; diff --git a/tests/assets/live_poll.test.mjs b/tests/assets/live_poll.test.mjs index c81f9636..4902f3c3 100644 --- a/tests/assets/live_poll.test.mjs +++ b/tests/assets/live_poll.test.mjs @@ -99,6 +99,35 @@ test('poll retries transient failures when retryOnError is enabled', async () => 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(); From 537172e770164487ac02f1f302c25c466cae729a Mon Sep 17 00:00:00 2001 From: Dominik Letica Date: Mon, 15 Jun 2026 12:45:06 +0200 Subject: [PATCH 67/67] Drain alert inbox before stream connect --- .../controllers/ui_alert_stream_controller.js | 15 ++++ dev/CLASSMAP.md | 2 +- dev/WORKLOG.md | 2 +- tests/assets/live_alert_controllers.test.mjs | 84 +++++++++++++++++-- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/assets/controllers/ui_alert_stream_controller.js b/assets/controllers/ui_alert_stream_controller.js index e0b7ce5d..f92977ba 100644 --- a/assets/controllers/ui_alert_stream_controller.js +++ b/assets/controllers/ui_alert_stream_controller.js @@ -31,6 +31,7 @@ export default class extends Controller { return; } + this.catchUp(); this.openSource(); } @@ -102,6 +103,14 @@ export default class extends Controller { return; } + if (this.catchUpRunning) { + this.catchUpRequested = true; + + return; + } + + this.catchUpRunning = true; + try { let previousCursor = -1; @@ -128,6 +137,12 @@ export default class extends Controller { } 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(); + } } } diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 603f7a6e..8bbacd39 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -370,7 +370,7 @@ | 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 | | 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 on stream open/reconnect and 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` | +| 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` | diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index 61b7f8cf..c153dfee 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -119,7 +119,7 @@ - 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, and made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream catch-up. +- 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. diff --git a/tests/assets/live_alert_controllers.test.mjs b/tests/assets/live_alert_controllers.test.mjs index 85e15b57..bdd269c7 100644 --- a/tests/assets/live_alert_controllers.test.mjs +++ b/tests/assets/live_alert_controllers.test.mjs @@ -312,17 +312,22 @@ test('UI alert stream drains queued catch-up pages when the stream opens', async const sources = []; const fetches = []; - const pages = [ - { cursor: 42, has_more: true, alerts: [{ id: 'queued-1', message: 'Queued one' }] }, - { cursor: 45, has_more: false, alerts: [{ id: 'queued-2', message: 'Queued two' }] }, - ]; window.fetch = async (url, options) => { fetches.push({ url, options }); + const cursor = Number(new URL(url).searchParams.get('cursor')); return { ok: true, async json() { - return pages.shift(); + 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: [] }; }, }; }; @@ -364,9 +369,10 @@ test('UI alert stream drains queued catch-up pages when the stream opens', async sources[0].listeners.get('open')(); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(fetches.length, 2); + 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' }, @@ -378,6 +384,72 @@ test('UI alert stream drains queued catch-up pages when the stream opens', async ]); }); +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();