diff --git a/.env b/.env index dbc78cd..e7ee443 100644 --- a/.env +++ b/.env @@ -27,4 +27,9 @@ PHPMAILER_SMTP_PASSWORD= PHPMAILER_SMTP_DEFAULT_SENDER=sammy@codific.com PHPMAILER_SMTP_USE_AUTH=true PHPMAILER_SMTP_DEFAULT_ENCRYPTION=ssl -PHPMAILER_SMTP_AUTO_TLS=true \ No newline at end of file +PHPMAILER_SMTP_AUTO_TLS=true +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/.gitignore b/.gitignore index 233978c..f9c8caa 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ private /.php-cs-fixer.cache ###< friendsofphp/php-cs-fixer ### /assets/vendor -/tests/_coverage \ No newline at end of file +/tests/_coverage +.claude \ No newline at end of file diff --git a/composer.json b/composer.json index 3e0ba04..78411da 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "symfony/html-sanitizer": "*", "symfony/http-client": "*", "symfony/intl": "*", + "symfony/lock": "7.3.*", "symfony/mime": "*", "symfony/monolog-bundle": "*", "symfony/notifier": "*", @@ -55,6 +56,7 @@ "symfony/property-access": "*", "symfony/property-info": "*", "symfony/proxy-manager-bridge": "*", + "symfony/rate-limiter": "7.3.*", "symfony/runtime": "*", "symfony/security-bundle": "*", "symfony/serializer": "*", diff --git a/composer.lock b/composer.lock index f76a447..ae1a74d 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": "8dbe1d91c5fa58abd7a40dd3abd1aac7", + "content-hash": "bb90959f6542c8fb3e23612d06379ff9", "packages": [ { "name": "bacon/bacon-qr-code", - "version": "v3.0.4", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "3feed0e212b8412cc5d2612706744789b0615824" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", - "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -57,9 +57,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2026-03-16T01:01:30+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "composer/package-versions-deprecated", @@ -1801,16 +1801,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.3", + "version": "v7.0.5", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -1818,6 +1818,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1857,10 +1858,10 @@ "php" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" }, - "time": "2026-02-25T22:16:40+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "friendsofphp/proxy-manager-lts", @@ -1946,16 +1947,16 @@ }, { "name": "google/apiclient", - "version": "v2.19.0", + "version": "v2.19.2", "source": { "type": "git", "url": "https://github.com/googleapis/google-api-php-client.git", - "reference": "b18fa8aed7b2b2dd4bcce74e2c7d267e16007ea9" + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/b18fa8aed7b2b2dd4bcce74e2c7d267e16007ea9", - "reference": "b18fa8aed7b2b2dd4bcce74e2c7d267e16007ea9", + "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/703ba9acfaf4ba71306108207feafb6d1d137eb0", + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0", "shasum": "" }, "require": { @@ -1966,11 +1967,11 @@ "guzzlehttp/psr7": "^2.6", "monolog/monolog": "^2.9||^3.0", "php": "^8.1", - "phpseclib/phpseclib": "^3.0.36" + "phpseclib/phpseclib": "^3.0.50" }, "require-dev": { "cache/filesystem-adapter": "^1.1", - "composer/composer": "^1.10.23", + "composer/composer": "^2.9", "phpcompatibility/php-compatibility": "^9.2", "phpspec/prophecy-phpunit": "^2.1", "phpunit/phpunit": "^9.6", @@ -1983,6 +1984,9 @@ }, "type": "library", "extra": { + "component": { + "entry": "src/Client.php" + }, "branch-alias": { "dev-main": "2.x-dev" } @@ -2009,22 +2013,22 @@ ], "support": { "issues": "https://github.com/googleapis/google-api-php-client/issues", - "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.0" + "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.2" }, - "time": "2026-01-09T19:59:47+00:00" + "time": "2026-03-30T18:54:44+00:00" }, { "name": "google/apiclient-services", - "version": "v0.435.0", + "version": "v0.437.0", "source": { "type": "git", "url": "https://github.com/googleapis/google-api-php-client-services.git", - "reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9" + "reference": "1a7dd2368adb7ee4d5d07f828ae2dd7ecc43247c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/1edf0f5f2876945c372366107b4d7a387b17a6b9", - "reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/1a7dd2368adb7ee4d5d07f828ae2dd7ecc43247c", + "reference": "1a7dd2368adb7ee4d5d07f828ae2dd7ecc43247c", "shasum": "" }, "require": { @@ -2053,9 +2057,9 @@ ], "support": { "issues": "https://github.com/googleapis/google-api-php-client-services/issues", - "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.435.0" + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.437.0" }, - "time": "2026-03-01T01:14:26+00:00" + "time": "2026-04-13T01:26:28+00:00" }, { "name": "google/auth", @@ -2510,34 +2514,34 @@ }, { "name": "lcobucci/clock", - "version": "3.5.0", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/lcobucci/clock.git", - "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058" + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/clock/zipball/a3139d9e97d47826f27e6a17bb63f13621f86058", - "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643", "shasum": "" }, "require": { - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "php": "~8.4.0 || ~8.5.0", "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "infection/infection": "^0.31", - "lcobucci/coding-standard": "^11.2.0", + "infection/infection": "^0.32", + "lcobucci/coding-standard": "^12.0", "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^2.0.0", - "phpstan/phpstan-deprecation-rules": "^2.0.0", - "phpstan/phpstan-phpunit": "^2.0.0", - "phpstan/phpstan-strict-rules": "^2.0.0", - "phpunit/phpunit": "^12.0.0" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.0" }, "type": "library", "autoload": { @@ -2558,7 +2562,7 @@ "description": "Yet another clock abstraction", "support": { "issues": "https://github.com/lcobucci/clock/issues", - "source": "https://github.com/lcobucci/clock/tree/3.5.0" + "source": "https://github.com/lcobucci/clock/tree/3.6.0" }, "funding": [ { @@ -2570,7 +2574,7 @@ "type": "patreon" } ], - "time": "2025-10-27T09:03:17+00:00" + "time": "2026-04-13T21:30:16+00:00" }, { "name": "lcobucci/jwt", @@ -3018,16 +3022,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", "shasum": "" }, "require": { @@ -3084,7 +3088,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" }, "funding": [ { @@ -3092,7 +3096,7 @@ "type": "github" } ], - "time": "2025-12-10T09:58:31+00:00" + "time": "2026-04-11T18:38:28+00:00" }, { "name": "markbaker/complex", @@ -4046,16 +4050,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "5.5.0", + "version": "5.6.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba" + "reference": "9b90dee03deb0d28761479c4a3a06fba5f7e012e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9b90dee03deb0d28761479c4a3a06fba5f7e012e", + "reference": "9b90dee03deb0d28761479c4a3a06fba5f7e012e", "shasum": "" }, "require": { @@ -4149,22 +4153,22 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.6.0" }, - "time": "2026-03-01T00:58:56+00:00" + "time": "2026-04-10T03:00:03+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.50", + "version": "3.0.51", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", - "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", + "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", "shasum": "" }, "require": { @@ -4245,7 +4249,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" }, "funding": [ { @@ -4261,7 +4265,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T02:57:58+00:00" + "time": "2026-04-10T01:33:53+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -7634,6 +7638,88 @@ ], "time": "2026-01-12T12:03:18+00:00" }, + { + "name": "symfony/lock", + "version": "v7.3.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "af564086b6529d1c58d652f5aad8d8851a71f01a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/af564086b6529d1c58d652f5aad8d8851a71f01a", + "reference": "af564086b6529d1c58d652f5aad8d8851a71f01a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v7.3.11" + }, + "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-01-27T16:12:03+00:00" + }, { "name": "symfony/mime", "version": "v7.3.11", @@ -7806,16 +7892,16 @@ }, { "name": "symfony/monolog-bundle", - "version": "v4.0.1", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66" + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66", - "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598", + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598", "shasum": "" }, "require": { @@ -7861,7 +7947,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v4.0.1" + "source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2" }, "funding": [ { @@ -7881,7 +7967,7 @@ "type": "tidelift" } ], - "time": "2025-12-08T08:00:13+00:00" + "time": "2026-04-02T18:27:21+00:00" }, { "name": "symfony/notifier", @@ -8116,16 +8202,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -8174,7 +8260,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" }, "funding": [ { @@ -8194,20 +8280,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" + "reference": "3510b63d07376b04e57e27e82607d468bb134f78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", - "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78", "shasum": "" }, "require": { @@ -8262,7 +8348,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.36.0" }, "funding": [ { @@ -8282,11 +8368,11 @@ "type": "tidelift" } ], - "time": "2025-06-20T22:24:30+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -8349,7 +8435,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0" }, "funding": [ { @@ -8373,7 +8459,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8434,7 +8520,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" }, "funding": [ { @@ -8458,16 +8544,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -8519,7 +8605,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -8539,20 +8625,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -8603,7 +8689,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0" }, "funding": [ { @@ -8623,20 +8709,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -8683,7 +8769,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -8703,20 +8789,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -8763,7 +8849,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0" }, "funding": [ { @@ -8783,7 +8869,7 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/process", @@ -9091,6 +9177,80 @@ ], "time": "2025-11-02T18:11:54+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v7.3.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "db2a62a57614a905a41b980aafa92dacae8e1ba3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/db2a62a57614a905a41b980aafa92dacae8e1ba3", + "reference": "db2a62a57614a905a41b980aafa92dacae8e1ba3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.3.11" + }, + "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-01-27T16:12:03+00:00" + }, { "name": "symfony/routing", "version": "v7.3.10", @@ -11164,16 +11324,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { @@ -11220,9 +11380,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-04-11T10:33:05+00:00" } ], "packages-dev": [ @@ -11500,6 +11660,75 @@ }, "time": "2025-02-04T14:37:36+00:00" }, + { + "name": "ergebnis/agent-detector", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", + "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-04-10T13:45:13+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -11610,22 +11839,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.94.2", + "version": "v3.95.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", - "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.1.1", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -11650,18 +11880,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7.1", - "infection/infection": "^0.32.3", - "justinrainbow/json-schema": "^6.6.4", + "facile-it/paraunit": "^1.3.1 || ^2.8.0", + "infection/infection": "^0.32.6", + "justinrainbow/json-schema": "^6.8.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.7", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", - "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", + "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.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" + "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.8" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -11702,7 +11932,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.94.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" }, "funding": [ { @@ -11710,7 +11940,7 @@ "type": "github" } ], - "time": "2026-02-20T16:13:53+00:00" + "time": "2026-04-12T17:00:09+00:00" }, { "name": "jetbrains/phpstorm-attributes", @@ -12106,11 +12336,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.50", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", "shasum": "" }, "require": { @@ -12155,7 +12385,7 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-04-17T13:10:32+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -12209,16 +12439,16 @@ }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.20", + "version": "2.0.21", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d" + "reference": "81dac0ee4363c2359128aec844df31efb215dddc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/72f4f7a02d6c98d9101e8616e0488bc0a785196d", - "reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/81dac0ee4363c2359128aec844df31efb215dddc", + "reference": "81dac0ee4363c2359128aec844df31efb215dddc", "shasum": "" }, "require": { @@ -12279,9 +12509,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.20" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.21" }, - "time": "2026-03-13T13:44:51+00:00" + "time": "2026-04-17T13:00:39+00:00" }, { "name": "phpstan/phpstan-strict-rules", @@ -13366,21 +13596,21 @@ }, { "name": "rector/rector", - "version": "2.3.9", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4" + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4", - "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.40" + "phpstan/phpstan": "^2.1.48" }, "conflict": { "rector/rector-doctrine": "*", @@ -13414,7 +13644,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.9" + "source": "https://github.com/rectorphp/rector/tree/2.4.2" }, "funding": [ { @@ -13422,7 +13652,7 @@ "type": "github" } ], - "time": "2026-03-16T09:43:55+00:00" + "time": "2026-04-16T13:07:34+00:00" }, { "name": "sebastian/cli-parser", @@ -14437,16 +14667,16 @@ }, { "name": "spaze/phpstan-disallowed-calls", - "version": "v4.9.0", + "version": "v4.10.0", "source": { "type": "git", "url": "https://github.com/spaze/phpstan-disallowed-calls.git", - "reference": "e80372a55bdad13d22a62fdf2116a67a5438c34b" + "reference": "501b212b15b8b4c5e2aaf44ee01bb024f85956d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spaze/phpstan-disallowed-calls/zipball/e80372a55bdad13d22a62fdf2116a67a5438c34b", - "reference": "e80372a55bdad13d22a62fdf2116a67a5438c34b", + "url": "https://api.github.com/repos/spaze/phpstan-disallowed-calls/zipball/501b212b15b8b4c5e2aaf44ee01bb024f85956d3", + "reference": "501b212b15b8b4c5e2aaf44ee01bb024f85956d3", "shasum": "" }, "require": { @@ -14486,13 +14716,13 @@ "homepage": "https://www.michalspacek.cz" } ], - "description": "PHPStan rules to detect disallowed method & function calls, constant, namespace, attribute & superglobal usages, with powerful rules to re-allow a call or a usage in places where it should be allowed.", + "description": "PHPStan rules to detect disallowed method & function calls, constant, namespace, attribute, property & superglobal usages, with powerful rules to re-allow a call or a usage in places where it should be allowed.", "keywords": [ "static analysis" ], "support": { "issues": "https://github.com/spaze/phpstan-disallowed-calls/issues", - "source": "https://github.com/spaze/phpstan-disallowed-calls/tree/v4.9.0" + "source": "https://github.com/spaze/phpstan-disallowed-calls/tree/v4.10.0" }, "funding": [ { @@ -14500,7 +14730,7 @@ "type": "github" } ], - "time": "2026-03-13T22:27:50+00:00" + "time": "2026-04-04T01:47:30+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -14918,16 +15148,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "f95d88d54e34b13ee220a81133261a3c8a6a287a" + "reference": "723ea96810135e776110bddb25aeb32b462134c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f95d88d54e34b13ee220a81133261a3c8a6a287a", - "reference": "f95d88d54e34b13ee220a81133261a3c8a6a287a", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/723ea96810135e776110bddb25aeb32b462134c8", + "reference": "723ea96810135e776110bddb25aeb32b462134c8", "shasum": "" }, "require": { @@ -14979,7 +15209,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v8.0.7" + "source": "https://github.com/symfony/phpunit-bridge/tree/v8.0.8" }, "funding": [ { @@ -14999,11 +15229,11 @@ "type": "tidelift" } ], - "time": "2026-03-04T13:55:34+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -15059,7 +15289,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" }, "funding": [ { @@ -15223,7 +15453,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -15238,6 +15468,6 @@ "ext-simplexml": "*", "ext-zip": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.9.0" + "platform-dev": [], + "plugin-api-version": "2.2.0" } diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 4b990e6..368496c 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -26,3 +26,17 @@ framework: validation: { enabled: true } + rate_limiter: + login: + policy: sliding_window + limit: 5 + interval: '1 minute' + mfa: + policy: sliding_window + limit: 5 + interval: '1 minute' + password_reset: + policy: sliding_window + limit: 5 + interval: '1 hour' + diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml index fb88bdb..edc3c78 100644 --- a/config/packages/test/framework.yaml +++ b/config/packages/test/framework.yaml @@ -4,4 +4,24 @@ framework: enabled : false test: true session: - storage_factory_id: session.storage.factory.mock_file \ No newline at end of file + storage_factory_id: session.storage.factory.mock_file + cache: + pools: + cache.rate_limiter: + adapter: cache.adapter.array + rate_limiter: + login: + policy: sliding_window + limit: 5 + interval: '1 minute' + cache_pool: cache.rate_limiter + mfa: + policy: sliding_window + limit: 5 + interval: '1 minute' + cache_pool: cache.rate_limiter + password_reset: + policy: sliding_window + limit: 5 + interval: '1 hour' + cache_pool: cache.rate_limiter \ No newline at end of file diff --git a/config/services_test.yaml b/config/services_test.yaml index 540c541..1c7866d 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -6,4 +6,8 @@ services: App\Repository\FeedbackRepository : public : true autowire : true + autoconfigure : true + App\Service\MailingService : + class : App\Tests\_support\StubMailingService + autowire : true autoconfigure : true \ No newline at end of file diff --git a/src/Controller/Application/DocumentationController.php b/src/Controller/Application/DocumentationController.php index a1f080c..be7e806 100644 --- a/src/Controller/Application/DocumentationController.php +++ b/src/Controller/Application/DocumentationController.php @@ -6,24 +6,15 @@ use App\DTO\DocumentationDTO; use App\Entity\AssessmentStream; -use App\Entity\Project; use App\Enum\Custom\RemarkType; use App\Exception\InsufficientAttachmentRemarkParameters; use App\Exception\InsufficientPermissionsToSaveRemarkException; use App\Form\Application\DocumentationType; use App\Service\RemarkService; -use App\Service\SanitizerService; -use Symfony\Component\Finder\Exception\DirectoryNotFoundException; -use Symfony\Component\Finder\Finder; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/documentation', name: 'documentation_')] @@ -124,75 +115,4 @@ public function documentationPartial( ]); } - #[Route('/show/{id}/{file}', name: 'show', requirements: ['file' => '.*'])] - #[IsGranted('PROJECT_ACCESS', 'project')] - public function show( - KernelInterface $kernel, - Project $project, - string $file, - ): Response { - $filePath = $this->findFilePath($project, $file, $kernel); - - $response = new Response(status: Response::HTTP_FORBIDDEN); - if ($filePath !== null) { - $response = $this->file($filePath, disposition: ResponseHeaderBag::DISPOSITION_INLINE); - } - - return $response; - } - - #[Route('/preview/{id}/{file}', name: 'preview', requirements: ['file' => '.*'])] - #[IsGranted('PROJECT_ACCESS', 'project')] - public function preview( - KernelInterface $kernel, - Project $project, - string $file, - ): Response { - $filePath = $this->findFilePath($project, $file, $kernel); - - $response = new Response(status: Response::HTTP_FORBIDDEN); - if ($filePath !== null) { - // Get MIME Type - $fileInfo = finfo_open(FILEINFO_MIME_TYPE); - $mimeType = finfo_file($fileInfo, $filePath); - finfo_close($fileInfo); - - $response = $this->render('/application/documentation/preview.html.twig', [ - 'mime' => $mimeType, - 'filePath' => $this->generateUrl( - 'app_documentation_show', - [ - 'id' => $project->getId(), - 'file' => $file, - ] - ), - 'text' => file_get_contents($filePath, length: 200), - ]); - } - - return $response; - } - - private function findFilePath(Project $project, string $file, KernelInterface $kernel): ?string - { - $finder = new Finder(); - - try { - $finder->files()->in($kernel->getProjectDir()."/private/projects/{$project->getId()}"); - $finder->path($file); - $iterator = $finder->getIterator(); - $iterator->rewind(); - $foundSPLInfo = $iterator->current(); - } catch (DirectoryNotFoundException $e) { - $foundSPLInfo = null; - } - - $result = null; - if ($foundSPLInfo !== null) { - $result = $foundSPLInfo->getRealPath(); - } - - return $result; - } - } diff --git a/src/Controller/Application/LoginController.php b/src/Controller/Application/LoginController.php index 57d1812..a123253 100644 --- a/src/Controller/Application/LoginController.php +++ b/src/Controller/Application/LoginController.php @@ -135,8 +135,8 @@ public function resetPasswordRequest( $user = $userRepository->findOneBy(['email' => $email, 'deletedAt' => null]); if ($user !== null && !in_array(Role::ADMINISTRATOR->string(), $user->getRoles(), true)) { $status = $passwordResetService->reset($user); - if ($status) { - $mailingService->add(\App\Enum\MailTemplateType::USER_PASSWORD_RESET, $user); + if ($status && !$mailingService->sendImmediate(\App\Enum\MailTemplateType::USER_PASSWORD_RESET, $user)) { + $this->logger->error('Password reset email send failed', ['userId' => $user->getId()]); } } diff --git a/src/Controller/Application/ReportingController.php b/src/Controller/Application/ReportingController.php index f0c412d..fe78e81 100644 --- a/src/Controller/Application/ReportingController.php +++ b/src/Controller/Application/ReportingController.php @@ -14,6 +14,7 @@ use App\Service\ScoreService; use Doctrine\ORM\NonUniqueResultException; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -109,6 +110,7 @@ public function exportSamm(AssessmentExporterService $assessmentExporterService, return $this->safeRedirect($request, 'app_index'); } $project = $projectService->getCurrentProject(); + $this->denyAccessUnlessGranted('PROJECT_ACCESS', $project); $assessment = $project->getAssessment(); $filePath = $assessmentExporterService->getToolbox($assessment, $project); @@ -124,6 +126,7 @@ public function exportSammJson(SammExportService $sammExportService, ProjectServ } $project = $projectService->getCurrentProject(); + $this->denyAccessUnlessGranted('PROJECT_ACCESS', $project); $assessment = $project->getAssessment(); try { @@ -136,12 +139,19 @@ public function exportSammJson(SammExportService $sammExportService, ProjectServ $frameworkName = $project->getMetamodel()?->getName() ?? 'SAMM'; $filename = sprintf('%s_%s_%s.samm.json', $project->getName(), $frameworkName, uniqid()); + $asciiFilename = preg_replace('/[^A-Za-z0-9._-]/', '_', $filename); - return new Response($json, Response::HTTP_OK, [ + $response = new Response($json, Response::HTTP_OK, [ 'Content-Type' => 'application/json', - 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), 'Content-Length' => strlen($json), ]); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + $filename, + $asciiFilename + )); + + return $response; } #[Route('/overviewPartial/{id}', name: 'overviewPartial', requirements: ['id' => "\d+"], methods: ['GET'])] diff --git a/src/Controller/Application/StageController.php b/src/Controller/Application/StageController.php index 17deb2e..95de124 100644 --- a/src/Controller/Application/StageController.php +++ b/src/Controller/Application/StageController.php @@ -18,7 +18,6 @@ use App\Service\ProjectService; use App\Service\StageService; use App\Service\UserService; -use App\Util\RepositoryParameters; use Doctrine\ORM\NonUniqueResultException; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\JsonResponse; @@ -108,10 +107,6 @@ public function ajaxindexForAssignment( ): JsonResponse { $currentUser = $this->getUser(); - $repositoryParameters = new RepositoryParameters(); - $repositoryParameters->setFilter($request->query->get('term', '')); - $repositoryParameters->setOrderBy([['_user.id', 'ASC']]); - $neededRole = match ($assessmentStream->getStatus()) { \App\Enum\AssessmentStatus::NEW, \App\Enum\AssessmentStatus::IN_EVALUATION => Role::EVALUATOR->string(), \App\Enum\AssessmentStatus::IN_VALIDATION => Role::VALIDATOR->string(), diff --git a/src/Controller/Application/UserController.php b/src/Controller/Application/UserController.php index 7cc3e29..c602c49 100644 --- a/src/Controller/Application/UserController.php +++ b/src/Controller/Application/UserController.php @@ -7,6 +7,7 @@ use App\DTO\NewUserDTO; use App\Entity\Group; use App\Entity\User; +use App\Enum\MailTemplateType; use App\Enum\Role; use App\Event\Admin\Create\UserCreatedEvent; use App\Exception\BadGroupForUserSuppliedException; @@ -178,6 +179,24 @@ public function delete(Request $request, User $chosenUser, UserService $userServ return $this->safeRedirect($request, 'app_user_index'); } + #[Route('/resend-welcome/{id}', name: 'resend_welcome', requirements: ['id' => "\d+"], methods: ['POST'])] + #[IsGranted('USER_EDIT', 'chosenUser')] + public function resendWelcome(Request $request, User $chosenUser, UserService $userService): Response + { + if (!$this->isCsrfTokenValid((string) $this->getUser()?->getId(), $request->request->get('_token'))) { + return $this->safeRedirect($request, 'app_user_index'); + } + + $sent = $userService->welcomeUser($chosenUser, MailTemplateType::USER_WELCOME, flush: true); + if ($sent) { + $this->addFlash('success', $this->translator->trans('application.user.resend_welcome_success', ['user' => trim("$chosenUser")], 'application')); + } else { + $this->addFlash('error', $this->translator->trans('application.user.resend_welcome_error', [], 'application')); + } + + return $this->safeRedirect($request, 'app_user_index'); + } + #[Route('/editUser/{id}', name: 'edituser', requirements: ['id' => "\d+"], methods: ['POST'])] #[IsGranted('USER_EDIT', 'chosenUser')] public function editUser(Request $request, User $chosenUser, UserService $userService): RedirectResponse diff --git a/src/Entity/Abstraction/FailedLogin.php b/src/Entity/Abstraction/FailedLogin.php deleted file mode 100644 index 2cbd550..0000000 --- a/src/Entity/Abstraction/FailedLogin.php +++ /dev/null @@ -1,60 +0,0 @@ - - * - * @see http://codific.com - */ - -declare(strict_types=1); - -namespace App\Entity\Abstraction; - -use Doctrine\DBAL\Types\Types; -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Table(name: '`failedlogin`')] -#[ORM\Entity(repositoryClass: "App\Repository\Abstraction\FailedLoginRepository")] -#[ORM\HasLifecycleCallbacks] -class FailedLogin extends AbstractEntity -{ - #[ORM\Column(name: 'username', type: Types::STRING, nullable: true)] - private ?string $username = ''; - - #[ORM\Column(name: 'ip', type: Types::STRING, nullable: true)] - private string $ip = ''; - - public function __toString(): string - { - return ''; - } - - public function setUsername(?string $username): FailedLogin - { - $this->username = $username; - - return $this; - } - - public function getUsername(): ?string - { - return $this->username; - } - - public function setIp(string $ip): FailedLogin - { - $this->ip = $ip; - - return $this; - } - - public function getIp(): string - { - return $this->ip; - } -} diff --git a/src/Entity/Abstraction/PasswordResetInterface.php b/src/Entity/Abstraction/PasswordResetInterface.php index 81d58c3..912913d 100644 --- a/src/Entity/Abstraction/PasswordResetInterface.php +++ b/src/Entity/Abstraction/PasswordResetInterface.php @@ -30,4 +30,15 @@ public function getPasswordResetHash(): ?string; public function setPasswordResetHashExpiration(?\DateTime $passwordResetHashExpiration); public function getPasswordResetHashExpiration(): ?\DateTime; + + /** + * Transient, in-memory only. Holds the plaintext token during the request + * in which the reset was generated so the mailer can embed it in the link. + * The persisted column stores a SHA-256 hash of this value. + * + * @return mixed + */ + public function setPlaintextPasswordResetHash(?string $plaintext); + + public function getPlaintextPasswordResetHash(): ?string; } diff --git a/src/Entity/User.php b/src/Entity/User.php index 3833083..a8c3677 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -89,10 +89,6 @@ class User extends AbstractEntity implements BackupCodeInterface, PasswordResetI #[ORM\Column(name: "`salt`", type: Types::STRING, nullable: true)] protected ?string $salt = ""; - #[Ignore] - #[ORM\Column(name: "`failed_logins`", type: Types::INTEGER)] - protected int $failedLogins = 0; - #[Ignore] #[ORM\Column(name: "`secret_key`", type: Types::STRING, nullable: true)] protected ?string $secretKey = ""; @@ -116,6 +112,9 @@ class User extends AbstractEntity implements BackupCodeInterface, PasswordResetI #[ORM\Column(name: "`password_reset_hash_expiration`", type: Types::DATETIME_MUTABLE, nullable: true)] protected ?\DateTime $passwordResetHashExpiration = null; + #[Ignore] + protected ?string $plaintextPasswordResetHash = null; + #[ORM\OneToMany(mappedBy: "assignedTo", targetEntity: Stage::class, cascade: ["persist"], fetch: "LAZY", orphanRemoval: false)] #[ORM\OrderBy(["id" => "ASC"])] #[MaxDepth(1)] @@ -342,18 +341,6 @@ public function getSalt(): ?string return $this->salt; } - public function setFailedLogins(int $failedLogins): self - { - $this->failedLogins = $failedLogins; - - return $this; - } - - public function getFailedLogins(): int - { - return $this->failedLogins; - } - /** * Erase credentials * @inheritDoc @@ -460,6 +447,19 @@ public function getPasswordResetHashExpiration(): ?\DateTime return $this->passwordResetHashExpiration; } + public function setPlaintextPasswordResetHash(?string $plaintext): self + { + $this->plaintextPasswordResetHash = $plaintext; + + return $this; + } + + #[Ignore] + public function getPlaintextPasswordResetHash(): ?string + { + return $this->plaintextPasswordResetHash; + } + /** * This method is a copy constructor that will return a copy object (except for the id field) * Note that this method will not save the object @@ -549,7 +549,6 @@ public function getReadOnlyFields(): array return [ "password", "salt", - "failedLogins", "secretKey", "trustedVersion", "backupCodes", diff --git a/src/EventSubscriber/LoginRateLimitSubscriber.php b/src/EventSubscriber/LoginRateLimitSubscriber.php new file mode 100644 index 0000000..33ec28a --- /dev/null +++ b/src/EventSubscriber/LoginRateLimitSubscriber.php @@ -0,0 +1,58 @@ + ['onKernelRequest', 20], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + + if (!$event->isMainRequest()) { + return; + } + + $route = $request->attributes->get('_route'); + if ($route !== 'app_login_login' || !$request->isMethod('POST')) { + return; + } + + $limiter = $this->loginLimiter->create($request->getClientIp()); + $limit = $limiter->consume(1); + + if (!$limit->isAccepted()) { + $retryAfter = $limit->getRetryAfter()->getTimestamp() - time(); + $event->setResponse(new Response( + $this->twig->render('application/auth/rate_limited.html.twig', [ + 'retry_after' => $retryAfter, + ]), + Response::HTTP_TOO_MANY_REQUESTS, + ['Retry-After' => $retryAfter] + )); + } + } +} diff --git a/src/EventSubscriber/MfaRateLimitSubscriber.php b/src/EventSubscriber/MfaRateLimitSubscriber.php new file mode 100644 index 0000000..6a651df --- /dev/null +++ b/src/EventSubscriber/MfaRateLimitSubscriber.php @@ -0,0 +1,44 @@ + ['onMfaAttempt', 20], + TwoFactorAuthenticationEvents::FAILURE => 'onMfaFailure', + ]; + } + + public function onMfaAttempt(TwoFactorAuthenticationEvent $event): void + { + $request = $event->getRequest(); + $limiter = $this->mfaLimiter->create($request->getClientIp()); + if ($limiter->consume(0)->isAccepted() === false) { + throw new TooManyLoginAttemptsAuthenticationException(); + } + } + + public function onMfaFailure(TwoFactorAuthenticationEvent $event): void + { + $request = $event->getRequest(); + $limiter = $this->mfaLimiter->create($request->getClientIp()); + $limiter->consume(1); + } +} diff --git a/src/EventSubscriber/PasswordResetRateLimitSubscriber.php b/src/EventSubscriber/PasswordResetRateLimitSubscriber.php new file mode 100644 index 0000000..457dfc0 --- /dev/null +++ b/src/EventSubscriber/PasswordResetRateLimitSubscriber.php @@ -0,0 +1,56 @@ + ['onKernelRequest', 20], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + + if (!$event->isMainRequest()) { + return; + } + + $route = $request->attributes->get('_route'); + if ($route !== 'app_login_reset_password_request' || !$request->isMethod('POST')) { + return; + } + + $limiter = $this->passwordResetLimiter->create($request->getClientIp()); + $limit = $limiter->consume(1); + + if (!$limit->isAccepted()) { + $retryAfter = $limit->getRetryAfter()->getTimestamp() - time(); + $event->setResponse(new Response( + $this->twig->render('application/auth/rate_limited.html.twig', [ + 'retry_after' => $retryAfter, + ]), + Response::HTTP_TOO_MANY_REQUESTS, + ['Retry-After' => $retryAfter] + )); + } + } +} diff --git a/src/EventSubscriber/UserSubscriber.php b/src/EventSubscriber/UserSubscriber.php index 6d10d29..7ae47ee 100644 --- a/src/EventSubscriber/UserSubscriber.php +++ b/src/EventSubscriber/UserSubscriber.php @@ -103,7 +103,6 @@ public function onUserAuthenticated(UserAuthenticatedEvent $event) $user->setLastLogin(new \DateTime('now')); $user->setPasswordResetHash(null); $user->setPasswordResetHashExpiration(null); - $user->setFailedLogins(0); $this->entityManager->flush(); $request = $event->getRequest(); diff --git a/src/Exception/FileExtensionNotAllowedException.php b/src/Exception/FileExtensionNotAllowedException.php new file mode 100644 index 0000000..2c6959d --- /dev/null +++ b/src/Exception/FileExtensionNotAllowedException.php @@ -0,0 +1,9 @@ +addSql('DROP TABLE IF EXISTS `failedlogin`'); + $this->addSql('ALTER TABLE `user` DROP COLUMN `failed_logins`'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE TABLE `failedlogin` (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(255) DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE `user` ADD `failed_logins` INT NOT NULL DEFAULT 0'); + } +} diff --git a/src/Repository/Abstraction/AbstractRepository.php b/src/Repository/Abstraction/AbstractRepository.php index beb12cf..e43220e 100644 --- a/src/Repository/Abstraction/AbstractRepository.php +++ b/src/Repository/Abstraction/AbstractRepository.php @@ -4,17 +4,12 @@ namespace App\Repository\Abstraction; -use App\Entity\Abstraction\AbstractEntity; use App\Interface\EntityInterface; -use App\Pagination\Paginator; -use App\Util\AbstractRepositoryParameters; -use App\Util\RepositoryParameters; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Query\Parameter; -use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; abstract class AbstractRepository extends ServiceEntityRepository @@ -126,92 +121,6 @@ public function restore(EntityInterface $model) $this->getEntityManager()->flush(); } - /** - * Get paginated list. - */ - public function getPaginatedList(QueryBuilder $queryBuilder, RepositoryParameters $repositoryParameters): Paginator - { - /** @var AbstractEntity $entityName */ - $entityName = $this->getEntityName(); - - $page = $repositoryParameters->getPage() ?? self::$defaultPage; - - $entityInstance = new $entityName(); - $filterFields = $entityInstance->getFilterFields(); - - $queryBuilder = $this->generateSearchQuery($queryBuilder, $repositoryParameters, $filterFields); - - if (!$this->getEntityManager()->getFilters()->isEnabled('deleted_entity')) { - $this->getEntityManager()->getFilters()->enable('deleted_entity'); - } - $filter = $this->getEntityManager()->getFilters()->getFilter('deleted_entity'); - $filter->setParameter('deleted', $repositoryParameters->getShowDeleted()); - - if (count($repositoryParameters->getOrderBy()) == 0) { - $repositoryParameters->setOrderBy([[AbstractRepositoryParameters::$defaultOrderColumn.' '.AbstractRepositoryParameters::$defaultOrderDirection]]); - } - foreach ($repositoryParameters->getOrderBy() as $order) { - $orderColumn = $order[0] ?? ''; - $orderDirection = $order[1] ?? ''; - if (property_exists($entityName, $orderColumn)) { - $queryBuilder->addOrderBy($this->getClassMetadata()->newInstance()->getAliasName().'.'.$orderColumn, $orderDirection); - } else { - if (str_contains($orderColumn, '.')) { - $queryBuilder->addOrderBy($orderColumn, $orderDirection); - } - } - } - - return (new Paginator($queryBuilder, $repositoryParameters->getPageSize()))->paginate($page); - } - - /** - * Generates where clause for the advanced search. - * - * @param QueryBuilder $queryBuilder Doctrine Query builder - * @param array $fields array of columns that should be matched against the $escapeSearch string - */ - protected function generateSearchQuery(QueryBuilder $queryBuilder, RepositoryParameters $repositoryParameters, array $fields = []): QueryBuilder - { - $search = $repositoryParameters->getFilter(); - if ($search != null) { - $searchWhere = ''; - $searchArray = array_filter(explode('+', $search)); - foreach ($searchArray as $i => $searchString) { - $subQuery = ''; - foreach ($fields as $searchField) { - $subQuery .= "$searchField LIKE :searchString$i OR "; - } - foreach ($repositoryParameters->getAdditionalSearchFields() as $searchField) { - $subQuery .= "$searchField LIKE :searchString$i OR "; - } - $searchWhere .= $subQuery; - $queryBuilder->setParameter("searchString$i", '%'.trim($searchString).'%'); - } - $andWhere = trim($searchWhere, ' OR '); - $queryBuilder->andWhere('('.$andWhere.')'); - } - - return $queryBuilder; - } - - /** - * Returns a collection of the supplied entity class - * filtered and ordered by the supplied repository parameters. - */ - public function getSearchResult(RepositoryParameters $repositoryParameters): ?array - { - /** @var EntityInterface $reflection */ - $reflection = $this->getClassMetadata()->newInstance(); - $queryBuilder = $this->createQueryBuilder($reflection->getAliasName()); - $queryBuilder = $this->generateSearchQuery($queryBuilder, $repositoryParameters, $reflection->getFilterFields()); - foreach ($repositoryParameters->getOrderBy() as $order) { - $queryBuilder->addOrderBy($order[0], $order[1]); - } - - return $queryBuilder->getQuery()->getResult(); - } - public function findAllIn(array $inCollection): array { $reflection = $this->getClassMetadata()->newInstance(); diff --git a/src/Repository/Abstraction/FailedLoginRepository.php b/src/Repository/Abstraction/FailedLoginRepository.php deleted file mode 100644 index 69ae001..0000000 --- a/src/Repository/Abstraction/FailedLoginRepository.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * @see http://codific.com - */ - -declare(strict_types=1); - -namespace App\Repository\Abstraction; - -use App\Entity\Abstraction\FailedLogin; -use Doctrine\Persistence\ManagerRegistry; - -/** - * Class FailedLoginRepository. - * - * @method FailedLogin|null find($id, $lockMode = null, $lockVersion = null) - * @method FailedLogin|null findOneBy(array $criteria, array $orderBy = null) - * @method FailedLogin[] findAll() - * @method FailedLogin[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class FailedLoginRepository extends AbstractRepository -{ - /** - * FailedLoginRepository constructor. - */ - public function __construct(ManagerRegistry $registry, string $entityClassName = FailedLogin::class) - { - parent::__construct($registry, $entityClassName); - } -} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index e0b908b..df4c6a9 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -175,7 +175,8 @@ public function deepRestore(EntityInterface $project) $assessmentRepository = $this->getEntityManager()->getRepository(Assessment::class); $groupProjectRepository = $this->getEntityManager()->getRepository(GroupProject::class); - $assessment = $assessmentRepository->findOneBy(['project' => $project]); + /** @var Project $project */ + $assessment = $project->getAssessment(); $groupProject = $groupProjectRepository->findOneBy(['project' => $project]); $assessmentRepository->deepRestore($assessment); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 63ec65c..fcb6b8e 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -21,6 +21,7 @@ use App\Interface\EntityInterface; use App\Pagination\Paginator; use App\Repository\Abstraction\AbstractRepository; +use App\Service\ResetPasswordService; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\ResultSetMapping; @@ -199,7 +200,7 @@ public function loadUserByPasswordResetHash(string $hash): ?User ->andWhere('user.deletedAt IS NULL') ->andWhere('user.passwordResetHashExpiration > CURRENT_TIMESTAMP()') ->setParameter('adminRole', '"'.Role::ADMINISTRATOR->string().'"') - ->setParameter('hash', $hash); + ->setParameter('hash', ResetPasswordService::hashToken($hash)); $user = $qb->getQuery()->getOneOrNullResult(); if ($user === null) { @@ -221,7 +222,7 @@ public function loadAdminByPasswordResetHash(string $hash): ?User ->andWhere('user.deletedAt IS NULL') ->andWhere('user.passwordResetHashExpiration > CURRENT_TIMESTAMP()') ->setParameter('adminRole', '"'.Role::ADMINISTRATOR->string().'"') - ->setParameter('hash', $hash); + ->setParameter('hash', ResetPasswordService::hashToken($hash)); $user = $qb->getQuery()->getOneOrNullResult(); if ($user === null) { diff --git a/src/Service/BruteForceService.php b/src/Service/BruteForceService.php deleted file mode 100644 index 1b9772b..0000000 --- a/src/Service/BruteForceService.php +++ /dev/null @@ -1,103 +0,0 @@ - - * - * @see http://codific.com - */ - -declare(strict_types=1); - -namespace App\Service; - -use App\Entity\Abstraction\FailedLogin; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\NonUniqueResultException; -use Symfony\Component\HttpFoundation\Request; - -class BruteForceService -{ - private Request $request; - - /** - * Use when retrieving the number of recent failed logins. - * In minutes. - */ - private int $timeFrame = 10; - - /** - * Threshold values - * Example: for 15 failed logins user will be not allowed to login for next 60 seconds. - */ - private array $threshold = [ - 15 => 60, - 25 => 120, - 35 => 240, - ]; - - public function __construct( - private readonly EntityManagerInterface $entityManager - ) { - $this->request = Request::createFromGlobals(); - } - - public function addFailAttempt(string $username = ''): void - { - $failedLogin = new FailedLogin(); - $failedLogin->setUsername($username); - $failedLogin->setIp($this->request->getClientIp()); - $this->entityManager->persist($failedLogin); - $this->entityManager->flush(); - } - - /** - * @param string $filter use IP(Default) or USERNAME or BOTH to choice how to check for login delay - * @param string $username if you choice USERNAME or BOTH for filter you must supply user name here - * - * @throws NonUniqueResultException - */ - public function getDelay(string $filter = 'IP', string $username = ''): int - { - $repoFailedLogin = $this->entityManager->getRepository(FailedLogin::class); - $queryBuilder = $repoFailedLogin->createQueryBuilder('a'); - $queryBuilder = $queryBuilder->select('count(a.id) as count, max(a.createdAt) as lastDate') - ->where('a.createdAt > :timeframe') - ->setParameter('timeframe', new \DateTime("-{$this->timeFrame} minutes")); - - if (strtoupper($filter) === 'IP') { - $queryBuilder->andWhere('a.ip = :ip') - ->setParameter('ip', $this->request->getClientIp()); - } - if (strtoupper($filter) === 'USERNAME') { - $queryBuilder->andWhere('a.username = :username') - ->setParameter('username', $username); - } - if (strtoupper($filter) === 'BOTH') { - $queryBuilder->andWhere('a.username = :username AND a.ip = :ip') - ->setParameter('username', $username) - ->setParameter('ip', $this->request->getClientIp()); - } - - $result = $queryBuilder->getQuery()->getOneOrNullResult(); - if ($result === null) { - return 0; - } - - $failedAttempts = $result['count']; - $lastFailedTimestamp = $result['lastDate']; - - krsort($this->threshold); - foreach ($this->threshold as $attempts => $delay) { - if ($failedAttempts > $attempts && time() < (strtotime($lastFailedTimestamp) + $delay)) { - return (strtotime($lastFailedTimestamp) + $delay) - time(); - } - } - - return 0; - } -} diff --git a/src/Service/MailingService.php b/src/Service/MailingService.php index 1aa272d..f6c9635 100644 --- a/src/Service/MailingService.php +++ b/src/Service/MailingService.php @@ -37,6 +37,12 @@ public function __construct( } public function add(MailTemplateType $mailTemplateType, User $user, array $extraPlaceholders = [], string $attachment = ''): void + { + $mailTemplateEntity = $this->resolveTemplate($mailTemplateType); + $this->addCustom($user, $mailTemplateEntity, $extraPlaceholders, $attachment); + } + + private function resolveTemplate(MailTemplateType $mailTemplateType): ?MailTemplate { $mailTemplateEntity = $this->mailTemplateRepository->findOneBy(['type' => $mailTemplateType]); if ($mailTemplateEntity === null) { @@ -56,26 +62,80 @@ public function add(MailTemplateType $mailTemplateType, User $user, array $extra $this->entityManager->flush(); } } - $this->addCustom($user, $mailTemplateEntity, $extraPlaceholders, $attachment); + + return $mailTemplateEntity; + } + + /** + * Send an email synchronously without queueing its rendered body. + * Used for one-shot messages that carry sensitive links (password reset, + * welcome). On success, a Mailing row with '[redacted]' as the body is + * persisted for audit; the rendered link never touches the DB. + */ + public function sendImmediate(MailTemplateType $mailTemplateType, User $user, array $extraPlaceholders = [], string $attachment = ''): bool + { + $template = $this->resolveTemplate($mailTemplateType); + if ($template === null) { + return false; + } + + [$subject, $message, $resolvedAttachment] = $this->replacePlaceholders( + $template->getSubject(), + $template->getMessage(), + $user, + $extraPlaceholders, + $attachment + ); + + $result = $this->sendMail( + $user->getEmail(), + "{$user->getName()} {$user->getSurname()}", + $subject, + $message, + $resolvedAttachment !== '' ? $resolvedAttachment : null + ); + + if ($result === false) { + $this->logger->log(LogLevel::ERROR, 'Immediate mail send failed', [ + 'templateType' => $mailTemplateType->value, + 'userId' => $user->getId(), + ]); + + return false; + } + + $mailing = $this->buildMailing($user, $template, $subject, '[redacted]', $resolvedAttachment); + $mailing->setStatus(\App\Enum\MailingStatus::SENT); + $mailing->setSentDate(new \DateTime()); + $this->entityManager->persist($mailing); + $this->entityManager->flush(); + + return true; } public function addCustom(User $user, ?MailTemplate $template, array $extraPlaceholders = [], string $attachment = ''): void + { + [$subject, $message, $attachment] = $this->replacePlaceholders($template->getSubject(), $template->getMessage(), $user, $extraPlaceholders, $attachment); + $mailing = $this->buildMailing($user, $template, $subject, $message, $attachment); + $this->entityManager->persist($mailing); + $this->entityManager->flush(); + } + + private function buildMailing(User $user, ?MailTemplate $template, string $subject, string $message, ?string $attachment): Mailing { $mailing = new Mailing(); $mailing->setEmail($user->getEmail()); - $isUserAdmin = in_array(Role::ADMINISTRATOR->string(), $user->getRoles(), true); - if ($isUserAdmin) { + if (in_array(Role::ADMINISTRATOR->string(), $user->getRoles(), true)) { $mailing->setUser($user); } $mailing->setName($user->getName()); $mailing->setSurname($user->getSurname()); - [$subject, $message, $attachment] = $this->replacePlaceholders($template->getSubject(), $template->getMessage(), $user, $extraPlaceholders, $attachment); $mailing->setSubject($subject); $mailing->setMessage($message); $mailing->setMailTemplate($template); $mailing->setAttachment($attachment); - $this->entityManager->persist($mailing); - $this->entityManager->flush(); + + return $mailing; } private function replacePlaceholders(string $subject, string $message, User $user, array $extraPlaceholders = [], ?string $attachment = null): array @@ -90,8 +150,9 @@ private function replacePlaceholders(string $subject, string $message, User $use $subject = str_ireplace($find, $replace, $subject); $message = str_ireplace($find, $replace, $message); - if (stristr($message, '[link]') !== false && $user->getPasswordResetHash() !== null && (strlen($user->getPasswordResetHash()) > 0)) { - $link = $this->urlGenerator->generate($urlName, ['hash' => $user->getPasswordResetHash()], $this->urlGenerator::ABSOLUTE_URL); + $plaintextResetToken = $user->getPlaintextPasswordResetHash(); + if (stristr($message, '[link]') !== false && $plaintextResetToken !== null && $plaintextResetToken !== '') { + $link = $this->urlGenerator->generate($urlName, ['hash' => $plaintextResetToken], $this->urlGenerator::ABSOLUTE_URL); $message = str_ireplace('[link]', $link, $message); $message = str_ireplace('[linkValidity]', $user->getPasswordResetHashExpiration()->format('d-M-Y @ H:i'), $message); } diff --git a/src/Service/Processing/ExcelImporter.php b/src/Service/Processing/ExcelImporter.php index 73da288..fbb0c43 100644 --- a/src/Service/Processing/ExcelImporter.php +++ b/src/Service/Processing/ExcelImporter.php @@ -22,6 +22,9 @@ abstract class ExcelImporter { protected function loadPhpExcelObject(string $fileName): Spreadsheet { - return IOFactory::load($fileName); + $reader = IOFactory::createReaderForFile($fileName); + $reader->setReadDataOnly(true); + + return $reader->load($fileName); } } diff --git a/src/Service/Processing/SammToolboxImporterService.php b/src/Service/Processing/SammToolboxImporterService.php index 9ce8382..8b1f3f3 100644 --- a/src/Service/Processing/SammToolboxImporterService.php +++ b/src/Service/Processing/SammToolboxImporterService.php @@ -131,8 +131,8 @@ private function importSamm(Spreadsheet $spreadsheet, bool $autoValidate, User $ if (!isset($validationRemarksForStream[$streamId])) { $validationRemarksForStream[$streamId] = ""; } - if ($interviewSheet->getCell([$interviewRemarksColumn, $interviewRemarksRow])->getCalculatedValue() !== null) { - $validationRemarksForStream[$streamId] .= $interviewSheet->getCell([$interviewRemarksColumn, $interviewRemarksRow])->getCalculatedValue()."
"; + if ($interviewSheet->getCell([$interviewRemarksColumn, $interviewRemarksRow])->getValue() !== null) { + $validationRemarksForStream[$streamId] .= $interviewSheet->getCell([$interviewRemarksColumn, $interviewRemarksRow])->getValue()."
"; } if ($answerFromSheet !== null && $answerFromSheet !== '' && $answerFromSheet !== 0) { $answerWasFound = false; diff --git a/src/Service/ResetPasswordService.php b/src/Service/ResetPasswordService.php index 61f11ec..f9a817a 100644 --- a/src/Service/ResetPasswordService.php +++ b/src/Service/ResetPasswordService.php @@ -11,7 +11,7 @@ class ResetPasswordService { - private string $passwordResetHashValid = '+8 hours'; + private string $passwordResetHashValid = '+1 hour'; private string $welcomeResetHashValid = '+8 hours'; public function __construct( @@ -24,6 +24,11 @@ public function __construct( $this->welcomeResetHashValid = (string) $this->configurationService->get('welcomeResetHashValid', $this->welcomeResetHashValid); } + public static function hashToken(string $plaintext): string + { + return hash('sha256', $plaintext); + } + public function reset(PasswordResetInterface $entity, bool $isWelcomeMail = false): bool { $resetHashValid = $this->passwordResetHashValid; @@ -32,9 +37,9 @@ public function reset(PasswordResetInterface $entity, bool $isWelcomeMail = fals } try { - if ($entity->getPasswordResetHash() === '' || (new \DateTime()) > $entity->getPasswordResetHashExpiration()) { - $entity->setPasswordResetHash($this->generator->base32(16)); - } + $plaintext = $this->generator->base32(16); + $entity->setPlaintextPasswordResetHash($plaintext); + $entity->setPasswordResetHash(self::hashToken($plaintext)); $entity->setPasswordResetHashExpiration(new \DateTime($resetHashValid)); $this->entityManager->flush(); diff --git a/src/Service/UploadService.php b/src/Service/UploadService.php index 99fe3ac..ee8e478 100644 --- a/src/Service/UploadService.php +++ b/src/Service/UploadService.php @@ -1,37 +1,92 @@ - * - * @see http://codific.com - */ declare(strict_types=1); namespace App\Service; +use App\Exception\FileExtensionNotAllowedException; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\String\Slugger\SluggerInterface; class UploadService { + public const DEFAULT_ALLOWED_EXTENSIONS = [ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'odt', 'ods', 'odp', 'rtf', 'txt', 'csv', + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', + 'zip', + ]; + + public const DEFAULT_ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/rtf', + 'text/rtf', + 'text/plain', + 'text/csv', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + 'application/zip', + ]; + public function __construct( private readonly SluggerInterface $slugger, private readonly ParameterBagInterface $parameterBag ) { } - public function upload(UploadedFile $file, string $targetDirectory): string - { + /** + * @param array $allowedExtensions + * @param array $allowedMimeTypes + * + * @throws FileExtensionNotAllowedException when sniffed MIME or extension + * is not in the allowlist. HTML, + * SVG, scripts, and any executable + * content are rejected by default. + */ + public function upload( + UploadedFile $file, + string $targetDirectory, + array $allowedExtensions = self::DEFAULT_ALLOWED_EXTENSIONS, + array $allowedMimeTypes = self::DEFAULT_ALLOWED_MIME_TYPES, + ): string { + $sniffedMime = $file->getMimeType(); + $sniffedExtension = $file->guessExtension(); + $clientExtension = strtolower(pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); + + if ($sniffedMime === null || !in_array($sniffedMime, $allowedMimeTypes, true)) { + throw new FileExtensionNotAllowedException( + sprintf('Upload rejected: MIME type "%s" is not allowed.', $sniffedMime ?? 'unknown') + ); + } + + if ($sniffedExtension === null || !in_array($sniffedExtension, $allowedExtensions, true)) { + throw new FileExtensionNotAllowedException( + sprintf('Upload rejected: detected extension "%s" is not allowed.', $sniffedExtension ?? 'unknown') + ); + } + + if (!in_array($clientExtension, $allowedExtensions, true)) { + throw new FileExtensionNotAllowedException( + sprintf('Upload rejected: client-supplied extension "%s" is not allowed.', $clientExtension) + ); + } + $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); $safeFilename = $this->slugger->slug($originalFilename); - $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension(); + $fileName = $safeFilename.'-'.uniqid().'.'.$sniffedExtension; $targetDirectory = $this->parameterBag->get('kernel.project_dir').'/private/'.$targetDirectory; $file->move($targetDirectory, $fileName); diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 74eb2db..3bf91a0 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -159,12 +159,12 @@ public function getUsersWithProjectAccess(Project $project, $neededRole = null): public function welcomeUser(User $user, MailTemplateType $mailTemplate, bool $flush = false): bool { $this->resetPasswordService->reset($user, true); - $this->mailingService->add($mailTemplate, $user); + $sent = $this->mailingService->sendImmediate($mailTemplate, $user); if ($flush) { $this->entityManager->flush(); } - return true; + return $sent; } diff --git a/src/Traits/UtilsBundle/CrudAjaxModifyTrait.php b/src/Traits/UtilsBundle/CrudAjaxModifyTrait.php index df0f4d0..03ede1c 100644 --- a/src/Traits/UtilsBundle/CrudAjaxModifyTrait.php +++ b/src/Traits/UtilsBundle/CrudAjaxModifyTrait.php @@ -46,9 +46,11 @@ public function abstractAjaxModify(Request $request, EntityInterface $entity): J return new JsonResponse(['status' => 'error', 'msg' => 'The field is readonly!'], Response::HTTP_BAD_REQUEST); } catch (\Throwable $t) { + $this->logger->error('abstractAjaxModify failed', ['exception' => $t]); + return new JsonResponse([ 'status' => 'error', - 'msg' => $this->translator->trans('admin.general.exception_message', ['message' => $t->getMessage()]), + 'msg' => $this->translator->trans('admin.general.exception_message', ['message' => '']), ]); } } diff --git a/src/Util/IndexViewParameters.php b/src/Util/IndexViewParameters.php deleted file mode 100644 index 598adb1..0000000 --- a/src/Util/IndexViewParameters.php +++ /dev/null @@ -1,93 +0,0 @@ -queryBuilder; - } - - public function setQueryBuilder(?QueryBuilder $queryBuilder): IndexViewParameters - { - $this->queryBuilder = $queryBuilder; - - return $this; - } - - public function getRepositoryParameters(): ?RepositoryParameters - { - return $this->repositoryParameters; - } - - public function setRepositoryParameters(?RepositoryParameters $repositoryParameters): IndexViewParameters - { - $this->repositoryParameters = $repositoryParameters; - - return $this; - } - - public function getViewParameters(): array - { - return $this->viewParameters; - } - - public function setViewParameters(array $viewParameters): IndexViewParameters - { - $this->viewParameters = $viewParameters; - - return $this; - } - - public function getQueryParams(): array - { - return $this->queryParams; - } - - public function setQueryParams(array $queryParams): IndexViewParameters - { - $this->queryParams = $queryParams; - - return $this; - } - - public function getEntityName(): string - { - return $this->entityName; - } - - public function setEntityName(string $entityName): IndexViewParameters - { - $this->entityName = $entityName; - - return $this; - } - - public function getEntityCamelCaseName(): string - { - return $this->entityCamelCaseName; - } - - public function setEntityCamelCaseName(string $entityCamelCaseName): IndexViewParameters - { - $this->entityCamelCaseName = $entityCamelCaseName; - - return $this; - } -} diff --git a/src/Util/RepositoryParameters.php b/src/Util/RepositoryParameters.php deleted file mode 100644 index f18b503..0000000 --- a/src/Util/RepositoryParameters.php +++ /dev/null @@ -1,30 +0,0 @@ -orderBy = $orderBy; - - return $this; - } - - /** - * Get order by. - */ - public function getOrderBy(): array - { - return $this->orderBy; - } -} diff --git a/src/Utils/RandomStringGenerator.php b/src/Utils/RandomStringGenerator.php index 1cfeeb0..3bdba4e 100644 --- a/src/Utils/RandomStringGenerator.php +++ b/src/Utils/RandomStringGenerator.php @@ -11,28 +11,12 @@ class RandomStringGenerator { public function base32(int $length = 8): string { - try { - return Base32::encodeUnpadded(random_bytes($length)); - } catch (\Exception $e) { - // this should not happen - // if happen there is something wrong with php config or with your pc - // TODO Should this catch exist? - } - - return ''; + return Base32::encodeUnpadded(random_bytes($length)); } public function base64(int $length = 8): string { - try { - return Base64::encodeUnpadded(random_bytes($length)); - } catch (\Exception $e) { - // this should not happen - // if happen there is something wrong with php config or with your pc - // TODO Should this catch exist? - } - - return ''; + return Base64::encodeUnpadded(random_bytes($length)); } /** diff --git a/symfony.lock b/symfony.lock index 626cd50..5a389ff 100644 --- a/symfony.lock +++ b/symfony.lock @@ -198,6 +198,18 @@ "src/Kernel.php" ] }, + "symfony/lock": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/maker-bundle": { "version": "1.50", "recipe": { diff --git a/templates/application/auth/rate_limited.html.twig b/templates/application/auth/rate_limited.html.twig new file mode 100644 index 0000000..58b9fd8 --- /dev/null +++ b/templates/application/auth/rate_limited.html.twig @@ -0,0 +1,20 @@ +{% extends 'application/base_login.html.twig' %} + +{% block title %}{{ 'application.general.login_title'|trans({}, 'application') }}{% endblock %} + +{% block body %} +
+
+
+
+
+ {{ 'application.general.too_many_login_attempts'|trans({'%seconds%': retry_after}, 'application') }} +
+ +
+
+
+
+{% endblock %} diff --git a/templates/application/documentation/preview.html.twig b/templates/application/documentation/preview.html.twig deleted file mode 100644 index 75375f6..0000000 --- a/templates/application/documentation/preview.html.twig +++ /dev/null @@ -1,15 +0,0 @@ -{% if mime in ['image/bmp', 'image/png', 'image/gif', 'image/jpeg'] %} - -{% elseif mime == 'text/plain' %} -

- {{ text }} -

-{% elseif mime == null %} -

- {{ "application.assessment.missing_file"|trans({},'application') }} -

-{% else %} -

- {{ "application.assessment.no_preview"|trans({},'application') }} -

-{% endif %} diff --git a/templates/application/user/partials/_user_row.html.twig b/templates/application/user/partials/_user_row.html.twig index 81e8ddb..af9861b 100644 --- a/templates/application/user/partials/_user_row.html.twig +++ b/templates/application/user/partials/_user_row.html.twig @@ -45,6 +45,11 @@ + + + {% if user.id is not same as app.user.id %} {% endif %} +