diff --git a/.env b/.env index bd009a9..7924cf6 100644 --- a/.env +++ b/.env @@ -50,6 +50,8 @@ MESSENGER_TRANSPORT_DSN_FAILED='doctrine://default?queue_name=failed' AZURE_AZ_OIDC_METADATA_URL=AZURE_AZ_APP_METADATA_URL AZURE_AZ_OIDC_CLIENT_ID=AZURE_AZ_APP_CLIENT_ID AZURE_AZ_OIDC_CLIENT_SECRET=AZURE_AZ_APP_CLIENT_SECRET +# Date the Azure client secret expires (any strtotime-parseable value) +AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT=2027-01-31 AZURE_AZ_OIDC_REDIRECT_URI=AZURE_AZ_APP_REDIRECT_URI AZURE_AZ_OIDC_ALLOW_HTTP=false AZURE_AZ_OIDC_LEEWAY=10 @@ -63,3 +65,22 @@ VAULT_SECRET_ID="CHANGE_ME_IN_LOCAL_ENV" # The number of old results for each server/result-type combination APP_KEEP_RESULTS=5 + +###> economics ### +APP_ECONOMICS_URI=https://economics.itkdev.dk +APP_ECONOMICS_API_KEY=changeme +###< economics ### + +###> app/leantime ### +APP_LEANTIME_URI=http://leantime.invalid +APP_LEANTIME_API_KEY= +###< app/leantime ### + +###> health ### +# Seconds to cache the health check results, so that monitoring polling +# /health/ready cannot amplify into load on the database and the broker. +HEALTH_CACHE_TTL=15 +# Seconds since the last detection result before ingest is reported degraded. +# The harvester currently reports several hundred times an hour. +HEALTH_INGEST_MAX_AGE=1800 +###< health ### diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..115e8ef --- /dev/null +++ b/.env.dev @@ -0,0 +1,19 @@ +# Committed defaults for the dev environment. +# +# Local development runs against the mock identity provider defined in +# docker-compose.override.yml, because the real one has no redirect URI +# registered for a developer machine. See README.md, "OpenID Connect". +# +# Override any of these in .env.local to point at a different provider. + +###> itk-dev/openid-connect-bundle ### +AZURE_AZ_OIDC_METADATA_URL=http://idp.itksites.local.itkdev.dk/.well-known/openid-configuration +# The mock accepts any client id and secret. +AZURE_AZ_OIDC_CLIENT_ID=client-id +AZURE_AZ_OIDC_CLIENT_SECRET=client-secret +AZURE_AZ_OIDC_REDIRECT_URI=https://itksites.local.itkdev.dk/openid-connect/generic +# The application reaches the mock over http inside the docker network. Never +# true anywhere else: since itk-dev/openid-connect 5.1 this governs every +# endpoint the discovery document announces, not only the metadata URL. +AZURE_AZ_OIDC_ALLOW_HTTP=true +###< itk-dev/openid-connect-bundle ### diff --git a/.env.test b/.env.test index 022bf68..af09179 100644 --- a/.env.test +++ b/.env.test @@ -4,3 +4,8 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots + +# The path of this URI is the only path the OIDC authenticator treats as a +# callback (openid-connect-bundle 6.0), so it has to be the app's own callback +# route for a callback to reach the authenticator at all. +AZURE_AZ_OIDC_REDIRECT_URI=https://itksites.example.org/openid-connect/generic diff --git a/.github/workflows/doctrine.yaml b/.github/workflows/doctrine.yaml new file mode 100644 index 0000000..8eb28dd --- /dev/null +++ b/.github/workflows/doctrine.yaml @@ -0,0 +1,115 @@ +name: Doctrine + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + validate-doctrine-schema: + name: Validate Doctrine Schema + runs-on: ubuntu-latest + env: + APP_ENV: prod + + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install + + - name: Run Doctrine Migrations + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + + - name: Setup messenger "failed" doctrine transport to ensure db schema is updated + run: | + docker compose run --rm phpfpm bin/console messenger:setup-transports failed + + - name: Validate Doctrine schema + run: | + docker compose run --rm phpfpm bin/console doctrine:schema:validate + + load-fixtures: + name: Load Doctrine fixtures + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install --no-interaction + + - name: Run Doctrine Migrations + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + + - name: Load fixtures + run: | + docker compose run --rm phpfpm composer fixtures + + # The jobs above migrate an empty database. A deployment migrates a database + # that already holds rows, so a migration that cannot cope with existing + # data passes those jobs unnoticed. This job builds the database as it looks + # before the pull request, fixtures included, and then applies the pull + # request's migrations on top of it. + migrate-populated-database: + name: Run migrations on a populated database + runs-on: ubuntu-latest + # This workflow also runs on pushes to main and develop, where there is + # no pull request to read a base branch from: the checkout steps below + # would get an empty revision and fail. A push has no base to compare + # against, so skip the job rather than guess one. + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create docker network + run: | + docker network create frontend + + - name: Check out the base branch + run: | + git checkout ${{ github.event.pull_request.base.sha }} + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install --no-interaction + + - name: Run Doctrine Migrations + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + + - name: Load fixtures + run: | + docker compose run --rm phpfpm composer fixtures + + - name: Check out the pull request + run: | + git checkout ${{ github.event.pull_request.head.sha }} + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install --no-interaction + + - name: Run Doctrine Migrations on the populated database + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5625c95..a726d00 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -3,24 +3,6 @@ name: Review env: COMPOSE_USER: runner jobs: - validate-doctrine-schema: - runs-on: ubuntu-latest - name: Validate Doctrine Schema - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create docker network - run: docker network create frontend - - - name: Install and validate - run: | - docker compose up --detach - docker compose exec phpfpm composer install --no-interaction - docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - docker compose exec phpfpm bin/console messenger:setup-transports failed - docker compose exec phpfpm bin/console doctrine:schema:validate - phpstan: runs-on: ubuntu-latest name: PHPStan @@ -56,30 +38,13 @@ jobs: docker compose exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-clover=coverage/unit.xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/unit.xml fail_ci_if_error: true flags: unittests - fixtures: - runs-on: ubuntu-latest - name: Load fixtures - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create docker network - run: docker network create frontend - - - name: Load fixtures - run: | - docker compose up --detach - docker compose exec phpfpm composer install --no-interaction - docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - docker compose exec phpfpm composer fixtures - build-assets: runs-on: ubuntu-latest name: Build assets diff --git a/.gitignore b/.gitignore index 64c6627..39d1d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,14 @@ yarn-error.log phpstan.neon ###< phpstan/phpstan ### .phpunit.cache + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### + .twig-cs-fixer.cache .playwright-mcp + +# Working docs not meant to ship +RENOVATE_PLAN.md diff --git a/.woodpecker/prod.yml b/.woodpecker/prod.yml index e3c1cc2..e38d7e7 100644 --- a/.woodpecker/prod.yml +++ b/.woodpecker/prod.yml @@ -24,8 +24,8 @@ steps: keep: 4 playbook: "release" pre_up: - - itkdev-docker-compose-server run phpfpm bin/console doctrine:migrations:migrate --no-interaction - - itkdev-docker-compose-server run phpfpm bin/console messenger:setup-transports + - itkdev-docker-compose-server run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + - itkdev-docker-compose-server run --rm phpfpm bin/console messenger:setup-transports - name: Run post deploy image: itkdev/ansible-plugin:1 diff --git a/.woodpecker/stg.yml b/.woodpecker/stg.yml index 0a5749a..cbf8bf2 100644 --- a/.woodpecker/stg.yml +++ b/.woodpecker/stg.yml @@ -31,7 +31,7 @@ steps: - git checkout ${CI_COMMIT_BRANCH} - git pull - itkdev-docker-compose-server up -d --force-recreate --remove-orphans - - itkdev-docker-compose-server exec phpfpm composer install -no-dev -o --classmap-authoritative + - itkdev-docker-compose-server exec phpfpm composer install --no-dev -o --classmap-authoritative - itkdev-docker-compose-server exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - itkdev-docker-compose-server exec phpfpm bin/console messenger:setup-transports - itkdev-docker-compose-server exec phpfpm bin/console cache:clear diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0971c..eae4b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.0] - 2026-09-04 + +- [#99](https://github.com/itk-dev/devops_itksites/pull/99) + Fix the staging deploy: `composer install` was passed `-no-dev` +- [#98](https://github.com/itk-dev/devops_itksites/pull/98) + Deprecate OIDC and Service certificates, keeping their data +- [#96](https://github.com/itk-dev/devops_itksites/pull/96) + Show the Service Agreements monthly price as Danish kroner, + `12.500,50 kr.`, on index and detail +- [#95](https://github.com/itk-dev/devops_itksites/pull/95) + Update `vincentlanglet/twig-cs-fixer` to 4.0. Every other dependency is + already at its latest minor; the remaining majors are held back by their + dependents +- [#94](https://github.com/itk-dev/devops_itksites/pull/94) + Use EasyAdmin's own components in the admin templates + - Replace hand-rolled badge and icon markup with `` and + ``, so the admin follows EasyAdmin's theming + - Drop the unused `AutoBadgeMenuItem`/`AutoBadgeCrudMenuItem` pair: EasyAdmin + hides a badge whose content is null + - Set the ITK blue with the theme API instead of overriding EasyAdmin's + colour variables one by one + - Load the admin stylesheet again: it was added as `css/admin.css`, a file + deleted in #81, so every admin page carried a 404 and no ITK styling +- [#93](https://github.com/itk-dev/devops_itksites/pull/93) + Update composer dependencies, clearing 15 security advisories + - `api-platform/core` 4.3.7 → 4.3.17, `easycorp/easyadmin-bundle` 5.0.11 → 5.5.1, + `guzzlehttp/guzzle` 7.10.6 → 7.15.5, `guzzlehttp/psr7` 2.10.4 → 2.13.1 + - Regenerated the API spec: `symfony/yaml` now writes sequence items on their + own line. No API changes +- [#92](https://github.com/itk-dev/devops_itksites/pull/92) Update openid-connect-bundle to 6.0 + - Bump `itk-dev/openid-connect-bundle` to `^6.0` + - A failed OIDC callback now raises an error instead of redirecting to the + identity provider again, so an expired client secret can no longer put the + site in a login loop + - Only the provider's callback path is treated as a callback; the configured + `redirect_uri` covers this, no `callback_path` needed + - Set `client_secret_expires_at` for the `azure_az` provider from the new + `AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT` variable, so the bundle warns + before the secret expires + - Render a failed login as a page saying so, instead of an unhandled + exception, in `OpenIdConnectFailureListener` + - Add an `oidc_client_secret` health check, so a client secret nearing its + expiry shows up in `/health/detail` instead of in a login loop +- [#83](https://github.com/itk-dev/devops_itksites/pull/83) 7523: Service agreements + - Add Project entity top-level Economics project. + - Add CodeOwner entity + - Add Leantime integration +- [#91](https://github.com/itk-dev/devops_itksites/pull/91) Health endpoints + - Add `/health/live`, `/health/ready` and `/health/detail` endpoints + - Add health checks for database, RabbitMQ transport and detection result freshness + - Cache check results in a dedicated `cache.health` pool + - Exclude `^/health` from the firewalls and protect `/health/detail` with `ITKBasicAuth` +- [#90](https://github.com/itk-dev/devops_itksites/pull/90) + - Fixed user API key migration failing on databases with more than one user + - Generated an API key for existing users, as users created since already get + - Added users to the fixtures and a CI job running migrations on a populated + database +- [#89](https://github.com/itk-dev/devops_itksites/pull/89) + Added `--rm` to `docker compose run` in prod deployment +- [#88](https://github.com/itk-dev/devops_itksites/pull/88) + - Let users use the API + - Add security to detection results API endpoint + - Add server and site collections API endpoints +- [#80](https://github.com/itk-dev/devops_itksites/pull/80) 5566: Service agreements + - Add security contract entity with crud controller + - Add Abstract full crud controller and extend on it in some cases + - Add economics service and sync action/command for service agreement synchronization +- [#81](https://github.com/itk-dev/devops_itksites/pull/81) 5564: Asset Mapper migration + - Add Symfony Asset Mapper bundle and importmap +- Add Renovate auto-patch + auto-release pipeline (Phase 1 fork validation) +- [#87](https://github.com/itk-dev/devops_itksites/pull/87) Update `codecov/codecov-action` to v7 + ## [1.11.2] - 2026-06-02 - [#85](https://github.com/itk-dev/devops_itksites/pull/85) @@ -191,7 +263,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2022-09-15 -[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.11.2...HEAD +[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.12.0...HEAD +[1.12.0]: https://github.com/itk-dev/devops_itksites/compare/1.11.2...1.12.0 [1.11.2]: https://github.com/itk-dev/devops_itksites/compare/1.11.1...1.11.2 [1.11.1]: https://github.com/itk-dev/devops_itksites/compare/1.11.0...1.11.1 [1.11.0]: https://github.com/itk-dev/devops_itksites/compare/1.10.1...1.11.0 diff --git a/README.md b/README.md index fc1305f..d8e51b3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,68 @@ The system is build so that all analyzed data can be truncated safely and rebuil by "replaying" the DetectionResults. This means that care must be taken when manually maintained data and auto updated data must have cross references. +## API + +Authenticated users can access a simple read-only API – see the API documentation on `/api/docs` for details. + +### API keys + +Run the `app:user:set-api-key` console command to set the API for a user: + +``` shell +docker compose exec phpfpm php bin/console app:user:set-api-key +``` + +Use the API key to make an authenticated request, e.g. + +``` shell +curl --header 'accept: application/json' --header 'authorization: Apikey ' https://itksites.local.itkdev.dk/api/sites +``` + +## Health checks + +Three endpoints report on the application, in increasing order of detail: + +| Endpoint | Access | Checks | +| --- | --- | --- | +| `/health/live` | Public | Nothing – only that the app responds | +| `/health/ready` | Public | All checks, aggregated status only | +| `/health/detail` | `ITKBasicAuth` in Traefik | Per-check results and timings | + +`/health/ready` answers `200` when everything is well and `503` when it is not. +It deliberately does not say *what* is wrong – point monitoring at this one and +read `/health/detail` when it goes red: + +``` shell +curl --silent https://itksites.local.itkdev.dk/health/detail | jq +``` + +The checks cover the database, the RabbitMQ messenger transport, the freshness +of the most recent detection result and the expiry of the OIDC client secret. +The freshness check catches an ingest pipeline that has stopped while the +application itself is still serving requests. The client secret check catches +the expiry that breaks every login at once. + +`HEALTH_INGEST_MAX_AGE` sets how old the most recent detection result may be +before ingest is reported as degraded. + +`AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT` is where the client secret check reads +the date. It reports degraded only once that date has passed, so watch +`days_remaining` in the detail payload rather than waiting for it to go red. With +no date configured the check reports skipped, which means nothing is watching the +secret. + +Results are cached for `HEALTH_CACHE_TTL` seconds so that polling does not turn +into load on the dependencies. The cache is the dedicated, filesystem-backed +`cache.health` pool in `config/packages/cache.yaml` – it has to keep working +while the database and the broker are down, and the adapter can be swapped +there without touching code. + +`^/health` is excluded from the Symfony firewalls: both user providers are +Doctrine entity providers, so an authenticated endpoint would fail to +authenticate during a database outage and answer `500` rather than reporting +that the database is down. + ## Development ```sh @@ -58,8 +120,22 @@ Then create a `.env.local` file to set secrets for your local setup. ### OpenID Connect -All users access is controlled by OpenID Connect. For local development you must -add the following to your `.env.local` file: +All user access is controlled by OpenID Connect. Locally the login runs against a +mock identity provider, defined as the `idp` service in `docker-compose.override.yml` +— the real provider has no redirect URI registered for a developer machine. + +Start it with the rest of the stack: + +```shell +docker compose up --detach +``` + +Then log in as `admin` or `editor`: the mock shows a form where you type the subject, +and hands back the claims for it. Both identities are defined in the compose file, and +their claims must include `name` and `upn`, which `AzureOIDCAuthenticator` reads. + +`.env.dev` carries the settings, so there is nothing to add to `.env.local` for an +ordinary setup. To develop against a real provider instead, override them there: ```dotenv ###> itk-dev/openid-connect-bundle ### @@ -67,13 +143,18 @@ AZURE_AZ_OIDC_METADATA_URL= AZURE_AZ_OIDC_CLIENT_ID= AZURE_AZ_OIDC_CLIENT_SECRET= AZURE_AZ_OIDC_REDIRECT_URI=https://itksites.local.itkdev.dk/openid-connect/generic +AZURE_AZ_OIDC_ALLOW_HTTP=false ###< itk-dev/openid-connect-bundle ### ``` > [!NOTE] -> In the `dev` environment the main firewall security is disabled -> (`security.yaml` → `when@dev`), so authentication is not required. -> This is because the current AAK OIDC setup doesn't support `itksites.local.itkdev.dk`. +> `AZURE_AZ_OIDC_ALLOW_HTTP=true` in `.env.dev` is what lets the application talk to +> the mock over http inside the docker network. It must never be true anywhere else: +> since `itk-dev/openid-connect` 5.1 it governs every endpoint the discovery document +> announces, not only the metadata URL. + +The mock accepts the PKCE challenge the bundle sends but does not verify it, so a +successful login here does not prove PKCE works against Azure. ### Fixtures diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..e2824a5 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,9 @@ +/* + * Welcome to your app's main JavaScript file! + * + * This file will be included onto the page via the importmap() Twig function, + * which should already be in your base.html.twig. + */ +import "./styles/app.css"; + +console.log("This log comes from assets/app.js - welcome to AssetMapper! 🎉"); diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..4181884 --- /dev/null +++ b/assets/styles/app.css @@ -0,0 +1,26 @@ +/* EasyAdmin overrides. The ITK blue is set with the theme API in + DashboardController; what is left here is what that API cannot express. */ +:root { + --body-max-width: 100%; + --sidebar-bg: #fff; + /* ITK red, for a false boolean badge and for danger states */ + --badge-boolean-false-bg: rgb(228, 73, 48); + --badge-boolean-false-color: var(--white); + --bs-danger-rgb: 228, 73, 48; + /* the theme API only takes named gray ramps, not an arbitrary gray */ + --sidebar-menu-color: rgb(66, 66, 66); + --text-color-dark: rgb(66, 66, 66); +} + +/* Grouped dropdown group styling for index pages */ +.dropdown-menu { + .btn-danger i, + .text-danger i { + color: var(--button-invisible-danger-color); + } + + a.btn-danger:hover, + a.text-danger:hover { + background: var(--button-invisible-danger-hover-hover-bg); + } +} diff --git a/composer.json b/composer.json index 32c3d24..fdfdcc7 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,14 @@ "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.0", "easycorp/easyadmin-bundle": "^5.0", - "itk-dev/openid-connect-bundle": "^5.0", + "itk-dev/openid-connect-bundle": "^6.1", "itk-dev/vault-bundle": "^1.0.0", "nelmio/cors-bundle": "^2.2", "ocramius/doctrine-batch-utils": "^2.8", "phpstan/phpdoc-parser": "^2.0", "symfony/amqp-messenger": "^8.0", "symfony/asset": "^8.0", + "symfony/asset-mapper": "~8.1.0", "symfony/browser-kit": "^8.0", "symfony/console": "^8.0", "symfony/doctrine-messenger": "^8.0", @@ -60,7 +61,7 @@ "symfony/stopwatch": "^8.0", "symfony/var-dumper": "^8.0", "symfony/web-profiler-bundle": "^8.0", - "vincentlanglet/twig-cs-fixer": "^3.14" + "vincentlanglet/twig-cs-fixer": "^4.0" }, "replace": { "symfony/polyfill-ctype": "*", @@ -111,7 +112,8 @@ ], "auto-scripts": { "cache:clear": "symfony-cmd", - "assets:install %PUBLIC_DIR%": "symfony-cmd" + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" }, "coding-standards-apply": [ "vendor/bin/php-cs-fixer fix" diff --git a/composer.lock b/composer.lock index 1d8280f..4c17a85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e6c525db068fde9ea49fd93a6f4f5ce5", + "content-hash": "64cb1b11b5e4c3da1bdc1f83e64b414f", "packages": [ { "name": "api-platform/core", - "version": "v4.3.7", + "version": "v4.3.17", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "25f80814e1f9c849bae2bc17ed2e07f2461c72d8" + "reference": "815f7f401e6e3acdf0530283cf9dedcddc537839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/25f80814e1f9c849bae2bc17ed2e07f2461c72d8", - "reference": "25f80814e1f9c849bae2bc17ed2e07f2461c72d8", + "url": "https://api.github.com/repos/api-platform/core/zipball/815f7f401e6e3acdf0530283cf9dedcddc537839", + "reference": "815f7f401e6e3acdf0530283cf9dedcddc537839", "shasum": "" }, "require": { @@ -75,16 +75,11 @@ "api-platform/validator": "self.version" }, "require-dev": { - "behat/behat": "^3.11", - "behat/mink": "^1.9", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", @@ -97,7 +92,7 @@ "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", "laravel/framework": "^11.0 || ^12.0 || ^13.0", - "mcp/sdk": ">=0.4 <1.0", + "mcp/sdk": "^0.6", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", @@ -110,7 +105,6 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "soyuka/contexts": "^3.3.10", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0 || ^8.0", @@ -226,9 +220,9 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v4.3.7" + "source": "https://github.com/api-platform/core/tree/v4.3.17" }, - "time": "2026-05-29T07:17:00+00:00" + "time": "2026-07-12T06:11:13+00:00" }, { "name": "composer/semver", @@ -395,16 +389,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.3", + "version": "4.4.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "61e730f1658814821a85f2402c945f3883407dec" + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", - "reference": "61e730f1658814821a85f2402c945f3883407dec", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", "shasum": "" }, "require": { @@ -481,7 +475,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.3" + "source": "https://github.com/doctrine/dbal/tree/4.4.4" }, "funding": [ { @@ -497,7 +491,7 @@ "type": "tidelift" } ], - "time": "2026-03-20T08:52:12+00:00" + "time": "2026-07-21T14:34:40+00:00" }, { "name": "doctrine/deprecations", @@ -549,16 +543,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "3.2.2", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce" + "reference": "9b231f957020de52d6ac94fd72f0cbe8cf1eaad5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/af84173db6978c3d2688ea3bcf3a91720b0704ce", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/9b231f957020de52d6ac94fd72f0cbe8cf1eaad5", + "reference": "9b231f957020de52d6ac94fd72f0cbe8cf1eaad5", "shasum": "" }, "require": { @@ -566,6 +560,7 @@ "doctrine/deprecations": "^1.0", "doctrine/persistence": "^4", "doctrine/sql-formatter": "^1.0.1", + "ext-mbstring": "*", "php": "^8.4", "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/config": "^6.4 || ^7.0 || ^8.0", @@ -582,16 +577,18 @@ "require-dev": { "doctrine/coding-standard": "^14", "doctrine/orm": "^3.4.4", - "phpstan/phpstan": "2.1.1", + "phpstan/phpstan": "^2.1.13", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-symfony": "^2.0.9", "phpunit/phpunit": "^12.3.10", "psr/log": "^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0 || ^8.0", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", "symfony/messenger": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/runtime": "^6.4 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", "symfony/string": "^6.4 || ^7.0 || ^8.0", @@ -644,7 +641,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.2" + "source": "https://github.com/doctrine/DoctrineBundle/tree/3.3.1" }, "funding": [ { @@ -660,20 +657,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T12:24:29+00:00" + "time": "2026-07-23T10:37:01+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c" + "reference": "43e9212c7441178e3748580e53f88c08f4e70e5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/20505da78735744fb4a42a3bb9a416b345ad6f7c", - "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/43e9212c7441178e3748580e53f88c08f4e70e5f", + "reference": "43e9212c7441178e3748580e53f88c08f4e70e5f", "shasum": "" }, "require": { @@ -736,7 +733,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/4.0.0" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/4.0.1" }, "funding": [ { @@ -752,7 +749,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T08:14:38+00:00" + "time": "2026-08-26T05:54:54+00:00" }, { "name": "doctrine/event-manager", @@ -1186,16 +1183,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.7", + "version": "3.6.8", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", "shasum": "" }, "require": { @@ -1225,6 +1222,7 @@ "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" }, "suggest": { + "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)", "ext-dom": "Provides support for XSD validation for XML mapping files", "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" }, @@ -1268,9 +1266,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.7" + "source": "https://github.com/doctrine/orm/tree/3.6.8" }, - "time": "2026-05-25T16:45:47+00:00" + "time": "2026-08-05T19:05:32+00:00" }, { "name": "doctrine/persistence", @@ -1423,16 +1421,16 @@ }, { "name": "easycorp/easyadmin-bundle", - "version": "v5.0.11", + "version": "v5.5.1", "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "e22ef0fddc532ab7847c887de428fbd7f6f118d5" + "reference": "77b2a6b13c7050cd5412b3ce2b86e909682b97c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/e22ef0fddc532ab7847c887de428fbd7f6f118d5", - "reference": "e22ef0fddc532ab7847c887de428fbd7f6f118d5", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/77b2a6b13c7050cd5412b3ce2b86e909682b97c3", + "reference": "77b2a6b13c7050cd5412b3ce2b86e909682b97c3", "shasum": "" }, "require": { @@ -1497,7 +1495,10 @@ "autoload": { "psr-4": { "EasyCorp\\Bundle\\EasyAdminBundle\\": "src/" - } + }, + "exclude-from-classmap": [ + "/tests/Functional/Apps/*/config/reference.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1518,7 +1519,7 @@ ], "support": { "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", - "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.0.11" + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.5.1" }, "funding": [ { @@ -1526,20 +1527,20 @@ "type": "github" } ], - "time": "2026-05-28T19:03:47+00:00" + "time": "2026-08-11T06:28:56+00:00" }, { "name": "firebase/php-jwt", - "version": "v7.0.5", + "version": "v7.1.0", "source": { "type": "git", "url": "https://github.com/googleapis/php-jwt.git", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", "shasum": "" }, "require": { @@ -1548,6 +1549,7 @@ "require-dev": { "guzzlehttp/guzzle": "^7.4", "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1556,7 +1558,8 @@ }, "suggest": { "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" }, "type": "library", "autoload": { @@ -1581,38 +1584,39 @@ } ], "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", + "homepage": "https://github.com/googleapis/php-jwt", "keywords": [ "jwt", "php" ], "support": { "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" }, - "time": "2026-04-01T20:38:03+00:00" + "time": "2026-06-11T17:54:14+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.10.6", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/e7412b3180912c01650cc66647f18c1d1cbe9b94", - "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1620,8 +1624,8 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1701,7 +1705,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.6" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1717,24 +1721,25 @@ "type": "tidelift" } ], - "time": "2026-06-01T13:06:22+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.4.1", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -1784,7 +1789,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1800,27 +1805,29 @@ "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.10.4", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "d2a1a094e396da8957e797489fddaf860c340cfc" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc", - "reference": "d2a1a094e396da8957e797489fddaf860c340cfc", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -1901,7 +1908,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.4" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -1917,35 +1924,35 @@ "type": "tidelift" } ], - "time": "2026-05-29T12:59:07+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "itk-dev/openid-connect", - "version": "5.0.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/itk-dev/openid-connect.git", - "reference": "f241f6794a2e74eab8c4808bc22f341a98e96f0b" + "reference": "950758b97397ab529f1df787c377ca54b321af79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itk-dev/openid-connect/zipball/f241f6794a2e74eab8c4808bc22f341a98e96f0b", - "reference": "f241f6794a2e74eab8c4808bc22f341a98e96f0b", + "url": "https://api.github.com/repos/itk-dev/openid-connect/zipball/950758b97397ab529f1df787c377ca54b321af79", + "reference": "950758b97397ab529f1df787c377ca54b321af79", "shasum": "" }, "require": { "ext-json": "*", "ext-openssl": "*", "firebase/php-jwt": "^7.0", - "league/oauth2-client": "^2.6", + "league/oauth2-client": "^2.8.1", "php": "^8.3", "psr/cache": "^2.0 || ^3.0", - "psr/http-client": "^1.0", - "robrichards/xmlseclibs": "^3.1.5" + "psr/http-client": "^1.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.50", "friendsofphp/php-cs-fixer": "^3.75", + "infection/infection": "^0.35.2", "mockery/mockery": "^1.6.12", "phpstan/phpstan": "^2.1.41", "phpstan/phpstan-mockery": "^2.0", @@ -1979,31 +1986,34 @@ "description": "OpenID connect configuration package", "support": { "issues": "https://github.com/itk-dev/openid-connect/issues", - "source": "https://github.com/itk-dev/openid-connect/tree/5.0.0" + "source": "https://github.com/itk-dev/openid-connect/tree/5.1.0" }, - "time": "2026-06-02T09:01:56+00:00" + "time": "2026-08-26T12:01:21+00:00" }, { "name": "itk-dev/openid-connect-bundle", - "version": "5.0.0", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/itk-dev/openid-connect-bundle.git", - "reference": "af57823b41629ad5bc1abc50eeb388c7b4b6dbb4" + "reference": "c990bd64fca599de02766671b99eab4cc260915b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itk-dev/openid-connect-bundle/zipball/af57823b41629ad5bc1abc50eeb388c7b4b6dbb4", - "reference": "af57823b41629ad5bc1abc50eeb388c7b4b6dbb4", + "url": "https://api.github.com/repos/itk-dev/openid-connect-bundle/zipball/c990bd64fca599de02766671b99eab4cc260915b", + "reference": "c990bd64fca599de02766671b99eab4cc260915b", "shasum": "" }, "require": { "doctrine/orm": "^2.8 || ^3.0", "ext-json": "*", "ext-openssl": "*", - "itk-dev/openid-connect": "^5.0", + "itk-dev/openid-connect": "^5.1", "php": "^8.3", + "psr/log": "^3.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/clock": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", "symfony/framework-bundle": "^6.4.13 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4.13 || ^7.0 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0", @@ -2012,6 +2022,8 @@ "require-dev": { "ergebnis/composer-normalize": "^2.28", "friendsofphp/php-cs-fixer": "^3.11", + "igor-php/igor-php": "^0.9", + "infection/infection": "*", "phpstan/phpstan": "^2.1.41", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -2044,9 +2056,9 @@ "description": "Symfony bundle for openid-connect", "support": { "issues": "https://github.com/itk-dev/openid-connect-bundle/issues", - "source": "https://github.com/itk-dev/openid-connect-bundle/tree/5.0.0" + "source": "https://github.com/itk-dev/openid-connect-bundle/tree/6.1.0" }, - "time": "2026-06-02T11:15:20+00:00" + "time": "2026-08-26T14:22:26+00:00" }, { "name": "itk-dev/vault", @@ -2532,16 +2544,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -2573,9 +2585,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "psr/cache", @@ -3138,48 +3150,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "robrichards/xmlseclibs", - "version": "3.1.5", - "source": { - "type": "git", - "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", - "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "php": ">= 5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "RobRichards\\XMLSecLibs\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "A PHP library for XML Security", - "homepage": "https://github.com/robrichards/xmlseclibs", - "keywords": [ - "security", - "signature", - "xml", - "xmldsig" - ], - "support": { - "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" - }, - "time": "2026-03-13T10:31:56+00:00" - }, { "name": "symfony/amqp-messenger", "version": "v8.1.0", @@ -3323,18 +3293,99 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/asset-mapper", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset-mapper.git", + "reference": "b13fd8210675bafac7e782569d0cafac860f2e8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/b13fd8210675bafac7e782569d0cafac860f2e8f", + "reference": "b13fd8210675bafac7e782569d0cafac860f2e8f", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "php": ">=8.4.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/event-dispatcher-contracts": "^3.0", + "symfony/finder": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\AssetMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset-mapper/tree/v8.1.5" + }, + "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-08-14T13:31:31+00:00" + }, { "name": "symfony/browser-kit", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853" + "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853", - "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", + "reference": "f6766c9fb232e72897d6d55cf8f6aec38db2dbbf", "shasum": "" }, "require": { @@ -3373,7 +3424,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v8.1.0" + "source": "https://github.com/symfony/browser-kit/tree/v8.1.5" }, "funding": [ { @@ -3393,20 +3444,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/cache", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" + "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "url": "https://api.github.com/repos/symfony/cache/zipball/c12be586c0e4798e649e7501aa9181ddc9d93310", + "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310", "shasum": "" }, "require": { @@ -3427,7 +3478,7 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", + "cache/integration-tests": "^1.0.3", "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", @@ -3472,7 +3523,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.0" + "source": "https://github.com/symfony/cache/tree/v8.1.5" }, "funding": [ { @@ -3492,20 +3543,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-19T08:32:28+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", "shasum": "" }, "require": { @@ -3552,7 +3603,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" }, "funding": [ { @@ -3572,7 +3623,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/clock", @@ -3653,16 +3704,16 @@ }, { "name": "symfony/config", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9" + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/429783a0c649696f2058ea5ab5315f082dba6de9", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9", + "url": "https://api.github.com/repos/symfony/config/zipball/ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", + "reference": "ec711a6c14ae287d9618fbd2c9de4e223a1a4b02", "shasum": "" }, "require": { @@ -3707,7 +3758,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.1.0" + "source": "https://github.com/symfony/config/tree/v8.1.5" }, "funding": [ { @@ -3727,20 +3778,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-20T09:59:12+00:00" }, { "name": "symfony/console", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + "reference": "d07c06839e33047e2c894a6793248f3fb66c8129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "url": "https://api.github.com/repos/symfony/console/zipball/d07c06839e33047e2c894a6793248f3fb66c8129", + "reference": "d07c06839e33047e2c894a6793248f3fb66c8129", "shasum": "" }, "require": { @@ -3807,7 +3858,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.0" + "source": "https://github.com/symfony/console/tree/v8.1.5" }, "funding": [ { @@ -3827,20 +3878,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T14:29:57+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b" + "reference": "e79d512848b75f92374e473f8e9ead4202c965c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b6ba1f45127106885de4b77558c5ecca8feb1e1b", - "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/e79d512848b75f92374e473f8e9ead4202c965c5", + "reference": "e79d512848b75f92374e473f8e9ead4202c965c5", "shasum": "" }, "require": { @@ -3888,7 +3939,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.1.0" + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.5" }, "funding": [ { @@ -3908,20 +3959,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3959,7 +4010,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3979,20 +4030,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/doctrine-bridge", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5" + "reference": "1f2acb6df169495f4993851c01bf98a44fa2c59e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", - "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/1f2acb6df169495f4993851c01bf98a44fa2c59e", + "reference": "1f2acb6df169495f4993851c01bf98a44fa2c59e", "shasum": "" }, "require": { @@ -4009,7 +4060,8 @@ "doctrine/dbal": "<4.3", "doctrine/lexer": "<1.1", "doctrine/orm": "<3.4", - "symfony/property-info": "<8.0" + "symfony/property-info": "<8.0", + "symfony/validator": "<7.4.17|>=8.0,<8.1.5" }, "require-dev": { "doctrine/collections": "^1.8|^2.0", @@ -4063,7 +4115,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.0" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.5" }, "funding": [ { @@ -4083,20 +4135,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:18:49+00:00" + "time": "2026-08-21T19:39:55+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "cafeac0dccfd4e971c9a5c646652d1037de469f8" + "reference": "a2d57cd7caf9160ce3e19a9da501e54b234cff72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/cafeac0dccfd4e971c9a5c646652d1037de469f8", - "reference": "cafeac0dccfd4e971c9a5c646652d1037de469f8", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/a2d57cd7caf9160ce3e19a9da501e54b234cff72", + "reference": "a2d57cd7caf9160ce3e19a9da501e54b234cff72", "shasum": "" }, "require": { @@ -4141,7 +4193,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v8.1.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/v8.1.4" }, "funding": [ { @@ -4161,20 +4213,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-07T13:15:43+00:00" }, { "name": "symfony/dom-crawler", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" + "reference": "94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", - "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d", + "reference": "94f70e1b2e5b7b8f8618871fd9eaad3e39e9f03d", "shasum": "" }, "require": { @@ -4211,7 +4263,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v8.1.0" + "source": "https://github.com/symfony/dom-crawler/tree/v8.1.5" }, "funding": [ { @@ -4231,20 +4283,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/dotenv", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c" + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", - "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", + "reference": "4ea87b35c75f7052309ec9735c0eb5c1fd7d2182", "shasum": "" }, "require": { @@ -4285,7 +4337,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.1.0" + "source": "https://github.com/symfony/dotenv/tree/v8.1.2" }, "funding": [ { @@ -4305,20 +4357,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-26T10:31:34+00:00" }, { "name": "symfony/error-handler", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", "shasum": "" }, "require": { @@ -4366,7 +4418,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.0" + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" }, "funding": [ { @@ -4386,20 +4438,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { @@ -4452,7 +4504,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -4472,20 +4524,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -4532,7 +4584,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -4552,20 +4604,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/expression-language", - "version": "v8.1.0", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "67f31731d5b316d0183c565933017d5d3331d609" + "reference": "13b74638d9c7c6854fcbb7e89a42a90fdca51f57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/67f31731d5b316d0183c565933017d5d3331d609", - "reference": "67f31731d5b316d0183c565933017d5d3331d609", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/13b74638d9c7c6854fcbb7e89a42a90fdca51f57", + "reference": "13b74638d9c7c6854fcbb7e89a42a90fdca51f57", "shasum": "" }, "require": { @@ -4599,7 +4651,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v8.1.0" + "source": "https://github.com/symfony/expression-language/tree/v8.1.1" }, "funding": [ { @@ -4619,20 +4671,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-09T10:54:51+00:00" }, { "name": "symfony/filesystem", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3", "shasum": "" }, "require": { @@ -4670,7 +4722,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + "source": "https://github.com/symfony/filesystem/tree/v8.1.5" }, "funding": [ { @@ -4690,20 +4742,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T12:16:08+00:00" }, { "name": "symfony/finder", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "58d2e767a66052c1487356f953445634a8194c64" + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", - "reference": "58d2e767a66052c1487356f953445634a8194c64", + "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", "shasum": "" }, "require": { @@ -4738,7 +4790,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.0" + "source": "https://github.com/symfony/finder/tree/v8.1.5" }, "funding": [ { @@ -4758,7 +4810,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T12:16:08+00:00" }, { "name": "symfony/flex", @@ -4835,16 +4887,16 @@ }, { "name": "symfony/form", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8" + "reference": "0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8", - "reference": "82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8", + "url": "https://api.github.com/repos/symfony/form/zipball/0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582", + "reference": "0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582", "shasum": "" }, "require": { @@ -4908,7 +4960,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.1.0" + "source": "https://github.com/symfony/form/tree/v8.1.5" }, "funding": [ { @@ -4928,20 +4980,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256" + "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a0953f4fd8b51db6136c2628af99b7193e63256", - "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/1c7b41364312c67376a560e35c7f7f0be0c1b4ea", + "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea", "shasum": "" }, "require": { @@ -4950,7 +5002,7 @@ "php": ">=8.4.1", "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^8.1", + "symfony/dependency-injection": "^8.1.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.4|^8.0", "symfony/event-dispatcher": "^8.1", @@ -4961,16 +5013,17 @@ "symfony/polyfill-mbstring": "^1.0", "symfony/polyfill-php85": "^1.33", "symfony/routing": "^7.4|^8.0", - "symfony/service-contracts": "^3.7", + "symfony/service-contracts": "^3.7.1", "symfony/var-exporter": "^8.1" }, "conflict": { "doctrine/persistence": "<1.3", "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/console": "<8.1", + "symfony/console": "<8.1.2", "symfony/form": "<7.4", "symfony/json-streamer": "<7.4", + "symfony/mailer": "<7.4.17|>=8.0,<8.1.5", "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", "symfony/mime": "<7.4.9|>=8.0,<8.0.9", "symfony/security-csrf": "<7.4", @@ -4989,7 +5042,7 @@ "symfony/asset-mapper": "^7.4|^8.0", "symfony/browser-kit": "^7.4|^8.0", "symfony/clock": "^7.4|^8.0", - "symfony/console": "^8.1", + "symfony/console": "^8.1.2", "symfony/css-selector": "^7.4|^8.0", "symfony/dom-crawler": "^7.4|^8.0", "symfony/dotenv": "^7.4|^8.0", @@ -5051,7 +5104,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.1.0" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.5" }, "funding": [ { @@ -5071,20 +5124,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T19:39:55+00:00" }, { "name": "symfony/http-client", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb" + "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", - "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", + "url": "https://api.github.com/repos/symfony/http-client/zipball/9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", + "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", "shasum": "" }, "require": { @@ -5107,7 +5160,7 @@ "require-dev": { "amphp/http-client": "^5.3.2", "amphp/http-tunnel": "^2.0", - "guzzlehttp/guzzle": "^7.10", + "guzzlehttp/guzzle": "^7.10|^8.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", @@ -5148,7 +5201,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.0" + "source": "https://github.com/symfony/http-client/tree/v8.1.5" }, "funding": [ { @@ -5168,20 +5221,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -5230,7 +5283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -5250,20 +5303,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", "shasum": "" }, "require": { @@ -5311,7 +5364,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" }, "funding": [ { @@ -5331,20 +5384,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-20T09:59:12+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", "shasum": "" }, "require": { @@ -5360,6 +5413,7 @@ "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", "symfony/translation-contracts": "<2.5", "symfony/var-dumper": "<8.1", "symfony/web-profiler-bundle": "<8.1", @@ -5392,7 +5446,7 @@ "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^8.1", "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21" + "twig/twig": "^3.21|^4.0" }, "type": "library", "autoload": { @@ -5420,7 +5474,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" }, "funding": [ { @@ -5440,20 +5494,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T08:46:08+00:00" + "time": "2026-08-22T13:45:00+00:00" }, { "name": "symfony/intl", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "7822dffb3e2128501a1ac781db5d8217fc9caac1" + "reference": "9a8ba3a0af4db32bf228ca841c319d5dc97f7d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/7822dffb3e2128501a1ac781db5d8217fc9caac1", - "reference": "7822dffb3e2128501a1ac781db5d8217fc9caac1", + "url": "https://api.github.com/repos/symfony/intl/zipball/9a8ba3a0af4db32bf228ca841c319d5dc97f7d02", + "reference": "9a8ba3a0af4db32bf228ca841c319d5dc97f7d02", "shasum": "" }, "require": { @@ -5509,7 +5563,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v8.1.0" + "source": "https://github.com/symfony/intl/tree/v8.1.5" }, "funding": [ { @@ -5529,20 +5583,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/messenger", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "c095398338a8e6f5ae6579ebb2092b7a16b7df72" + "reference": "fa890d0632db0b5e8bc547560b412de824b3b9cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/c095398338a8e6f5ae6579ebb2092b7a16b7df72", - "reference": "c095398338a8e6f5ae6579ebb2092b7a16b7df72", + "url": "https://api.github.com/repos/symfony/messenger/zipball/fa890d0632db0b5e8bc547560b412de824b3b9cd", + "reference": "fa890d0632db0b5e8bc547560b412de824b3b9cd", "shasum": "" }, "require": { @@ -5601,7 +5655,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.1.0" + "source": "https://github.com/symfony/messenger/tree/v8.1.5" }, "funding": [ { @@ -5621,20 +5675,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T20:43:01+00:00" }, { "name": "symfony/mime", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", + "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", "shasum": "" }, "require": { @@ -5655,7 +5709,7 @@ "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", "symfony/property-info": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0" + "symfony/serializer": "^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -5687,7 +5741,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.0" + "source": "https://github.com/symfony/mime/tree/v8.1.5" }, "funding": [ { @@ -5707,20 +5761,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "38563fac41ede8521e5e3dc139a4f2b097471c8c" + "reference": "710b0442eb63382bc64cffbb5989fa5c466eeddc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/38563fac41ede8521e5e3dc139a4f2b097471c8c", - "reference": "38563fac41ede8521e5e3dc139a4f2b097471c8c", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/710b0442eb63382bc64cffbb5989fa5c466eeddc", + "reference": "710b0442eb63382bc64cffbb5989fa5c466eeddc", "shasum": "" }, "require": { @@ -5735,6 +5789,7 @@ "symfony/mailer": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", "symfony/mime": "^7.4|^8.0", + "symfony/notifier": "^7.4|^8.0", "symfony/security-core": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, @@ -5764,7 +5819,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v8.1.0" + "source": "https://github.com/symfony/monolog-bridge/tree/v8.1.5" }, "funding": [ { @@ -5784,7 +5839,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/monolog-bundle", @@ -6011,16 +6066,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.37.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", "shasum": "" }, "require": { @@ -6074,7 +6129,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.42.0" }, "funding": [ { @@ -6094,20 +6149,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:03:27+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -6156,7 +6211,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -6176,7 +6231,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-icu", @@ -6268,16 +6323,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -6331,7 +6386,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -6351,20 +6406,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -6416,7 +6471,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -6436,20 +6491,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -6501,7 +6556,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -6521,7 +6576,91 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.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-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php84", @@ -6605,16 +6744,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -6661,7 +6800,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -6681,7 +6820,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -6768,16 +6907,16 @@ }, { "name": "symfony/property-access", - "version": "v8.1.0", + "version": "v8.1.4", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", - "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", "shasum": "" }, "require": { @@ -6825,7 +6964,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v8.1.0" + "source": "https://github.com/symfony/property-access/tree/v8.1.4" }, "funding": [ { @@ -6845,20 +6984,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { "name": "symfony/property-info", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", - "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "url": "https://api.github.com/repos/symfony/property-info/zipball/b335f8e7fb1440ed3448fb33340efc6127678e53", + "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53", "shasum": "" }, "require": { @@ -6911,7 +7050,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.0" + "source": "https://github.com/symfony/property-info/tree/v8.1.5" }, "funding": [ { @@ -6931,20 +7070,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/routing", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", "shasum": "" }, "require": { @@ -6991,7 +7130,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.0" + "source": "https://github.com/symfony/routing/tree/v8.1.5" }, "funding": [ { @@ -7011,7 +7150,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-17T13:18:34+00:00" }, { "name": "symfony/runtime", @@ -7099,16 +7238,16 @@ }, { "name": "symfony/security-bundle", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "0489a6247f729652db9b9ff408f69ac3bee3589e" + "reference": "c331a8e70da9568b26f341da8d66392e1536c797" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/0489a6247f729652db9b9ff408f69ac3bee3589e", - "reference": "0489a6247f729652db9b9ff408f69ac3bee3589e", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c331a8e70da9568b26f341da8d66392e1536c797", + "reference": "c331a8e70da9568b26f341da8d66392e1536c797", "shasum": "" }, "require": { @@ -7176,7 +7315,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.1.0" + "source": "https://github.com/symfony/security-bundle/tree/v8.1.2" }, "funding": [ { @@ -7196,20 +7335,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-29T07:22:54+00:00" }, { "name": "symfony/security-core", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97" + "reference": "5fdd9ad1449af76d537bcea471aaabaf8dbe92e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/a8239abe61dafdd0c01c0b4019138b2855717f97", - "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97", + "url": "https://api.github.com/repos/symfony/security-core/zipball/5fdd9ad1449af76d537bcea471aaabaf8dbe92e7", + "reference": "5fdd9ad1449af76d537bcea471aaabaf8dbe92e7", "shasum": "" }, "require": { @@ -7259,7 +7398,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.1.0" + "source": "https://github.com/symfony/security-core/tree/v8.1.5" }, "funding": [ { @@ -7279,7 +7418,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/security-csrf", @@ -7355,16 +7494,16 @@ }, { "name": "symfony/security-http", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b" + "reference": "3c45aece2c527722e276cf68f5751af578e59153" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/e0e6c7b9e80eec37248b92359cbd6938c7086f4b", - "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b", + "url": "https://api.github.com/repos/symfony/security-http/zipball/3c45aece2c527722e276cf68f5751af578e59153", + "reference": "3c45aece2c527722e276cf68f5751af578e59153", "shasum": "" }, "require": { @@ -7419,7 +7558,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.1.0" + "source": "https://github.com/symfony/security-http/tree/v8.1.5" }, "funding": [ { @@ -7439,20 +7578,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/serializer", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1" + "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/d101886195c5f772cf7033641fe9c40c3e3969e1", - "reference": "d101886195c5f772cf7033641fe9c40c3e3969e1", + "url": "https://api.github.com/repos/symfony/serializer/zipball/9c064d505c661e3e17aa8999870f4fb0ed05e024", + "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024", "shasum": "" }, "require": { @@ -7464,7 +7603,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/property-access": "<8.1", - "symfony/property-info": "<7.4", + "symfony/property-info": "<7.4.15", "symfony/type-info": "<7.4" }, "require-dev": { @@ -7483,7 +7622,7 @@ "symfony/messenger": "^7.4|^8.0", "symfony/mime": "^7.4|^8.0", "symfony/property-access": "^8.1", - "symfony/property-info": "^7.4|^8.0", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0", @@ -7518,7 +7657,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.0" + "source": "https://github.com/symfony/serializer/tree/v8.1.5" }, "funding": [ { @@ -7538,20 +7677,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7605,7 +7744,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -7625,7 +7764,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/stopwatch", @@ -7695,16 +7834,16 @@ }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -7761,7 +7900,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -7781,20 +7920,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", - "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -7854,7 +7993,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.0" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -7874,20 +8013,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", - "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -7936,7 +8075,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -7956,26 +8095,26 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/twig-bridge", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "25bb8c01edaab85e13142f6010df09b990388343" + "reference": "c721b1c98de3125ca195e35d52aaec5c97aa9c5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/25bb8c01edaab85e13142f6010df09b990388343", - "reference": "25bb8c01edaab85e13142f6010df09b990388343", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c721b1c98de3125ca195e35d52aaec5c97aa9c5f", + "reference": "c721b1c98de3125ca195e35d52aaec5c97aa9c5f", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.25" + "twig/twig": "^3.25|^4.0" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", @@ -8007,7 +8146,7 @@ "symfony/security-core": "^7.4|^8.0", "symfony/security-csrf": "^7.4|^8.0", "symfony/security-http": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5", "symfony/stopwatch": "^7.4|^8.0", "symfony/translation": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", @@ -8044,7 +8183,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v8.1.0" + "source": "https://github.com/symfony/twig-bridge/tree/v8.1.5" }, "funding": [ { @@ -8064,20 +8203,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/twig-bundle", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed" + "reference": "a70b67c2cd990d05e544ea5fab361aa0e69fab3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/b7f4a471a07b8b52174d153e4db12f46954192ed", - "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/a70b67c2cd990d05e544ea5fab361aa0e69fab3a", + "reference": "a70b67c2cd990d05e544ea5fab361aa0e69fab3a", "shasum": "" }, "require": { @@ -8128,7 +8267,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v8.1.0" + "source": "https://github.com/symfony/twig-bundle/tree/v8.1.2" }, "funding": [ { @@ -8148,20 +8287,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/type-info", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "url": "https://api.github.com/repos/symfony/type-info/zipball/ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5", "shasum": "" }, "require": { @@ -8210,7 +8349,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.1.0" + "source": "https://github.com/symfony/type-info/tree/v8.1.5" }, "funding": [ { @@ -8230,20 +8369,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/uid", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" + "reference": "a08aef47989093f32fe50fd11859be1b427df389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", - "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", + "url": "https://api.github.com/repos/symfony/uid/zipball/a08aef47989093f32fe50fd11859be1b427df389", + "reference": "a08aef47989093f32fe50fd11859be1b427df389", "shasum": "" }, "require": { @@ -8288,7 +8427,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.1.0" + "source": "https://github.com/symfony/uid/tree/v8.1.5" }, "funding": [ { @@ -8308,25 +8447,25 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-11T13:39:01+00:00" }, { "name": "symfony/ux-twig-component", - "version": "v3.1.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55" + "reference": "520e1da46bbb5974a2cf5839369aec3c8fc1a6f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/69763f39367d7185ebff3a8aec3e9d6ec7e10d55", - "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/520e1da46bbb5974a2cf5839369aec3c8fc1a6f0", + "reference": "520e1da46bbb5974a2cf5839369aec3c8fc1a6f0", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/dependency-injection": "^7.4|^8.0", + "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.2|^3.0", "symfony/event-dispatcher": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", @@ -8339,6 +8478,7 @@ "phpunit/phpunit": "^11.1|^12.0", "symfony/console": "^7.4|^8.0", "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", "symfony/dom-crawler": "^7.4|^8.0", "symfony/framework-bundle": "^7.4|^8.0", "symfony/stimulus-bundle": "^2.9.1|^3.0", @@ -8376,7 +8516,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v3.1.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v3.4.0" }, "funding": [ { @@ -8396,20 +8536,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T07:08:56+00:00" + "time": "2026-07-28T06:36:17+00:00" }, { "name": "symfony/validator", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "b122b2e384fa84166213ce98b887f01a3eea8d94" + "reference": "88b48d885df545a0fb1ffc25c8ebe215982168d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/b122b2e384fa84166213ce98b887f01a3eea8d94", - "reference": "b122b2e384fa84166213ce98b887f01a3eea8d94", + "url": "https://api.github.com/repos/symfony/validator/zipball/88b48d885df545a0fb1ffc25c8ebe215982168d8", + "reference": "88b48d885df545a0fb1ffc25c8ebe215982168d8", "shasum": "" }, "require": { @@ -8473,7 +8613,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.1.0" + "source": "https://github.com/symfony/validator/tree/v8.1.5" }, "funding": [ { @@ -8493,20 +8633,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" + "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", - "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a", "shasum": "" }, "require": { @@ -8522,7 +8662,7 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -8560,7 +8700,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.5" }, "funding": [ { @@ -8580,26 +8720,26 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T12:16:08+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.37" + "symfony/polyfill-deepclone": "^1.40" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -8643,7 +8783,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" }, "funding": [ { @@ -8663,7 +8803,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-13T16:11:28+00:00" }, { "name": "symfony/web-link", @@ -8751,16 +8891,16 @@ }, { "name": "symfony/webpack-encore-bundle", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/symfony/webpack-encore-bundle.git", - "reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e" + "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e", - "reference": "5b932e0feddd81aaf0ecd7d5fcd2e450e5a7817e", + "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", + "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", "shasum": "" }, "require": { @@ -8803,7 +8943,7 @@ "description": "Integration of your Symfony app with Webpack Encore", "support": { "issues": "https://github.com/symfony/webpack-encore-bundle/issues", - "source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.0" + "source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1" }, "funding": [ { @@ -8823,20 +8963,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:41:46+00:00" + "time": "2026-06-24T07:21:58+00:00" }, { "name": "symfony/yaml", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", - "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", + "reference": "b3fc9e8888eeb9daddc33bfbd15d282a61f543cd", "shasum": "" }, "require": { @@ -8879,7 +9019,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.0" + "source": "https://github.com/symfony/yaml/tree/v8.1.5" }, "funding": [ { @@ -8899,7 +9039,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T12:16:08+00:00" }, { "name": "twig/extra-bundle", @@ -8977,16 +9117,16 @@ }, { "name": "twig/html-extra", - "version": "v3.24.0", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/html-extra.git", - "reference": "313900fb98b371b006a55b1a29241a192634be13" + "reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/html-extra/zipball/313900fb98b371b006a55b1a29241a192634be13", - "reference": "313900fb98b371b006a55b1a29241a192634be13", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7", + "reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "shasum": "" }, "require": { @@ -9029,7 +9169,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/html-extra/tree/v3.24.0" + "source": "https://github.com/twigphp/html-extra/tree/v3.28.0" }, "funding": [ { @@ -9041,20 +9181,20 @@ "type": "tidelift" } ], - "time": "2026-03-17T07:24:08+00:00" + "time": "2026-06-25T06:50:01+00:00" }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -9109,7 +9249,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -9121,7 +9261,7 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "willdurand/negotiation", @@ -9247,28 +9387,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -9306,7 +9447,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -9316,13 +9457,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/xdebug-handler", @@ -10183,16 +10320,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.3", + "version": "v3.95.22", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3d681493acc0e93283481b1c63c263737df78687" + "reference": "482722e0783c16325a07a998a0d9eda560f9be99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3d681493acc0e93283481b1c63c263737df78687", - "reference": "3d681493acc0e93283481b1c63c263737df78687", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/482722e0783c16325a07a998a0d9eda560f9be99", + "reference": "482722e0783c16325a07a998a0d9eda560f9be99", "shasum": "" }, "require": { @@ -10210,7 +10347,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -10226,16 +10363,15 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.8.0", + "justinrainbow/json-schema": "^6.10.0", "keradus/cli-executor": "^2.3", - "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.37", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -10276,7 +10412,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.3" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.22" }, "funding": [ { @@ -10284,7 +10420,7 @@ "type": "github" } ], - "time": "2026-05-29T20:35:26+00:00" + "time": "2026-08-24T10:37:43+00:00" }, { "name": "hautelook/alice-bundle", @@ -10364,19 +10500,20 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.8.2", + "version": "6.11.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + "reference": "7e420a943a6fbc95e60e3cf67acfbee85b3b4da7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7e420a943a6fbc95e60e3cf67acfbee85b3b4da7", + "reference": "7e420a943a6fbc95e60e3cf67acfbee85b3b4da7", "shasum": "" }, "require": { + "ext-filter": "*", "ext-json": "*", "marc-mabe/php-enum": "^4.4", "php": "^7.2 || ^8.0" @@ -10409,20 +10546,9 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" } ], "description": "A library to validate a json schema.", @@ -10433,9 +10559,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.11.0" }, - "time": "2026-05-05T05:39:01+00:00" + "time": "2026-08-21T10:30:42+00:00" }, { "name": "localheinz/diff", @@ -10567,20 +10693,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -10615,15 +10741,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nelmio/alice", @@ -10722,20 +10848,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -10774,9 +10899,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -10946,11 +11071,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.1", + "version": "2.2.9", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", - "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", + "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", "shasum": "" }, "require": { @@ -11006,25 +11131,25 @@ "type": "github" } ], - "time": "2026-05-28T14:44:12+00:00" + "time": "2026-08-22T07:38:16+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.23", + "version": "2.0.28", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "4821d678585be36f585273de38c06b7e4a98bb91" + "reference": "b4623954d5ffee6311e4ddbaef65c074ae0f781a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/4821d678585be36f585273de38c06b7e4a98bb91", - "reference": "4821d678585be36f585273de38c06b7e4a98bb91", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/b4623954d5ffee6311e4ddbaef65c074ae0f781a", + "reference": "b4623954d5ffee6311e4ddbaef65c074ae0f781a", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.34" + "phpstan/phpstan": "^2.2.2" }, "conflict": { "doctrine/collections": "<1.0", @@ -11081,27 +11206,28 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.23" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.28" }, - "time": "2026-05-25T15:58:25+00:00" + "time": "2026-07-13T08:43:43+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.16", + "version": "2.0.18", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30", + "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30", "shasum": "" }, "require": { + "phar-io/version": "^3.2", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.2.3" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -11111,7 +11237,8 @@ "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "shipmonk/name-collision-detector": "^2.1" }, "type": "phpstan-extension", "extra": { @@ -11137,22 +11264,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18" }, - "time": "2026-02-14T09:05:21+00:00" + "time": "2026-07-04T12:16:09+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.19", + "version": "2.0.20", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "546071ed7f80a89ec30909346eb7cc741800740a" + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/546071ed7f80a89ec30909346eb7cc741800740a", - "reference": "546071ed7f80a89ec30909346eb7cc741800740a", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd", "shasum": "" }, "require": { @@ -11211,22 +11338,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.19" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20" }, - "time": "2026-05-29T12:52:44+00:00" + "time": "2026-06-16T09:17:35+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.1.9", + "version": "14.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "655533a65696bbc4231cd8027af150dadc40ec88" + "reference": "6ce313bb110384148d1dc7695a99175f59529069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/655533a65696bbc4231cd8027af150dadc40ec88", - "reference": "655533a65696bbc4231cd8027af150dadc40ec88", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6ce313bb110384148d1dc7695a99175f59529069", + "reference": "6ce313bb110384148d1dc7695a99175f59529069", "shasum": "" }, "require": { @@ -11234,18 +11361,18 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4", "phpunit/php-text-template": "^6.0", "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.2", + "sebastian/environment": "^9.3.2", "sebastian/git-state": "^1.0", - "sebastian/lines-of-code": "^5.0", + "sebastian/lines-of-code": "^5.0.2", "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.1" + "phpunit/phpunit": "^13.3.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -11254,7 +11381,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "14.1.x-dev" + "dev-main": "14.3.x-dev" } }, "autoload": { @@ -11283,7 +11410,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.3.1" }, "funding": [ { @@ -11303,27 +11430,27 @@ "type": "tidelift" } ], - "time": "2026-05-16T05:16:14+00:00" + "time": "2026-08-16T05:23:47+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.1" }, "type": "library", "extra": { @@ -11356,7 +11483,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.2" }, "funding": [ { @@ -11376,7 +11503,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:33:26+00:00" + "time": "2026-08-25T14:47:43+00:00" }, { "name": "phpunit/php-invoker", @@ -11600,44 +11727,45 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.13", + "version": "13.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e" + "reference": "fc024931d6ad047404e9d86536735923fe63a06b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddf7f25d9ee9652b464475d7f3bacde2613e355e", - "reference": "ddf7f25d9ee9652b464475d7f3bacde2613e355e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc024931d6ad047404e9d86536735923fe63a06b", + "reference": "fc024931d6ad047404e9d86536735923fe63a06b", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.1.9", - "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-code-coverage": "^14.3", + "phpunit/php-file-iterator": "^7.0.1", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.2.1", - "sebastian/diff": "^8.3.0", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0", "sebastian/environment": "^9.3.2", - "sebastian/exporter": "^8.1.0", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", "sebastian/git-state": "^1.0", - "sebastian/global-state": "^9.0.0", - "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", - "sebastian/type": "^7.0.1", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -11647,7 +11775,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "13.1-dev" + "dev-main": "13.3-dev" } }, "autoload": { @@ -11679,7 +11807,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.13" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.3.1" }, "funding": [ { @@ -11687,7 +11815,7 @@ "type": "other" } ], - "time": "2026-05-27T14:03:08+00:00" + "time": "2026-08-13T13:14:23+00:00" }, { "name": "react/cache", @@ -12217,21 +12345,21 @@ }, { "name": "rector/rector", - "version": "2.4.5", + "version": "2.6.3", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" + "reference": "7e46709996a4b3dc59e1d6ecbb6a38ace335bd58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/7e46709996a4b3dc59e1d6ecbb6a38ace335bd58", + "reference": "7e46709996a4b3dc59e1d6ecbb6a38ace335bd58", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.56" + "phpstan/phpstan": "^2.2.6" }, "conflict": { "rector/rector-doctrine": "*", @@ -12265,7 +12393,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.5" + "source": "https://github.com/rectorphp/rector/tree/2.6.3" }, "funding": [ { @@ -12273,27 +12401,27 @@ "type": "github" } ], - "time": "2026-05-26T21:03:22+00:00" + "time": "2026-08-18T22:01:18+00:00" }, { "name": "sebastian/cli-parser", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -12322,7 +12450,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" }, "funding": [ { @@ -12342,31 +12470,31 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:39:44+00:00" + "time": "2026-08-01T04:27:14+00:00" }, { "name": "sebastian/comparator", - "version": "8.2.1", + "version": "8.4.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", - "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.4", - "sebastian/diff": "^8.3", - "sebastian/exporter": "^8.0.3" + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -12374,7 +12502,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.2-dev" + "dev-main": "8.4-dev" } }, "autoload": { @@ -12414,7 +12542,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.2.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.4.0" }, "funding": [ { @@ -12434,7 +12562,7 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:46:40+00:00" + "time": "2026-08-07T07:23:13+00:00" }, { "name": "sebastian/complexity", @@ -12508,29 +12636,29 @@ }, { "name": "sebastian/diff", - "version": "8.3.0", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -12563,7 +12691,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.1" }, "funding": [ { @@ -12583,7 +12711,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T04:58:09+00:00" + "time": "2026-08-25T15:38:55+00:00" }, { "name": "sebastian/environment", @@ -12663,30 +12791,30 @@ }, { "name": "sebastian/exporter", - "version": "8.1.0", + "version": "8.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.4", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "8.2-dev" } }, "autoload": { @@ -12729,7 +12857,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.2.1" }, "funding": [ { @@ -12749,7 +12877,76 @@ "type": "tidelift" } ], - "time": "2026-05-21T11:50:56+00:00" + "time": "2026-08-07T07:22:06+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" }, { "name": "sebastian/git-state", @@ -12822,16 +13019,16 @@ }, { "name": "sebastian/global-state", - "version": "9.0.0", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", - "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", "shasum": "" }, "require": { @@ -12841,7 +13038,7 @@ }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.1.13" }, "type": "library", "extra": { @@ -12872,7 +13069,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" }, "funding": [ { @@ -12892,28 +13089,28 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:45:13+00:00" + "time": "2026-06-01T15:11:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", + "nikic/php-parser": "^5.8.0", "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.2.4" }, "type": "library", "extra": { @@ -12942,7 +13139,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" }, "funding": [ { @@ -12962,34 +13159,33 @@ "type": "tidelift" } ], - "time": "2026-05-19T16:23:37+00:00" + "time": "2026-07-09T08:42:34+00:00" }, { "name": "sebastian/object-enumerator", - "version": "8.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", "shasum": "" }, "require": { "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" + "sebastian/recursion-context": "^8.0.1" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -13012,7 +13208,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.1.0" }, "funding": [ { @@ -13032,27 +13228,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:46:36+00:00" + "time": "2026-08-13T07:05:05+00:00" }, { "name": "sebastian/object-reflector", - "version": "6.0.0", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -13080,7 +13276,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.1.0" }, "funding": [ { @@ -13100,27 +13296,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:47:13+00:00" + "time": "2026-08-13T06:34:36+00:00" }, { "name": "sebastian/recursion-context", - "version": "8.0.0", + "version": "8.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -13156,7 +13352,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { @@ -13176,27 +13372,27 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:51:28+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { "name": "sebastian/type", - "version": "7.0.1", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fee0309275847fefd7636167085e379c1dbf6990" + "reference": "bd1df467864cb95140414059a535b2d906173fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", - "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.1.10" + "phpunit/phpunit": "^13.3.0" }, "type": "library", "extra": { @@ -13225,7 +13421,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.2" }, "funding": [ { @@ -13245,7 +13441,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T06:49:11+00:00" + "time": "2026-08-10T08:00:57+00:00" }, { "name": "sebastian/version", @@ -13367,16 +13563,16 @@ }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/a291fb5adb65f52a4bb315db2d803698315dc64d", + "reference": "a291fb5adb65f52a4bb315db2d803698315dc64d", "shasum": "" }, "require": { @@ -13412,7 +13608,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.5" }, "funding": [ { @@ -13432,7 +13628,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/debug-bundle", @@ -13608,90 +13804,6 @@ ], "time": "2026-03-18T13:39:06+00:00" }, - { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.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-04-10T16:19:22+00:00" - }, { "name": "symfony/polyfill-php81", "version": "v1.38.1", @@ -13774,16 +13886,16 @@ }, { "name": "symfony/process", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", "shasum": "" }, "require": { @@ -13815,7 +13927,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.0" + "source": "https://github.com/symfony/process/tree/v8.1.5" }, "funding": [ { @@ -13835,20 +13947,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v8.1.0", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086" + "reference": "4ae952afc65cb63fc74067633466bd8295cdcebc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086", - "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4ae952afc65cb63fc74067633466bd8295cdcebc", + "reference": "4ae952afc65cb63fc74067633466bd8295cdcebc", "shasum": "" }, "require": { @@ -13900,7 +14012,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.0" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.5" }, "funding": [ { @@ -13920,7 +14032,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "theofidry/alice-data-fixtures", @@ -14073,16 +14185,16 @@ }, { "name": "vincentlanglet/twig-cs-fixer", - "version": "3.14.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", - "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3" + "reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/599f110f192c31af5deb5736d6c1a970afdf51f3", - "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a", + "reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a", "shasum": "" }, "require": { @@ -14093,7 +14205,7 @@ "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", - "twig/twig": "^3.4", + "twig/twig": "^3.15", "webmozart/assert": "^1.10 || ^2.0" }, "require-dev": { @@ -14138,7 +14250,7 @@ "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", "support": { "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", - "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.14.0" + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.2" }, "funding": [ { @@ -14146,20 +14258,20 @@ "type": "github" } ], - "time": "2026-02-23T13:21:35+00:00" + "time": "2026-06-29T15:22:14+00:00" }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -14210,9 +14322,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 6651fba..387fd87 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,6 +1,9 @@ api_platform: - title: 'ITKsites Detection API' - description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + title: 'ITKsites API' + description: | + REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories. + + The API also provides read-only access to information on servers and sites created by processing detection results. version: '1.0.0' mapping: diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 0000000..f7653e9 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index c3eb53d..c3cab50 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -15,5 +15,15 @@ framework: #app: cache.adapter.apcu # Namespaced pools use the above "app" backend by default - #pools: - #my.dedicated.cache: null + pools: + # Health check results. + # + # Filesystem-backed on purpose: this pool has to keep working while + # the database and the message broker are down, which is exactly + # when the health endpoints matter. + # + # It is a dedicated pool so the adapter can be swapped without + # touching code: cache.adapter.apcu is faster and shared between + # FPM workers, at the cost of being cleared on every FPM restart. + cache.health: + adapter: cache.adapter.filesystem diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 2c9057c..20230a0 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -13,6 +13,20 @@ framework: #esi: true #fragments: true + http_client: + scoped_clients: + economics.client: + base_uri: '%env(APP_ECONOMICS_URI)%' + scope: '%env(APP_ECONOMICS_URI)%' + headers: + x-api-key: '%env(APP_ECONOMICS_API_KEY)%' + leantime.client: + base_uri: '%env(APP_LEANTIME_URI)%' + scope: '%env(APP_LEANTIME_URI)%' + headers: + Content-Type: 'application/json' + x-api-key: '%env(APP_LEANTIME_API_KEY)%' + when@test: framework: test: true diff --git a/config/packages/itkdev_openid_connect.yaml b/config/packages/itkdev_openid_connect.yaml index 854a6d3..6194344 100644 --- a/config/packages/itkdev_openid_connect.yaml +++ b/config/packages/itkdev_openid_connect.yaml @@ -15,6 +15,9 @@ itkdev_openid_connect: metadata_url: '%env(string:AZURE_AZ_OIDC_METADATA_URL)%' client_id: '%env(AZURE_AZ_OIDC_CLIENT_ID)%' client_secret: '%env(AZURE_AZ_OIDC_CLIENT_SECRET)%' + # Date the client secret expires. Lets the bundle warn before it + # breaks every login, as it did on 2026-08-12. + client_secret_expires_at: '%env(string:AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT)%' # Specify redirect URI redirect_uri: '%env(string:AZURE_AZ_OIDC_REDIRECT_URI)%' allow_http: '%env(bool:AZURE_AZ_OIDC_ALLOW_HTTP)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f57e0e7..c730e4c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -12,15 +12,28 @@ security: entity: class: App\Entity\User property: email + + app_api_users: + chain: + providers: [app_server_provider, app_user_provider] + firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + # The health endpoints must answer while the database is down, so they + # cannot go through a firewall: both user providers above are Doctrine + # entity providers and authentication would itself fail. /health/detail + # is protected by the ITKBasicAuth middleware in Traefik instead. + health: + pattern: ^/health + security: false + api: pattern: ^/api custom_authenticators: - App\Security\ApiKeyAuthenticator - provider: app_server_provider + provider: app_api_users main: custom_authenticators: @@ -41,17 +54,10 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - - { path: ^/api, roles: ROLE_SERVER } + - { path: ^/api, roles: [ROLE_SERVER, ROLE_USER] } - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } -# Current AAK OIDC setup doesn't support `itksites.local.itkdev.dk` -when@dev: - security: - firewalls: - main: - security: false - when@test: security: password_hashers: diff --git a/config/reference.php b/config/reference.php index 54e58c4..db69eea 100644 --- a/config/reference.php +++ b/config/reference.php @@ -31,7 +31,7 @@ * @psalm-type ImportsConfig = list * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array @@ -127,7 +127,7 @@ * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array @@ -142,9 +142,9 @@ * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, - * trusted_hosts?: string|list, + * trusted_hosts?: Param|string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: string|list, + * trusted_headers?: Param|string|list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ @@ -208,23 +208,23 @@ * property?: scalar|Param|null, * service?: scalar|Param|null, * }, - * supports?: string|list, + * supports?: Param|string|list, * definition_validators?: list, * support_strategy?: scalar|Param|null, - * initial_marking?: \BackedEnum|string|list, + * initial_marking?: \BackedEnum|Param|string|list, * events_to_dispatch?: null|list, - * places?: string|list, * }>, * transitions?: list, - * to?: \BackedEnum|string|list, @@ -264,7 +264,7 @@ * }, * request?: bool|array{ // Request configuration * enabled?: bool|Param, // Default: false - * formats?: array>, + * formats?: array>, * }, * assets?: bool|array{ // Assets configuration * enabled?: bool|Param, // Default: true @@ -274,7 +274,7 @@ * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" - * base_urls?: string|list, + * base_urls?: Param|string|list, * packages?: array, + * base_urls?: Param|string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration - * enabled?: bool|Param, // Default: false - * paths?: string|array, + * enabled?: bool|Param, // Default: true + * paths?: Param|string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true @@ -306,7 +306,7 @@ * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: true - * fallbacks?: string|list, + * fallbacks?: Param|string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" @@ -325,7 +325,7 @@ * domains?: list, * locales?: list, * }>, - * globals?: array, @@ -335,7 +335,7 @@ * validation?: bool|array{ // Validation configuration * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true - * static_method?: string|list, + * static_method?: Param|string|list, * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" * mapping?: array{ @@ -396,7 +396,7 @@ * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, + * adapters?: Param|string|list, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. @@ -418,17 +418,17 @@ * web_link?: bool|array{ // Web links configuration * enabled?: bool|Param, // Default: true * }, - * lock?: bool|string|array{ // Lock configuration + * lock?: Param|bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: string|array>, + * resources?: Param|string|array>, * }, - * semaphore?: bool|string|array{ // Semaphore configuration + * semaphore?: Param|bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: string|array, + * resources?: Param|string|array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: true - * routing?: array>, + * routing?: array>, * serializer?: array{ * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ @@ -436,12 +436,12 @@ * context?: array, * }, * }, - * transports?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * retry_strategy?: string|array{ + * retry_strategy?: Param|string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -452,15 +452,15 @@ * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: int|string|list, + * stop_worker_on_signals?: Param|int|string|list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, @@ -509,9 +509,9 @@ * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: int|string|array, + * methods?: Param|string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -521,7 +521,7 @@ * }, * }, * mock_response_factory?: scalar|Param|null, // `true` to always return empty 200 responses, or the id of the service to use to generate mock responses - which should be either an invokable or an iterable. - * scoped_clients?: array, + * methods?: Param|string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -582,10 +582,10 @@ * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, - * recipients?: string|list, - * allowed_recipients?: string|list, + * recipients?: Param|string|list, + * allowed_recipients?: Param|string|list, * }, - * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration @@ -622,7 +622,7 @@ * chatter_transports?: array, * texter_transports?: array, * notification_on_failed_messages?: bool|Param, // Default: false - * channel_policy?: array>, + * channel_policy?: array>, * admin_recipients?: list, + * limiters?: Param|string|list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". @@ -661,20 +661,20 @@ * allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false * allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false * allow_elements?: array, - * block_elements?: string|list, - * drop_elements?: string|list, + * block_elements?: Param|string|list, + * drop_elements?: Param|string|list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: string|list, - * allowed_link_hosts?: null|string|list, + * allowed_link_schemes?: Param|string|list, + * allowed_link_hosts?: Param|null|string|list, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: string|list, - * allowed_media_hosts?: null|string|list, + * allowed_media_schemes?: Param|string|list, + * allowed_media_hosts?: Param|null|string|list, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: string|list, - * without_attribute_sanitizers?: string|list, + * with_attribute_sanitizers?: Param|string|list, + * without_attribute_sanitizers?: Param|string|list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, @@ -707,6 +707,7 @@ * id?: scalar|Param|null, * type?: scalar|Param|null, * value?: mixed, + * ... * }>, * autoescape_service?: scalar|Param|null, // Default: null * autoescape_service_method?: scalar|Param|null, // Default: null @@ -717,7 +718,7 @@ * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: string|list, + * file_name_pattern?: Param|string|list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" @@ -745,9 +746,9 @@ * allow_if_all_abstain?: bool|Param, // Default: false * allow_if_equal_granted_denied?: bool|Param, // Default: true * }, - * password_hashers?: array, + * migrate_from?: Param|string|list, * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" * key_length?: scalar|Param|null, // Default: 40 * ignore_case?: bool|Param, // Default: false @@ -761,12 +762,12 @@ * providers?: array, + * providers?: Param|string|list, * }, * memory?: array{ * users?: array, + * roles?: Param|string|list, * }>, * }, * ldap?: array{ @@ -775,7 +776,7 @@ * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, - * default_roles?: string|list, + * default_roles?: Param|string|list, * role_fetcher?: scalar|Param|null, // Default: null * uid_key?: scalar|Param|null, // Default: "sAMAccountName" * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" @@ -790,7 +791,7 @@ * firewalls?: array, + * methods?: Param|string|list, * security?: bool|Param, // Default: true * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" * request_matcher?: scalar|Param|null, @@ -809,8 +810,8 @@ * path?: scalar|Param|null, // Default: "/logout" * target?: scalar|Param|null, // Default: "/" * invalidate_session?: bool|Param, // Default: true - * clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"clientHints"|"executionContexts"|"prefetchCache"|"prerenderCache"|Param>, - * delete_cookies?: string|array, + * delete_cookies?: Param|string|array, - * token_handler?: string|array{ + * token_extractors?: Param|string|list, + * token_handler?: Param|string|array{ * id?: scalar|Param|null, - * oidc_user_info?: string|array{ + * oidc_user_info?: Param|string|array{ * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ @@ -963,7 +964,7 @@ * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri?: string|list, + * base_uri?: Param|string|list, * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, @@ -1005,10 +1006,10 @@ * remember_me?: array{ * secret?: scalar|Param|null, // Default: "%kernel.secret%" * service?: scalar|Param|null, - * user_providers?: string|list, + * user_providers?: Param|string|list, * catch_exceptions?: bool|Param, // Default: true * signature_properties?: list, - * token_provider?: string|array{ + * token_provider?: Param|string|array{ * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. * doctrine?: bool|array{ * enabled?: bool|Param, // Default: false @@ -1033,19 +1034,19 @@ * path?: scalar|Param|null, // Use the urldecoded format. // Default: null * host?: scalar|Param|null, // Default: null * port?: int|Param, // Default: null - * ips?: string|list, + * ips?: Param|string|list, * attributes?: array, * route?: scalar|Param|null, // Default: null - * methods?: string|list, + * methods?: Param|string|list, * allow_if?: scalar|Param|null, // Default: null - * roles?: string|list, + * roles?: Param|string|list, * }>, - * role_hierarchy?: array>, + * role_hierarchy?: array>, * } * @psalm-type DoctrineConfig = array{ * dbal?: array{ * default_connection?: scalar|Param|null, - * types?: array, * driver_schemes?: array, @@ -1068,7 +1069,7 @@ * servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter. * sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver * server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere. - * default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion. + * default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection. * sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL. * sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities. * sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL. @@ -1114,7 +1115,7 @@ * servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter. * sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver * server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere. - * default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion. + * default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection. * sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL. * sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities. * sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL. @@ -1124,8 +1125,11 @@ * MultipleActiveResultSets?: bool|Param, // Configuring MultipleActiveResultSets for the pdo_sqlsrv driver * instancename?: scalar|Param|null, // Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection. It is generally used to connect to an Oracle RAC server to select the name of a particular instance. * connectstring?: scalar|Param|null, // Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.When using this option, you will still need to provide the user and password parameters, but the other parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods from Doctrine\DBAL\Connection will no longer function as expected. + * ... * }>, + * ... * }>, + * ... * }, * orm?: array{ * default_entity_manager?: scalar|Param|null, @@ -1136,17 +1140,17 @@ * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false * }, * entity_managers?: array, * }>, * }>, + * ... * }, * connection?: scalar|Param|null, * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" @@ -1174,7 +1179,7 @@ * schema_ignore_classes?: list, * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/6728. // Default: false * second_level_cache?: array{ - * region_cache_driver?: string|array{ + * region_cache_driver?: Param|string|array{ * type?: scalar|Param|null, // Default: null * id?: scalar|Param|null, * pool?: scalar|Param|null, @@ -1185,7 +1190,7 @@ * enabled?: bool|Param, // Default: true * factory?: scalar|Param|null, * regions?: array, @@ -1203,7 +1208,7 @@ * }>, * }, * hydrators?: array, - * mappings?: array, * datetime_functions?: array, * }, - * filters?: array, + * ... * }>, * identity_generation_preferences?: array, * }>, * resolve_target_entities?: array, + * ... * }, * } * @psalm-type NelmioCorsConfig = array{ @@ -1270,6 +1277,7 @@ * }, * jsonapi?: array{ * use_iri_as_id?: bool|Param, // Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. // Default: true + * allow_client_generated_id?: bool|Param, // Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints. // Default: false * }, * eager_loading?: bool|array{ * enabled?: bool|Param, // Default: true @@ -1549,6 +1557,7 @@ * item_uri_template?: mixed, * ... * }, + * ... * } * @psalm-type MakerConfig = array{ * root_namespace?: scalar|Param|null, // Default: "App" @@ -1580,6 +1589,7 @@ * enabled?: bool|Param|null, // Default: null * date_format?: scalar|Param|null, * remove_used_context_fields?: bool|Param, + * ... * }, * path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log" * file_permission?: scalar|Param|null, // Default: null @@ -1640,18 +1650,18 @@ * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 - * tags?: string|list, + * tags?: Param|string|list, * console_formatter_options?: mixed, // Default: [] * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false - * publisher?: string|array{ + * publisher?: Param|string|array{ * id?: scalar|Param|null, * hostname?: scalar|Param|null, * port?: scalar|Param|null, // Default: 12201 * chunk_size?: scalar|Param|null, // Default: 1420 * encoder?: "json"|"compressed_json"|Param, * }, - * mongodb?: string|array{ + * mongodb?: Param|string|array{ * id?: scalar|Param|null, // ID of a MongoDB\Client service * uri?: scalar|Param|null, * username?: scalar|Param|null, @@ -1659,7 +1669,7 @@ * database?: scalar|Param|null, // Default: "monolog" * collection?: scalar|Param|null, // Default: "logs" * }, - * elasticsearch?: string|array{ + * elasticsearch?: Param|string|array{ * id?: scalar|Param|null, * hosts?: list, * host?: scalar|Param|null, @@ -1671,7 +1681,7 @@ * index?: scalar|Param|null, // Default: "monolog" * document_type?: scalar|Param|null, // Default: "logs" * ignore_error?: scalar|Param|null, // Default: false - * redis?: string|array{ + * redis?: Param|string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * password?: scalar|Param|null, // Default: null @@ -1679,17 +1689,17 @@ * database?: scalar|Param|null, // Default: 0 * key_name?: scalar|Param|null, // Default: "monolog_redis" * }, - * predis?: string|array{ + * predis?: Param|string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * }, * from_email?: scalar|Param|null, - * to_email?: string|list, + * to_email?: Param|string|list, * subject?: scalar|Param|null, * content_type?: scalar|Param|null, // Default: null * headers?: list, * mailer?: scalar|Param|null, // Default: null - * email_prototype?: string|array{ + * email_prototype?: Param|string|array{ * id?: scalar|Param|null, * method?: scalar|Param|null, // Default: null * }, @@ -1700,9 +1710,10 @@ * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" * }, - * channels?: string|array{ + * channels?: Param|string|array{ * type?: scalar|Param|null, * elements?: list, + * ... * }, * }>, * } @@ -1741,19 +1752,34 @@ * route?: scalar|Param|null, // Return route for CLI login * }, * user_provider?: scalar|Param|null, // The User Provider to inject // Default: null + * logging_options?: array{ + * logger?: scalar|Param|null, // Service id of the PSR-3 logger to receive this bundle's failure logs, e.g. "monolog.logger.openid_connect". Defaults to the application logger, which Symfony always provides. Set "itkdev_openid_connect.null_logger" to turn logging off. // Default: null + * }, + * audit_options?: array{ + * enabled?: bool|Param, // Write an authentication audit trail (logins, failures, CLI token issuance). Off by default: audit records identify people, so an existing installation must opt in rather than start logging personal data on upgrade. // Default: false + * logger?: scalar|Param|null, // Service id of the PSR-3 logger to receive audit records, e.g. "monolog.logger.openid_connect_audit". Defaults to the application logger. Keep this separate from logging_options.logger: an operational threshold of "error" would otherwise discard the whole trail. // Default: null + * identifier?: "raw"|"hashed"|Param, // Record user identifiers as-is ("raw") or pseudonymised ("hashed"). Hashing is keyed with the application secret, so records still correlate. Cannot come from an environment variable; use environment-specific configuration instead. // Default: "raw" + * }, + * secret_expiry_options?: array{ + * warning_days?: int|Param, // How many days before a client secret expires the bundle starts warning (default: 30) // Default: 30 + * }, * openid_providers?: list, + * pkce?: bool|Param, // Send a PKCE challenge (RFC 7636, S256) with the authorization request // Default: true * redirect_uri?: scalar|Param|null, // Redirect URI registered at identity provider * redirect_route?: scalar|Param|null, // Redirect route registered at identity provider (must not be set if redirect_uri is set) * redirect_route_parameters?: array, + * callback_path?: scalar|Param|null, // Optional. The request path the callback arrives on, for a proxy that rewrites it without sending X-Forwarded-Prefix. Include any base path. Defaults to the path of redirect_uri, or of the generated redirect_route; a trusted X-Forwarded-Prefix or a subdirectory deployment is already accounted for without this. * allow_http?: bool|Param, // Whether to allow http or not (default: false) // Default: false * http_client_options?: array{ // Options forwarded to the underlying Guzzle HTTP client. league/oauth2-client only forwards: timeout, proxy, verify (verify is only consulted when proxy is set). - * timeout?: float|Param, // Total request timeout in seconds + * timeout?: float|Param, // Total request timeout in seconds. Defaults to 30; set to 0 to wait indefinitely (Guzzle's own default). // Default: 30.0 * proxy?: scalar|Param|null, // HTTP proxy URI * verify?: bool|Param, // Verify TLS certificates (only consulted by Guzzle when proxy is set) * }, @@ -1823,7 +1849,7 @@ * }, * } * @psalm-type TwigComponentConfig = array{ - * defaults?: array, diff --git a/config/services.yaml b/config/services.yaml index 71d40c9..100e7e4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,6 +27,23 @@ services: App\Handler\DetectionResultHandlerInterface: tags: [app.handler.detection_result_handler] + App\Health\HealthCheckInterface: + tags: [app.health.check] + + App\Health\HealthChecker: + arguments: + $checks: !tagged_iterator app.health.check + $cache: '@cache.health' + $cacheTtl: '%env(int:HEALTH_CACHE_TTL)%' + + App\Health\Check\RabbitMqHealthCheck: + arguments: + $transport: '@messenger.transport.async' + + App\Health\Check\IngestFreshnessHealthCheck: + arguments: + $maxAgeSeconds: '%env(int:HEALTH_INGEST_MAX_AGE)%' + App\EventListener\RemovedRelationsListener: tags: - name: 'doctrine.event_listener' diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 73f8b43..be2dcc5 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,4 +1,35 @@ services: + # A mock identity provider, so the OpenID Connect login can be exercised locally. + # The real one has no redirect URI registered for a developer machine. + # + # https://github.com/geigerzaehler/oidc-provider-mock + # + # The container name is the external hostname on purpose: the browser and the + # application then reach the provider by the same name, so the issuer in the + # discovery document matches the one in the ID token. + # + # Claims must cover what AzureOIDCAuthenticator reads — `name` and `upn`. + idp: + image: ghcr.io/geigerzaehler/oidc-provider-mock:latest + container_name: idp.${COMPOSE_DOMAIN:?} + networks: + - app + - frontend + expose: + - "80" + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}_idp.rule=Host(`idp.${COMPOSE_DOMAIN:?}`)" + - "traefik.http.services.${COMPOSE_PROJECT_NAME:?}_idp.loadbalancer.server.port=80" + command: + - "--port" + - "80" + - "--user-claims" + - '{"sub": "admin", "name": "Admin Jensen", "upn": "admin@example.org", "email": "admin@example.org"}' + - "--user-claims" + - '{"sub": "editor", "name": "Ed Editor", "upn": "editor@example.org", "email": "editor@example.org"}' + rabbit: image: rabbitmq:4-management-alpine networks: diff --git a/docker-compose.server.yml b/docker-compose.server.yml index fffc693..45f2720 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -50,3 +50,8 @@ services: # Cron-metrics protection. - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.entrypoints=websecure" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" diff --git a/docker-compose.yml b/docker-compose.yml index 4dff245..444ce64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,10 @@ services: # Cron-metrics protection. - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" mail: image: axllent/mailpit diff --git a/fixtures/user.yaml b/fixtures/user.yaml index de04a82..76710ec 100644 --- a/fixtures/user.yaml +++ b/fixtures/user.yaml @@ -1,3 +1,14 @@ App\Entity\User: user_admin: - __construct: ["admin@example.com", "Admin", [ROLE_ADMIN]] + __construct: ["Admin", "admin@example.com", [ROLE_ADMIN]] + user_user: + __construct: ["User", "user@example.com", [ROLE_USER]] + # A few more users so migrations are exercised against a table holding more + # than one row. A single row cannot collide with itself, which hides + # migrations adding a unique column to an existing table. + user_1: + __construct: ["User 1", "user1@example.com", [ROLE_USER]] + user_2: + __construct: ["User 2", "user2@example.com", [ROLE_USER]] + user_3: + __construct: ["User 3", "user3@example.com", [ROLE_USER]] diff --git a/importmap.php b/importmap.php new file mode 100644 index 0000000..70ebf14 --- /dev/null +++ b/importmap.php @@ -0,0 +1,19 @@ + [ + 'path' => './assets/app.js', + 'entrypoint' => true, + ], +]; diff --git a/migrations/Version20260520101548.php b/migrations/Version20260520101548.php new file mode 100644 index 0000000..1a0e66f --- /dev/null +++ b/migrations/Version20260520101548.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE security_contract (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, project_name VARCHAR(255) NOT NULL, client_name VARCHAR(255) DEFAULT NULL, hosting_provider VARCHAR(255) DEFAULT NULL, document_url VARCHAR(255) DEFAULT NULL, monthly_price DOUBLE PRECISION DEFAULT NULL, valid_from DATE DEFAULT NULL, valid_to DATE DEFAULT NULL, active TINYINT NOT NULL, eol TINYINT NOT NULL, leantime_url VARCHAR(255) DEFAULT NULL, client_contact_name VARCHAR(255) DEFAULT NULL, client_contact_email VARCHAR(255) DEFAULT NULL, dedicated_server TINYINT NOT NULL, server_size VARCHAR(255) DEFAULT NULL, git_repos LONGTEXT DEFAULT NULL, system_owner_notices JSON DEFAULT NULL, project_tracker_key VARCHAR(255) DEFAULT NULL, quarterly_hours DOUBLE PRECISION DEFAULT NULL, cybersecurity_price DOUBLE PRECISION DEFAULT NULL, cybersecurity_note LONGTEXT DEFAULT NULL, UNIQUE INDEX UNIQ_8AE4AF8B4416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE security_contract'); + } +} diff --git a/migrations/Version20260527103011.php b/migrations/Version20260527103011.php new file mode 100644 index 0000000..b1f8b2d --- /dev/null +++ b/migrations/Version20260527103011.php @@ -0,0 +1,51 @@ +addSql('CREATE TABLE code_owner (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_2335FF304416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, name VARCHAR(255) NOT NULL, leantime_id VARCHAR(255) DEFAULT NULL, leantime_url VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_2FB3D0EE4416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project_code_owner (project_id BINARY(16) NOT NULL, code_owner_id BINARY(16) NOT NULL, INDEX IDX_3B938402166D1F9C (project_id), INDEX IDX_3B93840287BD19D2 (code_owner_id), PRIMARY KEY (project_id, code_owner_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE project_git_repo (project_id BINARY(16) NOT NULL, git_repo_id BINARY(16) NOT NULL, INDEX IDX_CB848708166D1F9C (project_id), INDEX IDX_CB8487083E8A2A0D (git_repo_id), PRIMARY KEY (project_id, git_repo_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE project_code_owner ADD CONSTRAINT FK_3B938402166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_code_owner ADD CONSTRAINT FK_3B93840287BD19D2 FOREIGN KEY (code_owner_id) REFERENCES code_owner (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_git_repo ADD CONSTRAINT FK_CB848708166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_git_repo ADD CONSTRAINT FK_CB8487083E8A2A0D FOREIGN KEY (git_repo_id) REFERENCES git_repo (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE security_contract ADD project_id BINARY(16) DEFAULT NULL, DROP project_name, DROP client_name, DROP leantime_url, DROP git_repos, DROP project_tracker_key, DROP quarterly_hours, DROP cybersecurity_price, DROP cybersecurity_note'); + $this->addSql('ALTER TABLE security_contract ADD CONSTRAINT FK_8AE4AF8B166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)'); + $this->addSql('CREATE INDEX IDX_8AE4AF8B166D1F9C ON security_contract (project_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE project_code_owner DROP FOREIGN KEY FK_3B938402166D1F9C'); + $this->addSql('ALTER TABLE project_code_owner DROP FOREIGN KEY FK_3B93840287BD19D2'); + $this->addSql('ALTER TABLE project_git_repo DROP FOREIGN KEY FK_CB848708166D1F9C'); + $this->addSql('ALTER TABLE project_git_repo DROP FOREIGN KEY FK_CB8487083E8A2A0D'); + $this->addSql('DROP TABLE code_owner'); + $this->addSql('DROP TABLE project'); + $this->addSql('DROP TABLE project_code_owner'); + $this->addSql('DROP TABLE project_git_repo'); + $this->addSql('ALTER TABLE security_contract DROP FOREIGN KEY FK_8AE4AF8B166D1F9C'); + $this->addSql('DROP INDEX IDX_8AE4AF8B166D1F9C ON security_contract'); + $this->addSql('ALTER TABLE security_contract ADD project_name VARCHAR(255) NOT NULL, ADD client_name VARCHAR(255) DEFAULT NULL, ADD leantime_url VARCHAR(255) DEFAULT NULL, ADD git_repos LONGTEXT DEFAULT NULL, ADD project_tracker_key VARCHAR(255) DEFAULT NULL, ADD quarterly_hours DOUBLE PRECISION DEFAULT NULL, ADD cybersecurity_price DOUBLE PRECISION DEFAULT NULL, ADD cybersecurity_note LONGTEXT DEFAULT NULL, DROP project_id'); + } +} diff --git a/migrations/Version20260702123347.php b/migrations/Version20260702123347.php new file mode 100644 index 0000000..7139f4a --- /dev/null +++ b/migrations/Version20260702123347.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE user ADD api_key VARCHAR(255) NOT NULL'); + + foreach ($this->connection->fetchFirstColumn('SELECT email FROM user') as $email) { + $this->addSql('UPDATE user SET api_key = ? WHERE email = ?', [sha1(\random_bytes(40)), $email]); + } + + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649C912ED9D ON user (api_key)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_8D93D649C912ED9D ON user'); + $this->addSql('ALTER TABLE user DROP api_key'); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fdc5dbc..e1103b1 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,8 @@ parameters: level: 6 + # Require a reason on every @phpstan-ignore so the next reader knows + # why a check was suppressed. https://phpstan.org/user-guide/ignoring-errors#requiring-comments-for-@phpstan-ignore + reportIgnoresWithoutComments: true paths: - bin/ - config/ diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index bedf5ac..0000000 --- a/psalm.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/public/api-spec-v1.json b/public/api-spec-v1.json index 47ce588..bc5923d 100644 --- a/public/api-spec-v1.json +++ b/public/api-spec-v1.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "ITKsites Detection API", - "description": "REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.", + "title": "ITKsites API", + "description": "REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.\n\nThe API also provides read-only access to information on servers and sites created by processing detection results.", "version": "1.0.0" }, "servers": [ @@ -92,6 +92,144 @@ "required": true } } + }, + "/api/servers": { + "get": { + "operationId": "api_servers_get_collection", + "tags": [ + "Server" + ], + "responses": { + "200": { + "description": "Server collection", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Server-export" + } + } + }, + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Server-export" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Server resources.", + "description": "Retrieves the collection of Server resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/sites": { + "get": { + "operationId": "api_sites_get_collection", + "tags": [ + "Site" + ], + "responses": { + "200": { + "description": "Site collection", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Site-export" + } + } + }, + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Site-export" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Site resources.", + "description": "Retrieves the collection of Site resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + } } }, "components": { @@ -353,6 +491,43 @@ ] } } + }, + "Server-export": { + "type": "object", + "properties": { + "Name": { + "default": "", + "type": "string" + } + } + }, + "Site-export": { + "type": "object", + "required": [ + "PHP version" + ], + "properties": { + "PHP version": { + "minLength": 1, + "maxLength": 10, + "default": "", + "type": "string" + }, + "Primary domain": { + "readOnly": true, + "type": "string" + }, + "Type": { + "default": "", + "type": "string" + }, + "Server": { + "$ref": "#/components/schemas/Server-export" + }, + "rootDir": { + "type": "string" + } + } } }, "responses": {}, @@ -378,6 +553,14 @@ { "name": "DetectionResult", "description": "Resource 'DetectionResult' operations." + }, + { + "name": "Server", + "description": "Resource 'Server' operations." + }, + { + "name": "Site", + "description": "Resource 'Site' operations." } ], "webhooks": {} diff --git a/public/api-spec-v1.yaml b/public/api-spec-v1.yaml index d2a0179..2c8a7c8 100755 --- a/public/api-spec-v1.yaml +++ b/public/api-spec-v1.yaml @@ -1,10 +1,14 @@ openapi: 3.1.0 info: - title: 'ITKsites Detection API' - description: 'REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories.' + title: 'ITKsites API' + description: |- + REST API for ingesting server detection results from the ITK sites server harvester. Detection results are processed asynchronously to track servers, sites, domains, packages, modules, Docker images, and git repositories. + + The API also provides read-only access to information on servers and sites created by processing detection results. version: 1.0.0 servers: - - url: 'https://itksites.local.itkdev.dk' + - + url: 'https://itksites.local.itkdev.dk' description: '' paths: /api/detection_results: @@ -59,6 +63,98 @@ paths: schema: $ref: '#/components/schemas/DetectionResult-write' required: true + /api/servers: + get: + operationId: api_servers_get_collection + tags: + - Server + responses: + '200': + description: 'Server collection' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Server-export' + application/ld+json: + schema: + type: array + items: + $ref: '#/components/schemas/Server-export' + '403': + description: Forbidden + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Error.jsonld' + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + application/json: + schema: + $ref: '#/components/schemas/Error' + links: {} + summary: 'Retrieves the collection of Server resources.' + description: 'Retrieves the collection of Server resources.' + parameters: + - + name: page + in: query + description: 'The collection page number' + required: false + deprecated: false + schema: + type: integer + default: 1 + style: form + explode: true + /api/sites: + get: + operationId: api_sites_get_collection + tags: + - Site + responses: + '200': + description: 'Site collection' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Site-export' + application/ld+json: + schema: + type: array + items: + $ref: '#/components/schemas/Site-export' + '403': + description: Forbidden + content: + application/ld+json: + schema: + $ref: '#/components/schemas/Error.jsonld' + application/problem+json: + schema: + $ref: '#/components/schemas/Error' + application/json: + schema: + $ref: '#/components/schemas/Error' + links: {} + summary: 'Retrieves the collection of Site resources.' + description: 'Retrieves the collection of Site resources.' + parameters: + - + name: page + in: query + description: 'The collection page number' + required: false + deprecated: false + schema: + type: integer + default: 1 + style: form + explode: true components: schemas: ConstraintViolation: @@ -250,6 +346,32 @@ components: type: - string - 'null' + Server-export: + type: object + properties: + Name: + default: '' + type: string + Site-export: + type: object + required: + - 'PHP version' + properties: + 'PHP version': + minLength: 1 + maxLength: 10 + default: '' + type: string + 'Primary domain': + readOnly: true + type: string + Type: + default: '' + type: string + Server: + $ref: '#/components/schemas/Server-export' + rootDir: + type: string responses: {} parameters: {} examples: {} @@ -265,6 +387,13 @@ security: - apiKey: [] tags: - - name: DetectionResult + - + name: DetectionResult description: "Resource 'DetectionResult' operations." + - + name: Server + description: "Resource 'Server' operations." + - + name: Site + description: "Resource 'Site' operations." webhooks: {} diff --git a/public/css/admin.css b/public/css/admin.css deleted file mode 100644 index 853c0f8..0000000 --- a/public/css/admin.css +++ /dev/null @@ -1,15 +0,0 @@ -:root { - --body-max-width: 100%; - --sidebar-bg: #fff; - /* make the base font size smaller */ - --button-primary-bg: rgb(0,123,166); - --pagination-active-bg: rgb(0,123,166); - --link-color: rgb(0,123,166); - --sidebar-menu-active-item-color: rgb(0,123,166); - --badge-boolean-true-bg: rgb(0,123,166); - --badge-boolean-false-bg: rgb(228, 73, 48); - --badge-boolean-false-color: var(--white); - --sidebar-menu-color: rgb(66,66,66); - --text-color-dark: rgb(66,66,66); - --bs-danger-rgb: 228, 73, 48; -} \ No newline at end of file diff --git a/src/Command/SyncServiceAgreementsCommand.php b/src/Command/SyncServiceAgreementsCommand.php new file mode 100644 index 0000000..8c4ee44 --- /dev/null +++ b/src/Command/SyncServiceAgreementsCommand.php @@ -0,0 +1,48 @@ +syncService->syncAll(); + + $io->success(sprintf('Synced %d projects successfully.', $result['projects'])); + + if (!empty($result['unmatchedRepoNames'])) { + $io->warning(sprintf( + 'Could not link %d GitHub repo name(s) to existing GitRepo entries: %s', + count($result['unmatchedRepoNames']), + implode(', ', $result['unmatchedRepoNames']), + )); + } + } catch (\Throwable $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/UserSetApiKeyCommand.php b/src/Command/UserSetApiKeyCommand.php new file mode 100644 index 0000000..e792662 --- /dev/null +++ b/src/Command/UserSetApiKeyCommand.php @@ -0,0 +1,53 @@ +userRepository->findOneBy(['email' => $userId]) + ?? $this->userRepository->findOneBy(['name' => $userId]); + + if (null === $user) { + $io->error(sprintf('Cannot load user with id %s', $userId)); + + return Command::INVALID; + } + + $question = sprintf('Really set API key on user %s', $user->getUserIdentifier()); + if (!$io->confirm($question)) { + return Command::SUCCESS; + } + + $user->setApiKey($user->generateApiKey()); + $this->entityManager->flush(); + + $io->success([ + sprintf('API key for user %s set to', $user->getUserIdentifier()), + $user->getApiKey(), + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/AbstractFullCrudController.php b/src/Controller/Admin/AbstractFullCrudController.php new file mode 100644 index 0000000..10c4c47 --- /dev/null +++ b/src/Controller/Admin/AbstractFullCrudController.php @@ -0,0 +1,65 @@ +showEntityActionsInlined(); + } + + #[\Override] + public function configureActions(Actions $actions): Actions + { + // Remove default actions + $actions + ->remove(Crud::PAGE_INDEX, Action::EDIT) + ->remove(Crud::PAGE_INDEX, Action::DELETE); + + // Re-add default actions as grouped action. + $groupedDefaultActions = ActionGroup::new('default', 'Default') + ->addMainAction( + Action::new('show', 'Show') + ->linkToCrudAction(Action::DETAIL) + ) + ->addAction( + Action::new('edit', 'Edit') + ->linkToCrudAction(Action::EDIT) + ->setIcon('fa fa-edit') + ) + ->addDivider() + ->addAction( + Action::new('delete', 'Delete') + ->linkToCrudAction(Action::DELETE) + ->setIcon('fa fa-trash') + ->setCssClass('btn-danger text-danger') + ); + + return $actions + ->add(Crud::PAGE_INDEX, $groupedDefaultActions) + ->add(Crud::PAGE_INDEX, $this->createExportAction()) + ->update(Crud::PAGE_INDEX, Action::NEW, + static fn (Action $action) => $action->setIcon('fa fa-plus') + ) + ; + } + + #[\Override] + public function configureAssets(Assets $assets): Assets + { + return $assets + ->addWebpackEncoreEntry('easyadmin'); + } +} diff --git a/src/Controller/Admin/AdvisoryCrudController.php b/src/Controller/Admin/AdvisoryCrudController.php index 50c18ec..362085e 100644 --- a/src/Controller/Admin/AdvisoryCrudController.php +++ b/src/Controller/Admin/AdvisoryCrudController.php @@ -18,6 +18,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; +use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter; class AdvisoryCrudController extends AbstractCrudController { @@ -66,7 +67,8 @@ public function configureFields(string $pageName): iterable public function configureFilters(Filters $filters): Filters { return $filters - ->add('package') + ->add(EntityFilter::new('package')->canSelectMultiple()) + ->add(EntityFilter::new('packageVersions')->canSelectMultiple()) ->add('advisoryId') ->add('cve') ->add('reportedAt') diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 1e00f92..efcebef 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -10,6 +10,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem; +use EasyCorp\Bundle\EasyAdminBundle\Config\Theme; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Symfony\Component\HttpFoundation\Response; @@ -37,9 +38,14 @@ public function index(): Response public function configureDashboard(): Dashboard { return Dashboard::new() - ->setTitle('ITK sites') + ->setTitle('ITK sites logo') ->setFaviconPath('img/favicon.ico') - ->renderContentMaximized(); + ->renderContentMaximized() + // ITK blue. Since EasyAdmin 5.4 one primary colour drives buttons, + // links, the active sidebar item and boolean badges, and the theme + // computes the text colour that sits on top of it — which the + // stylesheet used to approximate variable by variable. + ->setTheme(Theme::new()->primaryColor('#007ba6')); } #[\Override] @@ -50,12 +56,17 @@ public function configureMenuItems(): iterable yield MenuItem::linkTo(InstallationCrudController::class, 'Installations', 'fas fa-folder'); yield MenuItem::linkTo(SiteCrudController::class, 'Sites', 'fas fa-bookmark'); yield MenuItem::linkTo(DomainCrudController::class, 'Domains', 'fas fa-link'); - yield MenuItem::linkTo(OIDCCrudController::class, 'OIDC', 'fas fa-key'); - yield MenuItem::linkTo(ServiceCertificateCrudController::class, 'Service certificates', 'fas fa-lock'); + // OIDC and Service certificates are deprecated. The controllers and + // entities are kept so existing rows stay reachable by URL, but the + // menu no longer invites new registrations. + yield MenuItem::linkTo(SecurityContractCrudController::class, 'Service Agreements', 'fas fa-file-contract'); yield MenuItem::section('Dependencies'); yield MenuItem::linkTo(PackageCrudController::class, 'Packages', 'fas fa-cube'); yield MenuItem::linkTo(PackageVersionCrudController::class, 'Package Versions', 'fas fa-cubes'); - yield MenuItem::linkTo(AdvisoryCrudController::class, 'Advisories', 'fas fa-skull-crossbones')->setBadge($this->advisoryRepository->count([]), 'dark'); + // `?: null` because EasyAdmin hides a badge whose content is null but + // renders a literal "0" for a zero count, which is noise on a menu item. + yield MenuItem::linkTo(AdvisoryCrudController::class, 'Advisories', 'fas fa-skull-crossbones')->setBadge($this->advisoryRepository->count([]) ?: null, 'dark'); + yield MenuItem::linkToRoute('Repo advisories', 'fas fa-shield-virus', 'admin_repo_advisories'); yield MenuItem::linkTo(ModuleCrudController::class, 'Modules', 'fas fa-cube'); yield MenuItem::linkTo(ModuleVersionCrudController::class, 'Modules Versions', 'fas fa-cubes'); yield MenuItem::linkTo(DockerImageCrudController::class, 'Docker Images', 'fas fa-cube'); @@ -66,6 +77,20 @@ public function configureMenuItems(): iterable yield MenuItem::linkTo(DetectionResultCrudController::class, 'Detection Results', 'fas fa-upload'); } + /** + * The admin styles reach admin pages only from here. + * + * EasyAdmin renders its own layout rather than templates/base.html.twig, so + * neither `importmap()` nor that template's Encore tags apply to it. Until + * now this method added `css/admin.css`, a file deleted in #81, so every + * admin page carried a 404 and none of the ITK styling below it. + */ + #[\Override] + public function configureAssets(): Assets + { + return Assets::new()->addWebpackEncoreEntry('admin'); + } + #[\Override] public function configureCrud(): Crud { @@ -76,10 +101,4 @@ public function configureCrud(): Crud ->setPageTitle('detail', '%entity_label_singular%: %entity_as_string%') ; } - - #[\Override] - public function configureAssets(): Assets - { - return Assets::new()->addCssFile('css/admin.css'); - } } diff --git a/src/Controller/Admin/DetectionResultCrudController.php b/src/Controller/Admin/DetectionResultCrudController.php index 5027e68..a206930 100644 --- a/src/Controller/Admin/DetectionResultCrudController.php +++ b/src/Controller/Admin/DetectionResultCrudController.php @@ -36,12 +36,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DockerImageCrudController.php b/src/Controller/Admin/DockerImageCrudController.php index 980c426..dec7b4f 100644 --- a/src/Controller/Admin/DockerImageCrudController.php +++ b/src/Controller/Admin/DockerImageCrudController.php @@ -32,12 +32,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DockerImageTagCrudController.php b/src/Controller/Admin/DockerImageTagCrudController.php index 068318d..246a3d4 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -35,12 +35,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DomainCrudController.php b/src/Controller/Admin/DomainCrudController.php index d1a98ab..ade3a71 100644 --- a/src/Controller/Admin/DomainCrudController.php +++ b/src/Controller/Admin/DomainCrudController.php @@ -37,14 +37,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE) - ; + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/Controller/Admin/GitRepoCrudController.php b/src/Controller/Admin/GitRepoCrudController.php index 7fa2d4e..562c8c9 100644 --- a/src/Controller/Admin/GitRepoCrudController.php +++ b/src/Controller/Admin/GitRepoCrudController.php @@ -35,12 +35,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index f715f1e..628aa60 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -37,12 +37,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index cad1a95..046774f 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -45,13 +45,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/Controller/Admin/ModuleCrudController.php b/src/Controller/Admin/ModuleCrudController.php index 9cc6a73..311d478 100644 --- a/src/Controller/Admin/ModuleCrudController.php +++ b/src/Controller/Admin/ModuleCrudController.php @@ -31,12 +31,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 7db17aa..baaacaa 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -34,12 +34,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/OIDCCrudController.php b/src/Controller/Admin/OIDCCrudController.php index 176871e..bcde733 100644 --- a/src/Controller/Admin/OIDCCrudController.php +++ b/src/Controller/Admin/OIDCCrudController.php @@ -6,11 +6,8 @@ use App\Entity\OIDC; use App\Repository\SiteRepository; -use App\Trait\ExportCrudControllerTrait; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; +use App\Trait\DeprecatedCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; @@ -18,9 +15,13 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use Symfony\Component\Translation\TranslatableMessage; -class OIDCCrudController extends AbstractCrudController +/** + * @deprecated Removed from the admin menu. Kept so existing OIDC rows stay + * reachable by URL until the entity itself goes away. + */ +class OIDCCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; + use DeprecatedCrudControllerTrait; public function __construct( private readonly SiteRepository $siteRepository) @@ -33,18 +34,9 @@ public static function getEntityFqcn(): string } #[\Override] - public function configureCrud(Crud $crud): Crud + protected function getDeprecationNotice(): string { - return $crud->showEntityActionsInlined(); - } - - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ; + return 'OIDC registrations are deprecated and no longer maintained here. This page is only reachable by direct link, so that existing entries stay readable. Do not add new ones.'; } #[\Override] diff --git a/src/Controller/Admin/PackageCrudController.php b/src/Controller/Admin/PackageCrudController.php index 21fbd74..1e076b4 100644 --- a/src/Controller/Admin/PackageCrudController.php +++ b/src/Controller/Admin/PackageCrudController.php @@ -37,12 +37,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index 68f521f..49ba5fe 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -40,12 +40,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/RepoAdvisoryController.php b/src/Controller/Admin/RepoAdvisoryController.php new file mode 100644 index 0000000..1ab836b --- /dev/null +++ b/src/Controller/Admin/RepoAdvisoryController.php @@ -0,0 +1,129 @@ + + submit` form per row for the create-ticket action. EA's Action + * API only emits link-style row actions, not in-row form widgets, so a CRUD + * controller would need a custom index.html.twig override that re-implements + * the same row loops anyway. + * + * Data assembly and Leantime orchestration live in RepoAdvisoryService — this + * controller only handles request parsing, CSRF, flashes, and rendering. + * + * The actions still integrate with the admin shell: routes use #[AdminRoute], + * so EA auto-tags this class as an admin-route controller, populates + * AdminContext, and the templates extend the EasyAdmin layout normally. + */ +class RepoAdvisoryController extends AbstractController +{ + private const string CSRF_INTENT = 'repo_advisory_action'; + + public function __construct( + private readonly RepoAdvisoryService $repoAdvisoryService, + private readonly ServiceAgreementSyncService $serviceAgreementSyncService, + ) { + } + + #[AdminRoute(path: '/repo-advisories', name: 'repo_advisories', options: ['methods' => ['GET']])] + public function index(): Response + { + $result = $this->repoAdvisoryService->buildIndexRows(); + + if (null !== $result['leantimeError']) { + $this->addFlash('warning', sprintf('Could not fetch Leantime tickets: %s', $result['leantimeError'])); + } + + return $this->render('admin/repo_advisory/index.html.twig', [ + 'rows' => $result['rows'], + 'csrf_intent' => self::CSRF_INTENT, + ]); + } + + #[AdminRoute(path: '/repo-advisories/sync', name: 'repo_advisories_sync', options: ['methods' => ['POST']])] + public function sync(Request $request): RedirectResponse + { + if (!$this->isCsrfTokenValid(self::CSRF_INTENT, (string) $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token; please retry.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + try { + $result = $this->serviceAgreementSyncService->syncAll(); + + $this->addFlash('info', sprintf('Synced %d projects.', $result['projects'])); + + if (!empty($result['unmatchedRepoNames'])) { + $this->addFlash('warning', sprintf( + 'Could not link %d GitHub repo name(s) to existing GitRepo entries: %s', + count($result['unmatchedRepoNames']), + implode(', ', $result['unmatchedRepoNames']), + )); + } + } catch (\Throwable $e) { + $this->addFlash('error', sprintf('An error occurred while syncing: %s', $e->getMessage())); + } + + return $this->redirectToRoute('admin_repo_advisories'); + } + + #[AdminRoute(path: '/repo-advisories/{repoId}/create-ticket', name: 'repo_advisories_create_ticket', options: ['methods' => ['POST']])] + public function createTicket(Request $request, string $repoId): RedirectResponse + { + unset($repoId); // route param kept for future per-repo context + + if (!$this->isCsrfTokenValid(self::CSRF_INTENT, (string) $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token; please retry.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + $codeOwnerId = (string) $request->request->get('codeOwnerId', ''); + $leantimeProjectId = (int) $request->request->get('leantimeProjectId', 0); + + if ('' === $codeOwnerId) { + $this->addFlash('warning', 'No code owner selected.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + if (0 === $leantimeProjectId) { + $this->addFlash('warning', 'No Leantime project linked to this repo.'); + + return $this->redirectToRoute('admin_repo_advisories'); + } + + try { + $result = $this->repoAdvisoryService->createSecurityTicketForCodeOwner($codeOwnerId, $leantimeProjectId); + + if ($result['unassigned']) { + $this->addFlash('warning', sprintf( + 'Code owner %s has no matching Leantime user (email: %s); creating ticket unassigned.', + $result['codeOwner']->getName(), + $result['codeOwner']->getEmail(), + )); + } + + $this->addFlash('info', sprintf('Created Leantime ticket #%d.', $result['ticketId'])); + } catch (\Throwable $e) { + $this->addFlash('error', sprintf('Failed to create Leantime ticket: %s', $e->getMessage())); + } + + return $this->redirectToRoute('admin_repo_advisories'); + } +} diff --git a/src/Controller/Admin/SecurityContractCrudController.php b/src/Controller/Admin/SecurityContractCrudController.php new file mode 100644 index 0000000..d0e0cbb --- /dev/null +++ b/src/Controller/Admin/SecurityContractCrudController.php @@ -0,0 +1,150 @@ +setDefaultSort(['project.name' => 'ASC']) + ->setSearchFields(['project.name', 'hostingProvider', 'serverSize']) + ->showEntityActionsInlined() + ->setPageTitle(Crud::PAGE_INDEX, 'Service Agreements') + ->setHelp(Crud::PAGE_INDEX, 'Service agreements are synced from Economics. Click "Sync all" to update.'); + } + + #[\Override] + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL) + ->add(Crud::PAGE_INDEX, $this->createSyncAllAction()); + } + + #[\Override] + public function configureFields(string $pageName): iterable + { + yield FormField::addFieldset('Project'); + yield BooleanField::new('active')->renderAsSwitch(false)->setColumns(2); + yield BooleanField::new('eol')->setLabel('EOL')->renderAsSwitch(false)->setColumns(2); + yield TextField::new('project.name')->setLabel('Project')->setColumns(8); + yield TextField::new('project.leantimeId')->setLabel('Leantime ID')->hideOnIndex(); + yield TextField::new('projectGitRepos')->setLabel('GitHub repos')->hideOnIndex(); + yield TextField::new('hostingProvider'); + + yield FormField::addFieldset('Links'); + yield UrlField::new('project.leantimeUrl')->setLabel('Leantime URL')->hideOnIndex(); + yield UrlField::new('documentUrl')->setLabel('Document URL')->hideOnIndex(); + + yield FormField::addFieldset('Contact'); + yield TextField::new('clientContactName')->hideOnIndex(); + yield TextField::new('clientContactEmail')->hideOnIndex(); + + yield FormField::addFieldset('Budget'); + // The amount is Danish kroner, which the admin never said anywhere. + // 5.5's prepend()/append() addons would be the way to show a unit inside + // an input, but they render on form pages only and this CRUD disables + // NEW and EDIT (see configureActions), so index and detail are the only + // pages it has. Hence formatting instead of an addon. + yield NumberField::new('monthlyPrice')->setTextAlign('right')->setColumns(6)->formatValue(self::formatKroner(...)); + + yield FormField::addFieldset('Infrastructure'); + yield BooleanField::new('dedicatedServer')->renderAsSwitch(false)->hideOnIndex(); + yield TextField::new('serverSize')->hideOnIndex(); + + yield FormField::addFieldset('Validity'); + yield DateField::new('validFrom')->setColumns(6); + yield DateField::new('validTo')->setColumns(6); + } + + /** + * An amount as Danish kroner: 12.500,50 kr. + * + * Through Intl rather than by pasting a suffix on, so the grouping and the + * decimal separator are Danish too. The application locale is `en`, which + * would otherwise render 12,500.5 with no currency at all. + */ + public static function formatKroner(?float $value): ?string + { + if (null === $value) { + return null; + } + + return (new \NumberFormatter('da_DK', \NumberFormatter::CURRENCY))->formatCurrency($value, 'DKK') ?: null; + } + + /** + * The attribute is what makes this method reachable as a CRUD action. + * + * Without it EasyAdmin throws while rendering the "Sync all" button, which + * took the whole index page with it — see the "Custom CRUD Actions" section + * of the bundle's UPGRADE.md. + */ + #[AdminRoute] + public function syncAll(): RedirectResponse + { + try { + $result = $this->syncService->syncAll(); + + $this->addFlash('info', sprintf('Synced %d projects.', $result['projects'])); + + if (!empty($result['unmatchedRepoNames'])) { + $this->addFlash('warning', sprintf( + 'Could not link %d GitHub repo name(s) to existing GitRepo entries: %s', + count($result['unmatchedRepoNames']), + implode(', ', $result['unmatchedRepoNames']), + )); + } + } catch (\Throwable $e) { + $this->addFlash('error', sprintf('An error occurred while syncing: %s', $e->getMessage())); + } + + return $this->redirect( + $this->adminUrlGenerator + ->setController(static::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl() + ); + } + + private function createSyncAllAction(): Action + { + return Action::new('syncAll', new TranslatableMessage('Sync all'), 'fa fa-rotate') + ->createAsGlobalAction() + ->linkToCrudAction('syncAll') + ->setHtmlAttributes([ + 'onclick' => "const i=this.querySelector('i');if(i){i.classList.add('fa-spin')}this.style.pointerEvents='none';this.style.opacity='0.6'", + ]); + } +} diff --git a/src/Controller/Admin/ServerCrudController.php b/src/Controller/Admin/ServerCrudController.php index a7bbcda..e56522d 100644 --- a/src/Controller/Admin/ServerCrudController.php +++ b/src/Controller/Admin/ServerCrudController.php @@ -9,16 +9,12 @@ use App\Form\Type\Admin\MariaDbVersionFilter; use App\Form\Type\Admin\ServerTypeFilter; use App\Form\Type\Admin\SystemFilter; -use App\Trait\ExportCrudControllerTrait; use App\Types\DatabaseVersionType; use App\Types\HostingProviderType; use App\Types\ServerTypeType; use App\Types\SystemType; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; @@ -28,10 +24,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Component\HttpFoundation\RequestStack; -class ServerCrudController extends AbstractCrudController +class ServerCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; - public function __construct( private readonly RequestStack $requestStack, ) { @@ -54,17 +48,6 @@ public function configureCrud(Crud $crud): Crud return $crud; } - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ; - } - #[\Override] public function configureFields(string $pageName): iterable { diff --git a/src/Controller/Admin/ServiceCertificateCrudController.php b/src/Controller/Admin/ServiceCertificateCrudController.php index 8c68f7f..9d1de57 100644 --- a/src/Controller/Admin/ServiceCertificateCrudController.php +++ b/src/Controller/Admin/ServiceCertificateCrudController.php @@ -7,12 +7,8 @@ use App\Entity\ServiceCertificate; use App\Form\Type\ServiceCertificate\ServiceType; use App\Repository\SiteRepository; -use App\Trait\ExportCrudControllerTrait; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; -use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; +use App\Trait\DeprecatedCrudControllerTrait; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; @@ -21,12 +17,17 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use Symfony\Component\Translation\TranslatableMessage; -class ServiceCertificateCrudController extends AbstractCrudController +/** + * @deprecated Removed from the admin menu. Kept so existing service certificate + * rows stay reachable by URL until the entity itself goes away. + */ +class ServiceCertificateCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; + use DeprecatedCrudControllerTrait; - public function __construct(private readonly SiteRepository $siteRepository) - { + public function __construct( + private readonly SiteRepository $siteRepository, + ) { } public static function getEntityFqcn(): string @@ -34,6 +35,12 @@ public static function getEntityFqcn(): string return ServiceCertificate::class; } + #[\Override] + protected function getDeprecationNotice(): string + { + return 'Service certificates are deprecated and no longer maintained here. This page is only reachable by direct link, so that existing entries stay readable. Do not add new ones.'; + } + #[\Override] public function configureCrud(Crud $crud): Crud { @@ -46,15 +53,6 @@ public function configureCrud(Crud $crud): Crud ->setSearchFields(['domain', 'name', 'description', 'services.type']); } - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::DELETE); - } - #[\Override] public function configureFields(string $pageName): iterable { @@ -77,7 +75,7 @@ public function configureFields(string $pageName): iterable yield TextField::new('description')->onlyOnIndex() ->setHelp(new TranslatableMessage('Tell what this certificate is used for.'))->setMaxLength(33)->stripTags(); yield UrlField::new('onePasswordUrl') - ->setLabel(new TranslatableMessage('1Password url')); + ->setLabel(new TranslatableMessage('1Password url'))->hideOnIndex(); yield UrlField::new('usageDocumentationUrl')->hideOnIndex() ->setHelp(new TranslatableMessage('Tell where to find documentation on how the certificate is used on the site and how to configure the use.')); yield DateTimeField::new('expirationTime'); @@ -93,11 +91,4 @@ public function configureFields(string $pageName): iterable ->setTemplatePath('service_certificate/services.html.twig') ; } - - #[\Override] - public function configureAssets(Assets $assets): Assets - { - return $assets - ->addWebpackEncoreEntry('easyadmin'); - } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 4d49bb4..0287f29 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -48,13 +48,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/Controller/HealthController.php b/src/Controller/HealthController.php new file mode 100644 index 0000000..603df20 --- /dev/null +++ b/src/Controller/HealthController.php @@ -0,0 +1,98 @@ +respond(['status' => HealthStatus::Ok->value], true); + } + + #[Route('/health/ready', name: 'app_health_ready', methods: ['GET'])] + public function ready(): JsonResponse + { + $healthy = $this->healthChecker->isHealthy($this->healthChecker->run()); + + return $this->respond( + ['status' => $healthy ? HealthStatus::Ok->value : HealthStatus::Degraded->value], + $healthy + ); + } + + #[Route('/health/detail', name: 'app_health_detail', methods: ['GET'])] + public function detail(): JsonResponse + { + $results = $this->healthChecker->run(); + $healthy = $this->healthChecker->isHealthy($results); + + $checks = []; + foreach ($results as $result) { + $checks[$result->name] = array_filter([ + 'status' => $result->status->value, + 'message' => $result->message, + 'details' => $result->details, + ], static fn (mixed $value): bool => null !== $value && [] !== $value); + } + + return $this->respond([ + 'status' => $healthy ? HealthStatus::Ok->value : HealthStatus::Degraded->value, + 'checks' => $checks, + ], $healthy); + } + + /** + * @param array $payload + */ + private function respond(array $payload, bool $healthy): JsonResponse + { + $response = new JsonResponse( + $payload, + $healthy ? Response::HTTP_OK : Response::HTTP_SERVICE_UNAVAILABLE + ); + + // Health responses are cached inside HealthChecker, never by the client + // or an intermediary. + $response->headers->set('Cache-Control', 'no-store, private'); + + return $response; + } +} diff --git a/src/Entity/CodeOwner.php b/src/Entity/CodeOwner.php new file mode 100644 index 0000000..4d3e641 --- /dev/null +++ b/src/Entity/CodeOwner.php @@ -0,0 +1,84 @@ + + */ + #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'codeOwners')] + private Collection $projects; + + public function __construct() + { + $this->projects = new ArrayCollection(); + } + + #[\Override] + public function __toString(): string + { + return $this->name; + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * @return Collection + */ + public function getProjects(): Collection + { + return $this->projects; + } +} diff --git a/src/Entity/DetectionResult.php b/src/Entity/DetectionResult.php index d47821f..00769cd 100644 --- a/src/Entity/DetectionResult.php +++ b/src/Entity/DetectionResult.php @@ -17,6 +17,7 @@ #[ApiResource( operations: [ new Post( + security: "is_granted('ROLE_SERVER')", status: 202, output: false, messenger: true, diff --git a/src/Entity/OIDC.php b/src/Entity/OIDC.php index 56977df..7649667 100644 --- a/src/Entity/OIDC.php +++ b/src/Entity/OIDC.php @@ -11,6 +11,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +/** + * @deprecated OIDC registrations are no longer maintained here. The entity is + * kept so the table and its rows survive; do not add new usages. + */ #[ORM\Entity(repositoryClass: OIDCRepository::class)] class OIDC extends AbstractBaseEntity { diff --git a/src/Entity/Project.php b/src/Entity/Project.php new file mode 100644 index 0000000..acebcb9 --- /dev/null +++ b/src/Entity/Project.php @@ -0,0 +1,184 @@ + + */ + #[ORM\ManyToMany(targetEntity: CodeOwner::class, inversedBy: 'projects', cascade: ['persist'])] + #[ORM\JoinTable(name: 'project_code_owner')] + private Collection $codeOwners; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: GitRepo::class)] + #[ORM\JoinTable(name: 'project_git_repo')] + private Collection $gitRepos; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: SecurityContract::class, mappedBy: 'project', cascade: ['persist'])] + private Collection $serviceAgreements; + + public function __construct() + { + $this->codeOwners = new ArrayCollection(); + $this->gitRepos = new ArrayCollection(); + $this->serviceAgreements = new ArrayCollection(); + } + + #[\Override] + public function __toString(): string + { + return $this->name; + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getLeantimeId(): ?string + { + return $this->leantimeId; + } + + public function setLeantimeId(?string $leantimeId): static + { + $this->leantimeId = $leantimeId; + + return $this; + } + + public function getLeantimeUrl(): ?string + { + return $this->leantimeUrl; + } + + public function setLeantimeUrl(?string $leantimeUrl): static + { + $this->leantimeUrl = $leantimeUrl; + + return $this; + } + + /** + * @return Collection + */ + public function getCodeOwners(): Collection + { + return $this->codeOwners; + } + + public function addCodeOwner(CodeOwner $codeOwner): static + { + if (!$this->codeOwners->contains($codeOwner)) { + $this->codeOwners->add($codeOwner); + } + + return $this; + } + + public function removeCodeOwner(CodeOwner $codeOwner): static + { + $this->codeOwners->removeElement($codeOwner); + + return $this; + } + + /** + * @return Collection + */ + public function getGitRepos(): Collection + { + return $this->gitRepos; + } + + public function addGitRepo(GitRepo $gitRepo): static + { + if (!$this->gitRepos->contains($gitRepo)) { + $this->gitRepos->add($gitRepo); + } + + return $this; + } + + public function removeGitRepo(GitRepo $gitRepo): static + { + $this->gitRepos->removeElement($gitRepo); + + return $this; + } + + /** + * @return Collection + */ + public function getServiceAgreements(): Collection + { + return $this->serviceAgreements; + } + + public function addServiceAgreement(SecurityContract $serviceAgreement): static + { + if (!$this->serviceAgreements->contains($serviceAgreement)) { + $this->serviceAgreements->add($serviceAgreement); + $serviceAgreement->setProject($this); + } + + return $this; + } + + public function removeServiceAgreement(SecurityContract $serviceAgreement): static + { + if ($this->serviceAgreements->removeElement($serviceAgreement)) { + if ($serviceAgreement->getProject() === $this) { + $serviceAgreement->setProject(null); + } + } + + return $this; + } +} diff --git a/src/Entity/SecurityContract.php b/src/Entity/SecurityContract.php new file mode 100644 index 0000000..c03cdef --- /dev/null +++ b/src/Entity/SecurityContract.php @@ -0,0 +1,241 @@ +project?->getName() ?? (string) $this->economicsId; + } + + public function getProjectGitRepos(): ?string + { + if (null === $this->project) { + return null; + } + + $names = array_map( + static fn (GitRepo $repo): string => (string) $repo, + $this->project->getGitRepos()->toArray(), + ); + + return [] === $names ? null : implode(', ', $names); + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getHostingProvider(): ?string + { + return $this->hostingProvider; + } + + public function setHostingProvider(?string $hostingProvider): static + { + $this->hostingProvider = $hostingProvider; + + return $this; + } + + public function getDocumentUrl(): ?string + { + return $this->documentUrl; + } + + public function setDocumentUrl(?string $documentUrl): static + { + $this->documentUrl = $documentUrl; + + return $this; + } + + public function getMonthlyPrice(): ?float + { + return $this->monthlyPrice; + } + + public function setMonthlyPrice(?float $monthlyPrice): static + { + $this->monthlyPrice = $monthlyPrice; + + return $this; + } + + public function getValidFrom(): ?\DateTimeImmutable + { + return $this->validFrom; + } + + public function setValidFrom(?\DateTimeImmutable $validFrom): static + { + $this->validFrom = $validFrom; + + return $this; + } + + public function getValidTo(): ?\DateTimeImmutable + { + return $this->validTo; + } + + public function setValidTo(?\DateTimeImmutable $validTo): static + { + $this->validTo = $validTo; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } + + public function isEol(): bool + { + return $this->eol; + } + + public function setEol(bool $eol): static + { + $this->eol = $eol; + + return $this; + } + + public function getClientContactName(): ?string + { + return $this->clientContactName; + } + + public function setClientContactName(?string $clientContactName): static + { + $this->clientContactName = $clientContactName; + + return $this; + } + + public function getClientContactEmail(): ?string + { + return $this->clientContactEmail; + } + + public function setClientContactEmail(?string $clientContactEmail): static + { + $this->clientContactEmail = $clientContactEmail; + + return $this; + } + + public function isDedicatedServer(): bool + { + return $this->dedicatedServer; + } + + public function setDedicatedServer(bool $dedicatedServer): static + { + $this->dedicatedServer = $dedicatedServer; + + return $this; + } + + public function getServerSize(): ?string + { + return $this->serverSize; + } + + public function setServerSize(?string $serverSize): static + { + $this->serverSize = $serverSize; + + return $this; + } + + public function getSystemOwnerNotices(): ?array + { + return $this->systemOwnerNotices; + } + + public function setSystemOwnerNotices(?array $systemOwnerNotices): static + { + $this->systemOwnerNotices = $systemOwnerNotices; + + return $this; + } +} diff --git a/src/Entity/Server.php b/src/Entity/Server.php index cdf15b1..6b27d67 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -4,7 +4,10 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use App\Repository\ServerRepository; +use App\Trait\ApiKeyEntityTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -13,19 +16,17 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +#[ApiResource( + normalizationContext: ['groups' => ['export']], + security: "is_granted('ROLE_USER')", +)] +#[GetCollection()] #[ORM\Entity(repositoryClass: ServerRepository::class)] class Server extends AbstractBaseEntity implements UserInterface, \Stringable { - private const array ROLES = ['ROLE_USER', 'ROLE_SERVER']; + use ApiKeyEntityTrait; - #[ORM\Column(type: 'string', length: 255, unique: true)] - #[Assert\Length( - min: 40, - max: 255, - minMessage: 'Api key must be at least {{ limit }} characters long', - maxMessage: 'Api key cannot be longer than {{ limit }} characters', - )] - private string $apiKey; + private const array ROLES = ['ROLE_USER', 'ROLE_SERVER']; #[ORM\Column(type: 'string', length: 255, unique: true)] #[Groups(['export'])] @@ -93,8 +94,8 @@ class Server extends AbstractBaseEntity implements UserInterface, \Stringable */ public function __construct() { + $this->setApiKey($this->generateApiKey()); $this->detectionResults = new ArrayCollection(); - $this->apiKey = sha1(\random_bytes(40)); $this->installations = new ArrayCollection(); } @@ -258,18 +259,6 @@ public function setUsedFor(?string $usedFor): self return $this; } - public function getApiKey(): string - { - return $this->apiKey; - } - - public function setApiKey(string $apiKey): self - { - $this->apiKey = $apiKey; - - return $this; - } - /** * @return Collection */ diff --git a/src/Entity/ServiceCertificate.php b/src/Entity/ServiceCertificate.php index 2f11207..c8185d3 100644 --- a/src/Entity/ServiceCertificate.php +++ b/src/Entity/ServiceCertificate.php @@ -13,6 +13,10 @@ use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; +/** + * @deprecated Service certificates are no longer maintained here. The entity is + * kept so the table and its rows survive; do not add new usages. + */ #[ORM\Entity(repositoryClass: ServiceCertificateRepository::class)] class ServiceCertificate extends AbstractBaseEntity implements \Stringable { diff --git a/src/Entity/ServiceCertificate/Service.php b/src/Entity/ServiceCertificate/Service.php index b42f744..8cc4e5c 100644 --- a/src/Entity/ServiceCertificate/Service.php +++ b/src/Entity/ServiceCertificate/Service.php @@ -11,6 +11,10 @@ use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; +/** + * @deprecated Together with {@see ServiceCertificate}. The entity is + * kept so the table and its rows survive; do not add new usages. + */ #[ORM\Entity(repositoryClass: ServiceRepository::class)] #[ORM\Table(name: 'service_certificate_service')] class Service extends AbstractBaseEntity implements \Stringable diff --git a/src/Entity/Site.php b/src/Entity/Site.php index dbfd6c7..9c52cf9 100644 --- a/src/Entity/Site.php +++ b/src/Entity/Site.php @@ -4,6 +4,8 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use App\Repository\SiteRepository; use App\Types\SiteType; use Doctrine\Common\Collections\ArrayCollection; @@ -13,6 +15,11 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +#[ApiResource( + normalizationContext: ['groups' => ['export']], + security: "is_granted('ROLE_USER')", +)] +#[GetCollection()] #[ORM\Entity(repositoryClass: SiteRepository::class)] #[ORM\UniqueConstraint(name: 'server_rootDir_configFilePath_idx', fields: ['server', 'rootDir', 'configFilePath'])] class Site extends AbstractHandlerResult implements \Stringable diff --git a/src/Entity/User.php b/src/Entity/User.php index 70ad536..15de1c2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,12 +5,15 @@ namespace App\Entity; use App\Repository\UserRepository; +use App\Trait\ApiKeyEntityTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] class User extends AbstractBaseEntity implements UserInterface { + use ApiKeyEntityTrait; + public function __construct( #[ORM\Column(length: 255)] private string $name, @@ -19,6 +22,7 @@ public function __construct( #[ORM\Column(type: 'json')] private array $roles = [], ) { + $this->setApiKey($this->generateApiKey()); } #[\Override] diff --git a/src/EventListener/OIDCChangedListener.php b/src/EventListener/OIDCChangedListener.php index 0ff395b..2e6c7a9 100644 --- a/src/EventListener/OIDCChangedListener.php +++ b/src/EventListener/OIDCChangedListener.php @@ -10,6 +10,9 @@ use Doctrine\ORM\Event\PreFlushEventArgs; use Doctrine\ORM\Events; +/** + * @deprecated together with {@see OIDC} + */ #[AsEntityListener(event: Events::preFlush, method: 'preFlush', entity: OIDC::class)] class OIDCChangedListener { diff --git a/src/EventListener/OpenIdConnectFailureListener.php b/src/EventListener/OpenIdConnectFailureListener.php new file mode 100644 index 0000000..f233885 --- /dev/null +++ b/src/EventListener/OpenIdConnectFailureListener.php @@ -0,0 +1,82 @@ +getThrowable(); + + if (!$event->isMainRequest() || !$exception instanceof AuthenticationFailedException) { + return; + } + + // Deliberately without the exception message: it carries the identity + // provider's own error text, which belongs in the log, not in a browser. + // `declined` is the one thing worth telling the user, and it is the error + // code rather than that text. + $content = $this->twig->render('error/openid_connect_failed.html.twig', [ + 'login_url' => $this->urlGenerator->generate('itkdev_openid_connect_login', ['providerKey' => 'azure_az']), + 'declined' => $exception instanceof ProviderErrorException + && ProviderErrorException::ACCESS_DENIED === $exception->getError(), + ]); + + // A refusal states its own status — 403 where the user or a policy said + // no, 503 where Azure reports its own trouble. Everything else is a 500: + // the likely cause is on this side of the login, and it should read as an + // error in the log and in monitoring. + $status = $exception instanceof HttpExceptionInterface + ? $exception->getStatusCode() + : Response::HTTP_INTERNAL_SERVER_ERROR; + + $response = new Response($content, $status); + $response->headers->set('Cache-Control', 'no-store, private'); + + $event->setResponse($response); + } +} diff --git a/src/Form/Type/ServiceCertificate/ServiceType.php b/src/Form/Type/ServiceCertificate/ServiceType.php index 40a5565..a3749f0 100644 --- a/src/Form/Type/ServiceCertificate/ServiceType.php +++ b/src/Form/Type/ServiceCertificate/ServiceType.php @@ -13,6 +13,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Translation\TranslatableMessage; +/** + * @deprecated together with {@see \App\Entity\ServiceCertificate} + */ class ServiceType extends AbstractType { public function __construct(private readonly ServiceRepository $serviceRepository) diff --git a/src/Health/Check/ClientSecretExpiryHealthCheck.php b/src/Health/Check/ClientSecretExpiryHealthCheck.php new file mode 100644 index 0000000..ca0f56c --- /dev/null +++ b/src/Health/Check/ClientSecretExpiryHealthCheck.php @@ -0,0 +1,85 @@ +expiryChecker->getAllStatuses(); + + if ([] === $statuses) { + return HealthCheckResult::skipped($this->getName(), 'No OIDC providers are configured.'); + } + + $details = []; + $expired = []; + $unknown = []; + + foreach ($statuses as $providerKey => $expiry) { + $details[$providerKey.'.status'] = $expiry->status->value; + $details[$providerKey.'.expires_at'] = $expiry->expiresAt?->format(\DATE_ATOM); + $details[$providerKey.'.days_remaining'] = $expiry->daysRemaining; + + if ($expiry->isExpired()) { + $expired[] = $providerKey; + } elseif (ClientSecretExpiryStatus::Unknown === $expiry->status) { + $unknown[] = $providerKey; + } + } + + if ([] !== $expired) { + return HealthCheckResult::degraded( + $this->getName(), + \sprintf('Client secret past its configured expiry: %s.', implode(', ', $expired)), + $details + ); + } + + // No date anywhere is the state an installation is in before it + // configures any: nothing is being monitored, which is not the same as + // nothing being wrong, so it is reported as skipped rather than ok. + if (\count($unknown) === \count($statuses)) { + return HealthCheckResult::skipped($this->getName(), 'No client secret expiry dates are configured.'); + } + + return HealthCheckResult::ok($this->getName(), $details); + } +} diff --git a/src/Health/Check/DatabaseHealthCheck.php b/src/Health/Check/DatabaseHealthCheck.php new file mode 100644 index 0000000..94bb8fb --- /dev/null +++ b/src/Health/Check/DatabaseHealthCheck.php @@ -0,0 +1,48 @@ +connection->executeQuery('SELECT 1'); + } catch (\Throwable $throwable) { + $this->logger->error('Health check "database" failed: {message}', [ + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + + // The caller gets no detail; the reason stays in the logs. + return HealthCheckResult::degraded($this->getName(), 'Unable to query the database.'); + } + + return HealthCheckResult::ok($this->getName(), [ + 'response_time_ms' => round((microtime(true) - $start) * 1000, 1), + ]); + } +} diff --git a/src/Health/Check/IngestFreshnessHealthCheck.php b/src/Health/Check/IngestFreshnessHealthCheck.php new file mode 100644 index 0000000..ffe4f1e --- /dev/null +++ b/src/Health/Check/IngestFreshnessHealthCheck.php @@ -0,0 +1,78 @@ +repository->findLastContact(); + } catch (\Throwable $throwable) { + $this->logger->error('Health check "ingest_freshness" failed: {message}', [ + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + + // Almost certainly the database being down, which the database + // check reports separately. + return HealthCheckResult::degraded($this->getName(), 'Unable to query the last detection result.'); + } + + if (!$lastContact instanceof \DateTimeImmutable) { + return HealthCheckResult::degraded( + $this->getName(), + 'No detection results have been received yet.', + ['max_age_seconds' => $this->maxAgeSeconds] + ); + } + + $ageSeconds = time() - $lastContact->getTimestamp(); + $details = [ + 'last_contact' => $lastContact->format(\DATE_ATOM), + 'age_seconds' => $ageSeconds, + 'max_age_seconds' => $this->maxAgeSeconds, + ]; + + if ($ageSeconds > $this->maxAgeSeconds) { + return HealthCheckResult::degraded( + $this->getName(), + \sprintf('No detection result received for %d seconds.', $ageSeconds), + $details + ); + } + + return HealthCheckResult::ok($this->getName(), $details); + } +} diff --git a/src/Health/Check/RabbitMqHealthCheck.php b/src/Health/Check/RabbitMqHealthCheck.php new file mode 100644 index 0000000..77a8ef4 --- /dev/null +++ b/src/Health/Check/RabbitMqHealthCheck.php @@ -0,0 +1,57 @@ +transport instanceof MessageCountAwareInterface) { + return HealthCheckResult::skipped( + $this->getName(), + 'The configured transport does not support message counting.' + ); + } + + try { + $count = $this->transport->getMessageCount(); + } catch (\Throwable $throwable) { + $this->logger->error('Health check "rabbitmq" failed: {message}', [ + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + + return HealthCheckResult::degraded($this->getName(), 'Unable to reach the message queue.'); + } + + return HealthCheckResult::ok($this->getName(), ['queued_messages' => $count]); + } +} diff --git a/src/Health/HealthCheckInterface.php b/src/Health/HealthCheckInterface.php new file mode 100644 index 0000000..41bc448 --- /dev/null +++ b/src/Health/HealthCheckInterface.php @@ -0,0 +1,24 @@ + $details + */ + private function __construct( + public string $name, + public HealthStatus $status, + public ?string $message = null, + public array $details = [], + ) { + } + + /** + * @param array $details + */ + public static function ok(string $name, array $details = []): self + { + return new self($name, HealthStatus::Ok, null, $details); + } + + /** + * @param array $details + */ + public static function degraded(string $name, string $message, array $details = []): self + { + return new self($name, HealthStatus::Degraded, $message, $details); + } + + public static function skipped(string $name, string $message): self + { + return new self($name, HealthStatus::Skipped, $message); + } + + public function isDegraded(): bool + { + return HealthStatus::Degraded === $this->status; + } +} diff --git a/src/Health/HealthChecker.php b/src/Health/HealthChecker.php new file mode 100644 index 0000000..8f2a7cc --- /dev/null +++ b/src/Health/HealthChecker.php @@ -0,0 +1,88 @@ + $checks + */ + public function __construct( + private iterable $checks, + private CacheInterface $cache, + private LoggerInterface $logger, + private int $cacheTtl, + ) { + } + + /** + * @return array + */ + public function run(): array + { + return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array { + $item->expiresAfter($this->cacheTtl); + + $results = []; + foreach ($this->checks as $check) { + $results[] = $this->runCheck($check); + } + + return $results; + }); + } + + /** + * @param array $results + */ + public function isHealthy(array $results): bool + { + foreach ($results as $result) { + if ($result->isDegraded()) { + return false; + } + } + + return true; + } + + /** + * Run a check, turning an unexpected failure into a degraded result. + * + * A check that throws must not take down the endpoint that reports on it. + */ + private function runCheck(HealthCheckInterface $check): HealthCheckResult + { + try { + return $check->check(); + } catch (\Throwable $throwable) { + $this->logger->error('Health check "{check}" threw an exception: {message}', [ + 'check' => $check->getName(), + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + + return HealthCheckResult::degraded($check->getName(), 'The check failed unexpectedly.'); + } + } +} diff --git a/src/Health/HealthStatus.php b/src/Health/HealthStatus.php new file mode 100644 index 0000000..282466b --- /dev/null +++ b/src/Health/HealthStatus.php @@ -0,0 +1,20 @@ + + * + * @method CodeOwner|null find($id, $lockMode = null, $lockVersion = null) + * @method CodeOwner|null findOneBy(array $criteria, array $orderBy = null) + * @method CodeOwner[] findAll() + * @method CodeOwner[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class CodeOwnerRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CodeOwner::class); + } +} diff --git a/src/Repository/DetectionResultRepository.php b/src/Repository/DetectionResultRepository.php index beaa237..09f5ae8 100644 --- a/src/Repository/DetectionResultRepository.php +++ b/src/Repository/DetectionResultRepository.php @@ -22,6 +22,24 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, DetectionResult::class); } + /** + * Get the most recent contact from any harvester. + * + * Used by the ingest freshness health check. + * + * @return \DateTimeImmutable|null + * Null when no detection results have been received yet + */ + public function findLastContact(): ?\DateTimeImmutable + { + $lastContact = $this->createQueryBuilder('d') + ->select('MAX(d.lastContact)') + ->getQuery() + ->getSingleScalarResult(); + + return is_string($lastContact) ? new \DateTimeImmutable($lastContact) : null; + } + /** * Remove detection results base on last contact. * diff --git a/src/Repository/GitRepoRepository.php b/src/Repository/GitRepoRepository.php index d960d78..6303083 100644 --- a/src/Repository/GitRepoRepository.php +++ b/src/Repository/GitRepoRepository.php @@ -22,4 +22,71 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, GitRepo::class); } + + /** + * Repos reachable from any package-version advisory, with their advisory count. + * + * Path: GitRepo → GitTag → Installation → PackageVersion → Advisory. + * + * @return list + */ + public function findReposWithAdvisoryCount(): array + { + /** @var list $rows */ + $rows = $this->createQueryBuilder('r') + ->select('r AS repo', 'COUNT(DISTINCT a.id) AS advisoryCount') + ->innerJoin('r.gitTags', 'gt') + ->innerJoin('gt.installations', 'i') + ->innerJoin('i.packageVersions', 'pv') + ->innerJoin('pv.advisories', 'a') + ->groupBy('r.id') + ->having('COUNT(DISTINCT a.id) > 0') + ->orderBy('r.organization', 'ASC') + ->addOrderBy('r.repo', 'ASC') + ->getQuery() + ->getResult(); + + return array_map( + static fn (array $row): array => [ + 'repo' => $row['repo'], + 'advisoryCount' => (int) $row['advisoryCount'], + ], + $rows, + ); + } + + /** + * Distinct PackageVersion IDs reachable from each repo via + * gitTags → installations → packageVersions, restricted to versions that + * have at least one Advisory. This is the same chain used by + * findReposWithAdvisoryCount(), so a filter on Advisory.packageVersions + * with these IDs returns exactly the advisories counted on the repo list. + * + * Both keys and values are the Ulid's compact base32 form (i.e. its + * default __toString) so they match the choice keys Symfony's EntityType + * generates for filter URLs. + * + * @return array> repoUlid (base32) => list of PackageVersion Ulid (base32) + */ + public function findPackageVersionsPerRepoWithAdvisories(): array + { + /** @var list $rows */ + $rows = $this->createQueryBuilder('r') + ->select('r.id AS repoId', 'pv.id AS pvId') + ->distinct() + ->innerJoin('r.gitTags', 'gt') + ->innerJoin('gt.installations', 'i') + ->innerJoin('i.packageVersions', 'pv') + ->innerJoin('pv.advisories', 'a') + ->getQuery() + ->getResult(); + + $map = []; + foreach ($rows as $row) { + $repoKey = (string) $row['repoId']; + $map[$repoKey][] = (string) $row['pvId']; + } + + return $map; + } } diff --git a/src/Repository/OIDCRepository.php b/src/Repository/OIDCRepository.php index cfe5a56..88507bb 100644 --- a/src/Repository/OIDCRepository.php +++ b/src/Repository/OIDCRepository.php @@ -15,6 +15,8 @@ * @method OIDC|null findOneBy(array $criteria, array $orderBy = null) * @method OIDC[] findAll() * @method OIDC[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * + * @deprecated together with {@see OIDC} */ class OIDCRepository extends ServiceEntityRepository { @@ -40,4 +42,14 @@ public function remove(OIDC $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } + + public function countExpiredCertificates(): int + { + return $this->createQueryBuilder('o') + ->select('COUNT(o)') + ->where('o.expirationTime < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..0bd869f --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,47 @@ + + * + * @method Project|null find($id, $lockMode = null, $lockVersion = null) + * @method Project|null findOneBy(array $criteria, array $orderBy = null) + * @method Project[] findAll() + * @method Project[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class ProjectRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Project::class); + } + + /** + * Projects that link to the given repo, with codeOwners and serviceAgreements eager-loaded. + * + * @return list + */ + public function findByGitRepo(GitRepo $repo): array + { + /** @var list $projects */ + $projects = $this->createQueryBuilder('p') + ->select('p', 'co', 'sc') + ->leftJoin('p.codeOwners', 'co') + ->leftJoin('p.serviceAgreements', 'sc') + ->where(':repoId MEMBER OF p.gitRepos') + ->setParameter('repoId', $repo->getId(), 'ulid') + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + + return $projects; + } +} diff --git a/src/Repository/SecurityContractRepository.php b/src/Repository/SecurityContractRepository.php new file mode 100644 index 0000000..d33baf8 --- /dev/null +++ b/src/Repository/SecurityContractRepository.php @@ -0,0 +1,54 @@ + + */ +class SecurityContractRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SecurityContract::class); + } + + // /** + // * @return SecurityContract[] Returns an array of SecurityContract objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?SecurityContract + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } + + public function countExpiredContracts(): int + { + return $this->createQueryBuilder('c') + ->select('COUNT(c)') + ->where('c.validTo < :now') + ->andWhere('c.active = true') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Repository/ServiceCertificate/ServiceRepository.php b/src/Repository/ServiceCertificate/ServiceRepository.php index bfd5391..af38b0c 100644 --- a/src/Repository/ServiceCertificate/ServiceRepository.php +++ b/src/Repository/ServiceCertificate/ServiceRepository.php @@ -16,6 +16,8 @@ * @method Service|null findOneBy(array $criteria, array $orderBy = null) * @method Service[] findAll() * @method Service[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * + * @deprecated together with {@see \App\Entity\ServiceCertificate} */ class ServiceRepository extends ServiceEntityRepository { diff --git a/src/Repository/ServiceCertificateRepository.php b/src/Repository/ServiceCertificateRepository.php index 219da2b..d24e36b 100644 --- a/src/Repository/ServiceCertificateRepository.php +++ b/src/Repository/ServiceCertificateRepository.php @@ -15,6 +15,8 @@ * @method ServiceCertificate|null findOneBy(array $criteria, array $orderBy = null) * @method ServiceCertificate[] findAll() * @method ServiceCertificate[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * + * @deprecated together with {@see ServiceCertificate} */ class ServiceCertificateRepository extends ServiceEntityRepository { @@ -40,4 +42,14 @@ public function remove(ServiceCertificate $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } + + public function countExpiredCertificates(): int + { + return $this->createQueryBuilder('c') + ->select('COUNT(c)') + ->where('c.expirationTime < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php index 8232e27..f12e67e 100644 --- a/src/Security/ApiKeyAuthenticator.php +++ b/src/Security/ApiKeyAuthenticator.php @@ -4,11 +4,14 @@ namespace App\Security; +use App\Repository\ServerRepository; +use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; @@ -20,6 +23,12 @@ class ApiKeyAuthenticator extends AbstractAuthenticator public const string AUTH_HEADER = 'Authorization'; public const string AUTH_HEADER_PREFIX = 'Apikey '; + public function __construct( + private readonly ServerRepository $serverRepository, + private readonly UserRepository $userRepository, + ) { + } + /** * Called on every request to decide if this authenticator should be used for the request. * @@ -40,7 +49,14 @@ public function authenticate(Request $request): Passport throw new CustomUserMessageAuthenticationException('No API token provided'); } - return new SelfValidatingPassport(new UserBadge($apiKey)); + // Users and servers can authenticate to use the API. + $apiUser = $this->serverRepository->findOneBy(['apiKey' => $apiKey]) + ?? $this->userRepository->findOneBy(['apiKey' => $apiKey]); + if (null !== $apiUser) { + return new SelfValidatingPassport(new UserBadge($apiUser->getUserIdentifier())); + } + + throw new BadCredentialsException('Invalid credentials.'); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response diff --git a/src/Security/AzureOIDCAuthenticator.php b/src/Security/AzureOIDCAuthenticator.php index d2ab5d1..3d6198b 100644 --- a/src/Security/AzureOIDCAuthenticator.php +++ b/src/Security/AzureOIDCAuthenticator.php @@ -63,7 +63,10 @@ public function authenticate(Request $request): Passport return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier())); } catch (OpenIdConnectExceptionInterface $exception) { - throw new CustomUserMessageAuthenticationException($exception->getMessage()); + // Chained: the bundle reads the cause back in onAuthenticationFailure() + // to decide what the user is shown. Dropping it turns a refusal the + // user caused into an unexplained 500. + throw new CustomUserMessageAuthenticationException($exception->getMessage(), previous: $exception); } } diff --git a/src/Service/LeantimeService.php b/src/Service/LeantimeService.php new file mode 100644 index 0000000..ed1374a --- /dev/null +++ b/src/Service/LeantimeService.php @@ -0,0 +1,284 @@ + + */ + private const array OPEN_STATUS_IDS = ['1', '2', '3', '4']; + + private const float DEFAULT_HOURS = 1.0; + + /** + * @var array|null cached display-name lookup keyed by user id + */ + private ?array $userNamesById = null; + + /** + * @var array|null cached email → user id lookup (lowercase email) + */ + private ?array $userIdsByEmail = null; + + public function __construct( + private readonly HttpClientInterface $leantimeClient, + ) { + } + + /** + * Find currently-open security tickets across all Leantime projects. + * + * Pre-filters via the Leantime `searchCriteria` (term + type + status), + * tightens the LIKE match into an exact headline check, and keeps the + * most recent matching ticket per Leantime project id. + * + * @return array tickets keyed by Leantime project id + * + * @throws \RuntimeException if the Leantime API rejects the request or the transport fails + */ + public function findOpenSecurityTickets(): array + { + $result = $this->request('leantime.rpc.tickets.getAll', [ + 'searchCriteria' => [ + 'term' => self::SECURITY_TICKET_TITLE, + 'type' => self::TICKET_TYPE_TASK, + 'status' => self::OPEN_STATUS_IDS, + ], + ]); + + if (!is_array($result)) { + return []; + } + + $needle = mb_strtolower(self::SECURITY_TICKET_TITLE); + $exact = []; + foreach ($result as $ticket) { + if (!is_array($ticket) || !isset($ticket['projectId'])) { + continue; + } + $headline = isset($ticket['headline']) ? mb_strtolower(trim((string) $ticket['headline'])) : ''; + if ($headline !== $needle) { + continue; + } + $exact[] = $ticket; + } + + usort( + $exact, + static fn (array $a, array $b): int => strcmp((string) ($b['dateCreated'] ?? ''), (string) ($a['dateCreated'] ?? '')), + ); + + $byProjectId = []; + foreach ($exact as $ticket) { + $projectId = (int) $ticket['projectId']; + if (isset($byProjectId[$projectId])) { + continue; + } + $byProjectId[$projectId] = [ + 'id' => (int) ($ticket['id'] ?? 0), + 'assigneeName' => $this->resolveUserName($ticket['editorId'] ?? null), + 'createdAt' => isset($ticket['dateCreated']) ? (string) $ticket['dateCreated'] : null, + ]; + } + + return $byProjectId; + } + + /** + * Resolve a Leantime user id for an email address. + * + * Lazy-loads the Leantime user directory on first call and looks the + * email up case-insensitively. Returns null when the email is empty or + * unknown so the caller can fall back to an unassigned ticket. + * + * @param string $email free-form email — leading/trailing whitespace and case are normalized + * + * @return int|null the Leantime user id, or null when no match exists + * + * @throws \RuntimeException if the user directory fetch fails + */ + public function findUserIdByEmail(string $email): ?int + { + $email = mb_strtolower(trim($email)); + if ('' === $email) { + return null; + } + $this->loadUsers(); + + return $this->userIdsByEmail[$email] ?? null; + } + + /** + * Create a "Sikkerhedsopdatering" task in the given Leantime project. + * + * Submits a ticket with priority `critical`, status `new`, a one-hour + * planned estimate, and editFrom/editTo/dateToFinish all set to today. + * A null `$userId` produces an unassigned ticket. + * + * @param int $projectId Leantime project id the ticket belongs to + * @param int|null $userId Leantime user id to assign the ticket to, or null for unassigned + * + * @return int the new ticket's Leantime id, or 0 if Leantime returned an unexpected response shape + * + * @throws \RuntimeException if the API rejects the request or the transport fails + */ + public function createSecurityTicket(int $projectId, ?int $userId = null): int + { + $date = date(self::DATE_FORMAT); + + $result = $this->request('leantime.rpc.tickets.addTicket', [ + 'values' => [ + 'headline' => self::SECURITY_TICKET_TITLE, + 'description' => '', + 'projectId' => $projectId, + 'type' => self::TICKET_TYPE_TASK, + 'status' => self::TICKET_STATUS_NEW, + 'priority' => self::TICKET_PRIORITY_CRITICAL, + 'dateToFinish' => $date, + 'editFrom' => $date, + 'editTo' => $date, + 'planHours' => (string) self::DEFAULT_HOURS, + 'hourRemaining' => (string) self::DEFAULT_HOURS, + 'tags' => '', + 'milestoneid' => '', + 'editorId' => $userId ?? '', + ], + ]); + + if (is_array($result) && isset($result[0])) { + return (int) $result[0]; + } + + return is_numeric($result) ? (int) $result : 0; + } + + /** + * Send a JSON-RPC 2.0 request to the Leantime API. + * + * Wraps the call in the JSON-RPC envelope, decodes the response, and + * normalizes both transport failures and API-level error objects into + * RuntimeException. + * + * @param string $method JSON-RPC method name (e.g. `leantime.rpc.tickets.getAll`) + * @param array $params method parameters to forward verbatim to Leantime + * + * @return mixed the decoded `result` field from the JSON-RPC response, or null when absent + * + * @throws \RuntimeException on transport error or when the API responds with an `error` object + */ + private function request(string $method, array $params = []): mixed + { + try { + $response = $this->leantimeClient->request('POST', self::API_PATH, [ + 'json' => [ + 'jsonrpc' => self::JSONRPC_VERSION, + 'method' => $method, + 'params' => $params, + 'id' => uniqid('', true), + ], + ]); + $data = $response->toArray(false); + } catch (ExceptionInterface $e) { + throw new \RuntimeException('Leantime request failed: '.$e->getMessage(), 0, $e); + } + + if (isset($data['error'])) { + throw new \RuntimeException(sprintf('Leantime API error (%s): %s', $data['error']['code'] ?? '?', $data['error']['message'] ?? 'Unknown error')); + } + + return $data['result'] ?? null; + } + + /** + * Look up a Leantime user's display name by id. + * + * Lazy-loads the user directory on first call and coerces the id to int + * (Leantime delivers it as either string or int). Returns null when the + * id is empty or unknown. + * + * @param mixed $userId raw user id from the Leantime payload (int, numeric string, or null) + * + * @return string|null the user's display name, or null when no match exists + * + * @throws \RuntimeException if the user directory fetch fails + */ + private function resolveUserName(mixed $userId): ?string + { + if (null === $userId || '' === $userId) { + return null; + } + $this->loadUsers(); + + return $this->userNamesById[(int) $userId] ?? null; + } + + /** + * Populate the user id/name/email caches from Leantime. + * + * Idempotent — fetches the directory at most once per service instance. + * Builds an id → display-name map (firstname+lastname, falling back to + * username) and an email → id map keyed by lowercase email; entries with + * no id are skipped. + * + * @throws \RuntimeException if the Leantime API rejects the request or the transport fails + */ + private function loadUsers(): void + { + if (null !== $this->userNamesById) { + return; + } + + $this->userNamesById = []; + $this->userIdsByEmail = []; + + $result = $this->request('leantime.rpc.users.getAll'); + if (!is_array($result)) { + return; + } + + foreach ($result as $user) { + if (!is_array($user) || !isset($user['id'])) { + continue; + } + $id = (int) $user['id']; + + $name = trim(((string) ($user['firstname'] ?? '')).' '.((string) ($user['lastname'] ?? ''))); + if ('' === $name) { + $name = (string) ($user['username'] ?? 'Unknown'); + } + $this->userNamesById[$id] = $name; + + $email = mb_strtolower(trim((string) ($user['email'] ?? ''))); + if ('' !== $email) { + $this->userIdsByEmail[$email] = $id; + } + } + } +} diff --git a/src/Service/RepoAdvisoryService.php b/src/Service/RepoAdvisoryService.php new file mode 100644 index 0000000..82f094a --- /dev/null +++ b/src/Service/RepoAdvisoryService.php @@ -0,0 +1,176 @@ +>, leantimeError: ?string} rows plus an optional Leantime error for the caller to flash + */ + public function buildIndexRows(): array + { + $reposWithCount = $this->gitRepoRepository->findReposWithAdvisoryCount(); + $packageVersionsPerRepo = $this->gitRepoRepository->findPackageVersionsPerRepoWithAdvisories(); + + $ticketsByLeantimeId = []; + $leantimeError = null; + try { + $ticketsByLeantimeId = $this->leantimeService->findOpenSecurityTickets(); + } catch (\Throwable $e) { + $leantimeError = $e->getMessage(); + } + + $rows = []; + foreach ($reposWithCount as $entry) { + $rows[] = $this->buildRow($entry, $packageVersionsPerRepo, $ticketsByLeantimeId); + } + + return [ + 'rows' => $rows, + 'leantimeError' => $leantimeError, + ]; + } + + /** + * Create a Leantime "Sikkerhedsopdatering" ticket on behalf of a code owner. + * + * Resolves the code owner, maps its email to a Leantime user id, and + * creates the ticket on the given project. When no Leantime user matches + * the ticket is still created — just unassigned — signalled via the + * `unassigned` flag in the result. + * + * @param string $codeOwnerId RFC-4122 UUID of a CodeOwner entity + * @param int $leantimeProjectId numeric Leantime project id (external system's id, not an ORM id) + * + * @return array{ticketId: int, codeOwner: CodeOwner, unassigned: bool} the new ticket id, the resolved code owner, and whether the ticket is unassigned + * + * @throws \RuntimeException if the code owner cannot be found or the + * Leantime API rejects the request + */ + public function createSecurityTicketForCodeOwner(string $codeOwnerId, int $leantimeProjectId): array + { + $codeOwner = $this->codeOwnerRepository->find($codeOwnerId); + if (!$codeOwner instanceof CodeOwner) { + throw new \RuntimeException('Code owner not found.'); + } + + $userId = $this->leantimeService->findUserIdByEmail($codeOwner->getEmail()); + $ticketId = $this->leantimeService->createSecurityTicket($leantimeProjectId, $userId); + + return [ + 'ticketId' => $ticketId, + 'codeOwner' => $codeOwner, + 'unassigned' => null === $userId, + ]; + } + + /** + * Build a single repo-advisory row for the admin index. + * + * Resolves the repo's projects, deduplicates their code owners, derives + * installation type/version labels, builds the AdvisoryCrudController + * deep-link, and picks the Leantime project id — preferring one with an + * open ticket, otherwise the first non-empty project id. + * + * @param array{repo: \App\Entity\GitRepo, advisoryCount: int} $entry repo + precomputed advisory count + * @param array> $packageVersionsPerRepo map of repo-id → package version ids that have advisories + * @param array $ticketsByLeantimeId open security tickets keyed by Leantime project id + * + * @return array row data ready for the Twig template + */ + private function buildRow(array $entry, array $packageVersionsPerRepo, array $ticketsByLeantimeId): array + { + $repo = $entry['repo']; + $projects = $this->projectRepository->findByGitRepo($repo); + + $codeOwners = []; + foreach ($projects as $project) { + foreach ($project->getCodeOwners() as $codeOwner) { + $codeOwners[$codeOwner->getId()?->toRfc4122() ?? ''] = $codeOwner; + } + } + + $typesAndVersions = []; + foreach ($repo->getGitTags() as $gitTag) { + foreach ($gitTag->getInstallations() as $installation) { + $key = trim(($installation->getType() ?? '').' '.($installation->getFrameworkVersion() ?? '')); + if ('' !== $key) { + $typesAndVersions[$key] = true; + } + } + } + ksort($typesAndVersions); + + $repoKey = (string) $repo->getId(); + $packageVersionIds = $packageVersionsPerRepo[$repoKey] ?? []; + $advisoriesUrl = [] === $packageVersionIds ? null : $this->adminUrlGenerator + ->unsetAll() + ->setController(AdvisoryCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->set('filters', ['packageVersions' => ['comparison' => '=', 'value' => $packageVersionIds]]) + ->generateUrl(); + + $openTicket = null; + $leantimeProjectId = null; + foreach ($projects as $project) { + $rawLeantimeId = $project->getLeantimeId(); + if (null === $rawLeantimeId || '' === $rawLeantimeId) { + continue; + } + $candidateId = (int) $rawLeantimeId; + if (null === $leantimeProjectId) { + $leantimeProjectId = $candidateId; + } + if (isset($ticketsByLeantimeId[$candidateId])) { + $openTicket = $ticketsByLeantimeId[$candidateId]; + $leantimeProjectId = $candidateId; + break; + } + } + + return [ + 'repo' => $repo, + 'advisoryCount' => $entry['advisoryCount'], + 'advisoriesUrl' => $advisoriesUrl, + 'typesAndVersions' => array_keys($typesAndVersions), + 'projects' => $projects, + 'codeOwners' => array_values($codeOwners), + 'openTicket' => $openTicket, + 'leantimeProjectId' => $leantimeProjectId, + ]; + } +} diff --git a/src/Service/ServiceAgreementSyncService.php b/src/Service/ServiceAgreementSyncService.php new file mode 100644 index 0000000..10007e3 --- /dev/null +++ b/src/Service/ServiceAgreementSyncService.php @@ -0,0 +1,284 @@ +} count of projects processed and the list of unresolvable GitHub repo names + * + * @throws \RuntimeException if the Economics API request fails + * @throws \Exception if a date string in the payload cannot be parsed + */ + public function syncAll(): array + { + try { + $response = $this->economicsClient->request('GET', self::ENDPOINT); + $projectsData = $response->toArray(); + } catch (ExceptionInterface $e) { + throw new \RuntimeException('Failed to fetch projects from Economics API: '.$e->getMessage(), 0, $e); + } + + $existingProjects = []; + foreach ($this->projectRepository->findAll() as $project) { + $existingProjects[$project->getEconomicsId()] = $project; + } + + $existingCodeOwners = []; + foreach ($this->codeOwnerRepository->findAll() as $codeOwner) { + $existingCodeOwners[$codeOwner->getEconomicsId()] = $codeOwner; + } + + $existingContracts = []; + foreach ($this->securityContractRepository->findAll() as $contract) { + $existingContracts[$contract->getEconomicsId()] = $contract; + } + + $existingGitReposByRepo = []; + foreach ($this->gitRepoRepository->findAll() as $repo) { + $existingGitReposByRepo[$repo->getRepo()] = $repo; + } + + $seenProjectIds = []; + $seenCodeOwnerIds = []; + $seenContractIds = []; + $unmatchedRepoNames = []; + + foreach ($projectsData as $data) { + $project = $existingProjects[$data['id']] ?? new Project(); + $project->setEconomicsId($data['id']); + $project->setName($data['name'] ?? ''); + $project->setLeantimeId(isset($data['projectTrackerId']) ? (string) $data['projectTrackerId'] : null); + $project->setLeantimeUrl($data['leantimeUrl'] ?? null); + + $this->syncCodeOwners($project, $data['codeowners'] ?? [], $existingCodeOwners, $seenCodeOwnerIds); + $this->syncGitRepos($project, $data['githubRepos'] ?? null, $existingGitReposByRepo, $unmatchedRepoNames); + + $this->entityManager->persist($project); + $existingProjects[$data['id']] = $project; + $seenProjectIds[] = $data['id']; + + $serviceAgreementData = $data['serviceAgreement'] ?? null; + if (is_array($serviceAgreementData) && isset($serviceAgreementData['id'])) { + $contract = $existingContracts[$serviceAgreementData['id']] ?? new SecurityContract(); + $this->mapServiceAgreementToContract($contract, $serviceAgreementData, $project); + $this->entityManager->persist($contract); + $existingContracts[$serviceAgreementData['id']] = $contract; + $seenContractIds[] = $serviceAgreementData['id']; + } + } + + foreach ($existingContracts as $economicsId => $contract) { + if (!in_array($economicsId, $seenContractIds, true)) { + $this->entityManager->remove($contract); + } + } + + foreach ($existingProjects as $economicsId => $project) { + if (!in_array($economicsId, $seenProjectIds, true)) { + $this->entityManager->remove($project); + } + } + + foreach ($existingCodeOwners as $economicsId => $codeOwner) { + if (!in_array($economicsId, $seenCodeOwnerIds, true)) { + $this->entityManager->remove($codeOwner); + } + } + + $this->entityManager->flush(); + + return [ + 'projects' => count($projectsData), + 'unmatchedRepoNames' => array_keys($unmatchedRepoNames), + ]; + } + + /** + * Reconcile a project's code owners against the Economics payload. + * + * Upserts each owner in `$codeOwnersData`, persists it, and links the + * project to exactly the desired set. The `$existingCodeOwners` and + * `$seenCodeOwnerIds` arrays are by-reference so newly created owners + * are reused across projects in the same sync run, and the seen-list + * drives the post-loop cleanup pass. + * + * @param Project $project project being synced + * @param array> $codeOwnersData raw `codeowners` array from Economics + * @param array $existingCodeOwners by-reference id-keyed lookup; mutated to include newly-created owners + * @param list $seenCodeOwnerIds by-reference accumulator of every economicsId touched this run + * + * @param-out array $existingCodeOwners + * @param-out list $seenCodeOwnerIds + */ + private function syncCodeOwners(Project $project, array $codeOwnersData, array &$existingCodeOwners, array &$seenCodeOwnerIds): void + { + $desired = []; + foreach ($codeOwnersData as $ownerData) { + if (!isset($ownerData['id'])) { + continue; + } + + $economicsId = (int) $ownerData['id']; + $owner = $existingCodeOwners[$economicsId] ?? new CodeOwner(); + $owner->setEconomicsId($economicsId); + $owner->setName((string) ($ownerData['name'] ?? '')); + $owner->setEmail((string) ($ownerData['email'] ?? '')); + + $existingCodeOwners[$economicsId] = $owner; + $seenCodeOwnerIds[] = $economicsId; + + $this->entityManager->persist($owner); + $desired[$economicsId] = $owner; + } + + foreach ($project->getCodeOwners() as $existing) { + if (!isset($desired[$existing->getEconomicsId()])) { + $project->removeCodeOwner($existing); + } + } + foreach ($desired as $owner) { + $project->addCodeOwner($owner); + } + } + + /** + * Reconcile a project's GitHub repo associations against the Economics payload. + * + * Splits the multi-line `githubRepos` string and links the project to + * exactly the matching GitRepo entries. Unknown repo names are recorded + * in `$unmatchedRepoNames`; GitRepo entries are not created on the fly + * because that would hide the underlying harvester onboarding gap. + * + * @param Project $project project being synced + * @param string|null $githubReposString raw multi-line list from Economics, or null when the field is unset + * @param array $existingGitReposByRepo lookup keyed by GitRepo::getRepo() + * @param array $unmatchedRepoNames by-reference accumulator across all projects (set-like) + * + * @param-out array $unmatchedRepoNames + */ + private function syncGitRepos(Project $project, ?string $githubReposString, array $existingGitReposByRepo, array &$unmatchedRepoNames): void + { + $names = []; + if (null !== $githubReposString && '' !== $githubReposString) { + foreach (preg_split('/\r\n|\r|\n/', $githubReposString) ?: [] as $line) { + $trimmed = trim($line); + if ('' !== $trimmed) { + $names[] = $trimmed; + } + } + } + + $desired = []; + foreach ($names as $repoName) { + if (isset($existingGitReposByRepo[$repoName])) { + $repo = $existingGitReposByRepo[$repoName]; + $desired[spl_object_id($repo)] = $repo; + } else { + $unmatchedRepoNames[$repoName] = true; + } + } + + foreach ($project->getGitRepos() as $existing) { + if (!isset($desired[spl_object_id($existing)])) { + $project->removeGitRepo($existing); + } + } + foreach ($desired as $repo) { + $project->addGitRepo($repo); + } + } + + /** + * Copy a single Economics `serviceAgreement` payload onto a SecurityContract. + * + * Field-by-field mapping plus the project association. No persistence + * and no fetches — the caller persists the contract afterwards. Dates + * go through `parseDate()` to handle Economics' `{date, timezone_type, + * timezone}` shape. + * + * @param SecurityContract $contract entity to mutate in place + * @param array $data raw `serviceAgreement` payload from Economics + * @param Project $project project the contract belongs to + * + * @throws \Exception if a date string in the payload cannot be parsed + */ + private function mapServiceAgreementToContract(SecurityContract $contract, array $data, Project $project): void + { + $contract->setEconomicsId($data['id']); + $contract->setProject($project); + $contract->setHostingProvider($data['hostingProvider'] ?? null); + $contract->setDocumentUrl($data['documentUrl'] ?? null); + $contract->setMonthlyPrice(isset($data['price']) ? (float) $data['price'] : null); + $contract->setValidFrom($this->parseDate($data['validFrom'] ?? null)); + $contract->setValidTo($this->parseDate($data['validTo'] ?? null)); + $contract->setActive($data['isActive'] ?? false); + $contract->setEol($data['isEol'] ?? false); + $contract->setClientContactName($data['clientContactName'] ?? null); + $contract->setClientContactEmail($data['clientContactEmail'] ?? null); + $contract->setDedicatedServer($data['dedicatedServer'] ?? false); + $contract->setServerSize($data['serverSize'] ?? null); + $contract->setSystemOwnerNotices($data['systemOwnerNotices'] ?? null); + } + + /** + * Parse the Economics API's `{date, timezone_type, timezone}` shape into a DateTimeImmutable. + * + * Returns null when the payload is null or has no `date` key. The + * payload's timezone is honored when present; otherwise UTC is assumed. + * + * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date payload from Economics, or null when absent + * + * @return \DateTimeImmutable|null parsed date, or null when no date was supplied + * + * @throws \Exception if the date string cannot be parsed by DateTimeImmutable + */ + private function parseDate(?array $dateData): ?\DateTimeImmutable + { + if (null === $dateData || !isset($dateData['date'])) { + return null; + } + + $timezone = new \DateTimeZone($dateData['timezone'] ?? self::DEFAULT_TIMEZONE); + + return new \DateTimeImmutable($dateData['date'], $timezone); + } +} diff --git a/src/Trait/ApiKeyEntityTrait.php b/src/Trait/ApiKeyEntityTrait.php new file mode 100644 index 0000000..7c65c81 --- /dev/null +++ b/src/Trait/ApiKeyEntityTrait.php @@ -0,0 +1,35 @@ +apiKey; + } + + public function setApiKey(string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } + + public function generateApiKey(): string + { + return sha1(\random_bytes(40)); + } +} diff --git a/src/Trait/DeprecatedCrudControllerTrait.php b/src/Trait/DeprecatedCrudControllerTrait.php new file mode 100644 index 0000000..ffe81fd --- /dev/null +++ b/src/Trait/DeprecatedCrudControllerTrait.php @@ -0,0 +1,41 @@ +get('pageName')) { + $this->addFlash('warning', [ + 'title' => 'Deprecated', + // The style prefix is part of the name, as on the menu + // items: EasyAdmin writes it into `class` verbatim, and + // FontAwesome draws nothing without it. + 'icon' => 'fas fa-triangle-exclamation', + 'message' => $this->getDeprecationNotice(), + ]); + } + + return parent::configureResponseParameters($responseParameters); + } +} diff --git a/src/Trait/ExportCrudControllerTrait.php b/src/Trait/ExportCrudControllerTrait.php index 04fceac..71f5c2f 100644 --- a/src/Trait/ExportCrudControllerTrait.php +++ b/src/Trait/ExportCrudControllerTrait.php @@ -35,7 +35,7 @@ public function setExporter(Exporter $exporter): void protected function createExportAction(string|TranslatableMessage|null $label = null): Action { - return Action::new('export', $label ?? new TranslatableMessage('Export')) + return Action::new('export', $label ?? new TranslatableMessage('Export'), 'fa fa-file-csv') ->createAsGlobalAction() ->linkToCrudAction('export'); } diff --git a/symfony.lock b/symfony.lock index 1033474..4e95b38 100644 --- a/symfony.lock +++ b/symfony.lock @@ -307,6 +307,21 @@ "symfony/asset": { "version": "v6.0.3" }, + "symfony/asset-mapper": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a" + }, + "files": [ + "assets/app.js", + "assets/styles/app.css", + "config/packages/asset_mapper.yaml", + "importmap.php" + ] + }, "symfony/browser-kit": { "version": "v6.0.3" }, diff --git a/templates/EasyAdminBundle/Fields/advisories.html.twig b/templates/EasyAdminBundle/Fields/advisories.html.twig index cfe1bba..292cbfa 100644 --- a/templates/EasyAdminBundle/Fields/advisories.html.twig +++ b/templates/EasyAdminBundle/Fields/advisories.html.twig @@ -29,5 +29,5 @@ {% endif %} {% elseif field.formattedValue != 0 %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/advisory_count.html.twig b/templates/EasyAdminBundle/Fields/advisory_count.html.twig index 1b34775..e3b03da 100644 --- a/templates/EasyAdminBundle/Fields/advisory_count.html.twig +++ b/templates/EasyAdminBundle/Fields/advisory_count.html.twig @@ -2,5 +2,5 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue != 0 %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/affected_sites.html.twig b/templates/EasyAdminBundle/Fields/affected_sites.html.twig index a2eb5e3..3263118 100644 --- a/templates/EasyAdminBundle/Fields/affected_sites.html.twig +++ b/templates/EasyAdminBundle/Fields/affected_sites.html.twig @@ -29,5 +29,5 @@ None {% endif %} {% else %} - {{ sites|length }} + {{ sites|length }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/changes.html.twig b/templates/EasyAdminBundle/Fields/changes.html.twig index 06d19b9..a928d4f 100644 --- a/templates/EasyAdminBundle/Fields/changes.html.twig +++ b/templates/EasyAdminBundle/Fields/changes.html.twig @@ -2,7 +2,7 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue == 0 %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/cloned_by.html.twig b/templates/EasyAdminBundle/Fields/cloned_by.html.twig index 157c2d8..f47deea 100644 --- a/templates/EasyAdminBundle/Fields/cloned_by.html.twig +++ b/templates/EasyAdminBundle/Fields/cloned_by.html.twig @@ -2,9 +2,9 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue == 'unknown' %} - ? + ? {% elseif field.formattedValue starts with 'ssh' %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/code.html.twig b/templates/EasyAdminBundle/Fields/code.html.twig index 28a08e7..b2b8143 100644 --- a/templates/EasyAdminBundle/Fields/code.html.twig +++ b/templates/EasyAdminBundle/Fields/code.html.twig @@ -2,7 +2,7 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue is null %} - {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} + {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} {% else %} {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/db_version.html.twig b/templates/EasyAdminBundle/Fields/db_version.html.twig index c192aeb..0f757b0 100644 --- a/templates/EasyAdminBundle/Fields/db_version.html.twig +++ b/templates/EasyAdminBundle/Fields/db_version.html.twig @@ -2,7 +2,7 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue is null %} - {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} + {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/domain.html.twig b/templates/EasyAdminBundle/Fields/domain.html.twig index accac03..233a7ac 100644 --- a/templates/EasyAdminBundle/Fields/domain.html.twig +++ b/templates/EasyAdminBundle/Fields/domain.html.twig @@ -4,7 +4,7 @@ {# NOTE: the rel="noopener" attr is needed to avoid performance and security issues (see https://web.dev/external-anchors-use-rel-noopener/) #} {% if field.formattedValue == 'unknown' %} - ? + ? {% elseif ea().crud.currentAction == 'detail' %} {{ field.value }} {% else %} diff --git a/templates/EasyAdminBundle/Fields/eol.html.twig b/templates/EasyAdminBundle/Fields/eol.html.twig index 351ff76..fcb9e5e 100644 --- a/templates/EasyAdminBundle/Fields/eol.html.twig +++ b/templates/EasyAdminBundle/Fields/eol.html.twig @@ -3,6 +3,7 @@ {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% set slicedDate = field.formattedValue|slice(0, 7) %} + {% if ea().crud.currentAction == 'detail' %} {% set outputValue = field.formattedValue %} {% else %} @@ -10,11 +11,11 @@ {% endif %} {% if field.formattedValue is empty %} - ? + ? {% elseif 'Expired' in field.formattedValue %} - {{ outputValue }} + {{ outputValue }} {% elseif date('01/' ~ slicedDate) < date('+180days') %} - {{ outputValue }} + {{ outputValue }} {% else %} - {{ outputValue }} + {{ outputValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/hosting_provider.html.twig b/templates/EasyAdminBundle/Fields/hosting_provider.html.twig index cc1d7ab..d4c63f7 100644 --- a/templates/EasyAdminBundle/Fields/hosting_provider.html.twig +++ b/templates/EasyAdminBundle/Fields/hosting_provider.html.twig @@ -2,13 +2,13 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue is empty %} - ? + ? {% elseif field.formattedValue == constant('App\\Types\\HostingProviderType::AZURE') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.formattedValue == constant('App\\Types\\HostingProviderType::IT_RELATION') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.formattedValue == constant('App\\Types\\HostingProviderType::DBC') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/latest-status.html.twig b/templates/EasyAdminBundle/Fields/latest-status.html.twig index 5ace6a7..c145cc9 100644 --- a/templates/EasyAdminBundle/Fields/latest-status.html.twig +++ b/templates/EasyAdminBundle/Fields/latest-status.html.twig @@ -2,13 +2,13 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue == 'unknown' %} - ? + ? {% elseif field.formattedValue == 'up-to-date' %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.formattedValue == 'semver-safe-update' %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.formattedValue == 'update-possible' %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/server_type.html.twig b/templates/EasyAdminBundle/Fields/server_type.html.twig index 52369c6..c9afaba 100644 --- a/templates/EasyAdminBundle/Fields/server_type.html.twig +++ b/templates/EasyAdminBundle/Fields/server_type.html.twig @@ -2,13 +2,13 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.value is empty %} - ? + ? {% elseif field.value == constant('App\\Types\\ServerTypeType::PROD') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.value == constant('App\\Types\\ServerTypeType::STG') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.value == constant('App\\Types\\ServerTypeType::DEVOPS') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/site.html.twig b/templates/EasyAdminBundle/Fields/site.html.twig index 6bc1a58..eae8d9a 100644 --- a/templates/EasyAdminBundle/Fields/site.html.twig +++ b/templates/EasyAdminBundle/Fields/site.html.twig @@ -2,9 +2,9 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue == constant('App\\Types\\SiteType::NGINX') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% elseif field.formattedValue == constant('App\\Types\\SiteType::DOCKER') %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/ssh_link.html.twig b/templates/EasyAdminBundle/Fields/ssh_link.html.twig index 11e692b..8dbc74d 100644 --- a/templates/EasyAdminBundle/Fields/ssh_link.html.twig +++ b/templates/EasyAdminBundle/Fields/ssh_link.html.twig @@ -5,9 +5,7 @@
{{ field.formattedValue }}
diff --git a/templates/EasyAdminBundle/Fields/text_mono.html.twig b/templates/EasyAdminBundle/Fields/text_mono.html.twig index dab826d..65cc065 100644 --- a/templates/EasyAdminBundle/Fields/text_mono.html.twig +++ b/templates/EasyAdminBundle/Fields/text_mono.html.twig @@ -2,7 +2,7 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue is null %} - {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} + {{ 'label.null'|trans(domain: 'EasyAdminBundle') }} {% else %} {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/version.html.twig b/templates/EasyAdminBundle/Fields/version.html.twig index 40c514a..185621b 100644 --- a/templates/EasyAdminBundle/Fields/version.html.twig +++ b/templates/EasyAdminBundle/Fields/version.html.twig @@ -2,9 +2,9 @@ {# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if field.formattedValue == 'unknown' %} - ? + ? {% elseif field.formattedValue starts with '${' %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% else %} - {{ field.formattedValue }} + {{ field.formattedValue }} {% endif %} diff --git a/templates/EasyAdminBundle/Fields/warning.html.twig b/templates/EasyAdminBundle/Fields/warning.html.twig index 27adefc..7b91650 100644 --- a/templates/EasyAdminBundle/Fields/warning.html.twig +++ b/templates/EasyAdminBundle/Fields/warning.html.twig @@ -5,10 +5,10 @@ {{ field.formattedValue|raw|nl2br }} {% else %} {% if field.formattedValue is null %} - + {% else %} - + {% endif %} {% endif %} diff --git a/templates/admin/repo_advisory/index.html.twig b/templates/admin/repo_advisory/index.html.twig new file mode 100644 index 0000000..164a257 --- /dev/null +++ b/templates/admin/repo_advisory/index.html.twig @@ -0,0 +1,115 @@ +{% extends '@!EasyAdmin/layout.html.twig' %} + +{% block title %}Repositories with advisories{% endblock %} + +{% block content_title %}Repositories with advisories{% endblock %} + +{% block main %} +
+
+ + +
+
+ + {% if rows is empty %} +
No repositories have associated advisories.
+ {% else %} + + + + + + + + + + + + + {% for row in rows %} + {% set repo = row.repo %} + {% set repo_url = ea_url() + .unsetAll() + .setController('App\\Controller\\Admin\\GitRepoCrudController') + .setAction('detail') + .setEntityId(repo.id) %} + + + + + + + + + {% endfor %} + +
RepositoryProject type & versionAdvisoriesProjectService agreementsCode owner action
{{ repo }} + {% for tv in row.typesAndVersions %} + {{ tv }} + {% else %} + — + {% endfor %} + + {% if row.advisoriesUrl %} + + {{ row.advisoryCount }} + + {% else %} + {{ row.advisoryCount }} + {% endif %} + + {% for project in row.projects %} + {{ project.name }}{{ not loop.last ? ', ' }} + {% else %} + — + {% endfor %} + + {% set contracts = [] %} + {% for project in row.projects %} + {% for sc in project.serviceAgreements %} + {% set contracts = contracts|merge([sc]) %} + {% endfor %} + {% endfor %} + {% for sc in contracts %} +
+ {{ sc.hostingProvider ?? sc.economicsId }} + {% if sc.validTo %}(until {{ sc.validTo|date('Y-m-d') }}){% endif %} +
+ {% else %} + — + {% endfor %} +
+ {% if row.openTicket %} +
+ + Issue assigned to {{ row.openTicket.assigneeName ?? 'Unassigned' }} + {% if row.openTicket.createdAt %} + (created {{ row.openTicket.createdAt|date('Y-m-d') }}) + {% endif %} +
+ {% elseif row.leantimeProjectId is null %} + No Leantime project linked + {% elseif row.codeOwners is empty %} + No code owners + {% else %} +
+ + + + +
+ {% endif %} +
+ {% endif %} +{% endblock %} diff --git a/templates/bundles/EasyAdminBundle/crud/field/association.html.twig b/templates/bundles/EasyAdminBundle/crud/field/association.html.twig index 7d5393a..6fd3fd9 100644 --- a/templates/bundles/EasyAdminBundle/crud/field/association.html.twig +++ b/templates/bundles/EasyAdminBundle/crud/field/association.html.twig @@ -3,9 +3,9 @@ {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% if 'toMany' == field.customOptions.get('associationType') %} {% if field.value.count == 0 %} - None + None {% elseif not has_display(field.value[0]) and ea().crud.currentAction != 'detail' %} - {{ field.value|length }} + {{ field.value|length }} {% else %} {% for value in field.value %} {% if ea().crud.currentAction == 'detail' %} @@ -13,10 +13,10 @@ {% elseif has_display(value) %} {{ entity_display(value, ea().crud.currentAction) }} {% else %} - None + None {% endif %} {% else %} - None + None {% endfor %} {% endif %} {% else %} diff --git a/templates/error/openid_connect_failed.html.twig b/templates/error/openid_connect_failed.html.twig new file mode 100644 index 0000000..1141b94 --- /dev/null +++ b/templates/error/openid_connect_failed.html.twig @@ -0,0 +1,78 @@ +{# + Shown by App\EventListener\OpenIdConnectFailureListener when an OIDC login + could not be completed. + + Self-contained on purpose: an error page should not depend on the asset + build, and "Try again" is a link the user clicks rather than a redirect — + the bundle stopped redirecting to the identity provider precisely to end + the loop a broken login used to cause. +#} + + + + + + + + {% if declined %}Login declined{% else %}Login failed{% endif %} - ITKsites + + + + + +
+

ITKsites

+ + {% if declined %} +

+ The login was not completed. +

+ +

+ This happens if you cancelled at the login screen, or if your session + there had expired — in either case try again. +

+ {% else %} +

+ We could not complete your login. +

+ +

+ This happens if the login was left half-finished for too long — in that + case try again. +

+ +

+ If it keeps failing, the login setup itself needs attention: tell ITK Dev, + and mention the time you tried. The details are in the log. +

+ {% endif %} +
+ + diff --git a/templates/post_logout/index.html.twig b/templates/post_logout/index.html.twig index 025585e..2adcb81 100644 --- a/templates/post_logout/index.html.twig +++ b/templates/post_logout/index.html.twig @@ -101,7 +101,7 @@ diff --git a/tests/Command/UserSetApiKeyCommandTest.php b/tests/Command/UserSetApiKeyCommandTest.php new file mode 100644 index 0000000..3f78289 --- /dev/null +++ b/tests/Command/UserSetApiKeyCommandTest.php @@ -0,0 +1,67 @@ +setAutoExit(false); + + $applicationTester = new ApplicationTester($application); + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + '--help' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + // Missing user ID argument. + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + ]); + $applicationTester->assertCommandFailed(); + + // Invalid user ID argument. + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'this-user-does-no-exist', + ]); + $applicationTester->assertCommandIsInvalid(); + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('Cannot load user with id this-user-does-no-exist', $output); + + // Valid user ID (name). + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'admin', + '--no-interaction' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('API key for user admin@example.com set to', $output); + + // Valid user ID (email). + $applicationTester->run([ + 'command' => 'app:user:set-api-key', + 'user-id' => 'admin@example.com', + '--no-interaction' => true, + ]); + $applicationTester->assertCommandIsSuccessful(); + + $output = $applicationTester->getDisplay(); + $this->assertStringContainsString('API key for user admin@example.com set to', $output); + } +} diff --git a/tests/Controller/Admin/AdminSmokeTest.php b/tests/Controller/Admin/AdminSmokeTest.php index c41e253..52a416a 100644 --- a/tests/Controller/Admin/AdminSmokeTest.php +++ b/tests/Controller/Admin/AdminSmokeTest.php @@ -50,6 +50,63 @@ public function testCrudIndexPageLoads(string $controllerClass): void $this->assertResponseIsSuccessful(); } + /** + * The deprecated CRUD controllers are gone from the admin menu but still + * routed, so the only way in is a bookmark or an old link. Each page says + * so; without the warning it looks like a maintained part of the admin. + */ + #[DataProvider('deprecatedCrudControllerProvider')] + public function testDeprecatedCrudIndexPageWarns(string $controllerClass): void + { + $client = static::createClient(); + + $user = static::getContainer()->get('doctrine')->getManager() + ->getRepository(User::class)->findOneBy([]); + $client->loginUser($user); + + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController($controllerClass) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + + $crawler = $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('#flash-messages .alert-title', 'Deprecated'); + $this->assertStringContainsString( + 'deprecated and no longer maintained here', + $crawler->filter('#flash-messages')->text() + ); + } + + public function testMaintainedCrudIndexPageDoesNotWarn(): void + { + $client = static::createClient(); + + $user = static::getContainer()->get('doctrine')->getManager() + ->getRepository(User::class)->findOneBy([]); + $client->loginUser($user); + + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(ServerCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorNotExists('#flash-messages'); + } + + /** + * @return iterable + */ + public static function deprecatedCrudControllerProvider(): iterable + { + yield 'OIDC' => [OIDCCrudController::class]; + yield 'ServiceCertificate' => [ServiceCertificateCrudController::class]; + } + /** * @return iterable */ diff --git a/tests/Controller/Admin/SecurityContractCurrencyTest.php b/tests/Controller/Admin/SecurityContractCurrencyTest.php new file mode 100644 index 0000000..e4f8fd3 --- /dev/null +++ b/tests/Controller/Admin/SecurityContractCurrencyTest.php @@ -0,0 +1,65 @@ +get(EntityManagerInterface::class); + $client->loginUser($entityManager->getRepository(User::class)->findOneBy([])); + + $project = new Project(); + $project->setEconomicsId(4711); + $project->setName('Kroner probe'); + + $contract = new SecurityContract(); + $contract->setEconomicsId(4711); + $contract->setProject($project); + $contract->setMonthlyPrice(12500.5); + + $entityManager->persist($project); + $entityManager->persist($contract); + $entityManager->flush(); + + $url = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(SecurityContractCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + + $content = (string) $client->getResponse()->getContent(); + + // Danish grouping and separator, not the application locale's 12,500.5. + // Amount and unit are asserted apart because Intl joins them with a + // non-breaking space. + $this->assertStringContainsString('12.500,50', $content); + $this->assertStringContainsString('kr.', $content); + } +} diff --git a/tests/Controller/HealthControllerTest.php b/tests/Controller/HealthControllerTest.php new file mode 100644 index 0000000..2bb63a1 --- /dev/null +++ b/tests/Controller/HealthControllerTest.php @@ -0,0 +1,110 @@ +request('GET', '/health/live'); + + $this->assertResponseIsSuccessful(); + $this->assertSame(['status' => 'ok'], $this->decode($client->getResponse())); + } + + /** + * All three endpoints must bypass the firewalls. A redirect here means the + * OIDC entry point has caught them, which is what made a total ingest + * outage look healthy to monitoring. + */ + #[DataProvider('endpointProvider')] + public function testEndpointIsPubliclyReachable(string $path): void + { + $client = static::createClient(); + $client->request('GET', $path); + + $this->assertContains( + $client->getResponse()->getStatusCode(), + [Response::HTTP_OK, Response::HTTP_SERVICE_UNAVAILABLE], + \sprintf('%s should answer 200 or 503, never a redirect to login.', $path) + ); + } + + /** + * @return iterable + */ + public static function endpointProvider(): iterable + { + yield ['/health/live']; + yield ['/health/ready']; + yield ['/health/detail']; + } + + /** + * Readiness is public, so it must disclose the aggregated status and + * nothing else — no check names, no messages, no dependency detail. + */ + public function testReadyDisclosesNothingBeyondStatus(): void + { + $client = static::createClient(); + $client->request('GET', '/health/ready'); + + $payload = $this->decode($client->getResponse()); + + $this->assertSame(['status'], array_keys($payload)); + $this->assertContains($payload['status'], ['ok', 'degraded']); + } + + public function testDetailReportsEveryCheck(): void + { + $client = static::createClient(); + $client->request('GET', '/health/detail'); + + $payload = $this->decode($client->getResponse()); + + $this->assertArrayHasKey('checks', $payload); + $this->assertEqualsCanonicalizing( + ['database', 'rabbitmq', 'ingest_freshness', 'oidc_client_secret'], + array_keys($payload['checks']) + ); + + foreach ($payload['checks'] as $check) { + $this->assertContains($check['status'], ['ok', 'degraded', 'skipped']); + } + } + + public function testResponsesAreNotCacheableByClients(): void + { + $client = static::createClient(); + $client->request('GET', '/health/ready'); + + $this->assertResponseHeaderSame('Cache-Control', 'no-store, private'); + } + + /** + * @return array + */ + private function decode(Response $response): array + { + $content = $response->getContent(); + $this->assertIsString($content); + + $payload = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + $this->assertIsArray($payload); + + return $payload; + } +} diff --git a/tests/EventListener/OpenIdConnectFailureListenerTest.php b/tests/EventListener/OpenIdConnectFailureListenerTest.php new file mode 100644 index 0000000..d41cf56 --- /dev/null +++ b/tests/EventListener/OpenIdConnectFailureListenerTest.php @@ -0,0 +1,123 @@ +request('GET', '/openid-connect/generic?state=bogus&code=bogus'); + + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('could not complete your login', $content); + $this->assertStringContainsString('/openidconnect/login/azure_az', $content); + } + + /** + * The identity provider's own error text says why a login failed and + * belongs in the log. The page says none of it. + */ + public function testErrorPageDisclosesNothingAboutTheFailure(): void + { + $client = static::createClient(); + $client->request('GET', '/openid-connect/generic?state=bogus&code=bogus'); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringNotContainsString('Error occurred validating openid login', $content); + $this->assertStringContainsString( + 'no-store', + (string) $client->getResponse()->headers->get('Cache-Control'), + ); + } + + /** + * A refusal is not a server error. The user declined, or Azure declined on a + * policy, and answering 500 pages somebody for an ordinary outcome. + */ + public function testARefusedLoginAnswers403(): void + { + $client = static::createClient(); + $session = $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state='.$session.'&error=access_denied&error_description=User+cancelled'); + + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $this->assertNull( + $client->getResponse()->headers->get('Location'), + 'A refused login was answered with a redirect: the firewall re-entered its entry point and the loop is back.' + ); + } + + /** + * The page tells the user the login was declined rather than that something + * went wrong, and still repeats nothing the identity provider sent. + */ + public function testARefusedLoginSaysItWasDeclined(): void + { + $client = static::createClient(); + $session = $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state='.$session.'&error=access_denied&error_description=User+cancelled'); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('Login declined', $content); + $this->assertStringContainsString('cancelled at the login screen', $content); + $this->assertStringContainsString('/openidconnect/login/azure_az', $content); + $this->assertStringNotContainsString('access_denied', $content); + $this->assertStringNotContainsString('User cancelled', $content); + } + + /** + * A forged callback carries whatever text its sender chose. The state is + * checked first, so none of it is trusted and the answer is the ordinary + * failure page. + */ + public function testAForgedRefusalIsAnOrdinaryFailure(): void + { + $client = static::createClient(); + $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state=does-not-match&error=access_denied&error_description=Call+0800+SCAM'); + + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('could not complete your login', $content); + $this->assertStringNotContainsString('SCAM', $content); + } + + /** + * Put the session values a login leaves behind in place, and return the state + * the callback has to carry to be recognised as belonging to it. + */ + private function startedLogin(KernelBrowser $client): string + { + $session = static::getContainer()->get('session.factory')->createSession(); + $session->set('oauth2provider', 'azure_az'); + $session->set('oauth2state', 'the-real-state'); + $session->set('oauth2nonce', 'the-real-nonce'); + $session->set('oauth2pkce_verifier', 'the-real-verifier'); + $session->save(); + + $client->getCookieJar()->set(new Cookie($session->getName(), $session->getId())); + + return 'the-real-state'; + } +} diff --git a/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php b/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php new file mode 100644 index 0000000..3ad226b --- /dev/null +++ b/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php @@ -0,0 +1,109 @@ +check(['azure_az' => '2026-08-01']); + + self::assertSame(HealthStatus::Degraded, $result->status); + self::assertSame('Client secret past its configured expiry: azure_az.', $result->message); + self::assertSame('expired', $result->details['azure_az.status']); + self::assertLessThan(0, $result->details['azure_az.days_remaining']); + } + + /** + * Expiring soon stays ok. Thirty days of 503 would train everyone to ignore + * the endpoint before the day it matters; the remaining days are in the + * payload for whatever watches it. + */ + public function testExpiringSoonIsOkWithDaysRemaining(): void + { + $result = $this->check(['azure_az' => '2026-09-05']); + + self::assertSame(HealthStatus::Ok, $result->status); + self::assertSame('expiring_soon', $result->details['azure_az.status']); + // 2026-08-26 12:00 UTC to midnight on 2026-09-05 is 9.5 days, floored. + self::assertSame(9, $result->details['azure_az.days_remaining']); + } + + public function testHealthySecretIsOk(): void + { + $result = $this->check(['azure_az' => '2027-01-31']); + + self::assertSame(HealthStatus::Ok, $result->status); + self::assertSame('ok', $result->details['azure_az.status']); + self::assertSame('2027-01-31T00:00:00+00:00', $result->details['azure_az.expires_at']); + } + + /** + * An unconfigured date means nothing is being monitored, which must not read + * as healthy and must not fail readiness either. + */ + public function testUnconfiguredDateIsSkipped(): void + { + $result = $this->check(['azure_az' => null]); + + self::assertSame(HealthStatus::Skipped, $result->status); + self::assertSame('No client secret expiry dates are configured.', $result->message); + } + + public function testNoProvidersIsSkipped(): void + { + $result = $this->check([]); + + self::assertSame(HealthStatus::Skipped, $result->status); + self::assertSame('No OIDC providers are configured.', $result->message); + } + + /** + * One expired provider degrades the check even when another is fine, and + * both stay visible in the payload. + */ + public function testExpiredProviderDegradesAlongsideAHealthyOne(): void + { + $result = $this->check(['azure_az' => '2027-01-31', 'legacy' => '2026-08-01']); + + self::assertSame(HealthStatus::Degraded, $result->status); + self::assertSame('Client secret past its configured expiry: legacy.', $result->message); + self::assertSame('ok', $result->details['azure_az.status']); + self::assertSame('expired', $result->details['legacy.status']); + } + + /** + * @param array $expiryDates + */ + private function check(array $expiryDates): HealthCheckResult + { + $checker = new ClientSecretExpiryChecker( + new MockClock('2026-08-26 12:00:00', 'UTC'), + $expiryDates, + self::WARNING_DAYS, + new NullLogger(), + ); + + return (new ClientSecretExpiryHealthCheck($checker))->check(); + } +} diff --git a/webpack.config.js b/webpack.config.js index ea5ec27..f48bb67 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -22,6 +22,10 @@ Encore */ .addEntry("easyadmin", "./assets/easyadmin.js") + // CSS-only entry, loaded by DashboardController for every admin page. The + // stylesheet is EasyAdmin-specific despite living at styles/app.css. + .addStyleEntry("admin", "./assets/styles/app.css") + // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. .splitEntryChunks()