diff --git a/.env b/.env index 22ba44cf..7b6e2625 100644 --- a/.env +++ b/.env @@ -206,3 +206,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849 # postgresql+advisory://db_user:db_password@localhost/db_name LOCK_DSN=flock ###< symfony/lock ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/composer.json b/composer.json index a7844452..ae3f1d3c 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "ext-intl": "*", "ext-json": "*", "ext-mbstring": "*", + "api-platform/core": "^3.1", "beberlei/doctrineextensions": "^1.2", "brick/math": "^0.11.0", "composer/package-versions-deprecated": "^1.11.99.5", @@ -33,6 +34,7 @@ "liip/imagine-bundle": "^2.2", "nbgrp/onelogin-saml-bundle": "^1.3", "nelexa/zip": "^4.0", + "nelmio/cors-bundle": "^2.3", "nelmio/security-bundle": "^3.0", "nyholm/psr7": "^1.1", "ocramius/proxy-manager": "2.2.*", @@ -40,6 +42,7 @@ "part-db/label-fonts": "^1.0", "php-translation/symfony-bundle": "^0.14.0", "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.23", "s9e/text-formatter": "^2.1", "scheb/2fa-backup-code": "^6.8.0", "scheb/2fa-bundle": "^6.8.0", @@ -83,7 +86,8 @@ "twig/intl-extra": "^3.0", "twig/markdown-extra": "^3.0", "web-auth/webauthn-symfony-bundle": "^4.0.0", - "webmozart/assert": "^1.4" + "webmozart/assert": "^1.4", + "webonyx/graphql-php": "^15.6" }, "require-dev": { "dama/doctrine-test-bundle": "^7.0", diff --git a/composer.lock b/composer.lock index 971f75cd..fa777e8a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,177 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b1ad5ff23ca8d2cc779fed986a8e2c9a", + "content-hash": "1a2a25bc47002e077096b2aceaee7c7f", "packages": [ + { + "name": "api-platform/core", + "version": "v3.1.13", + "source": { + "type": "git", + "url": "https://github.com/api-platform/core.git", + "reference": "190bb4eabeafbe8e830af4a8ccac1feaf5b74e96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/core/zipball/190bb4eabeafbe8e830af4a8ccac1feaf5b74e96", + "reference": "190bb4eabeafbe8e830af4a8ccac1feaf5b74e96", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^1.0 || ^2.0", + "php": ">=8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^3.1", + "symfony/http-foundation": "^6.1", + "symfony/http-kernel": "^6.1", + "symfony/property-access": "^6.1", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "symfony/web-link": "^6.1", + "willdurand/negotiation": "^3.0" + }, + "conflict": { + "doctrine/common": "<3.2.2", + "doctrine/dbal": "<2.10", + "doctrine/mongodb-odm": "<2.4", + "doctrine/orm": "<2.14.0", + "doctrine/persistence": "<1.3", + "elasticsearch/elasticsearch": ">=8.0", + "phpspec/prophecy": "<1.15", + "phpunit/phpunit": "<9.5", + "symfony/service-contracts": "<3", + "symfony/var-exporter": "<6.1.1" + }, + "require-dev": { + "behat/behat": "^3.1", + "behat/mink": "^1.9", + "doctrine/cache": "^1.11 || ^2.1", + "doctrine/common": "^3.2.2", + "doctrine/dbal": "^3.4.0", + "doctrine/doctrine-bundle": "^1.12 || ^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/mongodb-odm-bundle": "^4.0", + "doctrine/orm": "^2.14", + "elasticsearch/elasticsearch": "^7.11.0", + "friends-of-behat/mink-browserkit-driver": "^1.3.1", + "friends-of-behat/mink-extension": "^2.2", + "friends-of-behat/symfony-extension": "^2.1", + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "jangregor/phpstan-prophecy": "^1.0", + "justinrainbow/json-schema": "^5.2.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpdoc-parser": "^1.13", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-doctrine": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-symfony": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^3.9.7 || ^4.0", + "ramsey/uuid-doctrine": "^1.4 || ^2.0", + "soyuka/contexts": "v3.3.9", + "soyuka/stubs-mongodb": "^1.0", + "symfony/asset": "^6.1", + "symfony/browser-kit": "^6.1", + "symfony/cache": "^6.1", + "symfony/config": "^6.1", + "symfony/console": "^6.1", + "symfony/css-selector": "^6.1", + "symfony/dependency-injection": "^6.1.12", + "symfony/doctrine-bridge": "^6.1", + "symfony/dom-crawler": "^6.1", + "symfony/error-handler": "^6.1", + "symfony/event-dispatcher": "^6.1", + "symfony/expression-language": "^6.1", + "symfony/finder": "^6.1", + "symfony/form": "^6.1", + "symfony/framework-bundle": "^6.1", + "symfony/http-client": "^6.1", + "symfony/intl": "^6.1", + "symfony/maker-bundle": "^1.24", + "symfony/mercure-bundle": "*", + "symfony/messenger": "^6.1", + "symfony/phpunit-bridge": "^6.1", + "symfony/routing": "^6.1", + "symfony/security-bundle": "^6.1", + "symfony/security-core": "^6.1", + "symfony/twig-bundle": "^6.1", + "symfony/uid": "^6.1", + "symfony/validator": "^6.1", + "symfony/web-profiler-bundle": "^6.1", + "symfony/yaml": "^6.1", + "twig/twig": "^1.42.3 || ^2.12 || ^3.0", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "suggest": { + "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", + "elasticsearch/elasticsearch": "To support Elasticsearch.", + "ocramius/package-versions": "To display the API Platform's version in the debug bar.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", + "psr/cache-implementation": "To use metadata caching.", + "ramsey/uuid": "To support Ramsey's UUID identifiers.", + "symfony/cache": "To have metadata caching when using Symfony integration.", + "symfony/config": "To load XML configuration files.", + "symfony/expression-language": "To use authorization features.", + "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/messenger": "To support messenger integration.", + "symfony/security": "To use authorization features.", + "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/web-profiler-bundle": "To use the data collector.", + "webonyx/graphql-php": "To support GraphQL." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + } + ], + "description": "Build a fully-featured hypermedia or GraphQL API in minutes!", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "issues": "https://github.com/api-platform/core/issues", + "source": "https://github.com/api-platform/core/tree/v3.1.13" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/api-platform/core", + "type": "tidelift" + } + ], + "time": "2023-08-03T16:44:18+00:00" + }, { "name": "beberlei/assert", "version": "v3.3.2", @@ -4100,6 +4269,68 @@ }, "time": "2022-06-17T11:17:46+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.3.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "185d2c0ae50a3f0b628790170164d5f1c5b7c281" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/185d2c0ae50a3f0b628790170164d5f1c5b7c281", + "reference": "185d2c0ae50a3f0b628790170164d5f1c5b7c281", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "symfony/phpunit-bridge": "^4.4 || ^5.4 || ^6.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.3.1" + }, + "time": "2023-02-16T08:49:29+00:00" + }, { "name": "nelmio/security-bundle", "version": "v3.0.0", @@ -14128,6 +14359,135 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "webonyx/graphql-php", + "version": "v15.6.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "d950e2b542ee5c092c5d1375240b561282c06af1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d950e2b542ee5c092c5d1375240b561282c06af1", + "reference": "d950e2b542ee5c092c5d1375240b561282c06af1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "mll-lab/php-cs-fixer-config": "^5", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "1.10.26", + "phpstan/phpstan-phpunit": "1.3.13", + "phpstan/phpstan-strict-rules": "1.5.1", + "phpunit/phpunit": "^9.5 || ^10", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.9", + "rector/rector": "^0.17", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6", + "thecodingmachine/safe": "^1.3 || ^2" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "api", + "graphql" + ], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" + } + ], + "time": "2023-08-04T09:43:22+00:00" + }, + { + "name": "willdurand/negotiation", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Negotiation\\": "src/Negotiation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", + "keywords": [ + "accept", + "content", + "format", + "header", + "negotiation" + ], + "support": { + "issues": "https://github.com/willdurand/Negotiation/issues", + "source": "https://github.com/willdurand/Negotiation/tree/3.1.0" + }, + "time": "2022-01-30T20:08:53+00:00" } ], "packages-dev": [ diff --git a/config/bundles.php b/config/bundles.php index 6545338d..70b10fa5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -31,4 +31,6 @@ return [ Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Jbtronics\DompdfFontLoaderBundle\DompdfFontLoaderBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml new file mode 100644 index 00000000..eb4bc686 --- /dev/null +++ b/config/packages/api_platform.yaml @@ -0,0 +1,6 @@ +api_platform: + + title: 'Part-DB API' + description: 'API of Part-DB' + + version: '0.1.0' \ No newline at end of file diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 00000000..c7665081 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,10 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/config/routes/api_platform.yaml b/config/routes/api_platform.yaml new file mode 100644 index 00000000..38f11cba --- /dev/null +++ b/config/routes/api_platform.yaml @@ -0,0 +1,4 @@ +api_platform: + resource: . + type: api_platform + prefix: /api diff --git a/config/services.yaml b/config/services.yaml index 24e6a6ac..8e6ee8af 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -277,6 +277,15 @@ services: $search_limit: '%env(int:PROVIDER_OCTOPART_SEARCH_LIMIT)%' $onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%' + #################################################################################################################### + # API system + #################################################################################################################### + App\State\PartDBInfoProvider: + arguments: + $default_uri: '%partdb.default_uri%' + $global_locale: '%partdb.locale%' + $global_timezone: '%partdb.timezone%' + #################################################################################################################### # Symfony overrides #################################################################################################################### @@ -319,6 +328,12 @@ services: arguments: $check_for_updates: '%partdb.check_for_updates%' + App\Services\System\BannerHelper: + arguments: + $partdb_banner: '%partdb.banner%' + $project_dir: '%kernel.project_dir%' + + #################################################################################################################### # Monolog #################################################################################################################### diff --git a/src/ApiResource/.gitignore b/src/ApiResource/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/src/ApiResource/PartDBInfo.php b/src/ApiResource/PartDBInfo.php new file mode 100644 index 00000000..f6356fcd --- /dev/null +++ b/src/ApiResource/PartDBInfo.php @@ -0,0 +1,63 @@ +. + */ + +declare(strict_types=1); + + +namespace App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use App\State\PartDBInfoProvider; + +/** + * This class is used to provide various information about the system. + */ +#[ApiResource( + uriTemplate: '/info', + description: 'Basic information about Part-DB like version, title, etc.', + operations: [new Get()], + provider: PartDBInfoProvider::class +)] +class PartDBInfo +{ + public function __construct( + /** The installed Part-DB version */ + public readonly string $version, + /** The Git branch name of the Part-DB version (or null, if not installed via git) */ + public readonly string|null $git_branch, + /** The Git branch commit of the Part-DB version (or null, if not installed via git) */ + public readonly string|null $git_commit, + /** The name of this Part-DB instance */ + public readonly string $title, + /** The banner, shown on homepage (markdown encoded) */ + public readonly string $banner, + /** The configured default URI for Part-DB */ + public readonly string $default_uri, + /** The global timezone of this Part-DB */ + public readonly string $global_timezone, + /** The base currency of Part-DB, used as internal representation of monetary values */ + public readonly string $base_currency, + /** The configured default language of Part-DB */ + public readonly string $global_locale, + ) { + + } +} \ No newline at end of file diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index dc728465..f946d133 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -25,6 +25,7 @@ namespace App\Controller; use App\DataTables\LogDataTable; use App\Entity\Parts\Part; use App\Services\Misc\GitVersionInfo; +use App\Services\System\BannerHelper; use App\Services\System\UpdateAvailableManager; use Doctrine\ORM\EntityManagerInterface; use const DIRECTORY_SEPARATOR; @@ -38,29 +39,11 @@ use Symfony\Contracts\Cache\CacheInterface; class HomepageController extends AbstractController { - public function __construct(protected CacheInterface $cache, protected KernelInterface $kernel, protected DataTableFactory $dataTable) + public function __construct(private readonly DataTableFactory $dataTable, private readonly BannerHelper $bannerHelper) { } - public function getBanner(): string - { - $banner = $this->getParameter('partdb.banner'); - if (!is_string($banner)) { - throw new \RuntimeException('The parameter "partdb.banner" must be a string.'); - } - if (empty($banner)) { - $banner_path = $this->kernel->getProjectDir() - .DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md'; - $tmp = file_get_contents($banner_path); - if (false === $tmp) { - throw new \RuntimeException('The banner file could not be read.'); - } - $banner = $tmp; - } - - return $banner; - } #[Route(path: '/', name: 'homepage')] public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager, @@ -94,7 +77,7 @@ class HomepageController extends AbstractController } return $this->render('homepage.html.twig', [ - 'banner' => $this->getBanner(), + 'banner' => $this->bannerHelper->getBanner(), 'git_branch' => $versionInfo->getGitBranchName(), 'git_commit' => $versionInfo->getGitCommitHash(), 'show_first_steps' => $show_first_steps, diff --git a/src/Services/System/BannerHelper.php b/src/Services/System/BannerHelper.php new file mode 100644 index 00000000..c0dbf600 --- /dev/null +++ b/src/Services/System/BannerHelper.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +/** + * Helper service to retrieve the banner of this Part-DB installation + */ +class BannerHelper +{ + public function __construct(private readonly string $project_dir, private readonly string $partdb_banner) + { + + } + + /** + * Retrieves the banner from either the env variable or the banner.md file. + * @return string + */ + public function getBanner(): string + { + $banner = $this->partdb_banner; + if (!is_string($banner)) { + throw new \RuntimeException('The parameter "partdb.banner" must be a string.'); + } + if (empty($banner)) { + $banner_path = $this->project_dir + .DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md'; + + $tmp = file_get_contents($banner_path); + if (false === $tmp) { + throw new \RuntimeException('The banner file could not be read.'); + } + $banner = $tmp; + } + + return $banner; + } +} \ No newline at end of file diff --git a/src/State/PartDBInfoProvider.php b/src/State/PartDBInfoProvider.php new file mode 100644 index 00000000..ad785e84 --- /dev/null +++ b/src/State/PartDBInfoProvider.php @@ -0,0 +1,42 @@ +versionManager->getVersion()->toString(), + git_branch: $this->gitVersionInfo->getGitBranchName(), + git_commit: $this->gitVersionInfo->getGitCommitHash(), + title: $this->partdb_title, + banner: $this->bannerHelper->getBanner(), + default_uri: $this->default_uri, + global_timezone: $this->global_timezone, + base_currency: $this->base_currency, + global_locale: $this->global_locale, + ); + } +} diff --git a/symfony.lock b/symfony.lock index d47e131c..4948e590 100644 --- a/symfony.lock +++ b/symfony.lock @@ -5,6 +5,19 @@ "amphp/byte-stream": { "version": "v1.6.1" }, + "api-platform/core": { + "version": "3.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "0330386d716d3eecc52ee5ac66976e733eb8f961" + }, + "files": [ + "./config/routes/api_platform.yaml", + "./src/ApiResource/.gitignore" + ] + }, "beberlei/assert": { "version": "v3.2.6" }, @@ -220,6 +233,18 @@ "nbgrp/onelogin-saml-bundle": { "version": "v1.3.2" }, + "nelmio/cors-bundle": { + "version": "2.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "./config/packages/nelmio_cors.yaml" + ] + }, "nelmio/security-bundle": { "version": "2.4", "recipe": {