From 7d76f0d26d02395cc9058c0b63dc69ad4ef390b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jan 2025 11:49:13 -0600 Subject: [PATCH 01/16] Bump embed/embed from 4.4.13 to 4.4.15 (#1352) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index 33f743d29..40dffc802 100644 --- a/composer.lock +++ b/composer.lock @@ -361,16 +361,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2" + "reference": "bc0593537a463e55cadf45fd938d23b75095b7e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/3b1fc3f0be055baa7c6258b1467849c3e8204eb2", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/bc0593537a463e55cadf45fd938d23b75095b7e1", + "reference": "bc0593537a463e55cadf45fd938d23b75095b7e1", "shasum": "" }, "require": { @@ -417,7 +417,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.3" + "source": "https://github.com/composer/ca-bundle/tree/1.5.4" }, "funding": [ { @@ -433,7 +433,7 @@ "type": "tidelift" } ], - "time": "2024-11-04T10:15:26+00:00" + "time": "2024-11-27T15:35:25+00:00" }, { "name": "dasprid/enum", @@ -2222,16 +2222,16 @@ }, { "name": "embed/embed", - "version": "v4.4.13", + "version": "v4.4.15", "source": { "type": "git", "url": "https://github.com/oscarotero/Embed.git", - "reference": "a1da3d425520bf68cefebc29e39c6122a69d7b18" + "reference": "62bd05060757a874673b4cda2299d94dd18f9a85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oscarotero/Embed/zipball/a1da3d425520bf68cefebc29e39c6122a69d7b18", - "reference": "a1da3d425520bf68cefebc29e39c6122a69d7b18", + "url": "https://api.github.com/repos/oscarotero/Embed/zipball/62bd05060757a874673b4cda2299d94dd18f9a85", + "reference": "62bd05060757a874673b4cda2299d94dd18f9a85", "shasum": "" }, "require": { @@ -2291,7 +2291,7 @@ "support": { "email": "oom@oscarotero.com", "issues": "https://github.com/oscarotero/Embed/issues", - "source": "https://github.com/oscarotero/Embed/tree/v4.4.13" + "source": "https://github.com/oscarotero/Embed/tree/v4.4.15" }, "funding": [ { @@ -2307,7 +2307,7 @@ "type": "patreon" } ], - "time": "2024-11-21T20:13:38+00:00" + "time": "2025-01-02T16:53:09+00:00" }, { "name": "endroid/qr-code", From 62b1932d41f55706a038346dca585eb59e54bee3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:09:38 +0100 Subject: [PATCH 02/16] Bump symfony/workflow from 7.1.6 to 7.2.0 (#1349) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 2e13ea586..ae7f39766 100644 --- a/composer.json +++ b/composer.json @@ -102,7 +102,7 @@ "symfony/ux-twig-component": "^2.18.1", "symfony/validator": "7.1.*", "symfony/webpack-encore-bundle": "^2.1.1", - "symfony/workflow": "7.1.*", + "symfony/workflow": "7.2.*", "symfony/yaml": "7.2.*", "symfonycasts/reset-password-bundle": "^1.22.0", "symfonycasts/verify-email-bundle": "^1.17.0", diff --git a/composer.lock b/composer.lock index 40dffc802..9b8c7844a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "287e1ee0f8b927c28a96182bddc4b14e", + "content-hash": "0e7eba17787bc7cfb7a61b82bf1d5930", "packages": [ { "name": "aws/aws-crt-php", @@ -14007,16 +14007,16 @@ }, { "name": "symfony/workflow", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/workflow.git", - "reference": "b5dca7ecc55ec401939cd101d1a23d64bbce113b" + "reference": "c284a6540f8458be4e7a506f204ee258e8001e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/workflow/zipball/b5dca7ecc55ec401939cd101d1a23d64bbce113b", - "reference": "b5dca7ecc55ec401939cd101d1a23d64bbce113b", + "url": "https://api.github.com/repos/symfony/workflow/zipball/c284a6540f8458be4e7a506f204ee258e8001e85", + "reference": "c284a6540f8458be4e7a506f204ee258e8001e85", "shasum": "" }, "require": { @@ -14074,7 +14074,7 @@ "workflow" ], "support": { - "source": "https://github.com/symfony/workflow/tree/v7.1.6" + "source": "https://github.com/symfony/workflow/tree/v7.2.0" }, "funding": [ { @@ -14090,7 +14090,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/yaml", From 40089ccdbc73b54e8c696b1aa5b16af113d75e28 Mon Sep 17 00:00:00 2001 From: Melroy van den Berg Date: Tue, 7 Jan 2025 06:53:02 +0100 Subject: [PATCH 03/16] Upgraded to Symfony v7.2.* and upgraded other minor packages as well (#1353) --- composer.json | 52 +-- composer.lock | 1211 +++++++++++++++++++++++++------------------------ 2 files changed, 655 insertions(+), 608 deletions(-) diff --git a/composer.json b/composer.json index ae7f39766..8320e04e1 100644 --- a/composer.json +++ b/composer.json @@ -65,42 +65,42 @@ "scienta/doctrine-json-functions": "^6.1.0", "stevenmaguire/oauth2-keycloak": "^5.1.0", "symfony/amqp-messenger": "7.2.*", - "symfony/asset": "7.1.*", + "symfony/asset": "7.2.*", "symfony/cache": "7.2.*", - "symfony/console": "7.1.*", - "symfony/css-selector": "7.1.*", + "symfony/console": "7.2.*", + "symfony/css-selector": "7.2.*", "symfony/doctrine-messenger": "7.2.*", "symfony/dotenv": "7.2.*", "symfony/expression-language": "7.2.*", "symfony/flex": "^2.4.5", - "symfony/form": "7.1.*", - "symfony/framework-bundle": "7.1.*", - "symfony/http-client": "7.1.*", - "symfony/lock": "7.1.*", - "symfony/mailer": "7.1.*", - "symfony/mailgun-mailer": "7.1.*", + "symfony/form": "7.2.*", + "symfony/framework-bundle": "7.2.*", + "symfony/http-client": "7.2.*", + "symfony/lock": "7.2.*", + "symfony/mailer": "7.2.*", + "symfony/mailgun-mailer": "7.2.*", "symfony/mercure-bundle": "0.3.*", - "symfony/messenger": "7.1.*", + "symfony/messenger": "7.2.*", "symfony/mime": "7.2.*", "symfony/monolog-bundle": "^3.10.0", - "symfony/property-access": "7.1.*", - "symfony/property-info": "7.1.*", + "symfony/property-access": "7.2.*", + "symfony/property-info": "7.2.*", "symfony/rate-limiter": "7.2.*", "symfony/redis-messenger": "7.2.*", - "symfony/runtime": "7.1.*", - "symfony/scheduler": "7.1.*", + "symfony/runtime": "7.2.*", + "symfony/scheduler": "7.2.*", "symfony/security-bundle": "7.2.*", "symfony/security-csrf": "7.2.*", - "symfony/serializer": "7.1.*", - "symfony/string": "7.1.*", - "symfony/translation": "7.1.*", - "symfony/twig-bundle": "7.1.*", - "symfony/type-info": "7.1.*", - "symfony/uid": "7.1.*", + "symfony/serializer": "7.2.*", + "symfony/string": "7.2.*", + "symfony/translation": "7.2.*", + "symfony/twig-bundle": "7.2.*", + "symfony/type-info": "7.2.*", + "symfony/uid": "7.2.*", "symfony/ux-autocomplete": "^2.18.0", "symfony/ux-chartjs": "^2.18.0", "symfony/ux-twig-component": "^2.18.1", - "symfony/validator": "7.1.*", + "symfony/validator": "7.2.*", "symfony/webpack-encore-bundle": "^2.1.1", "symfony/workflow": "7.2.*", "symfony/yaml": "7.2.*", @@ -124,11 +124,11 @@ "phpstan/phpstan": "^2.0.2", "phpunit/phpunit": "^11.3.4", "symfony/browser-kit": "7.2.*", - "symfony/debug-bundle": "7.1.*", + "symfony/debug-bundle": "7.2.*", "symfony/maker-bundle": "1.61.0", - "symfony/phpunit-bridge": "7.1.*", - "symfony/stopwatch": "7.1.*", - "symfony/web-profiler-bundle": "7.1.*" + "symfony/phpunit-bridge": "7.2.*", + "symfony/stopwatch": "7.2.*", + "symfony/web-profiler-bundle": "7.2.*" }, "replace": { "symfony/polyfill-ctype": "*", @@ -164,7 +164,7 @@ "extra": { "symfony": { "allow-contrib": false, - "require": "7.1.*" + "require": "7.2.*" } }, "scripts": { diff --git a/composer.lock b/composer.lock index 9b8c7844a..57c92b39c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0e7eba17787bc7cfb7a61b82bf1d5930", + "content-hash": "c4a85444c39a4e13cd6d19da266851cc", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.330.1", + "version": "3.336.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "136749f15d1dbff07064ef5ba1c2f08b96cf78ff" + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/136749f15d1dbff07064ef5ba1c2f08b96cf78ff", - "reference": "136749f15d1dbff07064ef5ba1c2f08b96cf78ff", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", "shasum": "" }, "require": { @@ -100,7 +100,7 @@ "nette/neon": "^2.3", "paragonie/random_compat": ">= 2", "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", - "psr/cache": "^1.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0", "yoast/phpunit-polyfills": "^1.0" @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.330.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.8" }, - "time": "2024-11-25T19:20:00+00:00" + "time": "2025-01-03T19:06:11+00:00" }, { "name": "babdev/pagerfanta-bundle", @@ -1007,20 +1007,20 @@ }, { "name": "doctrine/common", - "version": "3.4.5", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/common.git", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286" + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/common/zipball/6c8fef961f67b8bc802ce3e32e3ebd1022907286", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", "shasum": "" }, "require": { - "doctrine/persistence": "^2.0 || ^3.0", + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", "php": "^7.1 || ^8.0" }, "require-dev": { @@ -1078,7 +1078,7 @@ ], "support": { "issues": "https://github.com/doctrine/common/issues", - "source": "https://github.com/doctrine/common/tree/3.4.5" + "source": "https://github.com/doctrine/common/tree/3.5.0" }, "funding": [ { @@ -1094,7 +1094,7 @@ "type": "tidelift" } ], - "time": "2024-10-08T15:53:43+00:00" + "time": "2025-01-01T22:12:03+00:00" }, { "name": "doctrine/dbal", @@ -1900,16 +1900,16 @@ }, { "name": "doctrine/orm", - "version": "2.20.0", + "version": "2.20.1", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c" + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/8ed6c2234aba019f9737a6bcc9516438e62da27c", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/e3cabade99ebccc6ba078884c1c5f250866a494e", + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e", "shasum": "" }, "require": { @@ -1939,15 +1939,14 @@ "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", "phpstan/extension-installer": "~1.1.0 || ^1.4", - "phpstan/phpstan": "~1.4.10 || 1.12.6", - "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan": "~1.4.10 || 2.0.3", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2 || ^7.0", - "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "vimeo/psalm": "4.30.0 || 5.24.0" + "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -1997,9 +1996,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.20.0" + "source": "https://github.com/doctrine/orm/tree/2.20.1" }, - "time": "2024-10-11T11:47:24+00:00" + "time": "2024-12-19T06:48:36+00:00" }, { "name": "doctrine/persistence", @@ -2155,16 +2154,16 @@ }, { "name": "egulias/email-validator", - "version": "4.0.2", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e" + "reference": "b115554301161fa21467629f1e1391c1936de517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e", - "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517", + "reference": "b115554301161fa21467629f1e1391c1936de517", "shasum": "" }, "require": { @@ -2210,7 +2209,7 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/4.0.2" + "source": "https://github.com/egulias/EmailValidator/tree/4.0.3" }, "funding": [ { @@ -2218,7 +2217,7 @@ "type": "github" } ], - "time": "2023-10-06T06:47:41+00:00" + "time": "2024-12-27T00:36:43+00:00" }, { "name": "embed/embed", @@ -3003,20 +3002,20 @@ }, { "name": "imagine/imagine", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/php-imagine/Imagine.git", - "reference": "2c8887dc7e84e97283037f02dd9c957d4b3a0b82" + "reference": "80ab21434890dee9ba54969d31c51ac8d4d551e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/2c8887dc7e84e97283037f02dd9c957d4b3a0b82", - "reference": "2c8887dc7e84e97283037f02dd9c957d4b3a0b82", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/80ab21434890dee9ba54969d31c51ac8d4d551e0", + "reference": "80ab21434890dee9ba54969d31c51ac8d4d551e0", "shasum": "" }, "require": { - "php": ">=5.5" + "php": ">=7.1" }, "require-dev": { "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" @@ -3049,7 +3048,7 @@ "homepage": "http://avalanche123.com" } ], - "description": "Image processing for PHP 5.3", + "description": "Image processing for PHP", "homepage": "http://imagine.readthedocs.org/", "keywords": [ "drawing", @@ -3059,9 +3058,9 @@ ], "support": { "issues": "https://github.com/php-imagine/Imagine/issues", - "source": "https://github.com/php-imagine/Imagine/tree/1.4.0" + "source": "https://github.com/php-imagine/Imagine/tree/1.5.0" }, - "time": "2024-11-18T07:44:52+00:00" + "time": "2024-12-03T14:37:55+00:00" }, { "name": "knplabs/knp-time-bundle", @@ -3384,16 +3383,16 @@ }, { "name": "league/commonmark", - "version": "2.6.0", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "d150f911e0079e90ae3c106734c93137c184f932" + "reference": "d990688c91cedfb69753ffc2512727ec646df2ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932", - "reference": "d150f911e0079e90ae3c106734c93137c184f932", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", + "reference": "d990688c91cedfb69753ffc2512727ec646df2ad", "shasum": "" }, "require": { @@ -3487,7 +3486,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T15:34:16+00:00" + "time": "2024-12-29T14:10:59+00:00" }, { "name": "league/config", @@ -3959,35 +3958,30 @@ }, { "name": "league/oauth2-client", - "version": "2.7.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/3d5cf8d0543731dfb725ab30e4d7289891991e13", + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "paragonie/random_compat": "^1 || ^2 || ^9.99", - "php": "^5.6 || ^7.0 || ^8.0" + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" }, "require-dev": { "mockery/mockery": "^1.3.5", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", - "squizlabs/php_codesniffer": "^2.3 || ^3.0" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.0.x-dev" - } - }, "autoload": { "psr-4": { "League\\OAuth2\\Client\\": "src/" @@ -4023,9 +4017,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" }, - "time": "2023-04-16T18:19:15+00:00" + "time": "2024-12-11T05:05:52+00:00" }, { "name": "league/oauth2-facebook", @@ -4206,16 +4200,16 @@ }, { "name": "league/oauth2-server", - "version": "8.5.4", + "version": "8.5.5", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-server.git", - "reference": "ab7714d073844497fd222d5d0a217629089936bc" + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/ab7714d073844497fd222d5d0a217629089936bc", - "reference": "ab7714d073844497fd222d5d0a217629089936bc", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/cc8778350f905667e796b3c2364a9d3bd7a73518", + "reference": "cc8778350f905667e796b3c2364a9d3bd7a73518", "shasum": "" }, "require": { @@ -4282,7 +4276,7 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-server/issues", - "source": "https://github.com/thephpleague/oauth2-server/tree/8.5.4" + "source": "https://github.com/thephpleague/oauth2-server/tree/8.5.5" }, "funding": [ { @@ -4290,7 +4284,7 @@ "type": "github" } ], - "time": "2023-08-25T22:35:12+00:00" + "time": "2024-12-20T23:06:10+00:00" }, { "name": "league/oauth2-server-bundle", @@ -4367,20 +4361,20 @@ }, { "name": "league/uri", - "version": "7.4.1", + "version": "7.5.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "bedb6e55eff0c933668addaa7efa1e1f2c417cc4" + "reference": "81fb5145d2644324614cc532b28efd0215bda430" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/bedb6e55eff0c933668addaa7efa1e1f2c417cc4", - "reference": "bedb6e55eff0c933668addaa7efa1e1f2c417cc4", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.3", + "league/uri-interfaces": "^7.5", "php": "^8.1" }, "conflict": { @@ -4445,7 +4439,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.4.1" + "source": "https://github.com/thephpleague/uri/tree/7.5.1" }, "funding": [ { @@ -4453,20 +4447,20 @@ "type": "github" } ], - "time": "2024-03-23T07:42:40+00:00" + "time": "2024-12-08T08:40:02+00:00" }, { "name": "league/uri-interfaces", - "version": "7.4.1", + "version": "7.5.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "8d43ef5c841032c87e2de015972c06f3865ef718" + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/8d43ef5c841032c87e2de015972c06f3865ef718", - "reference": "8d43ef5c841032c87e2de015972c06f3865ef718", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", "shasum": "" }, "require": { @@ -4529,7 +4523,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.4.1" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" }, "funding": [ { @@ -4537,20 +4531,20 @@ "type": "github" } ], - "time": "2024-03-23T07:42:40+00:00" + "time": "2024-12-08T08:18:47+00:00" }, { "name": "liip/imagine-bundle", - "version": "2.13.2", + "version": "2.13.3", "source": { "type": "git", "url": "https://github.com/liip/LiipImagineBundle.git", - "reference": "98e0318ea0f7b9500343236e63cc29ded58a1d43" + "reference": "3faccde327f91368e51d05ecad49a9cd915abd81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/98e0318ea0f7b9500343236e63cc29ded58a1d43", - "reference": "98e0318ea0f7b9500343236e63cc29ded58a1d43", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/3faccde327f91368e51d05ecad49a9cd915abd81", + "reference": "3faccde327f91368e51d05ecad49a9cd915abd81", "shasum": "" }, "require": { @@ -4641,9 +4635,9 @@ ], "support": { "issues": "https://github.com/liip/LiipImagineBundle/issues", - "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.2" + "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.3" }, - "time": "2024-09-04T12:55:26+00:00" + "time": "2024-12-12T09:38:23+00:00" }, { "name": "meteo-concept/hcaptcha-bundle", @@ -4880,16 +4874,16 @@ }, { "name": "monolog/monolog", - "version": "3.8.0", + "version": "3.8.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67" + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/aef6ee73a77a66e404dd6540934a9ef1b3c855b4", + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4", "shasum": "" }, "require": { @@ -4967,7 +4961,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.8.0" + "source": "https://github.com/Seldaek/monolog/tree/3.8.1" }, "funding": [ { @@ -4979,7 +4973,7 @@ "type": "tidelift" } ], - "time": "2024-11-12T13:57:08+00:00" + "time": "2024-12-05T17:15:07+00:00" }, { "name": "mtdowling/jmespath.php", @@ -5090,16 +5084,16 @@ }, { "name": "nelmio/api-doc-bundle", - "version": "v4.33.4", + "version": "v4.33.6", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git", - "reference": "c43171895161c8eb342bc5fc5eb21760dd91b646" + "reference": "8e5694150fb0f7acfeff437ce14d1fd3bb3292ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/c43171895161c8eb342bc5fc5eb21760dd91b646", - "reference": "c43171895161c8eb342bc5fc5eb21760dd91b646", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/8e5694150fb0f7acfeff437ce14d1fd3bb3292ca", + "reference": "8e5694150fb0f7acfeff437ce14d1fd3bb3292ca", "shasum": "" }, "require": { @@ -5200,7 +5194,7 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", - "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.33.4" + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.33.6" }, "funding": [ { @@ -5208,7 +5202,7 @@ "type": "github" } ], - "time": "2024-11-08T15:00:51+00:00" + "time": "2025-01-06T10:15:49+00:00" }, { "name": "nelmio/cors-bundle", @@ -5500,16 +5494,16 @@ }, { "name": "omines/antispam-bundle", - "version": "0.1.9", + "version": "0.1.10", "source": { "type": "git", "url": "https://github.com/omines/antispam-bundle.git", - "reference": "d51e0e8642bb344e4752cd7307415d50f5834c6f" + "reference": "92b6c67b32f2528a45cffb2b328a73a38bd3077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/omines/antispam-bundle/zipball/d51e0e8642bb344e4752cd7307415d50f5834c6f", - "reference": "d51e0e8642bb344e4752cd7307415d50f5834c6f", + "url": "https://api.github.com/repos/omines/antispam-bundle/zipball/92b6c67b32f2528a45cffb2b328a73a38bd3077b", + "reference": "92b6c67b32f2528a45cffb2b328a73a38bd3077b", "shasum": "" }, "require": { @@ -5529,23 +5523,23 @@ "twig/twig": "<3.1" }, "require-dev": { - "ekino/phpstan-banned-code": "^1.0", - "friendsofphp/php-cs-fixer": "^3.59.3", + "ekino/phpstan-banned-code": "^2.1", + "friendsofphp/php-cs-fixer": "^3.65.0", "infection/infection": "^0.29.6", - "phpstan/extension-installer": "^1.4.1", - "phpstan/phpstan": "^1.11.6", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-symfony": "^1.4.5", - "phpunit/phpunit": "^10.5.15 || ^11.2.5", - "symfony/browser-kit": "^6.3|^7.1.1", - "symfony/css-selector": "^6.3|^7.1.1", - "symfony/debug-bundle": "^6.3|^7.1.1", - "symfony/dotenv": "^6.3|^7.1.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.13", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-symfony": "^1.4.12", + "phpunit/phpunit": "^10.5.15 || ^11.5.1", + "symfony/browser-kit": "^6.3|^7.2.0", + "symfony/css-selector": "^6.3|^7.2.0", + "symfony/debug-bundle": "^6.3|^7.2.0", + "symfony/dotenv": "^6.3|^7.2.0", "symfony/monolog-bundle": "^3.10", - "symfony/routing": "^6.3|^7.1.1", - "symfony/runtime": "^6.3|^7.1.1", - "symfony/twig-bundle": "^6.3|^7.1.1", - "symfony/web-profiler-bundle": "^6.3|^7.1.2" + "symfony/routing": "^6.3|^7.2.0", + "symfony/runtime": "^6.3|^7.2.0", + "symfony/twig-bundle": "^6.3|^7.2.0", + "symfony/web-profiler-bundle": "^6.3|^7.2.0" }, "type": "symfony-bundle", "extra": { @@ -5580,7 +5574,7 @@ ], "support": { "issues": "https://github.com/omines/antispam-bundle/issues", - "source": "https://github.com/omines/antispam-bundle/tree/0.1.9" + "source": "https://github.com/omines/antispam-bundle/tree/0.1.10" }, "funding": [ { @@ -5588,7 +5582,7 @@ "type": "github" } ], - "time": "2024-08-05T15:17:30+00:00" + "time": "2024-12-20T15:56:32+00:00" }, { "name": "oneup/flysystem-bundle", @@ -5777,16 +5771,16 @@ }, { "name": "pagerfanta/doctrine-collections-adapter", - "version": "v4.7.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/Pagerfanta/doctrine-collections-adapter.git", - "reference": "6a1cf09efbfe7fc7131247d7fcac9ce9baf3d50e" + "reference": "a1c59f8afbfc57958d54126d8357c4a769dd864f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Pagerfanta/doctrine-collections-adapter/zipball/6a1cf09efbfe7fc7131247d7fcac9ce9baf3d50e", - "reference": "6a1cf09efbfe7fc7131247d7fcac9ce9baf3d50e", + "url": "https://api.github.com/repos/Pagerfanta/doctrine-collections-adapter/zipball/a1c59f8afbfc57958d54126d8357c4a769dd864f", + "reference": "a1c59f8afbfc57958d54126d8357c4a769dd864f", "shasum": "" }, "require": { @@ -5817,9 +5811,9 @@ "pagerfanta" ], "support": { - "source": "https://github.com/Pagerfanta/doctrine-collections-adapter/tree/v4.7.0" + "source": "https://github.com/Pagerfanta/doctrine-collections-adapter/tree/v4.7.1" }, - "time": "2024-08-13T23:48:58+00:00" + "time": "2024-11-30T19:18:10+00:00" }, { "name": "pagerfanta/doctrine-dbal-adapter", @@ -5870,16 +5864,16 @@ }, { "name": "pagerfanta/doctrine-orm-adapter", - "version": "v4.7.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/Pagerfanta/doctrine-orm-adapter.git", - "reference": "c51e5c7b2c704e83442a365604d94ebcdec3d054" + "reference": "b3be49948e84b67c023c820abc54ea574a100d5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Pagerfanta/doctrine-orm-adapter/zipball/c51e5c7b2c704e83442a365604d94ebcdec3d054", - "reference": "c51e5c7b2c704e83442a365604d94ebcdec3d054", + "url": "https://api.github.com/repos/Pagerfanta/doctrine-orm-adapter/zipball/b3be49948e84b67c023c820abc54ea574a100d5d", + "reference": "b3be49948e84b67c023c820abc54ea574a100d5d", "shasum": "" }, "require": { @@ -5911,22 +5905,22 @@ "pagerfanta" ], "support": { - "source": "https://github.com/Pagerfanta/doctrine-orm-adapter/tree/v4.7.0" + "source": "https://github.com/Pagerfanta/doctrine-orm-adapter/tree/v4.7.1" }, - "time": "2024-03-06T23:51:47+00:00" + "time": "2024-11-30T19:18:10+00:00" }, { "name": "pagerfanta/twig", - "version": "v4.7.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/Pagerfanta/twig.git", - "reference": "8aae872221eff1093c55ba69e50f81031f50790d" + "reference": "f3eb17928c61fe6f72abc822825e1006afae6257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Pagerfanta/twig/zipball/8aae872221eff1093c55ba69e50f81031f50790d", - "reference": "8aae872221eff1093c55ba69e50f81031f50790d", + "url": "https://api.github.com/repos/Pagerfanta/twig/zipball/f3eb17928c61fe6f72abc822825e1006afae6257", + "reference": "f3eb17928c61fe6f72abc822825e1006afae6257", "shasum": "" }, "require": { @@ -5955,9 +5949,9 @@ "pagerfanta" ], "support": { - "source": "https://github.com/Pagerfanta/twig/tree/v4.7.0" + "source": "https://github.com/Pagerfanta/twig/tree/v4.7.1" }, - "time": "2024-08-13T23:43:44+00:00" + "time": "2024-12-13T15:11:13+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -6253,16 +6247,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.42", + "version": "3.0.43", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", "shasum": "" }, "require": { @@ -6343,7 +6337,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" }, "funding": [ { @@ -6359,7 +6353,7 @@ "type": "tidelift" } ], - "time": "2024-09-16T03:06:04+00:00" + "time": "2024-12-14T21:12:59+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -7431,16 +7425,16 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "0b10c8b53366729417d6226ae89a665f9e2d61b6" + "reference": "5ac374c3e295c8b917208ff41b4d30f76668478c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/0b10c8b53366729417d6226ae89a665f9e2d61b6", - "reference": "0b10c8b53366729417d6226ae89a665f9e2d61b6", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/5ac374c3e295c8b917208ff41b4d30f76668478c", + "reference": "5ac374c3e295c8b917208ff41b4d30f76668478c", "shasum": "" }, "require": { @@ -7449,21 +7443,19 @@ "php": ">=8.1" }, "require-dev": { - "ekino/phpstan-banned-code": "^1.0", + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28", + "infection/infection": "^0.28|^0.29", "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-beberlei-assert": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", "phpunit/phpunit": "^10.1|^11.0", - "rector/rector": "^1.0", + "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", - "symfony/phpunit-bridge": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", "symplify/easy-coding-standard": "^12.0" @@ -7526,7 +7518,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.2.1" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.2.2" }, "funding": [ { @@ -7538,7 +7530,7 @@ "type": "patreon" } ], - "time": "2024-03-30T18:03:49+00:00" + "time": "2025-01-03T09:35:48+00:00" }, { "name": "stevenmaguire/oauth2-keycloak", @@ -7672,16 +7664,16 @@ }, { "name": "symfony/asset", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "0dcd51490d7fc9fbf3c8f5aec6df182920fc0426" + "reference": "cb926cd59fefa1f9b4900b3695f0f846797ba5c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/0dcd51490d7fc9fbf3c8f5aec6df182920fc0426", - "reference": "0dcd51490d7fc9fbf3c8f5aec6df182920fc0426", + "url": "https://api.github.com/repos/symfony/asset/zipball/cb926cd59fefa1f9b4900b3695f0f846797ba5c0", + "reference": "cb926cd59fefa1f9b4900b3695f0f846797ba5c0", "shasum": "" }, "require": { @@ -7721,7 +7713,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v7.1.6" + "source": "https://github.com/symfony/asset/tree/v7.2.0" }, "funding": [ { @@ -7737,7 +7729,7 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:11:02+00:00" + "time": "2024-10-25T15:15:23+00:00" }, { "name": "symfony/cache", @@ -7857,12 +7849,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8064,16 +8056,16 @@ }, { "name": "symfony/console", - "version": "v7.1.8", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ff04e5b5ba043d2badfb308197b9e6b42883fcd5" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ff04e5b5ba043d2badfb308197b9e6b42883fcd5", - "reference": "ff04e5b5ba043d2badfb308197b9e6b42883fcd5", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { @@ -8137,7 +8129,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.1.8" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -8153,20 +8145,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:23:19+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { "name": "symfony/css-selector", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "4aa4f6b3d6749c14d3aa815eef8226632e7bbc66" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/4aa4f6b3d6749c14d3aa815eef8226632e7bbc66", - "reference": "4aa4f6b3d6749c14d3aa815eef8226632e7bbc66", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { @@ -8202,7 +8194,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.1.6" + "source": "https://github.com/symfony/css-selector/tree/v7.2.0" }, "funding": [ { @@ -8218,7 +8210,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/dependency-injection", @@ -8369,16 +8361,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "09dbb7c731430335e9ae89ee5054b5f5580c49bf" + "reference": "f12195479a55b77bc8427b48443b966622f4a18b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/09dbb7c731430335e9ae89ee5054b5f5580c49bf", - "reference": "09dbb7c731430335e9ae89ee5054b5f5580c49bf", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/f12195479a55b77bc8427b48443b966622f4a18b", + "reference": "f12195479a55b77bc8427b48443b966622f4a18b", "shasum": "" }, "require": { @@ -8458,7 +8450,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.2.0" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.2.2" }, "funding": [ { @@ -8474,20 +8466,20 @@ "type": "tidelift" } ], - "time": "2024-11-25T12:10:02+00:00" + "time": "2024-12-19T14:25:03+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "533e664a37b4208c5a26f1f7894f212690e806f5" + "reference": "1abbc58849847e6b43c042b17046371ef397be19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/533e664a37b4208c5a26f1f7894f212690e806f5", - "reference": "533e664a37b4208c5a26f1f7894f212690e806f5", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/1abbc58849847e6b43c042b17046371ef397be19", + "reference": "1abbc58849847e6b43c042b17046371ef397be19", "shasum": "" }, "require": { @@ -8530,7 +8522,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.2.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.2.2" }, "funding": [ { @@ -8546,7 +8538,7 @@ "type": "tidelift" } ], - "time": "2024-10-18T09:50:33+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/dotenv", @@ -8797,12 +8789,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -8985,16 +8977,16 @@ }, { "name": "symfony/finder", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" + "reference": "87a71856f2f56e4100373e92529eed3171695cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", + "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", + "reference": "87a71856f2f56e4100373e92529eed3171695cfb", "shasum": "" }, "require": { @@ -9029,7 +9021,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.0" + "source": "https://github.com/symfony/finder/tree/v7.2.2" }, "funding": [ { @@ -9045,7 +9037,7 @@ "type": "tidelift" } ], - "time": "2024-10-23T06:56:12+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/flex", @@ -9117,16 +9109,16 @@ }, { "name": "symfony/form", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "7a48dda96fe16711fc042df38ca1a7dd4d9d6387" + "reference": "264cff30f52f12149aff92bbc23e78160a45c2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/7a48dda96fe16711fc042df38ca1a7dd4d9d6387", - "reference": "7a48dda96fe16711fc042df38ca1a7dd4d9d6387", + "url": "https://api.github.com/repos/symfony/form/zipball/264cff30f52f12149aff92bbc23e78160a45c2f3", + "reference": "264cff30f52f12149aff92bbc23e78160a45c2f3", "shasum": "" }, "require": { @@ -9194,7 +9186,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v7.1.6" + "source": "https://github.com/symfony/form/tree/v7.2.0" }, "funding": [ { @@ -9210,20 +9202,20 @@ "type": "tidelift" } ], - "time": "2024-10-09T08:46:59+00:00" + "time": "2024-11-27T11:55:00+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.1.6", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "1d616d762905091e798d64c53ffe3840ccfc3d89" + "reference": "aaf86f38b483ce101c7e60be050bc0140431cfe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/1d616d762905091e798d64c53ffe3840ccfc3d89", - "reference": "1d616d762905091e798d64c53ffe3840ccfc3d89", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/aaf86f38b483ce101c7e60be050bc0140431cfe2", + "reference": "aaf86f38b483ce101c7e60be050bc0140431cfe2", "shasum": "" }, "require": { @@ -9232,14 +9224,14 @@ "php": ">=8.2", "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^7.1.5", + "symfony/dependency-injection": "^7.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/filesystem": "^7.1", "symfony/finder": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", + "symfony/http-kernel": "^7.2", "symfony/polyfill-mbstring": "~1.0", "symfony/routing": "^6.4|^7.0" }, @@ -9264,14 +9256,15 @@ "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", "symfony/security-core": "<6.4", - "symfony/security-csrf": "<6.4", - "symfony/serializer": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.1", "symfony/stopwatch": "<6.4", "symfony/translation": "<6.4", "symfony/twig-bridge": "<6.4", "symfony/twig-bundle": "<6.4", "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.2", "symfony/workflow": "<6.4" }, "require-dev": { @@ -9303,7 +9296,7 @@ "symfony/scheduler": "^6.4.4|^7.0.4", "symfony/security-bundle": "^6.4|^7.0", "symfony/semaphore": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/serializer": "^7.1", "symfony/stopwatch": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", @@ -9312,9 +9305,10 @@ "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", + "symfony/webhook": "^7.2", "symfony/workflow": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", - "twig/twig": "^3.0.4" + "twig/twig": "^3.12" }, "type": "symfony-bundle", "autoload": { @@ -9342,7 +9336,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.1.6" + "source": "https://github.com/symfony/framework-bundle/tree/v7.2.2" }, "funding": [ { @@ -9358,30 +9352,31 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:11:02+00:00" + "time": "2024-12-19T14:25:03+00:00" }, { "name": "symfony/http-client", - "version": "v7.1.8", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a" + "reference": "339ba21476eb184290361542f732ad12c97591ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a", - "reference": "c30d91a1deac0dc3ed5e604683cf2e1dfc635b8a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/339ba21476eb184290361542f732ad12c97591ec", + "reference": "339ba21476eb184290361542f732ad12c97591ec", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "^3.4.1", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", "symfony/service-contracts": "^2.5|^3" }, "conflict": { + "amphp/amp": "<2.5", "php-http/discovery": "<1.15", "symfony/http-foundation": "<6.4" }, @@ -9392,14 +9387,14 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/amp": "^2.5", - "amphp/http-client": "^4.2.1", - "amphp/http-tunnel": "^1.0", + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", "amphp/socket": "^1.1", "guzzlehttp/promises": "^1.4|^2.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", @@ -9436,7 +9431,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.1.8" + "source": "https://github.com/symfony/http-client/tree/v7.2.2" }, "funding": [ { @@ -9452,7 +9447,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:40:27+00:00" + "time": "2024-12-30T18:35:15+00:00" }, { "name": "symfony/http-client-contracts", @@ -9534,16 +9529,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "e88a66c3997859532bc2ddd6dd8f35aba2711744" + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e88a66c3997859532bc2ddd6dd8f35aba2711744", - "reference": "e88a66c3997859532bc2ddd6dd8f35aba2711744", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/62d1a43796ca3fea3f83a8470dfe63a4af3bc588", + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588", "shasum": "" }, "require": { @@ -9592,7 +9587,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.2.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.2.2" }, "funding": [ { @@ -9608,20 +9603,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T18:58:46+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.2.1", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "d8ae58eecae44c8e66833e76cc50a4ad3c002d97" + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/d8ae58eecae44c8e66833e76cc50a4ad3c002d97", - "reference": "d8ae58eecae44c8e66833e76cc50a4ad3c002d97", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3c432966bd8c7ec7429663105f5a02d7e75b4306", + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306", "shasum": "" }, "require": { @@ -9706,7 +9701,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.2.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.2.2" }, "funding": [ { @@ -9722,7 +9717,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T12:09:10+00:00" + "time": "2024-12-31T14:59:40+00:00" }, { "name": "symfony/intl", @@ -9812,16 +9807,16 @@ }, { "name": "symfony/lock", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "1b898398007d80b4f32128df4b4f0c07c0368cf4" + "reference": "07212a5994a30e3667e95e5b16b2dda0685aff84" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/1b898398007d80b4f32128df4b4f0c07c0368cf4", - "reference": "1b898398007d80b4f32128df4b4f0c07c0368cf4", + "url": "https://api.github.com/repos/symfony/lock/zipball/07212a5994a30e3667e95e5b16b2dda0685aff84", + "reference": "07212a5994a30e3667e95e5b16b2dda0685aff84", "shasum": "" }, "require": { @@ -9870,7 +9865,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v7.1.6" + "source": "https://github.com/symfony/lock/tree/v7.2.0" }, "funding": [ { @@ -9886,20 +9881,20 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:34:21+00:00" + "time": "2024-10-25T15:34:29+00:00" }, { "name": "symfony/mailer", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "69c9948451fb3a6a4d47dc8261d1794734e76cdd" + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/69c9948451fb3a6a4d47dc8261d1794734e76cdd", - "reference": "69c9948451fb3a6a4d47dc8261d1794734e76cdd", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", "shasum": "" }, "require": { @@ -9908,7 +9903,7 @@ "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", + "symfony/mime": "^7.2", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -9950,7 +9945,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.1.6" + "source": "https://github.com/symfony/mailer/tree/v7.2.0" }, "funding": [ { @@ -9966,25 +9961,25 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-11-25T15:21:05+00:00" }, { "name": "symfony/mailgun-mailer", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/mailgun-mailer.git", - "reference": "b0117bf42b6dd8dfcfcab2a7e18508b594520b5a" + "reference": "3c1dfd9ff0a487a4116baec42d11ae21a061e3f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailgun-mailer/zipball/b0117bf42b6dd8dfcfcab2a7e18508b594520b5a", - "reference": "b0117bf42b6dd8dfcfcab2a7e18508b594520b5a", + "url": "https://api.github.com/repos/symfony/mailgun-mailer/zipball/3c1dfd9ff0a487a4116baec42d11ae21a061e3f1", + "reference": "3c1dfd9ff0a487a4116baec42d11ae21a061e3f1", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/mailer": "^6.4|^7.0" + "symfony/mailer": "^7.2" }, "conflict": { "symfony/http-foundation": "<6.4" @@ -10019,7 +10014,7 @@ "description": "Symfony Mailgun Mailer Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailgun-mailer/tree/v7.1.6" + "source": "https://github.com/symfony/mailgun-mailer/tree/v7.2.0" }, "funding": [ { @@ -10035,7 +10030,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-28T08:24:38+00:00" }, { "name": "symfony/mercure", @@ -10072,12 +10067,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, "branch-alias": { "dev-main": "0.6.x-dev" - }, - "thanks": { - "name": "dunglas/mercure", - "url": "https://github.com/dunglas/mercure" } }, "autoload": { @@ -10206,25 +10201,26 @@ }, { "name": "symfony/messenger", - "version": "v7.1.9", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "51e2b8b6a14b78ad7db60ef5f195ae893c16b9cc" + "reference": "cc0e820c02a0a887a88ddb52b7c4de4634677ce6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/51e2b8b6a14b78ad7db60ef5f195ae893c16b9cc", - "reference": "51e2b8b6a14b78ad7db60ef5f195ae893c16b9cc", + "url": "https://api.github.com/repos/symfony/messenger/zipball/cc0e820c02a0a887a88ddb52b7c4de4634677ce6", + "reference": "cc0e820c02a0a887a88ddb52b7c4de4634677ce6", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/clock": "^6.4|^7.0" + "symfony/clock": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "symfony/console": "<6.4", + "symfony/console": "<7.2", "symfony/event-dispatcher": "<6.4", "symfony/event-dispatcher-contracts": "<2.5", "symfony/framework-bundle": "<6.4", @@ -10233,7 +10229,7 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/console": "^6.4|^7.0", + "symfony/console": "^7.2", "symfony/dependency-injection": "^6.4|^7.0", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -10272,7 +10268,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v7.1.9" + "source": "https://github.com/symfony/messenger/tree/v7.2.1" }, "funding": [ { @@ -10288,7 +10284,7 @@ "type": "tidelift" } ], - "time": "2024-11-26T09:50:51+00:00" + "time": "2024-12-07T08:08:50+00:00" }, { "name": "symfony/mime", @@ -10376,16 +10372,16 @@ }, { "name": "symfony/monolog-bridge", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "e1da878cf5f701df5f5c1799bdbf827acee5a76e" + "reference": "bbae784f0456c5a87c89d7c1a3fcc9cbee976c1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/e1da878cf5f701df5f5c1799bdbf827acee5a76e", - "reference": "e1da878cf5f701df5f5c1799bdbf827acee5a76e", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/bbae784f0456c5a87c89d7c1a3fcc9cbee976c1d", + "reference": "bbae784f0456c5a87c89d7c1a3fcc9cbee976c1d", "shasum": "" }, "require": { @@ -10434,7 +10430,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.1.6" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.2.0" }, "funding": [ { @@ -10450,7 +10446,7 @@ "type": "tidelift" } ], - "time": "2024-10-14T08:49:35+00:00" + "time": "2024-10-14T18:16:08+00:00" }, { "name": "symfony/monolog-bundle", @@ -10773,8 +10769,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -11336,8 +11332,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -11452,16 +11448,16 @@ }, { "name": "symfony/property-access", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "975d7f7fd8fcb952364c6badc46d01a580532bf9" + "reference": "3ae42efba01e45aaedecf5c93c8d6a3ab3a82276" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/975d7f7fd8fcb952364c6badc46d01a580532bf9", - "reference": "975d7f7fd8fcb952364c6badc46d01a580532bf9", + "url": "https://api.github.com/repos/symfony/property-access/zipball/3ae42efba01e45aaedecf5c93c8d6a3ab3a82276", + "reference": "3ae42efba01e45aaedecf5c93c8d6a3ab3a82276", "shasum": "" }, "require": { @@ -11508,7 +11504,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.1.6" + "source": "https://github.com/symfony/property-access/tree/v7.2.0" }, "funding": [ { @@ -11524,26 +11520,26 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-26T12:28:35+00:00" }, { "name": "symfony/property-info", - "version": "v7.1.9", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "e9a7b2a4984457c3849afd2b1a1ec7f2994cb1b5" + "reference": "1dfeb0dac7a99f7b3be42db9ccc299c5a6483fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/e9a7b2a4984457c3849afd2b1a1ec7f2994cb1b5", - "reference": "e9a7b2a4984457c3849afd2b1a1ec7f2994cb1b5", + "url": "https://api.github.com/repos/symfony/property-info/zipball/1dfeb0dac7a99f7b3be42db9ccc299c5a6483fcf", + "reference": "1dfeb0dac7a99f7b3be42db9ccc299c5a6483fcf", "shasum": "" }, "require": { "php": ">=8.2", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "~7.1.9|^7.2.2" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -11591,7 +11587,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.1.9" + "source": "https://github.com/symfony/property-info/tree/v7.2.2" }, "funding": [ { @@ -11607,20 +11603,20 @@ "type": "tidelift" } ], - "time": "2024-11-27T09:50:41+00:00" + "time": "2024-12-31T11:04:50+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "f16471bb19f6685b9ccf0a2c03c213840ae68cd6" + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/f16471bb19f6685b9ccf0a2c03c213840ae68cd6", - "reference": "f16471bb19f6685b9ccf0a2c03c213840ae68cd6", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", "shasum": "" }, "require": { @@ -11674,7 +11670,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.1.6" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.2.0" }, "funding": [ { @@ -11690,7 +11686,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-26T08:57:56+00:00" }, { "name": "symfony/rate-limiter", @@ -11912,16 +11908,16 @@ }, { "name": "symfony/runtime", - "version": "v7.1.7", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "9889783c17e8a68fa5e88c8e8a1a85e802558dba" + "reference": "2c350568f3eaccb25fbbbf962bd67cde273121a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/9889783c17e8a68fa5e88c8e8a1a85e802558dba", - "reference": "9889783c17e8a68fa5e88c8e8a1a85e802558dba", + "url": "https://api.github.com/repos/symfony/runtime/zipball/2c350568f3eaccb25fbbbf962bd67cde273121a7", + "reference": "2c350568f3eaccb25fbbbf962bd67cde273121a7", "shasum": "" }, "require": { @@ -11971,7 +11967,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.1.7" + "source": "https://github.com/symfony/runtime/tree/v7.2.0" }, "funding": [ { @@ -11987,20 +11983,20 @@ "type": "tidelift" } ], - "time": "2024-11-05T16:45:54+00:00" + "time": "2024-11-06T11:43:25+00:00" }, { "name": "symfony/scheduler", - "version": "v7.1.6", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/scheduler.git", - "reference": "26d10926d6b8a52897468e6e4fc5c447779af533" + "reference": "78add01da6cbbbdcc95494bcd30e4658441475e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/scheduler/zipball/26d10926d6b8a52897468e6e4fc5c447779af533", - "reference": "26d10926d6b8a52897468e6e4fc5c447779af533", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/78add01da6cbbbdcc95494bcd30e4658441475e1", + "reference": "78add01da6cbbbdcc95494bcd30e4658441475e1", "shasum": "" }, "require": { @@ -12051,7 +12047,7 @@ "scheduler" ], "support": { - "source": "https://github.com/symfony/scheduler/tree/v7.1.6" + "source": "https://github.com/symfony/scheduler/tree/v7.2.2" }, "funding": [ { @@ -12067,20 +12063,20 @@ "type": "tidelift" } ], - "time": "2024-10-14T06:12:26+00:00" + "time": "2024-12-19T14:25:03+00:00" }, { "name": "symfony/security-bundle", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "4bed2029576bf02a6915c5a58bc8a174af338e6f" + "reference": "e7b04b503a4eb49307b9997ac9370f403c2f5198" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/4bed2029576bf02a6915c5a58bc8a174af338e6f", - "reference": "4bed2029576bf02a6915c5a58bc8a174af338e6f", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/e7b04b503a4eb49307b9997ac9370f403c2f5198", + "reference": "e7b04b503a4eb49307b9997ac9370f403c2f5198", "shasum": "" }, "require": { @@ -12157,7 +12153,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.2.0" + "source": "https://github.com/symfony/security-bundle/tree/v7.2.2" }, "funding": [ { @@ -12173,7 +12169,7 @@ "type": "tidelift" } ], - "time": "2024-10-23T08:31:32+00:00" + "time": "2024-12-30T18:55:54+00:00" }, { "name": "symfony/security-core", @@ -12264,16 +12260,16 @@ }, { "name": "symfony/security-csrf", - "version": "v7.2.0", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "5d884aff316bd4f24c4c2ab4d5f02a00df4b08cf" + "reference": "a2031e57dc02002163770a5cc02fafdd70decf1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/5d884aff316bd4f24c4c2ab4d5f02a00df4b08cf", - "reference": "5d884aff316bd4f24c4c2ab4d5f02a00df4b08cf", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/a2031e57dc02002163770a5cc02fafdd70decf1d", + "reference": "a2031e57dc02002163770a5cc02fafdd70decf1d", "shasum": "" }, "require": { @@ -12314,7 +12310,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v7.2.0" + "source": "https://github.com/symfony/security-csrf/tree/v7.2.2" }, "funding": [ { @@ -12330,20 +12326,20 @@ "type": "tidelift" } ], - "time": "2024-10-08T13:08:31+00:00" + "time": "2024-12-20T09:56:48+00:00" }, { "name": "symfony/security-http", - "version": "v7.2.0", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "0d0ab4d491f22306c893b2d30ce73ea911201a61" + "reference": "125844598d9cef4fe72a9f6c4a78ac7c59c3f532" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/0d0ab4d491f22306c893b2d30ce73ea911201a61", - "reference": "0d0ab4d491f22306c893b2d30ce73ea911201a61", + "url": "https://api.github.com/repos/symfony/security-http/zipball/125844598d9cef4fe72a9f6c4a78ac7c59c3f532", + "reference": "125844598d9cef4fe72a9f6c4a78ac7c59c3f532", "shasum": "" }, "require": { @@ -12402,7 +12398,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.2.0" + "source": "https://github.com/symfony/security-http/tree/v7.2.1" }, "funding": [ { @@ -12418,20 +12414,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:40:36+00:00" + "time": "2024-12-07T08:50:44+00:00" }, { "name": "symfony/serializer", - "version": "v7.1.9", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "39ec8beb1b8149c96785abad0e9ee390375a3e58" + "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/39ec8beb1b8149c96785abad0e9ee390375a3e58", - "reference": "39ec8beb1b8149c96785abad0e9ee390375a3e58", + "url": "https://api.github.com/repos/symfony/serializer/zipball/3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", + "reference": "3f5ed9f5e6c02e3853109190ba38408f5e1d2dd0", "shasum": "" }, "require": { @@ -12445,7 +12441,6 @@ "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", - "symfony/type-info": "<7.1.5", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4" @@ -12457,7 +12452,7 @@ "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dependency-injection": "^7.2", "symfony/error-handler": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", "symfony/form": "^6.4|^7.0", @@ -12468,7 +12463,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1.5", + "symfony/type-info": "^7.1", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -12501,7 +12496,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.1.9" + "source": "https://github.com/symfony/serializer/tree/v7.2.0" }, "funding": [ { @@ -12517,7 +12512,7 @@ "type": "tidelift" } ], - "time": "2024-11-14T21:24:44+00:00" + "time": "2024-11-25T15:21:05+00:00" }, { "name": "symfony/service-contracts", @@ -12543,12 +12538,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -12604,16 +12599,16 @@ }, { "name": "symfony/stimulus-bundle", - "version": "v2.21.0", + "version": "v2.22.1", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "e5f7747b514865719e0990389ce35a9b71bebb48" + "reference": "e13034d428354023c82a1db108d40fdf6cec2d36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/e5f7747b514865719e0990389ce35a9b71bebb48", - "reference": "e5f7747b514865719e0990389ce35a9b71bebb48", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/e13034d428354023c82a1db108d40fdf6cec2d36", + "reference": "e13034d428354023c82a1db108d40fdf6cec2d36", "shasum": "" }, "require": { @@ -12653,7 +12648,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.21.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.22.1" }, "funding": [ { @@ -12669,20 +12664,20 @@ "type": "tidelift" } ], - "time": "2024-10-05T22:11:16+00:00" + "time": "2024-12-06T14:30:33+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.1.6", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "8b4a434e6e7faf6adedffb48783a5c75409a1a05" + "reference": "e46690d5b9d7164a6d061cab1e8d46141b9f49df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8b4a434e6e7faf6adedffb48783a5c75409a1a05", - "reference": "8b4a434e6e7faf6adedffb48783a5c75409a1a05", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/e46690d5b9d7164a6d061cab1e8d46141b9f49df", + "reference": "e46690d5b9d7164a6d061cab1e8d46141b9f49df", "shasum": "" }, "require": { @@ -12715,7 +12710,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.1.6" + "source": "https://github.com/symfony/stopwatch/tree/v7.2.2" }, "funding": [ { @@ -12731,20 +12726,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-12-18T14:28:33+00:00" }, { "name": "symfony/string", - "version": "v7.1.8", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "591ebd41565f356fcd8b090fe64dbb5878f50281" + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/591ebd41565f356fcd8b090fe64dbb5878f50281", - "reference": "591ebd41565f356fcd8b090fe64dbb5878f50281", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", "shasum": "" }, "require": { @@ -12802,7 +12797,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.8" + "source": "https://github.com/symfony/string/tree/v7.2.0" }, "funding": [ { @@ -12818,24 +12813,25 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:21+00:00" + "time": "2024-11-13T13:31:26+00:00" }, { "name": "symfony/translation", - "version": "v7.1.6", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "b9f72ab14efdb6b772f85041fa12f820dee8d55f" + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/b9f72ab14efdb6b772f85041fa12f820dee8d55f", - "reference": "b9f72ab14efdb6b772f85041fa12f820dee8d55f", + "url": "https://api.github.com/repos/symfony/translation/zipball/e2674a30132b7cc4d74540d6c2573aa363f05923", + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/translation-contracts": "^2.5|^3.0" }, @@ -12896,7 +12892,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.1.6" + "source": "https://github.com/symfony/translation/tree/v7.2.2" }, "funding": [ { @@ -12912,7 +12908,7 @@ "type": "tidelift" } ], - "time": "2024-09-28T12:35:13+00:00" + "time": "2024-12-07T08:18:10+00:00" }, { "name": "symfony/translation-contracts", @@ -12933,12 +12929,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -12994,22 +12990,23 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.1.8", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "535ab0be4fc563b2bc5fc0cc9e388626d226c63f" + "reference": "29e4c66de9618e67dc1f5f13bc667aca2a228f1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/535ab0be4fc563b2bc5fc0cc9e388626d226c63f", - "reference": "535ab0be4fc563b2bc5fc0cc9e388626d226c63f", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/29e4c66de9618e67dc1f5f13bc667aca2a228f1e", + "reference": "29e4c66de9618e67dc1f5f13bc667aca2a228f1e", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.9" + "twig/twig": "^3.12" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", @@ -13083,7 +13080,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.1.8" + "source": "https://github.com/symfony/twig-bridge/tree/v7.2.2" }, "funding": [ { @@ -13099,20 +13096,20 @@ "type": "tidelift" } ], - "time": "2024-11-10T02:47:09+00:00" + "time": "2024-12-19T14:25:03+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "af902314a71fb412ae412094f7e1d7e49594507b" + "reference": "cd2be4563afaef5285bb6e0a06c5445e644a5c01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/af902314a71fb412ae412094f7e1d7e49594507b", - "reference": "af902314a71fb412ae412094f7e1d7e49594507b", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/cd2be4563afaef5285bb6e0a06c5445e644a5c01", + "reference": "cd2be4563afaef5285bb6e0a06c5445e644a5c01", "shasum": "" }, "require": { @@ -13123,7 +13120,7 @@ "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/twig-bridge": "^6.4|^7.0", - "twig/twig": "^3.0.4" + "twig/twig": "^3.12" }, "conflict": { "symfony/framework-bundle": "<6.4", @@ -13167,7 +13164,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.1.6" + "source": "https://github.com/symfony/twig-bundle/tree/v7.2.0" }, "funding": [ { @@ -13183,35 +13180,28 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-10-23T08:11:15+00:00" }, { "name": "symfony/type-info", - "version": "v7.1.8", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "51535dde21c7abf65c9d000a30bb15f6478195e6" + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/51535dde21c7abf65c9d000a30bb15f6478195e6", - "reference": "51535dde21c7abf65c9d000a30bb15f6478195e6", + "url": "https://api.github.com/repos/symfony/type-info/zipball/3b5a17470fff0034f25fd4287cbdaa0010d2f749", + "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749", "shasum": "" }, "require": { "php": ">=8.2", "psr/container": "^1.1|^2.0" }, - "conflict": { - "phpstan/phpdoc-parser": "<1.0", - "symfony/dependency-injection": "<6.4", - "symfony/property-info": "<6.4" - }, "require-dev": { - "phpstan/phpdoc-parser": "^1.0|^2.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0" + "phpstan/phpdoc-parser": "^1.0|^2.0" }, "type": "library", "autoload": { @@ -13249,7 +13239,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.1.8" + "source": "https://github.com/symfony/type-info/tree/v7.2.2" }, "funding": [ { @@ -13265,20 +13255,20 @@ "type": "tidelift" } ], - "time": "2024-11-07T15:49:33+00:00" + "time": "2024-12-20T13:38:37+00:00" }, { "name": "symfony/uid", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "65befb3bb2d503bbffbd08c815aa38b472999917" + "reference": "2d294d0c48df244c71c105a169d0190bfb080426" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/65befb3bb2d503bbffbd08c815aa38b472999917", - "reference": "65befb3bb2d503bbffbd08c815aa38b472999917", + "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", + "reference": "2d294d0c48df244c71c105a169d0190bfb080426", "shasum": "" }, "require": { @@ -13323,7 +13313,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.1.6" + "source": "https://github.com/symfony/uid/tree/v7.2.0" }, "funding": [ { @@ -13339,7 +13329,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/ux-autocomplete", @@ -13433,16 +13423,16 @@ }, { "name": "symfony/ux-chartjs", - "version": "v2.21.0", + "version": "v2.22.1", "source": { "type": "git", "url": "https://github.com/symfony/ux-chartjs.git", - "reference": "70ff570d96812487c523afd5756df671ec5c5cee" + "reference": "6e7de01ea469840da2b7458b660b52e846e279e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/70ff570d96812487c523afd5756df671ec5c5cee", - "reference": "70ff570d96812487c523afd5756df671ec5c5cee", + "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/6e7de01ea469840da2b7458b660b52e846e279e1", + "reference": "6e7de01ea469840da2b7458b660b52e846e279e1", "shasum": "" }, "require": { @@ -13464,8 +13454,8 @@ "type": "symfony-bundle", "extra": { "thanks": { - "name": "symfony/ux", - "url": "https://github.com/symfony/ux" + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" } }, "autoload": { @@ -13493,7 +13483,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-chartjs/tree/v2.21.0" + "source": "https://github.com/symfony/ux-chartjs/tree/v2.22.1" }, "funding": [ { @@ -13509,20 +13499,20 @@ "type": "tidelift" } ], - "time": "2024-10-15T10:36:49+00:00" + "time": "2024-12-05T14:25:02+00:00" }, { "name": "symfony/ux-twig-component", - "version": "v2.22.0", + "version": "v2.22.1", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "03177a494399fbdcbb1f5f2aee017ccf8df581d9" + "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/03177a494399fbdcbb1f5f2aee017ccf8df581d9", - "reference": "03177a494399fbdcbb1f5f2aee017ccf8df581d9", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/9b347f6ca2d9e18cee630787f0a6aa453982bf18", + "reference": "9b347f6ca2d9e18cee630787f0a6aa453982bf18", "shasum": "" }, "require": { @@ -13576,7 +13566,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.22.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v2.22.1" }, "funding": [ { @@ -13592,20 +13582,20 @@ "type": "tidelift" } ], - "time": "2024-11-23T06:59:34+00:00" + "time": "2024-12-07T18:05:50+00:00" }, { "name": "symfony/validator", - "version": "v7.1.8", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "85a90c0a4ab0d10c118d3cdf39115e00d9cca7d0" + "reference": "5c01f00fed258a987ef35f0fefcc069f84111cb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/85a90c0a4ab0d10c118d3cdf39115e00d9cca7d0", - "reference": "85a90c0a4ab0d10c118d3cdf39115e00d9cca7d0", + "url": "https://api.github.com/repos/symfony/validator/zipball/5c01f00fed258a987ef35f0fefcc069f84111cb4", + "reference": "5c01f00fed258a987ef35f0fefcc069f84111cb4", "shasum": "" }, "require": { @@ -13673,7 +13663,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.1.8" + "source": "https://github.com/symfony/validator/tree/v7.2.2" }, "funding": [ { @@ -13689,7 +13679,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T15:46:42+00:00" + "time": "2024-12-30T18:35:15+00:00" }, { "name": "symfony/var-dumper", @@ -13852,16 +13842,16 @@ }, { "name": "symfony/web-link", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/web-link.git", - "reference": "383aa7566f25e3a1ab323732c2cc6a1748120d3a" + "reference": "f537556a885e14a1d28f6c759d41e57e93d0a532" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-link/zipball/383aa7566f25e3a1ab323732c2cc6a1748120d3a", - "reference": "383aa7566f25e3a1ab323732c2cc6a1748120d3a", + "url": "https://api.github.com/repos/symfony/web-link/zipball/f537556a885e14a1d28f6c759d41e57e93d0a532", + "reference": "f537556a885e14a1d28f6c759d41e57e93d0a532", "shasum": "" }, "require": { @@ -13915,7 +13905,7 @@ "push" ], "support": { - "source": "https://github.com/symfony/web-link/tree/v7.1.6" + "source": "https://github.com/symfony/web-link/tree/v7.2.0" }, "funding": [ { @@ -13931,7 +13921,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/webpack-encore-bundle", @@ -13965,8 +13955,8 @@ "type": "symfony-bundle", "extra": { "thanks": { - "name": "symfony/webpack-encore", - "url": "https://github.com/symfony/webpack-encore" + "url": "https://github.com/symfony/webpack-encore", + "name": "symfony/webpack-encore" } }, "autoload": { @@ -14214,16 +14204,16 @@ }, { "name": "symfonycasts/verify-email-bundle", - "version": "v1.17.2", + "version": "v1.17.3", "source": { "type": "git", "url": "https://github.com/SymfonyCasts/verify-email-bundle.git", - "reference": "05d47360e423cd8cb6cde639972abefcf7aac7f0" + "reference": "2cb1cd94ca7a65471563a5cb91ddf40e8433844e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/05d47360e423cd8cb6cde639972abefcf7aac7f0", - "reference": "05d47360e423cd8cb6cde639972abefcf7aac7f0", + "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/2cb1cd94ca7a65471563a5cb91ddf40e8433844e", + "reference": "2cb1cd94ca7a65471563a5cb91ddf40e8433844e", "shasum": "" }, "require": { @@ -14254,9 +14244,9 @@ "description": "Simple, stylish Email Verification for Symfony", "support": { "issues": "https://github.com/SymfonyCasts/verify-email-bundle/issues", - "source": "https://github.com/SymfonyCasts/verify-email-bundle/tree/v1.17.2" + "source": "https://github.com/SymfonyCasts/verify-email-bundle/tree/v1.17.3" }, - "time": "2024-10-22T11:14:17+00:00" + "time": "2024-12-09T18:44:25+00:00" }, { "name": "thenetworg/oauth2-azure", @@ -14375,7 +14365,7 @@ }, { "name": "twig/cssinliner-extra", - "version": "v3.17.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", @@ -14428,7 +14418,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.17.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.18.0" }, "funding": [ { @@ -14444,7 +14434,7 @@ }, { "name": "twig/extra-bundle", - "version": "v3.15.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", @@ -14502,7 +14492,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.15.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.18.0" }, "funding": [ { @@ -14518,16 +14508,16 @@ }, { "name": "twig/html-extra", - "version": "v3.15.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/html-extra.git", - "reference": "2086023d3ffc4bae2b1115f715d17f97fd013665" + "reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/html-extra/zipball/2086023d3ffc4bae2b1115f715d17f97fd013665", - "reference": "2086023d3ffc4bae2b1115f715d17f97fd013665", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/c63b28e192c1b7c15bb60f81d2e48b140846239a", + "reference": "c63b28e192c1b7c15bb60f81d2e48b140846239a", "shasum": "" }, "require": { @@ -14570,7 +14560,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/html-extra/tree/v3.15.0" + "source": "https://github.com/twigphp/html-extra/tree/v3.18.0" }, "funding": [ { @@ -14582,7 +14572,7 @@ "type": "tidelift" } ], - "time": "2024-09-30T06:41:48+00:00" + "time": "2024-12-29T10:29:59+00:00" }, { "name": "twig/intl-extra", @@ -14730,16 +14720,16 @@ }, { "name": "web-token/jwt-library", - "version": "4.0.1", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "cdf0d7ca1c9f3a7c6c8589a02e4b810f5ab74cd4" + "reference": "3009d118320dd78d9c1d4f4cc506a20647bf747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/cdf0d7ca1c9f3a7c6c8589a02e4b810f5ab74cd4", - "reference": "cdf0d7ca1c9f3a7c6c8589a02e4b810f5ab74cd4", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/3009d118320dd78d9c1d4f4cc506a20647bf747a", + "reference": "3009d118320dd78d9c1d4f4cc506a20647bf747a", "shasum": "" }, "require": { @@ -14804,7 +14794,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.0.1" + "source": "https://github.com/web-token/jwt-library/tree/4.0.2" }, "funding": [ { @@ -14816,7 +14806,7 @@ "type": "patreon" } ], - "time": "2024-07-09T16:28:26+00:00" + "time": "2025-01-03T17:50:45+00:00" }, { "name": "webmozart/assert", @@ -15069,16 +15059,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.6.0", + "version": "v7.7.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "68ff89a8de47d086588e391a516d2a5b5fde6254" + "reference": "4fb3f73bc5a4c3146bac2850af7dc72435a32daf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/68ff89a8de47d086588e391a516d2a5b5fde6254", - "reference": "68ff89a8de47d086588e391a516d2a5b5fde6254", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/4fb3f73bc5a4c3146bac2850af7dc72435a32daf", + "reference": "4fb3f73bc5a4c3146bac2850af7dc72435a32daf", "shasum": "" }, "require": { @@ -15087,26 +15077,26 @@ "ext-reflection": "*", "ext-simplexml": "*", "fidry/cpu-core-counter": "^1.2.0", - "jean85/pretty-package-versions": "^2.0.6", + "jean85/pretty-package-versions": "^2.1.0", "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.7", + "phpunit/php-code-coverage": "^11.0.8", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.4.1", + "phpunit/phpunit": "^11.5.1", "sebastian/environment": "^7.2.0", - "symfony/console": "^6.4.11 || ^7.1.5", - "symfony/process": "^6.4.8 || ^7.1.5" + "symfony/console": "^6.4.14 || ^7.2.1", + "symfony/process": "^6.4.14 || ^7.2.0" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^1.12.6", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "squizlabs/php_codesniffer": "^3.10.3", - "symfony/filesystem": "^6.4.9 || ^7.1.5" + "phpstan/phpstan": "^2.0.3", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.1", + "phpstan/phpstan-strict-rules": "^2", + "squizlabs/php_codesniffer": "^3.11.1", + "symfony/filesystem": "^6.4.13 || ^7.2.0" }, "bin": [ "bin/paratest", @@ -15146,7 +15136,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.6.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.7.0" }, "funding": [ { @@ -15158,7 +15148,7 @@ "type": "paypal" } ], - "time": "2024-10-15T12:38:31+00:00" + "time": "2024-12-11T14:50:44+00:00" }, { "name": "dama/doctrine-test-bundle", @@ -15229,16 +15219,16 @@ }, { "name": "doctrine/data-fixtures", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "eddc4c37beff550a9172a449ab285f4511573dc8" + "reference": "2ae45139f148c9272c49a521d82cc50491355a99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/eddc4c37beff550a9172a449ab285f4511573dc8", - "reference": "eddc4c37beff550a9172a449ab285f4511573dc8", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/2ae45139f148c9272c49a521d82cc50491355a99", + "reference": "2ae45139f148c9272c49a521d82cc50491355a99", "shasum": "" }, "require": { @@ -15292,7 +15282,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/2.0.0" + "source": "https://github.com/doctrine/data-fixtures/tree/2.0.1" }, "funding": [ { @@ -15308,44 +15298,44 @@ "type": "tidelift" } ], - "time": "2024-11-06T07:58:24+00:00" + "time": "2024-12-10T07:03:23+00:00" }, { "name": "doctrine/doctrine-fixtures-bundle", - "version": "3.7.0", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", - "reference": "3004c9b3ac3d2a8f484597416ae1882040b3ef3b" + "reference": "bd59519a7532b9e1a41cef4049d5326dfac7def9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/3004c9b3ac3d2a8f484597416ae1882040b3ef3b", - "reference": "3004c9b3ac3d2a8f484597416ae1882040b3ef3b", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/bd59519a7532b9e1a41cef4049d5326dfac7def9", + "reference": "bd59519a7532b9e1a41cef4049d5326dfac7def9", "shasum": "" }, "require": { - "doctrine/data-fixtures": "^1.5|^2.0", + "doctrine/data-fixtures": "^1.5 || ^2.0", "doctrine/doctrine-bundle": "^2.2", "doctrine/orm": "^2.14.0 || ^3.0", - "doctrine/persistence": "^2.4|^3.0", + "doctrine/persistence": "^2.4 || ^3.0", "php": "^7.4 || ^8.0", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/doctrine-bridge": "^5.4.48|^6.4.16|^7.1.9", - "symfony/http-kernel": "^5.4|^6.0|^7.0" + "psr/log": "^1 || ^2 || ^3", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^5.4.48 || ^6.4.16 || ^7.1.9", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0" }, "conflict": { "doctrine/dbal": "< 3" }, "require-dev": { "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10.39", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9.6.13", - "symfony/phpunit-bridge": "^6.3.6", - "vimeo/psalm": "^5.15" + "symfony/phpunit-bridge": "^6.3.6" }, "type": "symfony-bundle", "autoload": { @@ -15379,7 +15369,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", - "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.7.0" + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.7.1" }, "funding": [ { @@ -15395,7 +15385,7 @@ "type": "tidelift" } ], - "time": "2024-11-28T07:19:21+00:00" + "time": "2024-12-03T17:07:51+00:00" }, { "name": "fakerphp/faker", @@ -15769,16 +15759,16 @@ }, { "name": "marc-mabe/php-enum", - "version": "v4.7.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/marc-mabe/php-enum.git", - "reference": "3da42cc1daceaf98c858e56f59d1ccd52b011fdc" + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/3da42cc1daceaf98c858e56f59d1ccd52b011fdc", - "reference": "3da42cc1daceaf98c858e56f59d1ccd52b011fdc", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/7159809e5cfa041dca28e61f7f7ae58063aae8ed", + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed", "shasum": "" }, "require": { @@ -15789,13 +15779,13 @@ "phpbench/phpbench": "^0.16.10 || ^1.0.4", "phpstan/phpstan": "^1.3.1", "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", - "vimeo/psalm": "^4.17.0" + "vimeo/psalm": "^4.17.0 | ^5.26.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.6-dev", - "dev-3.x": "3.2-dev" + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" } }, "autoload": { @@ -15836,9 +15826,9 @@ ], "support": { "issues": "https://github.com/marc-mabe/php-enum/issues", - "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.0" + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.1" }, - "time": "2022-04-19T02:21:46+00:00" + "time": "2024-11-28T04:54:44+00:00" }, { "name": "masterminds/html5", @@ -15969,16 +15959,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.4.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "447a020a1f875a434d62f2a401f53b82a396e494" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", + "reference": "447a020a1f875a434d62f2a401f53b82a396e494", "shasum": "" }, "require": { @@ -16021,9 +16011,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2024-12-30T11:07:19+00:00" }, { "name": "phar-io/manifest", @@ -16145,16 +16135,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "46b4d3529b12178112d9008337beda0cc2a1a6b4" + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46b4d3529b12178112d9008337beda0cc2a1a6b4", - "reference": "46b4d3529b12178112d9008337beda0cc2a1a6b4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", "shasum": "" }, "require": { @@ -16199,20 +16189,20 @@ "type": "github" } ], - "time": "2024-11-28T22:19:37+00:00" + "time": "2025-01-05T16:43:48+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.7", + "version": "11.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca" + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7f08030e8811582cc459871d28d6f5a1a4d35ca", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/418c59fd080954f8c4aa5631d9502ecda2387118", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118", "shasum": "" }, "require": { @@ -16231,7 +16221,7 @@ "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^11.4.1" + "phpunit/phpunit": "^11.5.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -16269,7 +16259,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.7" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.8" }, "funding": [ { @@ -16277,7 +16267,7 @@ "type": "github" } ], - "time": "2024-10-09T06:21:38+00:00" + "time": "2024-12-11T12:34:27+00:00" }, { "name": "phpunit/php-file-iterator", @@ -16526,16 +16516,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.4.3", + "version": "11.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76" + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e8e8ed1854de5d36c088ec1833beae40d2dedd76", - "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/153d0531b9f7e883c5053160cad6dd5ac28140b3", + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3", "shasum": "" }, "require": { @@ -16545,25 +16535,26 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.7", + "phpunit/php-code-coverage": "^11.0.8", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.1.1", + "sebastian/code-unit": "^3.0.2", + "sebastian/comparator": "^6.2.1", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.0", - "sebastian/exporter": "^6.1.3", + "sebastian/exporter": "^6.3.0", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.0", - "sebastian/version": "^5.0.2" + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -16574,7 +16565,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "11.4-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -16606,7 +16597,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.2" }, "funding": [ { @@ -16622,7 +16613,7 @@ "type": "tidelift" } ], - "time": "2024-10-28T13:07:50+00:00" + "time": "2024-12-21T05:51:08+00:00" }, { "name": "sebastian/cli-parser", @@ -16683,23 +16674,23 @@ }, { "name": "sebastian/code-unit", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268" + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6bb7d09d6623567178cf54126afa9c2310114268", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { @@ -16728,7 +16719,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" }, "funding": [ { @@ -16736,7 +16727,7 @@ "type": "github" } ], - "time": "2024-07-03T04:44:28+00:00" + "time": "2024-12-12T09:59:06+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -16796,16 +16787,16 @@ }, { "name": "sebastian/comparator", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739" + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/43d129d6a0f81c78bee378b46688293eb7ea3739", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/d4e47a769525c4dd38cea90e5dcd435ddbbc7115", + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115", "shasum": "" }, "require": { @@ -16818,6 +16809,9 @@ "require-dev": { "phpunit/phpunit": "^11.4" }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, "type": "library", "extra": { "branch-alias": { @@ -16861,7 +16855,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.2.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.0" }, "funding": [ { @@ -16869,7 +16863,7 @@ "type": "github" } ], - "time": "2024-10-31T05:30:08+00:00" + "time": "2025-01-06T10:28:19+00:00" }, { "name": "sebastian/complexity", @@ -17062,16 +17056,16 @@ }, { "name": "sebastian/exporter", - "version": "6.1.3", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e" + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", "shasum": "" }, "require": { @@ -17080,7 +17074,7 @@ "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { @@ -17128,7 +17122,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.1.3" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" }, "funding": [ { @@ -17136,7 +17130,7 @@ "type": "github" } ], - "time": "2024-07-03T04:56:19+00:00" + "time": "2024-12-05T09:17:50+00:00" }, { "name": "sebastian/global-state", @@ -17547,6 +17541,58 @@ ], "time": "2024-10-09T05:16:32+00:00" }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, { "name": "symfony/browser-kit", "version": "v7.2.0", @@ -17617,16 +17663,16 @@ }, { "name": "symfony/debug-bundle", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/debug-bundle.git", - "reference": "c91a650aa390071d22dfaf32c2ff77fda27e9583" + "reference": "2dade0d1415c08b627379b5ec214ec8424cb2e32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/c91a650aa390071d22dfaf32c2ff77fda27e9583", - "reference": "c91a650aa390071d22dfaf32c2ff77fda27e9583", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/2dade0d1415c08b627379b5ec214ec8424cb2e32", + "reference": "2dade0d1415c08b627379b5ec214ec8424cb2e32", "shasum": "" }, "require": { @@ -17671,7 +17717,7 @@ "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug-bundle/tree/v7.1.6" + "source": "https://github.com/symfony/debug-bundle/tree/v7.2.0" }, "funding": [ { @@ -17687,7 +17733,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/dom-crawler", @@ -17850,16 +17896,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v7.1.6", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "c6b9d8f52d3e276bedb49612aa4a2a046171287f" + "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/c6b9d8f52d3e276bedb49612aa4a2a046171287f", - "reference": "c6b9d8f52d3e276bedb49612aa4a2a046171287f", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/2bbde92ab25a0e2c88160857af7be9db5da0d145", + "reference": "2bbde92ab25a0e2c88160857af7be9db5da0d145", "shasum": "" }, "require": { @@ -17879,8 +17925,8 @@ "type": "symfony-bridge", "extra": { "thanks": { - "name": "phpunit/phpunit", - "url": "https://github.com/sebastianbergmann/phpunit" + "url": "https://github.com/sebastianbergmann/phpunit", + "name": "phpunit/phpunit" } }, "autoload": { @@ -17912,7 +17958,7 @@ "description": "Provides utilities for PHPUnit, especially user deprecation notices management", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.1.6" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.2.0" }, "funding": [ { @@ -17928,20 +17974,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-11-13T16:15:23+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v7.1.7", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "13d97a9acb23663550495fa2480e8cc95033ad61" + "reference": "5d37d9bd86ab49bd94c57e18e601e27fb6760f2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/13d97a9acb23663550495fa2480e8cc95033ad61", - "reference": "13d97a9acb23663550495fa2480e8cc95033ad61", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/5d37d9bd86ab49bd94c57e18e601e27fb6760f2c", + "reference": "5d37d9bd86ab49bd94c57e18e601e27fb6760f2c", "shasum": "" }, "require": { @@ -17951,12 +17997,13 @@ "symfony/http-kernel": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0", "symfony/twig-bundle": "^6.4|^7.0", - "twig/twig": "^3.10" + "twig/twig": "^3.12" }, "conflict": { "symfony/form": "<6.4", "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4" + "symfony/messenger": "<6.4", + "symfony/serializer": "<7.2" }, "require-dev": { "symfony/browser-kit": "^6.4|^7.0", @@ -17993,7 +18040,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.1.7" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.2.2" }, "funding": [ { @@ -18009,7 +18056,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T07:52:02+00:00" + "time": "2024-12-11T15:34:14+00:00" }, { "name": "theseer/tokenizer", From 9a50b8c7459d99f7a32a987363cddecfa9edd0d6 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 10 Jan 2025 22:35:41 +0100 Subject: [PATCH 04/16] Translations update from Hosted Weblate (#1358) Co-authored-by: hankskyjames777 --- translations/messages.fil.yaml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/translations/messages.fil.yaml b/translations/messages.fil.yaml index dcf7b21cc..363d11692 100644 --- a/translations/messages.fil.yaml +++ b/translations/messages.fil.yaml @@ -196,7 +196,7 @@ position_top: Itaas pending: Nakabinbin close: Isara direct_message: Direktang mensahe -top: Pinakamahusay +top: Nangunguna edited_post: Binago ang post edited_comment: Binago ang puna last_active: Huling Aktibo @@ -244,3 +244,23 @@ open_report: nakabukas na ulat already_have_account: Mayroon ka na bang account? eng: ENG comment_not_found: Hindi nakita ang puna +subscription_header: Mga naka-subscribe na mga magasin +flash_user_settings_general_success: Matagumpay na nakaimbak ang mga setting ng tagagamit. +subscribed: Naka-subscribe +old_email: Kasalukuyang email +restored_comment_by: binalik ang puna ni +show_related_magazines: Ipakita ang mga pasadyang magasin +hot: Patok +reset_check_email_desc2: Kapag hindi ka nakatanggap ng "e-mail" mangyaring tingnan + ang folder ng spam. +settings: Mga setting +hide_adult: Itago ang nilalaman na NSFW +notify_on_new_posts: Bagong mga post sa anumang magasin sa saan ako naka-subscribe +writing: Pagsusulat +firstname: Unang pangalan +active_users: Mga aktibong tao +boost: Palakasin +all_time: Lahat ng oras +random_magazines: Mga pasadyang magasin +restore: Ibalik +flash_user_settings_general_error: Nabigong iimbak ang mga setting ng tagagamit. From 64f51e5276344fba707a42d890312343cb024fff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 08:46:43 +0000 Subject: [PATCH 05/16] Bump nelmio/api-doc-bundle from 4.33.6 to 4.34.0 (#1359) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 58 +++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/composer.lock b/composer.lock index 57c92b39c..0fbc50e7a 100644 --- a/composer.lock +++ b/composer.lock @@ -5084,16 +5084,16 @@ }, { "name": "nelmio/api-doc-bundle", - "version": "v4.33.6", + "version": "v4.34.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git", - "reference": "8e5694150fb0f7acfeff437ce14d1fd3bb3292ca" + "reference": "ff9139576376695d2c3febc23a5e7eab91866d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/8e5694150fb0f7acfeff437ce14d1fd3bb3292ca", - "reference": "8e5694150fb0f7acfeff437ce14d1fd3bb3292ca", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/ff9139576376695d2c3febc23a5e7eab91866d83", + "reference": "ff9139576376695d2c3febc23a5e7eab91866d83", "shasum": "" }, "require": { @@ -5104,16 +5104,16 @@ "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/container": "^1.0 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/config": "^5.4 || ^6.4 || ^7.0", - "symfony/console": "^5.4 || ^6.4 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/config": "^5.4 || ^6.4 || ^7.1", + "symfony/console": "^5.4 || ^6.4 || ^7.1", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.1", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4.24 || ^6.4 || ^7.0", - "symfony/http-foundation": "^5.4 || ^6.4 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", - "symfony/property-info": "^5.4.10 || ^6.4 || ^7.0", - "symfony/routing": "^5.4 || ^6.4 || ^7.0", + "symfony/framework-bundle": "^5.4.24 || ^6.4 || ^7.1", + "symfony/http-foundation": "^5.4 || ^6.4 || ^7.1", + "symfony/http-kernel": "^5.4 || ^6.4 || ^7.1", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.1", + "symfony/property-info": "^5.4.10 || ^6.4 || ^7.1", + "symfony/routing": "^5.4 || ^6.4 || ^7.1", "zircote/swagger-php": "^4.6.1" }, "conflict": { @@ -5132,21 +5132,21 @@ "phpstan/phpstan-strict-rules": "^1.5", "phpstan/phpstan-symfony": "^1.3", "phpunit/phpunit": "^9.6 || ^10.5", - "symfony/asset": "^5.4 || ^6.4 || ^7.0", - "symfony/browser-kit": "^5.4 || ^6.4 || ^7.0", - "symfony/cache": "^5.4 || ^6.4 || ^7.0", - "symfony/dom-crawler": "^5.4 || ^6.4 || ^7.0", - "symfony/expression-language": "^5.4 || ^6.4 || ^7.0", - "symfony/form": "^5.4 || ^6.4 || ^7.0", + "symfony/asset": "^5.4 || ^6.4 || ^7.1", + "symfony/browser-kit": "^5.4 || ^6.4 || ^7.1", + "symfony/cache": "^5.4 || ^6.4 || ^7.1", + "symfony/dom-crawler": "^5.4 || ^6.4 || ^7.1", + "symfony/expression-language": "^5.4 || ^6.4 || ^7.1", + "symfony/form": "^5.4 || ^6.4 || ^7.1", "symfony/phpunit-bridge": "^6.4", - "symfony/property-access": "^5.4 || ^6.4 || ^7.0", - "symfony/security-csrf": "^5.4 || ^6.4 || ^7.0", - "symfony/serializer": "^5.4 || ^6.4 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0", - "symfony/templating": "^5.4 || ^6.4 || ^7.0", - "symfony/twig-bundle": "^5.4 || ^6.4 || ^7.0", - "symfony/uid": "^5.4 || ^6.4 || ^7.0", - "symfony/validator": "^5.4 || ^6.4 || ^7.0", + "symfony/property-access": "^5.4 || ^6.4 || ^7.1", + "symfony/security-csrf": "^5.4 || ^6.4 || ^7.1", + "symfony/serializer": "^5.4 || ^6.4 || ^7.1", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.1", + "symfony/templating": "^5.4 || ^6.4 || ^7.1", + "symfony/twig-bundle": "^5.4 || ^6.4 || ^7.1", + "symfony/uid": "^5.4 || ^6.4 || ^7.1", + "symfony/validator": "^5.4 || ^6.4 || ^7.1", "willdurand/hateoas-bundle": "^1.0 || ^2.0" }, "suggest": { @@ -5194,7 +5194,7 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", - "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.33.6" + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.34.0" }, "funding": [ { @@ -5202,7 +5202,7 @@ "type": "github" } ], - "time": "2025-01-06T10:15:49+00:00" + "time": "2025-01-08T11:43:02+00:00" }, { "name": "nelmio/cors-bundle", From 8ead006635838ab1155d7433b8159e457ad72d6a Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Mon, 13 Jan 2025 12:24:31 +0000 Subject: [PATCH 06/16] Add testcase for hashtag matching (#1362) Co-authored-by: Melroy van den Berg --- .env.test | 2 +- src/Command/ApImportObject.php | 4 +- .../ActivityPub/WebFingerController.php | 13 +- src/Controller/SearchController.php | 4 +- src/Entity/User.php | 4 +- .../ActivityPub/WebfingerResponseEvent.php | 4 +- .../GroupWebFingerProfileSubscriber.php | 3 +- .../ActivityPub/GroupWebFingerSubscriber.php | 6 +- .../UserWebFingerProfileSubscriber.php | 3 +- .../ActivityPub/UserWebFingerSubscriber.php | 6 +- .../ActivityPub/EntryCommentNoteFactory.php | 4 +- src/Factory/ActivityPub/EntryPageFactory.php | 4 +- src/Factory/ActivityPub/InstanceFactory.php | 4 +- .../ActivityPub/PostCommentNoteFactory.php | 4 +- src/Factory/ActivityPub/PostNoteFactory.php | 4 +- .../ActivityPub/Inbox/ActivityHandler.php | 4 +- .../ActivityPub/Inbox/AddHandler.php | 4 +- .../Inbox/ChainActivityHandler.php | 4 +- .../ActivityPub/Inbox/FollowHandler.php | 4 +- .../ActivityPub/Outbox/DeliverHandler.php | 4 +- .../ActivityPub/Outbox/FollowHandler.php | 4 +- .../ActivityPub/UpdateActorHandler.php | 4 +- src/Service/ActivityPub/ApHttpClient.php | 2 +- .../ActivityPub/ApHttpClientInterface.php | 97 +++++++ .../ActivityPub/SignatureValidator.php | 2 +- .../Webfinger/WebFingerFactory.php | 4 +- src/Service/ActivityPubManager.php | 4 +- src/Service/EntryManager.php | 4 +- src/Service/PostManager.php | 4 +- src/Service/RemoteInstanceManager.php | 4 +- src/Service/SearchManager.php | 4 +- tests/FactoryTrait.php | 1 + .../Controller/Api/Search/SearchApiTest.php | 3 +- tests/TestingApHttpClient.php | 128 +++++++++ tests/Unit/ActivityPub/TagMatchTest.php | 248 ++++++++++++++++++ .../ActivityPub/SignatureValidatorTest.php | 12 +- tests/WebTestCase.php | 30 +++ 37 files changed, 575 insertions(+), 69 deletions(-) create mode 100644 src/Service/ActivityPub/ApHttpClientInterface.php create mode 100644 tests/TestingApHttpClient.php create mode 100644 tests/Unit/ActivityPub/TagMatchTest.php diff --git a/.env.test b/.env.test index 3ffdf339a..1d1169dfe 100644 --- a/.env.test +++ b/.env.test @@ -18,7 +18,7 @@ KBIN_DEFAULT_LANG=en KBIN_DOMAIN=kbin.test ELASTICSEARCH_ENABLED=false KBIN_API_ITEMS_PER_PAGE=2 -KBIN_FEDERATION_ENABLED=false +KBIN_FEDERATION_ENABLED=true ###> league/oauth2-server-bundle ### OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/oauth2/tests/private.pem diff --git a/src/Command/ApImportObject.php b/src/Command/ApImportObject.php index 924d8186e..9ad97d1ac 100644 --- a/src/Command/ApImportObject.php +++ b/src/Command/ApImportObject.php @@ -5,7 +5,7 @@ namespace App\Command; use App\Message\ActivityPub\Inbox\ActivityMessage; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -21,7 +21,7 @@ class ApImportObject extends Command { public function __construct( private readonly MessageBusInterface $bus, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, ) { parent::__construct(); } diff --git a/src/Controller/ActivityPub/WebFingerController.php b/src/Controller/ActivityPub/WebFingerController.php index 37a3c80d1..87af69bef 100644 --- a/src/Controller/ActivityPub/WebFingerController.php +++ b/src/Controller/ActivityPub/WebFingerController.php @@ -6,19 +6,26 @@ use App\ActivityPub\JsonRd; use App\Event\ActivityPub\WebfingerResponseEvent; +use App\Service\ActivityPub\Webfinger\WebFingerParameters; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; class WebFingerController { - public function __construct(private readonly EventDispatcherInterface $eventDispatcher) - { + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + private readonly WebFingerParameters $webFingerParameters, + ) { } public function __invoke(Request $request): JsonResponse { - $event = new WebfingerResponseEvent(new JsonRd(), $request); + $event = new WebfingerResponseEvent( + new JsonRd(), + $request->query->get('resource') ?: '', + $this->webFingerParameters->getParams($request), + ); $this->eventDispatcher->dispatch($event); if (!empty($event->jsonRd->getLinks())) { diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index 9a6f42248..a8426b855 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -9,7 +9,7 @@ use App\Entity\User; use App\Message\ActivityPub\Inbox\CreateMessage; use App\MessageHandler\ActivityPub\Inbox\CreateHandler; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPubManager; use App\Service\SearchManager; use App\Service\SettingsManager; @@ -23,7 +23,7 @@ class SearchController extends AbstractController public function __construct( private readonly SearchManager $manager, private readonly ActivityPubManager $activityPubManager, - private readonly ApHttpClient $apHttpClient, + private readonly ApHttpClientInterface $apHttpClient, private readonly SubjectOverviewManager $overviewManager, private readonly SettingsManager $settingsManager, private readonly LoggerInterface $logger, diff --git a/src/Entity/User.php b/src/Entity/User.php index d9e786581..6e081f528 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -11,7 +11,7 @@ use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\VisibilityTrait; use App\Repository\UserRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; @@ -869,7 +869,7 @@ public function hasMagazineOwnershipRequest(Magazine $magazine): bool return $this->magazineOwnershipRequests->matching($criteria)->count() > 0; } - public function getFollowerUrl(ApHttpClient $client, UrlGeneratorInterface $urlGenerator, bool $isRemote): ?string + public function getFollowerUrl(ApHttpClientInterface $client, UrlGeneratorInterface $urlGenerator, bool $isRemote): ?string { if ($isRemote) { $actorObject = $client->getActorObject($this->apProfileId); diff --git a/src/Event/ActivityPub/WebfingerResponseEvent.php b/src/Event/ActivityPub/WebfingerResponseEvent.php index 7af331408..75761e770 100644 --- a/src/Event/ActivityPub/WebfingerResponseEvent.php +++ b/src/Event/ActivityPub/WebfingerResponseEvent.php @@ -5,13 +5,13 @@ namespace App\Event\ActivityPub; use App\ActivityPub\JsonRd; -use Symfony\Component\HttpFoundation\Request; class WebfingerResponseEvent { public function __construct( public JsonRd $jsonRd, - public Request $request, + public string $subject, + public array $params, ) { } } diff --git a/src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php b/src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php index e0f81f692..14a8ee15e 100644 --- a/src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php +++ b/src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php @@ -16,7 +16,6 @@ class GroupWebFingerProfileSubscriber implements EventSubscriberInterface { public function __construct( - private readonly WebFingerParameters $webfingerParameters, private readonly MagazineRepository $magazineRepository, private readonly UrlGeneratorInterface $urlGenerator, ) { @@ -32,7 +31,7 @@ public static function getSubscribedEvents(): array public function buildResponse(WebfingerResponseEvent $event): void { - $params = $this->webfingerParameters->getParams($event->request); + $params = $event->params; $jsonRd = $event->jsonRd; if ( diff --git a/src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php b/src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php index fa6cb9f9e..7913ba7cf 100644 --- a/src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php +++ b/src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php @@ -16,7 +16,6 @@ class GroupWebFingerSubscriber implements EventSubscriberInterface { public function __construct( - private readonly WebFingerParameters $webfingerParameters, private readonly MagazineRepository $magazineRepository, private readonly UrlGeneratorInterface $urlGenerator, ) { @@ -32,11 +31,10 @@ public static function getSubscribedEvents(): array public function buildResponse(WebfingerResponseEvent $event): void { - $request = $event->request; - $params = $this->webfingerParameters->getParams($request); + $params = $event->params; $jsonRd = $event->jsonRd; - $subject = $request->query->get('resource') ?: ''; + $subject = $event->subject; if (!empty($subject)) { $jsonRd->setSubject($subject); } diff --git a/src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php b/src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php index 078788371..c6e1f2b59 100644 --- a/src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php +++ b/src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php @@ -19,7 +19,6 @@ class UserWebFingerProfileSubscriber implements EventSubscriberInterface { public function __construct( - private readonly WebFingerParameters $webfingerParameters, private readonly UserRepository $userRepository, private readonly UrlGeneratorInterface $urlGenerator, private readonly SettingsManager $settingsManager, @@ -38,7 +37,7 @@ public static function getSubscribedEvents(): array public function buildResponse(WebfingerResponseEvent $event): void { - $params = $this->webfingerParameters->getParams($event->request); + $params = $event->params; $jsonRd = $event->jsonRd; if (isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])) { diff --git a/src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php b/src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php index 19eb7faba..6cd9d7ac4 100644 --- a/src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php +++ b/src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php @@ -16,7 +16,6 @@ class UserWebFingerSubscriber implements EventSubscriberInterface { public function __construct( - private readonly WebFingerParameters $webfingerParameters, private readonly UserRepository $userRepository, private readonly UrlGeneratorInterface $urlGenerator, ) { @@ -32,11 +31,10 @@ public static function getSubscribedEvents(): array public function buildResponse(WebfingerResponseEvent $event): void { - $request = $event->request; - $params = $this->webfingerParameters->getParams($request); + $params = $event->params; $jsonRd = $event->jsonRd; - $subject = $request->query->get('resource') ?: ''; + $subject = $event->subject; if (!empty($subject)) { $jsonRd->setSubject($subject); } diff --git a/src/Factory/ActivityPub/EntryCommentNoteFactory.php b/src/Factory/ActivityPub/EntryCommentNoteFactory.php index 4dbec1556..727e3d9a3 100644 --- a/src/Factory/ActivityPub/EntryCommentNoteFactory.php +++ b/src/Factory/ActivityPub/EntryCommentNoteFactory.php @@ -8,7 +8,7 @@ use App\Entity\EntryComment; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\ContextsProvider; use App\Service\ActivityPub\Wrapper\ImageWrapper; use App\Service\ActivityPub\Wrapper\MentionsWrapper; @@ -28,7 +28,7 @@ public function __construct( private readonly MentionsWrapper $mentionsWrapper, private readonly MentionManager $mentionManager, private readonly EntryPageFactory $pageFactory, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, ) { diff --git a/src/Factory/ActivityPub/EntryPageFactory.php b/src/Factory/ActivityPub/EntryPageFactory.php index 77f4b2aad..e0a74c2f4 100644 --- a/src/Factory/ActivityPub/EntryPageFactory.php +++ b/src/Factory/ActivityPub/EntryPageFactory.php @@ -8,7 +8,7 @@ use App\Entity\Entry; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\ContextsProvider; use App\Service\ActivityPub\Wrapper\ImageWrapper; use App\Service\ActivityPub\Wrapper\MentionsWrapper; @@ -27,7 +27,7 @@ public function __construct( private readonly ImageWrapper $imageWrapper, private readonly TagsWrapper $tagsWrapper, private readonly MentionsWrapper $mentionsWrapper, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, ) { diff --git a/src/Factory/ActivityPub/InstanceFactory.php b/src/Factory/ActivityPub/InstanceFactory.php index 27528360f..cd0cb5152 100644 --- a/src/Factory/ActivityPub/InstanceFactory.php +++ b/src/Factory/ActivityPub/InstanceFactory.php @@ -4,7 +4,7 @@ namespace App\Factory\ActivityPub; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\ContextsProvider; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -12,7 +12,7 @@ class InstanceFactory { public function __construct( private string $kbinDomain, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly UrlGeneratorInterface $urlGenerator, private readonly ContextsProvider $contextProvider, ) { diff --git a/src/Factory/ActivityPub/PostCommentNoteFactory.php b/src/Factory/ActivityPub/PostCommentNoteFactory.php index 62d9b43d2..52e932019 100644 --- a/src/Factory/ActivityPub/PostCommentNoteFactory.php +++ b/src/Factory/ActivityPub/PostCommentNoteFactory.php @@ -8,7 +8,7 @@ use App\Entity\PostComment; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\ContextsProvider; use App\Service\ActivityPub\Wrapper\ImageWrapper; use App\Service\ActivityPub\Wrapper\MentionsWrapper; @@ -28,7 +28,7 @@ public function __construct( private readonly TagsWrapper $tagsWrapper, private readonly MentionsWrapper $mentionsWrapper, private readonly MentionManager $mentionManager, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MarkdownConverter $markdownConverter, ) { diff --git a/src/Factory/ActivityPub/PostNoteFactory.php b/src/Factory/ActivityPub/PostNoteFactory.php index 4edd13819..73f6ba73e 100644 --- a/src/Factory/ActivityPub/PostNoteFactory.php +++ b/src/Factory/ActivityPub/PostNoteFactory.php @@ -8,7 +8,7 @@ use App\Entity\Post; use App\Markdown\MarkdownConverter; use App\Markdown\RenderTarget; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\ContextsProvider; use App\Service\ActivityPub\Wrapper\ImageWrapper; use App\Service\ActivityPub\Wrapper\MentionsWrapper; @@ -27,7 +27,7 @@ public function __construct( private readonly ImageWrapper $imageWrapper, private readonly TagsWrapper $tagsWrapper, private readonly MentionsWrapper $mentionsWrapper, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly MentionManager $mentionManager, private readonly TagExtractor $tagExtractor, diff --git a/src/MessageHandler/ActivityPub/Inbox/ActivityHandler.php b/src/MessageHandler/ActivityPub/Inbox/ActivityHandler.php index 473a819a4..3ca978e38 100644 --- a/src/MessageHandler/ActivityPub/Inbox/ActivityHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/ActivityHandler.php @@ -23,7 +23,7 @@ use App\Message\Contracts\MessageInterface; use App\MessageHandler\MbinMessageHandler; use App\Repository\InstanceRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\SignatureValidator; use App\Service\ActivityPubManager; use App\Service\RemoteInstanceManager; @@ -45,7 +45,7 @@ public function __construct( private readonly SettingsManager $settingsManager, private readonly MessageBusInterface $bus, private readonly ActivityPubManager $activityPubManager, - private readonly ApHttpClient $apHttpClient, + private readonly ApHttpClientInterface $apHttpClient, private readonly InstanceRepository $instanceRepository, private readonly RemoteInstanceManager $remoteInstanceManager, private readonly LoggerInterface $logger, diff --git a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php index 25164012b..40a62ab6c 100644 --- a/src/MessageHandler/ActivityPub/Inbox/AddHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/AddHandler.php @@ -15,7 +15,7 @@ use App\Repository\ApActivityRepository; use App\Repository\EntryRepository; use App\Repository\MagazineRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPubManager; use App\Service\EntryManager; use App\Service\MagazineManager; @@ -33,7 +33,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly KernelInterface $kernel, private readonly ActivityPubManager $activityPubManager, - private readonly ApHttpClient $apHttpClient, + private readonly ApHttpClientInterface $apHttpClient, private readonly ApActivityRepository $apActivityRepository, private readonly MagazineRepository $magazineRepository, private readonly MagazineManager $magazineManager, diff --git a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php index d0970bff8..6334a9ce2 100644 --- a/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php @@ -20,7 +20,7 @@ use App\Message\Contracts\MessageInterface; use App\MessageHandler\MbinMessageHandler; use App\Repository\ApActivityRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\Note; use App\Service\ActivityPub\Page; use App\Service\SettingsManager; @@ -37,7 +37,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly KernelInterface $kernel, private readonly LoggerInterface $logger, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly MessageBusInterface $bus, private readonly ApActivityRepository $repository, private readonly Note $note, diff --git a/src/MessageHandler/ActivityPub/Inbox/FollowHandler.php b/src/MessageHandler/ActivityPub/Inbox/FollowHandler.php index a31f0651c..6cb0a38de 100644 --- a/src/MessageHandler/ActivityPub/Inbox/FollowHandler.php +++ b/src/MessageHandler/ActivityPub/Inbox/FollowHandler.php @@ -9,7 +9,7 @@ use App\Message\ActivityPub\Inbox\FollowMessage; use App\Message\Contracts\MessageInterface; use App\MessageHandler\MbinMessageHandler; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\Wrapper\FollowResponseWrapper; use App\Service\ActivityPubManager; use App\Service\MagazineManager; @@ -28,7 +28,7 @@ public function __construct( private readonly ActivityPubManager $activityPubManager, private readonly UserManager $userManager, private readonly MagazineManager $magazineManager, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly LoggerInterface $logger, private readonly FollowResponseWrapper $followResponseWrapper, ) { diff --git a/src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php b/src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php index 16b0841c1..e24cd00fe 100644 --- a/src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php @@ -12,7 +12,7 @@ use App\Message\Contracts\MessageInterface; use App\MessageHandler\MbinMessageHandler; use App\Repository\InstanceRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPubManager; use App\Service\SettingsManager; use Doctrine\ORM\EntityManagerInterface; @@ -32,7 +32,7 @@ class DeliverHandler extends MbinMessageHandler public function __construct( private readonly EntityManagerInterface $entityManager, private readonly KernelInterface $kernel, - private readonly ApHttpClient $client, + private readonly ApHttpClientInterface $client, private readonly ActivityPubManager $activityPubManager, private readonly SettingsManager $settingsManager, private readonly LoggerInterface $logger, diff --git a/src/MessageHandler/ActivityPub/Outbox/FollowHandler.php b/src/MessageHandler/ActivityPub/Outbox/FollowHandler.php index 4280c1b1f..6bd127247 100644 --- a/src/MessageHandler/ActivityPub/Outbox/FollowHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/FollowHandler.php @@ -9,7 +9,7 @@ use App\MessageHandler\MbinMessageHandler; use App\Repository\MagazineRepository; use App\Repository\UserRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\Wrapper\FollowWrapper; use App\Service\ActivityPub\Wrapper\UndoWrapper; use App\Service\ActivityPubManager; @@ -30,7 +30,7 @@ public function __construct( private readonly ActivityPubManager $activityPubManager, private readonly FollowWrapper $followWrapper, private readonly UndoWrapper $undoWrapper, - private readonly ApHttpClient $apHttpClient, + private readonly ApHttpClientInterface $apHttpClient, private readonly SettingsManager $settingsManager, private readonly DeliverManager $deliverManager, ) { diff --git a/src/MessageHandler/ActivityPub/UpdateActorHandler.php b/src/MessageHandler/ActivityPub/UpdateActorHandler.php index 365121d98..37a20d1df 100644 --- a/src/MessageHandler/ActivityPub/UpdateActorHandler.php +++ b/src/MessageHandler/ActivityPub/UpdateActorHandler.php @@ -9,7 +9,7 @@ use App\MessageHandler\MbinMessageHandler; use App\Repository\MagazineRepository; use App\Repository\UserRepository; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPubManager; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -24,7 +24,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly KernelInterface $kernel, private readonly ActivityPubManager $activityPubManager, - private readonly ApHttpClient $apHttpClient, + private readonly ApHttpClientInterface $apHttpClient, private readonly LockFactory $lockFactory, private readonly UserRepository $userRepository, private readonly MagazineRepository $magazineRepository, diff --git a/src/Service/ActivityPub/ApHttpClient.php b/src/Service/ActivityPub/ApHttpClient.php index 4e097c1c0..4975da3b8 100644 --- a/src/Service/ActivityPub/ApHttpClient.php +++ b/src/Service/ActivityPub/ApHttpClient.php @@ -41,7 +41,7 @@ enum ApRequestType case NodeInfo; } -class ApHttpClient +class ApHttpClient implements ApHttpClientInterface { public const TIMEOUT = 8; public const MAX_DURATION = 15; diff --git a/src/Service/ActivityPub/ApHttpClientInterface.php b/src/Service/ActivityPub/ApHttpClientInterface.php new file mode 100644 index 000000000..643900410 --- /dev/null +++ b/src/Service/ActivityPub/ApHttpClientInterface.php @@ -0,0 +1,97 @@ +entityManager->persist($newMod); $magazine = $this->magazineManager->create($dto, $newMod); + $this->entityManager->persist($magazine); $this->magazines->add($magazine); diff --git a/tests/Functional/Controller/Api/Search/SearchApiTest.php b/tests/Functional/Controller/Api/Search/SearchApiTest.php index d33bc6c91..3be3afa13 100644 --- a/tests/Functional/Controller/Api/Search/SearchApiTest.php +++ b/tests/Functional/Controller/Api/Search/SearchApiTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Search; use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\SettingsManager; use App\Tests\WebTestCase; use phpseclib3\Crypt\RSA; @@ -395,6 +396,6 @@ private function setCacheKeysForApHttpClient(string $domain, ?LoggerInterface $l $this->siteRepository, $this->projectInfoService, ); - self::getContainer()->set(ApHttpClient::class, $apHttpClient); + self::getContainer()->set(ApHttpClientInterface::class, $apHttpClient); } } diff --git a/tests/TestingApHttpClient.php b/tests/TestingApHttpClient.php new file mode 100644 index 000000000..1c0c9fe5b --- /dev/null +++ b/tests/TestingApHttpClient.php @@ -0,0 +1,128 @@ + $activityObjects + */ + public array $activityObjects = []; + + /** + * @phpstan-var array $collectionObjects + */ + public array $collectionObjects = []; + + /** + * @phpstan-var array $webfingerObjects + */ + public array $webfingerObjects = []; + + /** + * @phpstan-var array $actorObjects + */ + public array $actorObjects = []; + + /** + * @var array + */ + private array $postedObjects = []; + + public function getActivityObject(string $url, bool $decoded = true): array|string|null + { + if (\array_key_exists($url, $this->activityObjects)) { + return $this->activityObjects[$url]; + } + + return null; + } + + public function getCollectionObject(string $apAddress): ?array + { + if (\array_key_exists($apAddress, $this->collectionObjects)) { + return $this->collectionObjects[$apAddress]; + } + + return null; + } + + public function getActorObject(string $apProfileId): ?array + { + if (\array_key_exists($apProfileId, $this->actorObjects)) { + return $this->actorObjects[$apProfileId]; + } + + return null; + } + + public function getWebfingerObject(string $url): ?array + { + if (\array_key_exists($url, $this->webfingerObjects)) { + return $this->webfingerObjects[$url]; + } + + return null; + } + + public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null + { + return null; + } + + public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null + { + return null; + } + + public function post(string $url, Magazine|User $actor, ?array $body = null): void + { + $this->postedObjects[] = [ + 'inboxUrl' => $url, + 'actor' => $actor, + 'payload' => $body, + ]; + } + + /** + * @return array + */ + public function getPostedObjects(): array + { + return $this->postedObjects; + } + + public function getActivityObjectCacheKey(string $url): string + { + return 'SOME_TESTING_CACHE_KEY'; + } + + public function getInboxUrl(string $apProfileId): string + { + $actor = $this->getActorObject($apProfileId); + if (!empty($actor)) { + return $actor['endpoints']['sharedInbox'] ?? $actor['inbox']; + } else { + throw new \LogicException("Unable to find AP actor (user or magazine) with URL: $apProfileId"); + } + } + + public function invalidateActorObjectCache(string $apProfileId): void + { + } + + public function invalidateCollectionObjectCache(string $apAddress): void + { + } + + public function getInstancePublicKey(): string + { + return 'TESTING PUBLIC KEY'; + } +} diff --git a/tests/Unit/ActivityPub/TagMatchTest.php b/tests/Unit/ActivityPub/TagMatchTest.php new file mode 100644 index 000000000..b4e764cfb --- /dev/null +++ b/tests/Unit/ActivityPub/TagMatchTest.php @@ -0,0 +1,248 @@ +settingsManager->get('KBIN_DOMAIN'); + + foreach ($this->domains as $domain) { + $this->settingsManager->set('KBIN_DOMAIN', $domain); + $context = $this->router->getContext(); + $context->setHost($domain); + + $username = 'user'; + $user = $this->getUserByUsername($username); + $json = $this->personFactory->create($user); + $this->testingApHttpClient->actorObjects[$json['id']] = $json; + + $userEvent = new WebfingerResponseEvent(new JsonRd(), "$username@$domain", ['account' => $username]); + $this->eventDispatcher->dispatch($userEvent); + $realDomain = \sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', "$username@$domain"); + $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray(); + + $magazineName = 'mbin'; + $magazine = $this->getMagazineByName($magazineName, user: $user); + $json = $this->groupFactory->create($magazine); + $this->testingApHttpClient->actorObjects[$json['id']] = $json; + + $magazineEvent = new WebfingerResponseEvent(new JsonRd(), "$magazineName@$domain", ['account' => $magazineName]); + $this->eventDispatcher->dispatch($magazineEvent); + $realDomain = \sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', "$magazineName@$domain"); + $this->testingApHttpClient->webfingerObjects[$realDomain] = $magazineEvent->jsonRd->toArray(); + + $entry = $this->getEntryByTitle("test from $domain", magazine: $magazine, user: $user); + $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfEntry($entry)); + $this->testingApHttpClient->activityObjects[$json['id']] = $json; + + $create = $this->createWrapper->build($entry); + $this->testingApHttpClient->activityObjects[$create['id']] = $create; + + $this->entryManager->purge($user, $entry); + $this->magazineManager->purge($magazine); + $this->entityManager->remove($user); + + $this->entries = new ArrayCollection(); + $this->magazines = new ArrayCollection(); + $this->users = new ArrayCollection(); + } + + $this->settingsManager->set('KBIN_DOMAIN', $prevDomain); + $context = $this->router->getContext(); + $context->setHost($prevDomain); + + $this->testingApHttpClient->actorObjects[$this->mastodonUser['id']] = $this->mastodonUser; + $this->testingApHttpClient->activityObjects[$this->mastodonPost['id']] = $this->mastodonPost; + $this->testingApHttpClient->webfingerObjects[\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', 'masto.don', '', 'User@masto.don')] = $this->mastodonWebfinger; + } + + public function setUp(): void + { + sort($this->domains); + parent::setUp(); + + $admin = $this->getUserByUsername('admin', isAdmin: true); + $this->getMagazineByName('random', user: $admin); + + $this->createMockedRemoteObjects(); + $user = $this->getUserByUsername('user'); + $magazine = $this->getMagazineByName('matching_mbin', user: $user); + $magazine->title = 'Matching Mbin'; + $magazine->tags = ['mbin']; + $this->entityManager->persist($magazine); + $this->entityManager->flush(); + + foreach ($this->domains as $domain) { + $this->remoteUsers[] = $this->activityPubManager->findActorOrCreate("user@$domain"); + $this->remoteMagazines[] = $this->activityPubManager->findActorOrCreate("mbin@$domain"); + } + + foreach ($this->remoteUsers as $remoteUser) { + $this->magazineManager->subscribe($magazine, $remoteUser); + } + + foreach ($this->remoteMagazines as $remoteMagazine) { + $this->magazineManager->subscribe($remoteMagazine, $user); + } + } + + public function testMatching(): void + { + self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteMagazines))); + self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteUsers))); + + // pull in the 10 prepared remote entries + foreach (array_filter($this->testingApHttpClient->activityObjects, fn ($item) => 'Page' === $item['type']) as $apObject) { + $this->bus->dispatch(new CreateMessage($apObject)); + $entry = $this->entryRepository->findOneBy(['apId' => $apObject['id']]); + self::assertNotNull($entry); + } + + $this->bus->dispatch(new CreateMessage($this->mastodonPost)); + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + $postedAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type']); + $targetInboxes = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedAnnounces); + sort($targetInboxes); + self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes, []); + } + + private array $mastodonUser = [ + 'id' => 'https://masto.don/users/User', + 'type' => 'Person', + 'following' => 'https://masto.don/users/User/following', + 'followers' => 'https://masto.don/users/User/followers', + 'inbox' => 'https://masto.don/users/User/inbox', + 'outbox' => 'https://masto.don/users/User/outbox', + 'featured' => 'https://masto.don/users/User/collections/featured', + 'featuredTags' => 'https://masto.don/users/User/collections/tags', + 'preferredUsername' => 'User', + 'name' => 'User', + 'summary' => '

Some summary

', + 'url' => 'https://masto.don/@User', + 'manuallyApprovesFollowers' => false, + 'discoverable' => true, + 'indexable' => true, + 'published' => '2025-01-01T00:00:00Z', + 'memorial' => false, + 'publicKey' => [ + 'id' => 'https://masto.don/users/User#main-key', + 'owner' => 'https://masto.don/users/User', + 'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAujdiYalTtr7R1CJIVBIy\nP50V+/JX+P15o0Cz0LUOhKvJIVyeV6szQGHj6Idu74x9e3+xf9jzQRCH6eq8ASAH\nHAKwdnHfhSmKbCQaTEI5V8497/4yU9z9Zn7uJ+C1rrKVIEoGGkpt8bK8fynfR/hb\n17FctW6EnrVrvNHyW+WwbyEbyqAxwbcOYd78PhdftWEdP6D+t4+XUoF9N1XGpsGO\nrixJDzMwNqkg9Gg9l/mnCmxV367xgh8qHC0SNmwaMbWv6AV/07dHWlr0N1pXmHqo\n9YkOEy7XuH1hovBzHWEf++P1Ew4bstwdfyS/m5bcakmSe+dR3WDylW336nO88vAF\nCQIDAQAB\n-----END PUBLIC KEY-----\n", + ], + 'tag' => [], + 'attachment' => [], + 'endpoints' => [ + 'sharedInbox' => 'https://masto.don/inbox', + ], + ]; + + private array $mastodonPost = [ + 'id' => 'https://masto.don/users/User/statuses/110226274955756643', + 'type' => 'Note', + 'summary' => null, + 'inReplyTo' => null, + 'published' => '2025-01-01T15:51:18Z', + 'url' => 'https://masto.don/@User/110226274955756643', + 'attributedTo' => 'https://masto.don/users/User', + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public', + ], + 'cc' => [ + 'https://masto.don/users/User/followers', + ], + 'sensitive' => false, + 'atomUri' => 'https://masto.don/users/User/statuses/110226274955756643', + 'inReplyToAtomUri' => null, + 'conversation' => 'tag:masto.don,2025-01-01:objectId=399588:objectType=Conversation', + 'content' => '

I am very excited about

', + 'contentMap' => [ + 'de' => '

I am very excited about

', + ], + 'attachment' => [], + 'tag' => [ + [ + 'type' => 'Hashtag', + 'href' => 'https://masto.don/tags/mbin', + 'name' => '#mbin', + ], + ], + 'replies' => [ + 'id' => 'https://masto.don/users/User/statuses/110226274955756643/replies', + 'type' => 'Collection', + 'first' => [ + 'type' => 'CollectionPage', + 'next' => 'https://masto.don/users/User/statuses/110226274955756643/replies?min_id=110226283102047096&page=true', + 'partOf' => 'https://masto.don/users/User/statuses/110226274955756643/replies', + 'items' => [ + 'https://masto.don/users/User/statuses/110226283102047096', + ], + ], + ], + 'likes' => [ + 'id' => 'https://masto.don/users/User/statuses/110226274955756643/likes', + 'type' => 'Collection', + 'totalItems' => 0, + ], + 'shares' => [ + 'id' => 'https://masto.don/users/User/statuses/110226274955756643/shares', + 'type' => 'Collection', + 'totalItems' => 0, + ], + ]; + + private array $mastodonWebfinger = [ + 'subject' => 'acct:User@masto.don', + 'aliases' => [ + 'https://masto.don/@User', + 'https://masto.don/users/User', + ], + 'links' => [ + [ + 'rel' => 'http://webfinger.net/rel/profile-page', + 'type' => 'text/html', + 'href' => 'https://masto.don/@User', + ], + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => 'https://masto.don/users/User', + ], + [ + 'rel' => 'http://ostatus.org/schema/1.0/subscribe', + 'template' => 'https://masto.don/authorize_interaction?uri=[uri]', + ], + ], + ]; +} diff --git a/tests/Unit/Service/ActivityPub/SignatureValidatorTest.php b/tests/Unit/Service/ActivityPub/SignatureValidatorTest.php index 392672910..158a027e3 100644 --- a/tests/Unit/Service/ActivityPub/SignatureValidatorTest.php +++ b/tests/Unit/Service/ActivityPub/SignatureValidatorTest.php @@ -6,7 +6,7 @@ use App\Entity\Magazine; use App\Exception\InvalidApSignatureException; -use App\Service\ActivityPub\ApHttpClient; +use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\SignatureValidator; use App\Service\ActivityPubManager; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; @@ -97,7 +97,7 @@ public function testItValidatesACorrectlySignedRequest(): void $apManager->method('findActorOrCreate') ->willReturn($stubMagazine); - $apHttpClient = $this->createStub(ApHttpClient::class); + $apHttpClient = $this->createStub(ApHttpClientInterface::class); $apHttpClient->method('getActorObject') ->willReturn( [ @@ -128,7 +128,7 @@ public function testItValidatesACorrectlySignedRequestToAPersonalInbox(): void $apManager->method('findActorOrCreate') ->willReturn($stubMagazine); - $apHttpClient = $this->createStub(ApHttpClient::class); + $apHttpClient = $this->createStub(ApHttpClientInterface::class); $apHttpClient->method('getActorObject') ->willReturn( [ @@ -158,7 +158,7 @@ public function testItDoesNotValidateARequestWithDifferentBody(): void $apManager->method('findActorOrCreate') ->willReturn($stubMagazine); - $apHttpClient = $this->createStub(ApHttpClient::class); + $apHttpClient = $this->createStub(ApHttpClientInterface::class); $apHttpClient->method('getActorObject') ->willReturn( [ @@ -192,7 +192,7 @@ public function testItDoesNotValidateARequestWhenDomainsDoNotMatch(): void $this->headers['signature'][0] = \sprintf($this->headers['signature'][0], 'https://kbin.localhost/m/group'); $apManager = $this->createStub(ActivityPubManager::class); - $apHttpClient = $this->createStub(ApHttpClient::class); + $apHttpClient = $this->createStub(ApHttpClientInterface::class); $logger = $this->createStub(LoggerInterface::class); @@ -218,7 +218,7 @@ public function testItDoesNotValidateARequestWhenUrlsAreNotHTTPS(): void $this->headers['signature'][0] = \sprintf($this->headers['signature'][0], 'http://kbin.localhost/m/group'); $apManager = $this->createStub(ActivityPubManager::class); - $apHttpClient = $this->createStub(ApHttpClient::class); + $apHttpClient = $this->createStub(ApHttpClientInterface::class); $logger = $this->createStub(LoggerInterface::class); diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index b0f0b244f..958b0da51 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -4,11 +4,13 @@ namespace App\Tests; +use App\Factory\ActivityPub\EntryPageFactory; use App\Factory\ActivityPub\GroupFactory; use App\Factory\ActivityPub\PersonFactory; use App\Factory\ActivityPub\TombstoneFactory; use App\Factory\ImageFactory; use App\Factory\MagazineFactory; +use App\MessageHandler\ActivityPub\Outbox\DeliverHandler; use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; use App\Repository\ImageRepository; @@ -20,7 +22,11 @@ use App\Repository\ReportRepository; use App\Repository\SettingsRepository; use App\Repository\SiteRepository; +use App\Repository\TagLinkRepository; use App\Repository\UserRepository; +use App\Service\ActivityPub\ApHttpClientInterface; +use App\Service\ActivityPub\Wrapper\CreateWrapper; +use App\Service\ActivityPubManager; use App\Service\BadgeManager; use App\Service\DomainManager; use App\Service\EntryCommentManager; @@ -47,7 +53,9 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; use Symfony\Contracts\Translation\TranslatorInterface; abstract class WebTestCase extends BaseWebTestCase @@ -97,6 +105,7 @@ abstract class WebTestCase extends BaseWebTestCase protected BadgeManager $badgeManager; protected NotificationManager $notificationManager; protected MentionManager $mentionManager; + protected ActivityPubManager $activityPubManager; protected MagazineRepository $magazineRepository; protected EntryRepository $entryRepository; @@ -110,12 +119,17 @@ abstract class WebTestCase extends BaseWebTestCase protected ReportRepository $reportRepository; protected SettingsRepository $settingsRepository; protected UserRepository $userRepository; + protected TagLinkRepository $tagLinkRepository; protected ImageFactory $imageFactory; protected MagazineFactory $magazineFactory; protected TombstoneFactory $tombstoneFactory; protected PersonFactory $personFactory; protected GroupFactory $groupFactory; + protected EntryPageFactory $pageFactory; + protected TestingApHttpClient $testingApHttpClient; + + protected CreateWrapper $createWrapper; protected UrlGeneratorInterface $urlGenerator; protected TranslatorInterface $translator; @@ -123,6 +137,10 @@ abstract class WebTestCase extends BaseWebTestCase protected RequestStack $requestStack; protected LoggerInterface $logger; protected ProjectInfoService $projectInfoService; + protected RouterInterface $router; + protected MessageBusInterface $bus; + + protected DeliverHandler $deliverHandler; protected string $kibbyPath; @@ -134,6 +152,9 @@ public function setUp(): void $this->kibbyPath = \dirname(__FILE__).'/assets/kibby_emoji.png'; $this->client = static::createClient(); + $this->testingApHttpClient = new TestingApHttpClient(); + self::getContainer()->set(ApHttpClientInterface::class, $this->testingApHttpClient); + $this->entityManager = $this->getService(EntityManagerInterface::class); $this->magazineManager = $this->getService(MagazineManager::class); $this->userManager = $this->getService(UserManager::class); @@ -150,6 +171,7 @@ public function setUp(): void $this->reportManager = $this->getService(ReportManager::class); $this->badgeManager = $this->getService(BadgeManager::class); $this->notificationManager = $this->getService(NotificationManager::class); + $this->activityPubManager = $this->getService(ActivityPubManager::class); $this->magazineRepository = $this->getService(MagazineRepository::class); $this->entryRepository = $this->getService(EntryRepository::class); @@ -163,14 +185,22 @@ public function setUp(): void $this->reportRepository = $this->getService(ReportRepository::class); $this->settingsRepository = $this->getService(SettingsRepository::class); $this->userRepository = $this->getService(UserRepository::class); + $this->tagLinkRepository = $this->getService(TagLinkRepository::class); $this->imageFactory = $this->getService(ImageFactory::class); + $this->personFactory = $this->getService(PersonFactory::class); $this->magazineFactory = $this->getService(MagazineFactory::class); + $this->groupFactory = $this->getService(GroupFactory::class); + $this->pageFactory = $this->getService(EntryPageFactory::class); + + $this->createWrapper = $this->getService(CreateWrapper::class); $this->urlGenerator = $this->getService(UrlGeneratorInterface::class); $this->translator = $this->getService(TranslatorInterface::class); $this->eventDispatcher = $this->getService(EventDispatcherInterface::class); $this->requestStack = $this->getService(RequestStack::class); + $this->router = $this->getService(RouterInterface::class); + $this->bus = $this->getService(MessageBusInterface::class); // clear all cache before every test $app = new Application($this->client->getKernel()); From 801fb299027e90d0981e1c523989b5e2f8b945fb Mon Sep 17 00:00:00 2001 From: Melroy van den Berg Date: Mon, 13 Jan 2025 15:41:02 +0100 Subject: [PATCH 07/16] Upgraded Caddy image to v2.9.1 (#1363) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b48dbaa63..753bec819 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -36,7 +36,7 @@ RUN chown -R $USER:$GROUP /usr/local/etc/php-fpm.d #################### -FROM caddy:2.8.4-builder-alpine AS builder-caddy +FROM caddy:2.9.1-builder-alpine AS builder-caddy # Build Caddy with the Mercure and Vulcain and brotil cache modules RUN xcaddy build \ From 9cf5e1a77a04f7a9f8b3dc8ff1c04355235d6de4 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Mon, 13 Jan 2025 19:09:44 +0000 Subject: [PATCH 08/16] Add test case for liking a hashtag matched post (#1364) --- .../Outbox/AnnounceLikeHandler.php | 9 ++- tests/Unit/ActivityPub/TagMatchTest.php | 61 +++++++++++++++++-- tests/WebTestCase.php | 3 + 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php b/src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php index ac532971f..667f0c97e 100644 --- a/src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php +++ b/src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php @@ -85,11 +85,10 @@ public function doWork(MessageInterface $message): void $likeActivity ); - $inboxes = array_filter(array_unique(array_merge( - $this->magazineRepository->findAudience($object->magazine), - $this->userRepository->findAudience($user), - [$object->user->apInboxUrl] - ))); + // send the announcement only to the subscribers of the magazine + $inboxes = array_filter( + $this->magazineRepository->findAudience($object->magazine) + ); $this->deliverManager->deliver($inboxes, $activity); } } diff --git a/tests/Unit/ActivityPub/TagMatchTest.php b/tests/Unit/ActivityPub/TagMatchTest.php index b4e764cfb..34ba714ba 100644 --- a/tests/Unit/ActivityPub/TagMatchTest.php +++ b/tests/Unit/ActivityPub/TagMatchTest.php @@ -5,8 +5,11 @@ namespace App\Tests\Unit\ActivityPub; use App\ActivityPub\JsonRd; +use App\Entity\Magazine; +use App\Entity\User; use App\Event\ActivityPub\WebfingerResponseEvent; use App\Message\ActivityPub\Inbox\CreateMessage; +use App\Message\ActivityPub\Inbox\LikeMessage; use App\Service\ActivityPub\Webfinger\WebFingerFactory; use App\Tests\WebTestCase; use Doctrine\Common\Collections\ArrayCollection; @@ -26,8 +29,10 @@ class TagMatchTest extends WebTestCase 'mbin10.tld', ]; + /** @var User[] */ private array $remoteUsers = []; + /** @var Magazine[] */ private array $remoteMagazines = []; /** @@ -123,19 +128,63 @@ public function testMatching(): void self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteMagazines))); self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteUsers))); - // pull in the 10 prepared remote entries + $this->pullInRemoteEntries(); + $this->pullInMastodonPost(); + + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + $postedAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type']); + $targetInboxes = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedAnnounces); + sort($targetInboxes); + self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes, []); + } + + public function testMatchingLikeAnnouncing(): void + { + self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteMagazines))); + self::assertEquals(\sizeof($this->domains), \sizeof(array_filter($this->remoteUsers))); + + $this->pullInRemoteEntries(); + $this->pullInMastodonPost(); + + $mastodonPost = $this->postRepository->findOneBy(['apId' => $this->mastodonPost['id']]); + $user = $this->getUserByUsername('user'); + $this->favouriteManager->toggle($user, $mastodonPost); + + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + $postedLikes = array_filter($postedObjects, fn ($item) => 'Like' === $item['payload']['type']); + $targetInboxes2 = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedLikes); + sort($targetInboxes2); + + // the pure like activity is expected to be sent to the author of the post + $expectedInboxes = [...$this->domains, parse_url($mastodonPost->user->apInboxUrl, PHP_URL_HOST)]; + sort($expectedInboxes); + self::assertArrayIsEqualToArrayIgnoringListOfKeys($expectedInboxes, $targetInboxes2, []); + + // dispatch a remote like message, so we trigger the announcement of it + $this->bus->dispatch(new LikeMessage($this->likeWrapper->build($this->remoteUsers[0]->apProfileId, $this->mastodonPost))); + + $postedObjects = $this->testingApHttpClient->getPostedObjects(); + $postedLikeAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type'] && 'Like' === $item['payload']['object']['type']); + $targetInboxes3 = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedLikeAnnounces); + sort($targetInboxes3); + + // the announcement of the like is expected to be delivered only to the subscribers of the magazine, + // because we expect the pure like activity to already be sent to the author of the post by the remote server + self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes3, []); + } + + private function pullInRemoteEntries(): void + { foreach (array_filter($this->testingApHttpClient->activityObjects, fn ($item) => 'Page' === $item['type']) as $apObject) { $this->bus->dispatch(new CreateMessage($apObject)); $entry = $this->entryRepository->findOneBy(['apId' => $apObject['id']]); self::assertNotNull($entry); } + } + private function pullInMastodonPost(): void + { $this->bus->dispatch(new CreateMessage($this->mastodonPost)); - $postedObjects = $this->testingApHttpClient->getPostedObjects(); - $postedAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type']); - $targetInboxes = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedAnnounces); - sort($targetInboxes); - self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes, []); } private array $mastodonUser = [ diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 958b0da51..cc86ea738 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -26,6 +26,7 @@ use App\Repository\UserRepository; use App\Service\ActivityPub\ApHttpClientInterface; use App\Service\ActivityPub\Wrapper\CreateWrapper; +use App\Service\ActivityPub\Wrapper\LikeWrapper; use App\Service\ActivityPubManager; use App\Service\BadgeManager; use App\Service\DomainManager; @@ -130,6 +131,7 @@ abstract class WebTestCase extends BaseWebTestCase protected TestingApHttpClient $testingApHttpClient; protected CreateWrapper $createWrapper; + protected LikeWrapper $likeWrapper; protected UrlGeneratorInterface $urlGenerator; protected TranslatorInterface $translator; @@ -194,6 +196,7 @@ public function setUp(): void $this->pageFactory = $this->getService(EntryPageFactory::class); $this->createWrapper = $this->getService(CreateWrapper::class); + $this->likeWrapper = $this->getService(LikeWrapper::class); $this->urlGenerator = $this->getService(UrlGeneratorInterface::class); $this->translator = $this->getService(TranslatorInterface::class); From ec8bc2d5539ab821132fafb3ae9b491b4c8d8217 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Mon, 13 Jan 2025 19:47:55 +0000 Subject: [PATCH 09/16] Bump the version to v1.7.4 (#1365) --- config/packages/framework.yaml | 2 +- docs/02-admin/01-installation/01-bare_metal.md | 2 +- src/Service/ProjectInfoService.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 762702af9..067a5f81a 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -31,7 +31,7 @@ framework: http_client: default_options: headers: - 'User-Agent': 'Mbin/1.7.3 (+https://%kbin_domain%/agent)' + 'User-Agent': 'Mbin/1.7.4 (+https://%kbin_domain%/agent)' #esi: true #fragments: true diff --git a/docs/02-admin/01-installation/01-bare_metal.md b/docs/02-admin/01-installation/01-bare_metal.md index 065737663..f70d12551 100644 --- a/docs/02-admin/01-installation/01-bare_metal.md +++ b/docs/02-admin/01-installation/01-bare_metal.md @@ -127,7 +127,7 @@ git clone https://github.com/MbinOrg/mbin.git . > [!TIP] > You might now want to switch to the latest stable release tag instead of using the `main` branch. -> Try: `git checkout v1.7.3` (v1.7.3 might **not** be the latest version: [lookup the latest version](https://github.com/MbinOrg/mbin/releases)) +> Try: `git checkout v1.7.4` (v1.7.4 might **not** be the latest version: [lookup the latest version](https://github.com/MbinOrg/mbin/releases)) ### Create & configure media directory diff --git a/src/Service/ProjectInfoService.php b/src/Service/ProjectInfoService.php index 97439aa03..28eb47f92 100644 --- a/src/Service/ProjectInfoService.php +++ b/src/Service/ProjectInfoService.php @@ -10,7 +10,7 @@ class ProjectInfoService { // If updating version, please also update http client UA in [/config/packages/framework.yaml] - private const VERSION = '1.7.3'; // TODO: Retrieve the version from git tags or getenv()? + private const VERSION = '1.7.4'; // TODO: Retrieve the version from git tags or getenv()? private const NAME = 'mbin'; private const CANONICAL_NAME = 'Mbin'; private const REPOSITORY_URL = 'https://github.com/MbinOrg/mbin'; From d5858acb7f3cb53a5e2912633814ea14b7806429 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 6 Oct 2024 14:16:11 +0200 Subject: [PATCH 10/16] Introducing bookmarks (#1095) Co-authored-by: Melroy van den Berg --- assets/controllers/subject_controller.js | 40 ++ assets/styles/app.scss | 3 + assets/styles/layout/_icons.scss | 3 + assets/styles/pages/page_bookmarks.scss | 6 + config/mbin_routes/bookmark.yaml | 71 ++++ config/mbin_routes/bookmark_api.yaml | 61 +++ config/packages/league_oauth2_server.yaml | 7 + config/packages/security.yaml | 11 + config/services.yaml | 1 + migrations/Version20240831151328.php | 56 +++ src/Controller/Api/BaseApi.php | 10 +- .../Api/Bookmark/BookmarkApiController.php | 265 ++++++++++++ .../Bookmark/BookmarkListApiController.php | 378 ++++++++++++++++++ src/Controller/BookmarkController.php | 145 +++++++ src/Controller/BookmarkListController.php | 180 +++++++++ src/DTO/BookmarkListDto.php | 42 ++ src/DTO/OAuth2ClientDto.php | 7 + src/Entity/Bookmark.php | 70 ++++ src/Entity/BookmarkList.php | 51 +++ src/Entity/User.php | 2 + src/Form/BookmarkListType.php | 35 ++ src/Pagination/NativeQueryAdapter.php | 17 +- .../ContentPopulationTransformer.php | 69 +++- src/Repository/BookmarkListRepository.php | 83 ++++ src/Repository/BookmarkRepository.php | 180 +++++++++ src/Schema/PaginationSchema.php | 4 +- src/Service/BookmarkManager.php | 90 +++++ src/Twig/Components/BookmarkListComponent.php | 20 + .../Components/BookmarkMenuListComponent.php | 23 ++ .../Components/BookmarkStandardComponent.php | 18 + .../EntryCommentsNestedComponent.php | 40 +- .../PostCommentsNestedComponent.php | 43 +- src/Twig/Extension/BookmarkExtension.php | 22 + src/Twig/Extension/FrontExtension.php | 2 + src/Twig/Runtime/BookmarkExtensionRuntime.php | 47 +++ src/Twig/Runtime/FrontExtensionRuntime.php | 24 ++ templates/bookmark/_form_edit.html.twig | 14 + templates/bookmark/_options.html.twig | 231 +++++++++++ templates/bookmark/edit.html.twig | 24 ++ templates/bookmark/front.html.twig | 25 ++ templates/bookmark/overview.html.twig | 77 ++++ templates/components/bookmark_list.html.twig | 19 + .../components/bookmark_menu_list.html.twig | 5 + .../components/bookmark_standard.html.twig | 17 + templates/components/entry.html.twig | 3 + templates/components/entry_comment.html.twig | 3 + templates/components/post.html.twig | 3 + templates/components/post_comment.html.twig | 3 + templates/entry/_menu.html.twig | 9 + templates/entry/comment/_menu.html.twig | 11 + templates/layout/_header.html.twig | 6 + templates/post/_menu.html.twig | 11 + templates/post/comment/_menu.html.twig | 11 + translations/messages.en.yaml | 17 + 54 files changed, 2524 insertions(+), 91 deletions(-) create mode 100644 assets/styles/layout/_icons.scss create mode 100644 assets/styles/pages/page_bookmarks.scss create mode 100644 config/mbin_routes/bookmark.yaml create mode 100644 config/mbin_routes/bookmark_api.yaml create mode 100644 migrations/Version20240831151328.php create mode 100644 src/Controller/Api/Bookmark/BookmarkApiController.php create mode 100644 src/Controller/Api/Bookmark/BookmarkListApiController.php create mode 100644 src/Controller/BookmarkController.php create mode 100644 src/Controller/BookmarkListController.php create mode 100644 src/DTO/BookmarkListDto.php create mode 100644 src/Entity/Bookmark.php create mode 100644 src/Entity/BookmarkList.php create mode 100644 src/Form/BookmarkListType.php create mode 100644 src/Repository/BookmarkListRepository.php create mode 100644 src/Repository/BookmarkRepository.php create mode 100644 src/Service/BookmarkManager.php create mode 100644 src/Twig/Components/BookmarkListComponent.php create mode 100644 src/Twig/Components/BookmarkMenuListComponent.php create mode 100644 src/Twig/Components/BookmarkStandardComponent.php create mode 100644 src/Twig/Extension/BookmarkExtension.php create mode 100644 src/Twig/Runtime/BookmarkExtensionRuntime.php create mode 100644 templates/bookmark/_form_edit.html.twig create mode 100644 templates/bookmark/_options.html.twig create mode 100644 templates/bookmark/edit.html.twig create mode 100644 templates/bookmark/front.html.twig create mode 100644 templates/bookmark/overview.html.twig create mode 100644 templates/components/bookmark_list.html.twig create mode 100644 templates/components/bookmark_menu_list.html.twig create mode 100644 templates/components/bookmark_standard.html.twig diff --git a/assets/controllers/subject_controller.js b/assets/controllers/subject_controller.js index 3b180aff8..093944d60 100644 --- a/assets/controllers/subject_controller.js +++ b/assets/controllers/subject_controller.js @@ -199,6 +199,46 @@ export default class extends Controller { } } + /** + * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter + * with the response from the link + */ + async linkCallback(event) { + const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params + event.preventDefault(); + + const a = event.target.closest('a'); + + try { + this.loadingValue = true; + + let response = await fetch(a.href, { + method: 'GET', + }); + + response = await ok(response); + response = await response.json(); + + event.target.closest(`.${cssClass}`).outerHTML = response.html; + + const refreshElement = this.element.querySelector(refreshSelector) + console.log("linkCallback refresh stuff", refreshLink, refreshSelector, refreshElement) + + if (!!refreshLink && refreshLink !== "" && !!refreshElement) { + let response = await fetch(refreshLink, { + method: 'GET', + }); + + response = await ok(response); + response = await response.json(); + refreshElement.outerHTML = response.html; + } + } catch (e) { + } finally { + this.loadingValue = false; + } + } + loadingValueChanged(val) { const submitButton = this.containerTarget.querySelector('form button[type="submit"]'); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 8cfa2b384..f0761101d 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,5 +1,6 @@ @use '@fortawesome/fontawesome-free/scss/fontawesome'; @use '@fortawesome/fontawesome-free/scss/solid'; +@use '@fortawesome/fontawesome-free/scss/regular'; @use '@fortawesome/fontawesome-free/scss/brands'; @use 'simple-icons-font/font/simple-icons'; @use 'variables'; @@ -14,6 +15,7 @@ @use 'layout/alerts'; @use 'layout/forms'; @use 'layout/images'; +@use 'layout/icons'; @use 'components/announcement'; @use 'components/topbar'; @use 'components/header'; @@ -44,6 +46,7 @@ @use 'components/settings_row'; @use 'pages/post_single'; @use 'pages/post_front'; +@use 'pages/page_bookmarks'; @use 'themes/kbin'; @use 'themes/default'; @use 'themes/solarized'; diff --git a/assets/styles/layout/_icons.scss b/assets/styles/layout/_icons.scss new file mode 100644 index 000000000..0f94dbfc0 --- /dev/null +++ b/assets/styles/layout/_icons.scss @@ -0,0 +1,3 @@ +i.active { + color: var(--kbin-color-icon-active, orange); +} diff --git a/assets/styles/pages/page_bookmarks.scss b/assets/styles/pages/page_bookmarks.scss new file mode 100644 index 000000000..557f314dd --- /dev/null +++ b/assets/styles/pages/page_bookmarks.scss @@ -0,0 +1,6 @@ +.page-bookmarks { + .entry, .entry-comment, .post, .post-comment, .comment { + margin-top: 0!important; + margin-bottom: .5em!important; + } +} diff --git a/config/mbin_routes/bookmark.yaml b/config/mbin_routes/bookmark.yaml new file mode 100644 index 000000000..487c8f307 --- /dev/null +++ b/config/mbin_routes/bookmark.yaml @@ -0,0 +1,71 @@ +bookmark_front: + controller: App\Controller\BookmarkListController::front + defaults: { sortBy: hot, time: '∞', federation: all } + path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation} + methods: [GET] + requirements: &front_requirement + sortBy: "%default_sort_options%" + time: "%default_time_options%" + federation: "%default_federation_options%" + +bookmark_lists: + controller: App\Controller\BookmarkListController::list + path: /bookmark-lists + methods: [GET, POST] + +bookmark_lists_menu_refresh_status: + controller: App\Controller\BookmarkListController::subjectBookmarkMenuListRefresh + path: /blr/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +bookmark_lists_make_default: + controller: App\Controller\BookmarkListController::makeDefault + path: /bookmark-lists/makeDefault + methods: [GET] + +bookmark_lists_edit_list: + controller: App\Controller\BookmarkListController::editList + path: /bookmark-lists/editList/{list} + methods: [GET, POST] + +bookmark_lists_delete_list: + controller: App\Controller\BookmarkListController::deleteList + path: /bookmark-lists/deleteList/{list} + methods: [GET] + +subject_bookmark_standard: + controller: App\Controller\BookmarkController::subjectBookmarkStandard + path: /bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_refresh_status: + controller: App\Controller\BookmarkController::subjectBookmarkRefresh + path: /bor/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_to_list: + controller: App\Controller\BookmarkController::subjectBookmarkToList + path: /bol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmarks: + controller: App\Controller\BookmarkController::subjectRemoveBookmarks + path: /rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmark_from_list: + controller: App\Controller\BookmarkController::subjectRemoveBookmarkFromList + path: /rbol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] diff --git a/config/mbin_routes/bookmark_api.yaml b/config/mbin_routes/bookmark_api.yaml new file mode 100644 index 000000000..f69ad45eb --- /dev/null +++ b/config/mbin_routes/bookmark_api.yaml @@ -0,0 +1,61 @@ +api_bookmark_front: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::front + path: /api/bookmark-lists/show + methods: [GET] + format: json + +api_bookmark_lists: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::list + path: /api/bookmark-lists + methods: [GET] + format: json + +api_bookmark_lists_make_default: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::makeDefault + path: /api/bookmark-lists/{list_name}/makeDefault + methods: [GET] + format: json + +api_bookmark_lists_edit_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::editList + path: /api/bookmark-lists/{list_name} + methods: [POST] + format: json + +api_bookmark_lists_delete_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::deleteList + path: /api/bookmark-lists/{list_name} + methods: [DELETE] + format: json + +api_subject_bookmark_standard: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkStandard + path: /api/bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_bookmark_to_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkToList + path: /api/bol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmarks: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarks + path: /api/rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmark_from_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarkFromList + path: /api/rbol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index 714d49007..221fde7b4 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -59,6 +59,13 @@ league_oauth2_server: "user:profile", "user:profile:read", "user:profile:edit", + "user:bookmark", + "user:bookmark:add", + "user:bookmark:remove", + "user:bookmark:list", + "user:bookmark:list:read", + "user:bookmark:list:edit", + "user:bookmark:list:delete", "user:message", "user:message:read", "user:message:create", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 626baa730..c3dee042c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -230,6 +230,17 @@ security: 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ', 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT', ] + 'ROLE_OAUTH2_USER:BOOKMARK': + [ + 'ROLE_OAUTH2_USER:BOOKMARK:ADD', + 'ROLE_OAUTH2_USER:BOOKMARK:REMOVE', + ] + 'ROLE_OAUTH2_USER:BOOKMARK_LIST': + [ + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:READ', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE', + ] 'ROLE_OAUTH2_MODERATE': [ 'ROLE_OAUTH2_MODERATE:ENTRY', diff --git a/config/services.yaml b/config/services.yaml index f93ba30ee..5c651d986 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -82,6 +82,7 @@ parameters: default_subscription_options: sub|fav|mod|all|home default_federation_options: local|all default_content_options: threads|microblog + default_subject_type_options: entry|entry_comment|post|post_comment comment_sort_options: top|hot|active|newest|oldest diff --git a/migrations/Version20240831151328.php b/migrations/Version20240831151328.php new file mode 100644 index 000000000..d680579ca --- /dev/null +++ b/migrations/Version20240831151328.php @@ -0,0 +1,56 @@ +addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)'); + $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)'); + $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2'); + $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395'); + $this->addSql('DROP TABLE bookmark'); + $this->addSql('DROP TABLE bookmark_list'); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index f2849ca1f..f4b729b7a 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -31,6 +31,8 @@ use App\Factory\PostCommentFactory; use App\Factory\PostFactory; use App\Form\Constraint\ImageConstraint; +use App\Repository\BookmarkListRepository; +use App\Repository\BookmarkRepository; use App\Repository\Criteria; use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; @@ -40,12 +42,13 @@ use App\Repository\PostRepository; use App\Repository\TagLinkRepository; use App\Schema\PaginationSchema; +use App\Service\BookmarkManager; use App\Service\IpResolver; use App\Service\ReportManager; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; @@ -88,6 +91,9 @@ public function __construct( protected readonly EntryCommentRepository $entryCommentRepository, protected readonly PostRepository $postRepository, protected readonly PostCommentRepository $postCommentRepository, + protected readonly BookmarkListRepository $bookmarkListRepository, + protected readonly BookmarkRepository $bookmarkRepository, + protected readonly BookmarkManager $bookmarkManager, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, @@ -192,7 +198,7 @@ public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken ->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]); } - public function serializePaginated(array $serializedItems, Pagerfanta $pagerfanta): array + public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array { return [ 'items' => $serializedItems, diff --git a/src/Controller/Api/Bookmark/BookmarkApiController.php b/src/Controller/Api/Bookmark/BookmarkApiController.php new file mode 100644 index 000000000..e7a79f9f5 --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkApiController.php @@ -0,0 +1,265 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmarkToDefaultList($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Add a bookmark for the subject in the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be added to the specified list', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:add'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:ADD')] + public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmark($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove bookmark for the subject from the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove all bookmarks for the subject', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/Api/Bookmark/BookmarkListApiController.php b/src/Controller/Api/Bookmark/BookmarkListApiController.php new file mode 100644 index 000000000..d7e5c10bc --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkListApiController.php @@ -0,0 +1,378 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $criteria = new EntryPageView($p ?? 1); + $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL)); + $criteria->setType($criteria->resolveType($type ?? 'all')); + $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation ?? Criteria::AP_ALL); + + if (null !== $list_id) { + $bookmarkList = $this->bookmarkListRepository->findOneBy(['id' => $list_id, 'user' => $user]); + if (null === $bookmarkList) { + return new JsonResponse(status: 404, headers: $headers); + } + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage); + $objects = $pagerfanta->getCurrentPageResults(); + $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects); + $result = $this->serializePaginated($items, $pagerfanta); + + return new JsonResponse($result, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Returns all bookmark lists from the user', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: BookmarkListDto::class)) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:read'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:READ')] + public function list(RateLimiterFactory $apiReadLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user)); + $response = [ + 'items' => $items, + ]; + + return new JsonResponse($response, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Sets the provided list as the default', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be made the default', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function makeDefault(string $list_name, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->makeListDefault($user, $list); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Edits the supplied list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: BookmarkListDto::class), + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be edited', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\RequestBody(content: new Model( + type: BookmarkListDto::class, + groups: ['common'] + ))] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->editList($user, $list, $dto); + $list = $this->bookmarkListRepository->findOneBy(['id' => $list->getId()]); + + return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Deletes the provided list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be deleted', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:delete'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE')] + public function deleteList(string $list_name, RateLimiterFactory $apiDeleteLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiDeleteLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->deleteList($list); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/BookmarkController.php b/src/Controller/BookmarkController.php new file mode 100644 index 000000000..55f4c1b18 --- /dev/null +++ b/src/Controller/BookmarkController.php @@ -0,0 +1,145 @@ +entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkManager->addBookmark($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } +} diff --git a/src/Controller/BookmarkListController.php b/src/Controller/BookmarkListController.php new file mode 100644 index 000000000..098f5bcbe --- /dev/null +++ b/src/Controller/BookmarkListController.php @@ -0,0 +1,180 @@ +getPageNb($request); + $user = $this->getUserOrThrow(); + $criteria = new EntryPageView($page); + $criteria->setTime($criteria->resolveTime($time)); + $criteria->setType($criteria->resolveType($type)); + $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation); + + if (null !== $list) { + $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list); + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria); + $objects = $res->getCurrentPageResults(); + $lists = $this->bookmarkListRepository->findByUser($user); + + $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]); + + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('layout/_subject_list.html.twig', [ + 'results' => $objects, + 'pagination' => $res, + ]), + ]); + } + + return $this->render( + 'bookmark/front.html.twig', + [ + 'criteria' => $criteria, + 'list' => $bookmarkList, + 'lists' => $lists, + 'results' => $objects, + 'pagination' => $res, + ] + ); + } + + #[IsGranted('ROLE_USER')] + public function list(Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = new BookmarkListDto(); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var BookmarkListDto $dto */ + $dto = $form->getData(); + $list = $this->bookmarkManager->createList($user, $dto->name); + if ($dto->isDefault) { + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/overview.html.twig', [ + 'lists' => $this->bookmarkListRepository->findByUser($user), + 'form' => $form->createView(), + ], + new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) + ); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $user = $this->getUserOrThrow(); + $bookmarkLists = $this->bookmarkListRepository->findByUser($user); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_menu_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'bookmarkLists' => $bookmarkLists, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response + { + $user = $this->getUserOrThrow(); + $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]); + if (null !== $makeDefault) { + $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]); + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + #[IsGranted('ROLE_USER')] + public function editList(#[MapEntity] BookmarkList $list, Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = BookmarkListDto::fromList($list); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $dto = $form->getData(); + $this->bookmarkListRepository->editList($user, $list, $dto); + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/edit.html.twig', [ + 'list' => $list, + 'form' => $form->createView(), + ]); + } + + #[IsGranted('ROLE_USER')] + public function deleteList(#[MapEntity] BookmarkList $list): Response + { + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => "$list->name ({$list->getId()})"]); + throw new AccessDeniedHttpException(); + } + $this->bookmarkListRepository->deleteList($list); + + return $this->redirectToRoute('bookmark_lists'); + } +} diff --git a/src/DTO/BookmarkListDto.php b/src/DTO/BookmarkListDto.php new file mode 100644 index 000000000..39bc3d8e2 --- /dev/null +++ b/src/DTO/BookmarkListDto.php @@ -0,0 +1,42 @@ +name = $list->name; + $dto->isDefault = $list->isDefault; + $dto->count = $list->entities->count(); + + return $dto; + } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'isDefault' => $this->isDefault, + 'count' => $this->count, + ]; + } +} diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index 229435a69..b751d60ef 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -62,6 +62,13 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'user:profile', 'user:profile:read', 'user:profile:edit', + 'user:bookmark', + 'user:bookmark:add', + 'user:bookmark:remove', + 'user:bookmark:list', + 'user:bookmark:list:read', + 'user:bookmark:list:edit', + 'user:bookmark:list:delete', 'user:message', 'user:message:read', 'user:message:create', diff --git a/src/Entity/Bookmark.php b/src/Entity/Bookmark.php new file mode 100644 index 000000000..3902e3948 --- /dev/null +++ b/src/Entity/Bookmark.php @@ -0,0 +1,70 @@ +user = $user; + $this->list = $list; + $this->createdAtTraitConstruct(); + } + + public function setContent(Post|EntryComment|PostComment|Entry $content): void + { + if ($content instanceof Entry) { + $this->entry = $content; + } elseif ($content instanceof EntryComment) { + $this->entryComment = $content; + } elseif ($content instanceof Post) { + $this->post = $content; + } elseif ($content instanceof PostComment) { + $this->postComment = $content; + } + } + + public function getContent(): Entry|EntryComment|Post|PostComment + { + return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; + } +} diff --git a/src/Entity/BookmarkList.php b/src/Entity/BookmarkList.php new file mode 100644 index 000000000..52673e1cf --- /dev/null +++ b/src/Entity/BookmarkList.php @@ -0,0 +1,51 @@ +user = $user; + $this->name = $name; + $this->isDefault = $isDefault; + $this->entities = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 6e081f528..9417439f0 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -224,6 +224,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public Collection $notifications; #[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')] public Collection $pushSubscriptions; + #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')] + public Collection $bookmarkLists; #[Id] #[GeneratedValue] #[Column(type: 'integer')] diff --git a/src/Form/BookmarkListType.php b/src/Form/BookmarkListType.php new file mode 100644 index 000000000..f10ba5cf1 --- /dev/null +++ b/src/Form/BookmarkListType.php @@ -0,0 +1,35 @@ +add('name', TextType::class) + ->add('isDefault', CheckboxType::class, [ + 'required' => false, + ]) + ->add('submit', SubmitType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => BookmarkListDto::class, + ] + ); + } +} diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php index 959d47b60..4888ca604 100644 --- a/src/Pagination/NativeQueryAdapter.php +++ b/src/Pagination/NativeQueryAdapter.php @@ -8,7 +8,9 @@ use App\Pagination\Transformation\VoidTransformer; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Statement; +use Doctrine\DBAL\Types\Types; use Pagerfanta\Adapter\AdapterInterface; /** @@ -35,7 +37,7 @@ public function __construct( $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; $stmt2 = $this->conn->prepare($sql2); foreach ($this->parameters as $key => $value) { - $stmt2->bindValue($key, $value); + $stmt2->bindValue($key, $value, $this->getSqlType($value)); } $result = $stmt2->executeQuery()->fetchAllAssociative(); $this->numOfResults = $result[0]['cnt']; @@ -43,7 +45,7 @@ public function __construct( $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); foreach ($this->parameters as $key => $value) { - $this->statement->bindValue($key, $value); + $this->statement->bindValue($key, $value, $this->getSqlType($value)); } } @@ -59,4 +61,15 @@ public function getSlice(int $offset, int $length): iterable return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); } + + private function getSqlType(mixed $value): mixed + { + if ($value instanceof \DateTimeImmutable) { + return Types::DATETIMETZ_IMMUTABLE; + } elseif ($value instanceof \DateTime) { + return Types::DATETIMETZ_MUTABLE; + } + + return ParameterType::STRING; + } } diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php index 522e98d97..41b005136 100644 --- a/src/Pagination/Transformation/ContentPopulationTransformer.php +++ b/src/Pagination/Transformation/ContentPopulationTransformer.php @@ -19,6 +19,7 @@ public function __construct( public function transform(iterable $input): iterable { + $positionsArray = $this->buildPositionArray($input); $entries = $this->entityManager->getRepository(Entry::class)->findBy( ['id' => $this->getOverviewIds((array) $input, 'entry')] ); @@ -32,10 +33,7 @@ public function transform(iterable $input): iterable ['id' => $this->getOverviewIds((array) $input, 'post_comment')] ); - $result = array_merge($entries, $entryComments, $post, $postComment); - uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); - - return $result; + return $this->applyPositions($positionsArray, $entries, $entryComments, $post, $postComment); } private function getOverviewIds(array $result, string $type): array @@ -44,4 +42,67 @@ private function getOverviewIds(array $result, string $type): array return array_map(fn ($subject) => $subject['id'], $result); } + + /** + * @return int[][] + */ + private function buildPositionArray(iterable $input): array + { + $entryPositions = []; + $entryCommentPositions = []; + $postPositions = []; + $postCommentPositions = []; + $i = 0; + foreach ($input as $current) { + switch ($current['type']) { + case 'entry': + $entryPositions[$current['id']] = $i; + break; + case 'entry_comment': + $entryCommentPositions[$current['id']] = $i; + break; + case 'post': + $postPositions[$current['id']] = $i; + break; + case 'post_comment': + $postCommentPositions[$current['id']] = $i; + break; + } + ++$i; + } + + return [ + 'entry' => $entryPositions, + 'entry_comment' => $entryCommentPositions, + 'post' => $postPositions, + 'post_comment' => $postCommentPositions, + ]; + } + + /** + * @param int[][] $positionsArray + * @param Entry[] $entries + * @param EntryComment[] $entryComments + * @param Post[] $posts + * @param PostComment[] $postComments + */ + private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments): array + { + $result = []; + foreach ($entries as $entry) { + $result[$positionsArray['entry'][$entry->getId()]] = $entry; + } + foreach ($entryComments as $entryComment) { + $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment; + } + foreach ($posts as $post) { + $result[$positionsArray['post'][$post->getId()]] = $post; + } + foreach ($postComments as $postComment) { + $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment; + } + ksort($result, SORT_NUMERIC); + + return $result; + } } diff --git a/src/Repository/BookmarkListRepository.php b/src/Repository/BookmarkListRepository.php new file mode 100644 index 000000000..c942dc500 --- /dev/null +++ b/src/Repository/BookmarkListRepository.php @@ -0,0 +1,83 @@ +findBy(['user' => $user]); + } + + public function findOneByUserAndName(User $user, string $name): ?BookmarkList + { + return $this->findOneBy(['user' => $user, 'name' => $name]); + } + + public function findOneByUserDefault(User $user): BookmarkList + { + $list = $this->findOneBy(['user' => $user, 'isDefault' => true]); + if (null === $list) { + $list = new BookmarkList($user, 'Default', true); + $this->entityManager->persist($list); + $this->entityManager->flush(); + } + + return $list; + } + + public function makeListDefault(User $user, BookmarkList $list): void + { + $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId()]); + + $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id'; + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId(), 'id' => $list->getId()]); + } + + public function deleteList(BookmarkList $list): void + { + $sql = 'DELETE FROM bookmark_list WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId()]); + } + + public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void + { + $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId(), 'name' => $dto->name]); + + if ($dto->isDefault) { + $this->makeListDefault($user, $list); + } + } +} diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php new file mode 100644 index 000000000..39cd79c1c --- /dev/null +++ b/src/Repository/BookmarkRepository.php @@ -0,0 +1,180 @@ +createQueryBuilder('b') + ->where('b.user = :user') + ->andWhere('b.list = :list') + ->setParameter('user', $user) + ->setParameter('list', $list) + ->getQuery() + ->getResult(); + } + + public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'id' => $content->getId()]); + } + + public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'l' => $list->getId(), 'id' => $content->getId()]); + } + + public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface + { + $entryWhereArr = ['b.list_id = :list']; + $entryCommentWhereArr = ['b.list_id = :list']; + $postWhereArr = ['b.list_id = :list']; + $postCommentWhereArr = ['b.list_id = :list']; + $parameters = [ + 'list' => $list->getId(), + ]; + + $orderBy = match ($criteria->sortOption) { + Criteria::SORT_OLD => 'ORDER BY i.created_at ASC', + Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC', + Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC', + default => 'ORDER BY created_at DESC', + }; + + if (Criteria::AP_LOCAL === $criteria->federation) { + $entryWhereArr[] = 'e.ap_id IS NULL'; + $entryCommentWhereArr[] = 'ec.ap_id IS NULL'; + $postWhereArr[] = 'p.ap_id IS NULL'; + $postCommentWhereArr[] = 'pc.ap_id IS NULL'; + } + + if ('all' !== $criteria->type) { + $entryWhereArr[] = 'e.type = :type'; + $entryCommentWhereArr[] = 'false'; + $postWhereArr[] = 'false'; + $postCommentWhereArr[] = 'false'; + + $parameters['type'] = $criteria->type; + } + + if (Criteria::TIME_ALL !== $criteria->time) { + $entryWhereArr[] = 'b.created_at > :time'; + $entryCommentWhereArr[] = 'b.created_at > :time'; + $postWhereArr[] = 'b.created_at > :time'; + $postCommentWhereArr[] = 'b.created_at > :time'; + + $parameters['time'] = $criteria->getSince(); + } + + $entryWhere = $this->makeWhereString($entryWhereArr); + $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); + $postWhere = $this->makeWhereString($postWhereArr); + $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + + $sql = " + SELECT * FROM ( + SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b + INNER JOIN entry e ON b.entry_id = e.id $entryWhere + UNION + SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b + INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere + UNION + SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b + INNER JOIN post p ON b.post_id = p.id $postWhere + UNION + SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b + INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere + ) i $orderBy + "; + + $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]); + + $conn = $this->entityManager->getConnection(); + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); + + return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); + } + + private function makeWhereString(array $whereClauses): string + { + if (empty($whereClauses)) { + return ''; + } + + $where = 'WHERE '; + $i = 0; + foreach ($whereClauses as $whereClause) { + if ($i > 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } +} diff --git a/src/Schema/PaginationSchema.php b/src/Schema/PaginationSchema.php index a311d533d..15b1c9021 100644 --- a/src/Schema/PaginationSchema.php +++ b/src/Schema/PaginationSchema.php @@ -5,7 +5,7 @@ namespace App\Schema; use OpenApi\Attributes as OA; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; #[OA\Schema()] class PaginationSchema implements \JsonSerializable @@ -19,7 +19,7 @@ class PaginationSchema implements \JsonSerializable #[OA\Property(description: 'Max number of items per page')] public int $perPage = 0; - public function __construct(Pagerfanta $pagerfanta) + public function __construct(PagerfantaInterface $pagerfanta) { $this->count = $pagerfanta->count(); $this->currentPage = $pagerfanta->getCurrentPage(); diff --git a/src/Service/BookmarkManager.php b/src/Service/BookmarkManager.php new file mode 100644 index 000000000..7e3912adf --- /dev/null +++ b/src/Service/BookmarkManager.php @@ -0,0 +1,90 @@ +entityManager->persist($list); + $this->entityManager->flush(); + + return $list; + } + + public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content])); + } elseif ($content instanceof EntryComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content])); + } elseif ($content instanceof Post) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content])); + } elseif ($content instanceof PostComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content])); + } + + return false; + } + + public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]); + } elseif ($content instanceof EntryComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]); + } elseif ($content instanceof Post) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]); + } elseif ($content instanceof PostComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]); + } + + return false; + } + + public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void + { + $list = $this->bookmarkListRepository->findOneByUserDefault($user); + $this->addBookmark($user, $list, $content); + } + + public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + $bookmark = new Bookmark($user, $list); + $bookmark->setContent($content); + $this->entityManager->persist($bookmark); + $this->entityManager->flush(); + } + + public static function GetClassFromSubjectType(string $subjectType): string + { + return match ($subjectType) { + 'entry' => Entry::class, + 'entry_comment' => EntryComment::class, + 'post' => Post::class, + 'post_comment' => PostComment::class, + default => throw new \LogicException("cannot match type $subjectType"), + }; + } +} diff --git a/src/Twig/Components/BookmarkListComponent.php b/src/Twig/Components/BookmarkListComponent.php new file mode 100644 index 000000000..95ac59965 --- /dev/null +++ b/src/Twig/Components/BookmarkListComponent.php @@ -0,0 +1,20 @@ +comment->root?->getId() ?? $this->comment->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "entry_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId) { - $item->expiresAfter(3600); - $item->tag(['entry_comments_user_'.$userId]); - $item->tag(['entry_comment_'.$commentId]); - - return $this->twig->render( - 'components/entry_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Components/PostCommentsNestedComponent.php b/src/Twig/Components/PostCommentsNestedComponent.php index 48ed6e43c..f812856e2 100644 --- a/src/Twig/Components/PostCommentsNestedComponent.php +++ b/src/Twig/Components/PostCommentsNestedComponent.php @@ -6,53 +6,12 @@ use App\Controller\User\ThemeSettingsController; use App\Entity\PostComment; -use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Symfony\UX\TwigComponent\ComponentAttributes; -use Twig\Environment; -#[AsTwigComponent('post_comments_nested', template: 'components/_cached.html.twig')] +#[AsTwigComponent('post_comments_nested')] final class PostCommentsNestedComponent { public PostComment $comment; public int $level; public string $view = ThemeSettingsController::TREE; - - public function __construct( - private readonly Environment $twig, - private readonly Security $security, - private readonly CacheInterface $cache, - private readonly RequestStack $requestStack, - ) { - } - - public function getHtml(ComponentAttributes $attributes): string - { - $comment = $this->comment->root ?? $this->comment; - $commentId = $comment->getId(); - $postId = $comment->post->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "post_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId, $postId) { - $item->expiresAfter(3600); - $item->tag(['post_comments_user_'.$userId]); - $item->tag(['post_comment_'.$commentId]); - $item->tag(['post_'.$postId]); - - return $this->twig->render( - 'components/post_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Extension/BookmarkExtension.php b/src/Twig/Extension/BookmarkExtension.php new file mode 100644 index 000000000..e3ac3367c --- /dev/null +++ b/src/Twig/Extension/BookmarkExtension.php @@ -0,0 +1,22 @@ +bookmarkListRepository->findByUser($user); + } + + public function getBookmarkListEntryCount(BookmarkList $list): int + { + return $list->entities->count(); + } + + public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarked($user, $content); + } + + public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarkedInList($user, $list, $content); + } +} diff --git a/src/Twig/Runtime/FrontExtensionRuntime.php b/src/Twig/Runtime/FrontExtensionRuntime.php index 5bb43294c..b52735041 100644 --- a/src/Twig/Runtime/FrontExtensionRuntime.php +++ b/src/Twig/Runtime/FrontExtensionRuntime.php @@ -4,6 +4,10 @@ namespace App\Twig\Runtime; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -63,4 +67,24 @@ private function getFrontRoute(string $currentRoute, array $params): string return 'front_short'; } } + + public function getClass(mixed $object): string + { + return \get_class($object); + } + + public function getSubjectType(mixed $object): string + { + if ($object instanceof Entry) { + return 'entry'; + } elseif ($object instanceof EntryComment) { + return 'entry_comment'; + } elseif ($object instanceof Post) { + return 'post'; + } elseif ($object instanceof PostComment) { + return 'post_comment'; + } else { + throw new \LogicException('unknown class '.\get_class($object)); + } + } } diff --git a/templates/bookmark/_form_edit.html.twig b/templates/bookmark/_form_edit.html.twig new file mode 100644 index 000000000..ca58c553f --- /dev/null +++ b/templates/bookmark/_form_edit.html.twig @@ -0,0 +1,14 @@ +{{ form_start(form, {attr: {class: 'bookmark_edit'}}) }} + +{{ form_row(form.name, {label: 'bookmark_list_create_label'}) }} + +
+ {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }} +
+ +
+ {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %} + {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }} +
+ +{{ form_end(form) }} diff --git a/templates/bookmark/_options.html.twig b/templates/bookmark/_options.html.twig new file mode 100644 index 000000000..d57833c0f --- /dev/null +++ b/templates/bookmark/_options.html.twig @@ -0,0 +1,231 @@ +{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} + + diff --git a/templates/bookmark/edit.html.twig b/templates/bookmark/edit.html.twig new file mode 100644 index 000000000..4aec0c0b8 --- /dev/null +++ b/templates/bookmark/edit.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks_list_edit'|trans }}

+ +
+
+ {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %} +
+
+ +{% endblock %} diff --git a/templates/bookmark/front.html.twig b/templates/bookmark/front.html.twig new file mode 100644 index 000000000..627370b74 --- /dev/null +++ b/templates/bookmark/front.html.twig @@ -0,0 +1,25 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks'|trans }}

+ + {% include 'bookmark/_options.html.twig' %} +
+ {% include 'layout/_subject_list.html.twig' %} +
+{% endblock %} diff --git a/templates/bookmark/overview.html.twig b/templates/bookmark/overview.html.twig new file mode 100644 index 000000000..4972ea4e6 --- /dev/null +++ b/templates/bookmark/overview.html.twig @@ -0,0 +1,77 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmark_lists'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmark-lists{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmark_lists'|trans }}

+ +
+
+ {% include('bookmark/_form_edit.html.twig') with {is_create: true} %} +
+
+ + {% if lists|length %} +
+
+ + + + + + + + + + + {% for list in lists %} + + + + + + + {% endfor %} + +
{{ 'name'|trans }}{{ 'count'|trans }}
+ {% if list.isDefault %} + + {% endif %} + {{ list.name }}{{ get_bookmark_list_entry_count(list) }} + {% if not list.isDefault %} +
+ + +
+ {% endif %} + + + + + + +
+
+
+ {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/components/bookmark_list.html.twig b/templates/components/bookmark_list.html.twig new file mode 100644 index 000000000..be65c8849 --- /dev/null +++ b/templates/components/bookmark_list.html.twig @@ -0,0 +1,19 @@ +
  • + {% if is_bookmarked_in_list(app.user, list, subject) %} + + + {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} + + {% else %} + + + {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} + + {% endif %} +
  • diff --git a/templates/components/bookmark_menu_list.html.twig b/templates/components/bookmark_menu_list.html.twig new file mode 100644 index 000000000..3a6126616 --- /dev/null +++ b/templates/components/bookmark_menu_list.html.twig @@ -0,0 +1,5 @@ +
    + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: subject, list: list }) }} + {% endfor %} +
    diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig new file mode 100644 index 000000000..d8d502a9e --- /dev/null +++ b/templates/components/bookmark_standard.html.twig @@ -0,0 +1,17 @@ +
  • + {% if is_bookmarked(app.user, subject) %} + + + + {% else %} + + + + {% endif %} +
  • diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index 59cd3242f..a4a402432 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -171,6 +171,9 @@ subject: entry }) }} + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: entry }) }} + {% endif %} {% include 'entry/_menu.html.twig' %}
  • diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 2bbec6930..1f87f77b5 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -102,6 +102,9 @@
  • {{ component('boost', {subject: comment}) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'entry/comment/_menu.html.twig' %}
  • diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index 04a79d7e6..d03e769ca 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -110,6 +110,9 @@ subject: post }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: post }) }} + {% endif %} {% include 'post/_menu.html.twig' %}
  • diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index b91eb650a..7755aceac 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -102,6 +102,9 @@ subject: comment }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • diff --git a/templates/entry/_menu.html.twig b/templates/entry/_menu.html.twig index e94186bfd..5dae6bf39 100644 --- a/templates/entry/_menu.html.twig +++ b/templates/entry/_menu.html.twig @@ -20,6 +20,14 @@
  • {% endif %} + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }} + {% endif %} + {% endif %} +
  • {{ 'copy_url'|trans }}
  • + {% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %} {% endif %} diff --git a/templates/entry/comment/_menu.html.twig b/templates/entry/comment/_menu.html.twig index 7b1fc2c80..6c250689d 100644 --- a/templates/entry/comment/_menu.html.twig +++ b/templates/entry/comment/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }} + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • {{ app.user.countNewNotifications }}
  • +
  • + + {{ 'bookmark_lists'|trans }} + +
  • {% if is_granted('ROLE_ADMIN') %}
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • Date: Sun, 13 Oct 2024 17:36:04 +0200 Subject: [PATCH 11/16] Make related panels respect blocks (#1183) --- src/Repository/BookmarkRepository.php | 28 ++------- src/Repository/EntryRepository.php | 44 ++++++++++---- src/Repository/MagazineRepository.php | 39 +++++++----- src/Repository/PostRepository.php | 48 +++++++++++---- .../Components/RelatedEntriesComponent.php | 17 ++++-- .../Components/RelatedMagazinesComponent.php | 15 +++-- src/Twig/Components/RelatedPostsComponent.php | 17 ++++-- src/Utils/SqlHelpers.php | 59 +++++++++++++++++++ 8 files changed, 191 insertions(+), 76 deletions(-) create mode 100644 src/Utils/SqlHelpers.php diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php index 39cd79c1c..e8982f4c6 100644 --- a/src/Repository/BookmarkRepository.php +++ b/src/Repository/BookmarkRepository.php @@ -14,6 +14,7 @@ use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; use App\Pagination\Transformation\ContentPopulationTransformer; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -130,10 +131,10 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $parameters['time'] = $criteria->getSince(); } - $entryWhere = $this->makeWhereString($entryWhereArr); - $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); - $postWhere = $this->makeWhereString($postWhereArr); - $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + $entryWhere = SqlHelpers::makeWhereString($entryWhereArr); + $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr); + $postWhere = SqlHelpers::makeWhereString($postWhereArr); + $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr); $sql = " SELECT * FROM ( @@ -158,23 +159,4 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); } - - private function makeWhereString(array $whereClauses): string - { - if (empty($whereClauses)) { - return ''; - } - - $where = 'WHERE '; - $i = 0; - foreach ($whereClauses as $whereClause) { - if ($i > 0) { - $where .= ' AND '; - } - $where .= $whereClause; - ++$i; - } - - return $where; - } } diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index d38890f21..33466bff9 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -24,6 +24,7 @@ use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -58,6 +59,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Entry::class); } @@ -153,6 +155,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { + /** @var User $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -341,12 +344,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb - ->andWhere('e.visibility = :visibility') + $qb->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') @@ -362,16 +364,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'tag' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -384,12 +393,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit): array + public function findLast(int $limit, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); @@ -403,10 +419,16 @@ public function findLast(int $limit): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 1c4f451bc..1c58c49e3 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -16,6 +16,7 @@ use App\Entity\User; use App\PageView\MagazinePageView; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use App\Utils\SubscriptionSort; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Collections\Collection; @@ -49,7 +50,7 @@ class MagazineRepository extends ServiceEntityRepository self::SORT_NEWEST, ]; - public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager) + public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers) { parent::__construct($registry, Magazine::class); } @@ -478,21 +479,23 @@ public function search(string $magazine, int $page, int $perPage = self::PER_PAG return $pagerfanta; } - public function findRandom(): array + public function findRandom(?User $user = null): array { $conn = $this->getEntityManager()->getConnection(); - $sql = ' - SELECT id FROM magazine - '; + $whereClauses = []; + $parameters = []; if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_LOCAL_ONLY')) { - $sql .= 'WHERE ap_id IS NULL'; + $whereClauses[] = 'm.ap_id IS NULL'; + } + if (null !== $user) { + $subSql = 'SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :user'; + $whereClauses[] = "NOT EXISTS($subSql)"; + $parameters['user'] = $user->getId(); } - $sql .= ' - ORDER BY random() - LIMIT 5 - '; + $whereString = SqlHelpers::makeWhereString($whereClauses); + $sql = "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5"; $stmt = $conn->prepare($sql); - $stmt = $stmt->executeQuery(); + $stmt = $stmt->executeQuery($parameters); $ids = $stmt->fetchAllAssociative(); return $this->createQueryBuilder('m') @@ -505,17 +508,23 @@ public function findRandom(): array ->getResult(); } - public function findRelated(string $magazine): array + public function findRelated(string $magazine, ?User $user = null): array { - return $this->createQueryBuilder('m') + $qb = $this->createQueryBuilder('m') ->where('m.entryCount > 0 OR m.postCount > 0') ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', "%{$magazine}%") - ->setMaxResults(5) - ->getQuery() + ->setMaxResults(5); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 870b20490..380e7470d 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -23,6 +23,7 @@ use App\PageView\PostPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -54,6 +55,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Post::class); } @@ -143,6 +145,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { + /** @var User|null $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -168,8 +171,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($criteria->subscribed) { $qb->andWhere( - 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) - OR + 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) + OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' @@ -307,11 +310,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb + $qb = $qb ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -328,16 +331,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'name' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -349,12 +359,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit = 1): array + public function findLast(int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); @@ -365,9 +382,16 @@ public function findLast(int $limit = 1): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('p.magazine', 'm') + ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Twig/Components/RelatedEntriesComponent.php b/src/Twig/Components/RelatedEntriesComponent.php index 990d7be26..4615006c3 100644 --- a/src/Twig/Components/RelatedEntriesComponent.php +++ b/src/Twig/Components/RelatedEntriesComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Entry; +use App\Entity\User; use App\Repository\EntryRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -32,6 +34,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -49,19 +52,23 @@ public function mount(?string $magazine, ?string $tag): void $entryId = $this->entry?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); + $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}"; $entryIds = $this->cache->get( - "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + $cacheKey, + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $entries = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user, ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult); diff --git a/src/Twig/Components/RelatedMagazinesComponent.php b/src/Twig/Components/RelatedMagazinesComponent.php index 741dea67f..0871cda57 100644 --- a/src/Twig/Components/RelatedMagazinesComponent.php +++ b/src/Twig/Components/RelatedMagazinesComponent.php @@ -5,8 +5,10 @@ namespace App\Twig\Components; use App\Entity\Magazine; +use App\Entity\User; use App\Repository\MagazineRepository; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -28,6 +30,7 @@ public function __construct( private readonly MagazineRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, + private readonly Security $security, ) { } @@ -44,16 +47,18 @@ public function mount(?string $magazine, ?string $tag): void } $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); $magazineIds = $this->cache->get( - "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $magazines = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelated($tag), - self::TYPE_MAGAZINE => $this->repository->findRelated($magazine), - default => $this->repository->findRandom(), + self::TYPE_TAG => $this->repository->findRelated($tag, user: $user), + self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user), + default => $this->repository->findRandom(user: $user), }; $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine); diff --git a/src/Twig/Components/RelatedPostsComponent.php b/src/Twig/Components/RelatedPostsComponent.php index 7c907ef91..a5a033950 100644 --- a/src/Twig/Components/RelatedPostsComponent.php +++ b/src/Twig/Components/RelatedPostsComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Post; +use App\Entity\User; use App\Repository\PostRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -31,6 +33,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -46,21 +49,25 @@ public function mount(?string $magazine, ?string $tag): void $this->type = self::TYPE_MAGAZINE; } + /** @var User|null $user */ + $user = $this->security->getUser(); + $postId = $this->post?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); $postIds = $this->cache->get( - "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $posts = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult); diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php new file mode 100644 index 000000000..39e9de186 --- /dev/null +++ b/src/Utils/SqlHelpers.php @@ -0,0 +1,59 @@ + 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } + + public function getBlockedMagazinesDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('bm') + ->from(MagazineBlock::class, 'bm') + ->where('bm.magazine = m') + ->andWhere('bm.user = :user') + ->setParameter('user', $user) + ->getDQL(); + } + + public function getBlockedUsersDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('ub') + ->from(UserBlock::class, 'ub') + ->where('ub.blocker = :user') + ->andWhere('ub.blocked = u') + ->setParameter('user', $user) + ->getDql(); + } +} From 8dd3fa5baa6d54f1a0187d05816a9ac7883c7c0a Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 16 Oct 2024 13:11:40 +0200 Subject: [PATCH 12/16] Improve search (#1167) --- assets/styles/app.scss | 1 + assets/styles/components/_search.scss | 24 ++++ assets/styles/layout/_forms.scss | 6 + assets/styles/layout/_layout.scss | 10 +- assets/styles/layout/_section.scss | 8 -- .../Api/Search/SearchRetrieveApi.php | 31 ++++- src/Controller/Api/User/UserRetrieveApi.php | 20 +++- src/Controller/SearchController.php | 107 ++++++++++-------- src/DTO/SearchDto.php | 8 +- src/Form/SearchType.php | 36 ++++++ src/Form/Type/UserAutocompleteType.php | 59 ++++++++++ src/Repository/SearchRepository.php | 50 ++++++-- src/Repository/UserRepository.php | 25 ++-- src/Service/SearchManager.php | 4 +- templates/magazine/list_all.html.twig | 6 +- templates/search/form.html.twig | 19 ++++ templates/search/front.html.twig | 14 +-- .../Api/User/UserRetrieveApiTest.php | 5 +- translations/messages.en.yaml | 4 + 19 files changed, 341 insertions(+), 96 deletions(-) create mode 100644 assets/styles/components/_search.scss create mode 100644 src/Form/SearchType.php create mode 100644 src/Form/Type/UserAutocompleteType.php create mode 100644 templates/search/form.html.twig diff --git a/assets/styles/app.scss b/assets/styles/app.scss index f0761101d..6f65b2c8f 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -30,6 +30,7 @@ @use 'components/figure_image'; @use 'components/figure_lightbox'; @use 'components/post'; +@use 'components/search'; @use 'components/subject'; @use 'components/login'; @use 'components/modlog'; diff --git a/assets/styles/components/_search.scss b/assets/styles/components/_search.scss new file mode 100644 index 000000000..d179dd372 --- /dev/null +++ b/assets/styles/components/_search.scss @@ -0,0 +1,24 @@ +.search-container { + background: var(--kbin-input-bg); + border: var(--kbin-input-border); + border-radius: var(--kbin-rounded-edges-radius) !important; + + input.form-control { + border-radius: 0 !important; + border: none; + background: transparent; + margin: 0 .5em; + padding: .5rem .25rem; + } + + button { + border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; + border: 0; + padding: 1rem 0.5rem; + + &:not(:hover) { + background: var(--kbin-input-bg); + color: var(--kbin-input-text-color) !important; + } + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 55ef8a9e4..a27d83847 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -528,3 +528,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100%; + +} diff --git a/assets/styles/layout/_layout.scss b/assets/styles/layout/_layout.scss index 278b41bd4..8db0b48a9 100644 --- a/assets/styles/layout/_layout.scss +++ b/assets/styles/layout/_layout.scss @@ -216,7 +216,9 @@ figure { code, .ts-control > [data-value].item, .image-preview-container { - border-radius: var(--kbin-rounded-edges-radius) !important; + &:not(.ignore-edges) { + border-radius: var(--kbin-rounded-edges-radius) !important; + } } .ts-wrapper { @@ -363,6 +365,12 @@ figure { gap: .25rem; } +@include b.media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 7f44e8166..47e75a8eb 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -68,11 +68,3 @@ color: var(--kbin-alert-danger-text-color); } } - -.page-search { - .section--top { - button { - padding: 1rem 1.5rem; - } - } -} \ No newline at end of file diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index cde4c5dae..31e333a55 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi required: true, schema: new OA\Schema(type: 'string') )] + #[OA\Parameter( + name: 'authorId', + description: 'User id of the author', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'magazineId', + description: 'Id of the magazine', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'type', + description: 'The type of content', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) + )] #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, @@ -122,8 +143,16 @@ public function __invoke( $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); + $authorIdRaw = $request->get('authorId'); + $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); + $magazineIdRaw = $request->get('magazineId'); + $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); + $type = $request->get('type'); + if ('entry' !== $type && 'post' !== $type && null !== $type) { + throw new BadRequestHttpException(); + } - $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage); + $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php index d620014e4..e69cca9e8 100644 --- a/src/Controller/Api/User/UserRetrieveApi.php +++ b/src/Controller/Api/User/UserRetrieveApi.php @@ -275,6 +275,18 @@ public function settings( in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] + #[OA\Parameter( + name: 'q', + description: 'The term to search for', + in: 'query', + schema: new OA\Schema(type: 'string') + )] + #[OA\Parameter( + name: 'withAbout', + description: 'Only include users with a filled in profile', + in: 'query', + schema: new OA\Schema(type: 'boolean') + )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, @@ -286,11 +298,15 @@ public function collection( $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); + $withAboutRaw = $request->get('withAbout'); + $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); - $users = $userRepository->findWithAboutPaginated( + $users = $userRepository->findPaginated( $this->getPageNb($request), + $withAbout, $group, - $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) + $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), + $request->get('q'), ); $dtos = []; diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index a8426b855..99955aff7 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\ActivityPub\ActorHandle; +use App\DTO\SearchDto; use App\Entity\Magazine; use App\Entity\User; +use App\Form\SearchType; use App\Message\ActivityPub\Inbox\CreateMessage; use App\MessageHandler\ActivityPub\Inbox\CreateHandler; use App\Service\ActivityPub\ApHttpClientInterface; @@ -33,63 +35,74 @@ public function __construct( public function __invoke(Request $request): Response { - $query = $request->query->get('q') ? trim($request->query->get('q')) : null; - - if (!$query) { - return $this->render( - 'search/front.html.twig', - [ - 'objects' => [], - 'results' => [], - 'q' => '', - ] - ); - } - - $this->logger->debug('searching for {query}', ['query' => $query]); - - $objects = []; - - // looking up handles (users and mags) - if (str_contains($query, '@') && $this->federatedSearchAllowed()) { - if ($handle = ActorHandle::parse($query)) { - $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = array_merge($objects, $this->lookupHandle($handle)); - } else { - $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); - } - } + $dto = new SearchDto(); + $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); + try { + $form = $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchDto $dto */ + $dto = $form->getData(); + $query = $dto->q; + $this->logger->debug('searching for {query}', ['query' => $query]); + + $objects = []; + + // looking up handles (users and mags) + if (str_contains($query, '@') && $this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($query)) { + $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); + $objects = array_merge($objects, $this->lookupHandle($handle)); + } else { + $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); + } + } - // looking up object by AP id (i.e. urls) - if (false !== filter_var($query, FILTER_VALIDATE_URL)) { - $this->logger->debug('Query is a valid url'); - $objects = $this->manager->findByApId($query); - if (0 === \sizeof($objects)) { - $body = $this->apHttpClient->getActivityObject($query); - // the returned id could be different from the query url. - $postId = $body['id']; - $objects = $this->manager->findByApId($postId); - if (0 === \sizeof($objects)) { - try { - $this->createHandler->doWork(new CreateMessage($body)); + // looking up object by AP id (i.e. urls) + if (false !== filter_var($query, FILTER_VALIDATE_URL)) { + $this->logger->debug('Query is a valid url'); + $objects = $this->manager->findByApId($query); + if (0 === \sizeof($objects)) { + $body = $this->apHttpClient->getActivityObject($query, false); + // the returned id could be different from the query url. + $postId = $body['id']; $objects = $this->manager->findByApId($postId); - } catch (\Exception $e) { - $this->addFlash('error', $e->getMessage()); + if (0 === \sizeof($objects)) { + try { + $this->createHandler->doWork(new CreateMessage($body)); + $objects = $this->manager->findByApId($postId); + } catch (\Exception $e) { + $this->addFlash('error', $e->getMessage()); + } + } } } + + $user = $this->getUser(); + $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type); + + $this->logger->debug('results: {num}', ['num' => $res->count()]); + + return $this->render( + 'search/front.html.twig', + [ + 'objects' => $objects, + 'results' => $this->overviewManager->buildList($res), + 'pagination' => $res, + 'form' => $form->createView(), + 'q' => $query, + ] + ); } + } catch (\Exception $e) { + $this->logger->error($e); } - $user = $this->getUser(); - $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request)); - return $this->render( 'search/front.html.twig', [ - 'objects' => $objects, - 'results' => $this->overviewManager->buildList($res), - 'pagination' => $res, - 'q' => $request->query->get('q'), + 'objects' => [], + 'results' => [], + 'form' => $form->createView(), ] ); } diff --git a/src/DTO/SearchDto.php b/src/DTO/SearchDto.php index c9070f0df..de26533c6 100644 --- a/src/DTO/SearchDto.php +++ b/src/DTO/SearchDto.php @@ -4,7 +4,13 @@ namespace App\DTO; +use App\Entity\Magazine; +use App\Entity\User; + class SearchDto { - public string $val; + public string $q; + public ?string $type = null; + public ?User $user = null; + public ?Magazine $magazine = null; } diff --git a/src/Form/SearchType.php b/src/Form/SearchType.php new file mode 100644 index 000000000..5d7410e50 --- /dev/null +++ b/src/Form/SearchType.php @@ -0,0 +1,36 @@ +setMethod('GET') + ->add('q', TextType::class, [ + 'required' => true, + 'attr' => [ + 'placeholder' => 'type_search_term', + ], + ]) + ->add('magazine', MagazineAutocompleteType::class, ['required' => false]) + ->add('user', UserAutocompleteType::class, ['required' => false]) + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'search_type_all' => null, + 'search_type_entry' => 'entry', + 'search_type_post' => 'post', + ], + ]); + } +} diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php new file mode 100644 index 000000000..d1cc01909 --- /dev/null +++ b/src/Form/Type/UserAutocompleteType.php @@ -0,0 +1,59 @@ +setDefaults([ + 'class' => User::class, + 'choice_label' => 'username', + 'placeholder' => 'select_user', + 'filter_query' => function (QueryBuilder $qb, string $query) { + if ($currentUser = $this->security->getUser()) { + $qb + ->andWhere( + \sprintf( + 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', + UserBlock::class, + ) + ) + ->setParameter('user', $currentUser); + } + + if (!$query) { + return; + } + + $qb->andWhere('entity.username LIKE :filter') + ->andWhere('entity.visibility = :visibility') + ->setParameter('filter', '%'.$query.'%') + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ; + }, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a5d89e5eb..84a412133 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; class SearchRepository { @@ -21,6 +22,7 @@ class SearchRepository public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ContentPopulationTransformer $transformer, + private readonly LoggerInterface $logger, ) { } @@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface return $pagerfanta; } - public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface + /** + * @param 'entry'|'post'|null $specificType + */ + public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { + $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; + $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $conn = $this->entityManager->getConnection(); - $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e + $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) @@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id @@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) - UNION ALL - SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e + $authorWhere $magazineWhere + "; + $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE body_ts @@ plainto_tsquery( :query ) = true @@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id @@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) - ORDER BY created_at DESC"; - $adapter = new NativeQueryAdapter($conn, $sql, [ + $authorWhere $magazineWhere + "; + + if (null === $specificType) { + $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC"; + } else { + if ('entry' === $specificType) { + $sql = "$sqlEntry ORDER BY created_at DESC"; + } elseif ('post' === $specificType) { + $sql = "$sqlPost ORDER BY created_at DESC"; + } else { + throw new \LogicException($specificType.' is not supported'); + } + } + + $this->logger->debug('Search query: {sql}', ['sql' => $sql]); + + $parameters = [ 'query' => $query, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, - ], transformer: $this->transformer); + ]; + + if (null !== $authorId) { + $parameters['authorId'] = $authorId; + } + + if (null !== $magazineId) { + $parameters['magazineId'] = $magazineId; + } + + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setCurrentPage($page); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 5f9df467a..a7b0e29a6 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr ->orderBy('u.lastActive', 'DESC'); } - public function findWithAboutPaginated( - int $page, - string $group = self::USERS_ALL, - int $perPage = self::PER_PAGE, - ): PagerfantaInterface { - $query = $this->findWithAboutQueryBuilder($group)->getQuery(); + public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface + { + $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( @@ -500,11 +497,19 @@ public function findWithAboutPaginated( return $pagerfanta; } - private function findWithAboutQueryBuilder(string $group): QueryBuilder + private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { - $qb = $this->createQueryBuilder('u') - ->andWhere('u.about != \'\'') - ->andWhere('u.about IS NOT NULL'); + $qb = $this->createQueryBuilder('u'); + + if ($needsAbout) { + $qb->andWhere('u.about != \'\'') + ->andWhere('u.about IS NOT NULL'); + } + + if (null !== $query) { + $qb->andWhere('u.username LIKE :query') + ->setParameter('query', '%'.$query.'%'); + } switch ($group) { case self::USERS_LOCAL: diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index b574dca98..9756aea59 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage return $this->domainRepository->search($domain, $page, $perPage); } - public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface + public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, $perPage); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType); } public function findByApId(string $url): array diff --git a/templates/magazine/list_all.html.twig b/templates/magazine/list_all.html.twig index 5a9f45cbe..452c2349e 100644 --- a/templates/magazine/list_all.html.twig +++ b/templates/magazine/list_all.html.twig @@ -21,9 +21,9 @@
    {{ form_start(form) }} -
    - {{ form_widget(form.query) }} -
    diff --git a/templates/search/form.html.twig b/templates/search/form.html.twig new file mode 100644 index 000000000..fcec8301b --- /dev/null +++ b/templates/search/form.html.twig @@ -0,0 +1,19 @@ +{{ form_start(form, {'attr': {'class': 'search-form'}}) }} + +
    + {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }} + + +
    + +
    + {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }} + {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }} +
    + {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }} +
    +
    + +{{ form_end(form) }} diff --git a/templates/search/front.html.twig b/templates/search/front.html.twig index feac7ebf2..0b72851d0 100644 --- a/templates/search/front.html.twig +++ b/templates/search/front.html.twig @@ -15,17 +15,7 @@ {% block body %}

    {{ 'search'|trans }}

    -
    -
    -
    - - -
    -
    -
    + {% include 'search/form.html.twig' %}
    Date: Sun, 24 Nov 2024 19:01:45 +0100 Subject: [PATCH 14/16] [Feature] Admin approval (#1232) Co-authored-by: TheVillageGuy <47496248+TheVillageGuy@users.noreply.github.com> --- .env.example | 3 + .env.example_docker | 3 + composer.json | 2 +- composer.lock | 6 +- config/mbin_routes/admin.yaml | 15 ++ config/mbin_routes/admin_api.yaml | 18 ++ config/packages/doctrine.yaml | 1 + config/services.yaml | 3 + .../03-optional-features/user_application.md | 10 + migrations/Version20241104162329.php | 30 +++ src/Command/UserCommand.php | 9 +- .../ActivityPub/User/UserController.php | 5 + .../Admin/AdminSignupRequestsController.php | 50 ++++ src/Controller/Api/BaseApi.php | 4 + .../Api/User/Admin/UserApplicationApi.php | 213 ++++++++++++++++++ .../Security/RegisterController.php | 11 +- .../ResendActivationEmailController.php | 3 + src/Controller/User/UserFrontController.php | 5 + src/DTO/SettingsDto.php | 3 + src/DTO/UserDto.php | 3 + .../DBAL/Types/EnumApplicationStatus.php | 20 ++ .../DBAL/Types/EnumType.php | 47 ++++ src/Entity/User.php | 21 ++ src/Enums/EApplicationStatus.php | 34 +++ .../User/UserApplicationApprovedEvent.php | 15 ++ .../User/UserApplicationRejectedEvent.php | 15 ++ .../User/UserApplicationSubscriber.php | 41 ++++ src/Form/SettingsType.php | 1 + src/Form/UserRegisterType.php | 8 + src/Message/UserApplicationAnswerMessage.php | 16 ++ src/MessageHandler/DeleteUserHandler.php | 2 +- .../SendApplicationAnswerMailHandler.php | 72 ++++++ .../SentUserConfirmationEmailHandler.php | 1 + src/Repository/UserRepository.php | 45 +++- src/Security/UserChecker.php | 12 + src/Service/ActivityPubManager.php | 3 +- src/Service/SettingsManager.php | 16 ++ src/Service/UserManager.php | 39 +++- src/Twig/Extension/AdminExtension.php | 1 + src/Twig/Runtime/AdminExtensionRuntime.php | 10 + .../_email/application_approved.html.twig | 19 ++ .../_email/application_rejected.html.twig | 11 + templates/_email/confirmation_email.html.twig | 7 +- templates/admin/_options.html.twig | 8 + templates/admin/settings.html.twig | 4 + templates/admin/signup_requests.html.twig | 52 +++++ templates/user/register.html.twig | 5 + tests/FactoryTrait.php | 2 +- translations/messages.en.yaml | 15 ++ 49 files changed, 923 insertions(+), 16 deletions(-) create mode 100644 docs/02-admin/03-optional-features/user_application.md create mode 100644 migrations/Version20241104162329.php create mode 100644 src/Controller/Admin/AdminSignupRequestsController.php create mode 100644 src/Controller/Api/User/Admin/UserApplicationApi.php create mode 100644 src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php create mode 100644 src/DoctrineExtensions/DBAL/Types/EnumType.php create mode 100644 src/Enums/EApplicationStatus.php create mode 100644 src/Event/User/UserApplicationApprovedEvent.php create mode 100644 src/Event/User/UserApplicationRejectedEvent.php create mode 100644 src/EventSubscriber/User/UserApplicationSubscriber.php create mode 100644 src/Message/UserApplicationAnswerMessage.php create mode 100644 src/MessageHandler/SendApplicationAnswerMailHandler.php create mode 100644 templates/_email/application_approved.html.twig create mode 100644 templates/_email/application_rejected.html.twig create mode 100644 templates/admin/signup_requests.html.twig diff --git a/.env.example b/.env.example index 991e08da9..c56d04ce1 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,9 @@ S3_VERSION= # Only let admins generated oauth clients KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false +# Manually approve every new user +MBIN_NEW_USERS_NEED_APPROVAL=false + # oAuth (optional) OAUTH_AZURE_ID= OAUTH_AZURE_SECRET= diff --git a/.env.example_docker b/.env.example_docker index 370f106ca..98d888678 100644 --- a/.env.example_docker +++ b/.env.example_docker @@ -70,6 +70,9 @@ S3_VERSION= # Only let admins generate oauth clients KBIN_ADMIN_ONLY_OAUTH_CLIENTS=false +# Manually approve every new user +MBIN_NEW_USERS_NEED_APPROVAL=false + # oAuth (optional) OAUTH_AZURE_ID= OAUTH_AZURE_SECRET= diff --git a/composer.json b/composer.json index 8320e04e1..79cf26e92 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "twig/extra-bundle": "^3.10.0", "twig/html-extra": "^3.10.0", "twig/intl-extra": "^3.10.0", - "twig/twig": "^3.10.3", + "twig/twig": "^3.15.0", "webmozart/assert": "^1.11.0", "wohali/oauth2-discord-new": "^1.2.1" }, diff --git a/composer.lock b/composer.lock index 0fbc50e7a..c5c83d6e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4a85444c39a4e13cd6d19da266851cc", + "content-hash": "cc959d40c451562db92e2c7a5078b3d4", "packages": [ { "name": "aws/aws-crt-php", @@ -18111,7 +18111,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -18127,6 +18127,6 @@ "ext-openssl": "*", "ext-redis": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/mbin_routes/admin.yaml b/config/mbin_routes/admin.yaml index 0c6dcd6c6..201e8f8a3 100644 --- a/config/mbin_routes/admin.yaml +++ b/config/mbin_routes/admin.yaml @@ -77,6 +77,21 @@ admin_magazine_ownership_requests_reject: path: /admin/magazine_ownership/{name}/{username}/reject methods: [POST] +admin_signup_requests: + controller: App\Controller\Admin\AdminSignupRequestsController::requests + path: /admin/signup_requests + methods: [ GET ] + +admin_signup_requests_approve: + controller: App\Controller\Admin\AdminSignupRequestsController::approve + path: /admin/signup_requests/{id}/approve + methods: [ POST ] + +admin_signup_requests_reject: + controller: App\Controller\Admin\AdminSignupRequestsController::reject + path: /admin/signup_requests/{id}/reject + methods: [ POST ] + admin_cc: controller: App\Controller\Admin\AdminClearCacheController path: /admin/cc diff --git a/config/mbin_routes/admin_api.yaml b/config/mbin_routes/admin_api.yaml index f2b0cd19f..eb198b1fa 100644 --- a/config/mbin_routes/admin_api.yaml +++ b/config/mbin_routes/admin_api.yaml @@ -111,3 +111,21 @@ api_admin_purge_magazine: path: /api/admin/magazine/{magazine_id}/purge methods: [ DELETE ] format: json + +api_admin_view_user_applications: + controller: App\Controller\Api\User\Admin\UserApplicationApi::retrieve + path: /api/admin/users/applications + methods: [ GET ] + format: json + +api_admin_view_user_application_approve: + controller: App\Controller\Api\User\Admin\UserApplicationApi::approve + path: /api/admin/users/applications/{user_id}/approve + methods: [ GET ] + format: json + +api_admin_view_user_application_reject: + controller: App\Controller\Api\User\Admin\UserApplicationApi::reject + path: /api/admin/users/applications/{user_id}/reject + methods: [ GET ] + format: json diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a5126459e..2f891dc79 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -3,6 +3,7 @@ doctrine: url: '%env(resolve:DATABASE_URL)%' types: citext: App\DoctrineExtensions\DBAL\Types\Citext + enumApplicationStatus: App\DoctrineExtensions\DBAL\Types\EnumApplicationStatus mapping_types: user_type: string citext: citext diff --git a/config/services.yaml b/config/services.yaml index 5c651d986..adb224e40 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -113,6 +113,8 @@ parameters: mbin_downvotes_mode_default: 'enabled' mbin_downvotes_mode: '%env(enum:\App\Utils\DownvotesMode:default:mbin_downvotes_mode_default:MBIN_DOWNVOTES_MODE)%' + mbin_new_users_need_approval: '%env(bool:default::MBIN_NEW_USERS_NEED_APPROVAL)%' + services: # default configuration for services in *this* file _defaults: @@ -182,6 +184,7 @@ services: $mbinSsoOnlyMode: '%sso_only_mode%' $maxImageBytes: '%max_image_bytes%' $mbinDownvotesMode: '%mbin_downvotes_mode%' + $mbinNewUsersNeedApproval: '%mbin_new_users_need_approval%' # Markdown App\Markdown\Factory\EnvironmentFactory: diff --git a/docs/02-admin/03-optional-features/user_application.md b/docs/02-admin/03-optional-features/user_application.md new file mode 100644 index 000000000..03b937ce7 --- /dev/null +++ b/docs/02-admin/03-optional-features/user_application.md @@ -0,0 +1,10 @@ +# Manually Approving New Users + +If you want to manually approve users before they can log into your server, +you can either tick the checkbox in the admin settings put this in the `.env` file: +```dotenv +MBIN_NEW_USERS_NEED_APPROVAL=true +``` + +You will then see a new admin panel called `Applications` where new users will appear until you approve or deny them. +When you have decided on one or the other, the user will get an email notification about it. diff --git a/migrations/Version20241104162329.php b/migrations/Version20241104162329.php new file mode 100644 index 000000000..cb6f02383 --- /dev/null +++ b/migrations/Version20241104162329.php @@ -0,0 +1,30 @@ +addSql('CREATE TYPE enumApplicationStatus AS ENUM (\'Approved\', \'Rejected\', \'Pending\')'); + $this->addSql('ALTER TABLE "user" ADD application_text TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD application_status enumApplicationStatus DEFAULT \'Approved\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP application_text'); + $this->addSql('ALTER TABLE "user" DROP application_status'); + $this->addSql('DROP TYPE enumApplicationStatus'); + } +} diff --git a/src/Command/UserCommand.php b/src/Command/UserCommand.php index c7c1d305c..127c8aece 100644 --- a/src/Command/UserCommand.php +++ b/src/Command/UserCommand.php @@ -35,6 +35,7 @@ protected function configure(): void $this->addArgument('username', InputArgument::REQUIRED) ->addArgument('email', InputArgument::REQUIRED) ->addArgument('password', InputArgument::REQUIRED) + ->addOption('applicationText', 'a', InputOption::VALUE_REQUIRED, 'The application text of the user, if set the user will not be pre-approved') ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove user') ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant administrator privileges') ->addOption('moderator', null, InputOption::VALUE_NONE, 'Grant global moderator privileges'); @@ -69,10 +70,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function createUser(InputInterface $input, SymfonyStyle $io): void { - $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email')); + $applicationText = $input->getOption('applicationText'); + if ('' === $applicationText) { + $applicationText = null; + } + $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email'), applicationText: $applicationText); $dto->plainPassword = $input->getArgument('password'); - $user = $this->manager->create($dto, false, false); + $user = $this->manager->create($dto, false, false, preApprove: null === $applicationText); if ($input->getOption('admin')) { $user->setOrRemoveAdminRole(); diff --git a/src/Controller/ActivityPub/User/UserController.php b/src/Controller/ActivityPub/User/UserController.php index 664a8d0c1..f0d2b1d98 100644 --- a/src/Controller/ActivityPub/User/UserController.php +++ b/src/Controller/ActivityPub/User/UserController.php @@ -6,6 +6,7 @@ use App\Controller\AbstractController; use App\Entity\User; +use App\Enums\EApplicationStatus; use App\Factory\ActivityPub\PersonFactory; use App\Factory\ActivityPub\TombstoneFactory; use Symfony\Component\HttpFoundation\JsonResponse; @@ -25,6 +26,10 @@ public function __invoke(User $user, Request $request): JsonResponse throw $this->createNotFoundException(); } + if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { + throw $this->createNotFoundException(); + } + if (!$user->isDeleted || null !== $user->markedForDeletionAt) { $response = new JsonResponse($this->personFactory->create($user, true)); } else { diff --git a/src/Controller/Admin/AdminSignupRequestsController.php b/src/Controller/Admin/AdminSignupRequestsController.php new file mode 100644 index 000000000..e8af01be7 --- /dev/null +++ b/src/Controller/Admin/AdminSignupRequestsController.php @@ -0,0 +1,50 @@ +repository->findAllSignupRequestsPaginated($page); + + return $this->render('admin/signup_requests.html.twig', [ + 'requests' => $requests, + 'page' => $page, + ]); + } + + #[IsGranted('ROLE_ADMIN')] + public function approve(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response + { + $this->userManager->approveUserApplication($user); + + return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); + } + + #[IsGranted('ROLE_ADMIN')] + public function reject(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response + { + $this->userManager->rejectUserApplication($user); + + return $this->redirectToRoute('admin_signup_requests', ['page' => $page]); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index f4b729b7a..8818f5071 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -41,10 +41,12 @@ use App\Repository\PostCommentRepository; use App\Repository\PostRepository; use App\Repository\TagLinkRepository; +use App\Repository\UserRepository; use App\Schema\PaginationSchema; use App\Service\BookmarkManager; use App\Service\IpResolver; use App\Service\ReportManager; +use App\Service\UserManager; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; @@ -94,6 +96,8 @@ public function __construct( protected readonly BookmarkListRepository $bookmarkListRepository, protected readonly BookmarkRepository $bookmarkRepository, protected readonly BookmarkManager $bookmarkManager, + protected readonly UserManager $userManager, + protected readonly UserRepository $userRepository, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, diff --git a/src/Controller/Api/User/Admin/UserApplicationApi.php b/src/Controller/Api/User/Admin/UserApplicationApi.php new file mode 100644 index 000000000..a0ed2b8a7 --- /dev/null +++ b/src/Controller/Api/User/Admin/UserApplicationApi.php @@ -0,0 +1,213 @@ +rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $users = $this->userRepository->findAllSignupRequestsPaginated($p); + + $dtos = []; + foreach ($users->getCurrentPageResults() as $value) { + \assert($value instanceof User); + $dtos[] = $this->serializeUser($userFactory->createDto($value)); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $users), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns nothing on success', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 403, + description: 'You are not authorized to verify this user', + content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'User not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'user_id', + description: 'The user to approve', + in: 'path', + schema: new OA\Schema(type: 'integer', minimum: 1) + )] + #[OA\Tag(name: 'admin/user')] + #[IsGranted('ROLE_ADMIN')] + #[Security(name: 'oauth2', scopes: ['admin:user:application'])] + #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] + public function approve( + RateLimiterFactory $apiReadLimiter, + RateLimiterFactory $anonymousApiReadLimiter, + #[MapEntity(id: 'user_id')] User $user, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $this->userManager->approveUserApplication($user); + + return new JsonResponse(null, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Returns nothing on success', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 403, + description: 'You are not authorized to verify this user', + content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'User not found', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'user_id', + description: 'The user to reject', + in: 'path', + schema: new OA\Schema(type: 'integer', minimum: 1) + )] + #[OA\Tag(name: 'admin/user')] + #[IsGranted('ROLE_ADMIN')] + #[Security(name: 'oauth2', scopes: ['admin:user:application'])] + #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')] + public function reject( + RateLimiterFactory $apiReadLimiter, + RateLimiterFactory $anonymousApiReadLimiter, + #[MapEntity(id: 'user_id')] User $user, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $this->userManager->rejectUserApplication($user); + + return new JsonResponse(null, headers: $headers); + } +} diff --git a/src/Controller/Security/RegisterController.php b/src/Controller/Security/RegisterController.php index 0e69971bb..95e0276f2 100644 --- a/src/Controller/Security/RegisterController.php +++ b/src/Controller/Security/RegisterController.php @@ -5,6 +5,7 @@ namespace App\Controller\Security; use App\Controller\AbstractController; +use App\DTO\UserDto; use App\Form\UserRegisterType; use App\Service\IpResolver; use App\Service\SettingsManager; @@ -39,6 +40,7 @@ public function __invoke(Request $request): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + /** @var UserDto $dto */ $dto = $form->getData(); $dto->ip = $this->ipResolver->resolve(); @@ -49,7 +51,14 @@ public function __invoke(Request $request): Response 'flash_register_success' ); - return $this->redirectToRoute('front'); + if ($this->settingsManager->getNewUsersNeedApproval()) { + $this->addFlash( + 'success', + 'flash_application_info' + ); + } + + return $this->redirectToRoute('app_login'); } elseif ($form->isSubmitted() && !$form->isValid()) { $this->logger->error('Registration form submission was invalid.', [ 'errors' => $form->getErrors(true, false), diff --git a/src/Controller/Security/ResendActivationEmailController.php b/src/Controller/Security/ResendActivationEmailController.php index 514305a88..e109d24bc 100644 --- a/src/Controller/Security/ResendActivationEmailController.php +++ b/src/Controller/Security/ResendActivationEmailController.php @@ -9,6 +9,7 @@ use App\Form\ResendEmailActivationFormType; use App\MessageHandler\SentUserConfirmationEmailHandler; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,6 +17,7 @@ class ResendActivationEmailController extends AbstractController { public function __construct( private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, ) { } @@ -43,6 +45,7 @@ public function resend(Request $request, SentUserConfirmationEmailHandler $confi return $this->redirectToRoute('app_resend_email_activation'); } catch (\Exception $e) { + $this->logger->error('There was an exception trying to re-send the activation email to: {u} - {mail}: {e} - {msg}', ['u' => $user->username, 'mail' => $user->email, 'e' => \get_class($e), 'msg' => $e->getMessage()]); $this->addFlash('error', 'resend_account_activation_email_error'); return $this->redirectToRoute('app_resend_email_activation'); diff --git a/src/Controller/User/UserFrontController.php b/src/Controller/User/UserFrontController.php index fe8354035..ab2783007 100644 --- a/src/Controller/User/UserFrontController.php +++ b/src/Controller/User/UserFrontController.php @@ -6,6 +6,7 @@ use App\Controller\AbstractController; use App\Entity\User; +use App\Enums\EApplicationStatus; use App\PageView\EntryCommentPageView; use App\PageView\EntryPageView; use App\PageView\MagazinePageView; @@ -40,6 +41,10 @@ public function front(User $user, Request $request, UserRepository $repository): $requestedByUser = $this->getUser(); $hideAdult = (!$requestedByUser || $requestedByUser->hideAdult); + if (EApplicationStatus::Approved !== $user->getApplicationStatus()) { + throw $this->createNotFoundException(); + } + if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) { throw $this->createNotFoundException(); } diff --git a/src/DTO/SettingsDto.php b/src/DTO/SettingsDto.php index d298bf40e..d278ef5ee 100644 --- a/src/DTO/SettingsDto.php +++ b/src/DTO/SettingsDto.php @@ -38,6 +38,7 @@ public function __construct( public bool $MBIN_SSO_SHOW_FIRST, public int $MAX_IMAGE_BYTES, public string $MBIN_DOWNVOTES_MODE, + public bool $MBIN_NEW_USERS_NEED_APPROVAL, ) { } @@ -70,6 +71,7 @@ public function mergeIntoDto(SettingsDto $dto): SettingsDto $dto->MBIN_SSO_SHOW_FIRST = $this->MBIN_SSO_SHOW_FIRST ?? $dto->MBIN_SSO_SHOW_FIRST; $dto->MAX_IMAGE_BYTES = $this->MAX_IMAGE_BYTES ?? $dto->MAX_IMAGE_BYTES; $dto->MBIN_DOWNVOTES_MODE = $this->MBIN_DOWNVOTES_MODE ?? $dto->MBIN_DOWNVOTES_MODE; + $dto->MBIN_NEW_USERS_NEED_APPROVAL = $this->MBIN_NEW_USERS_NEED_APPROVAL ?? $dto->MBIN_NEW_USERS_NEED_APPROVAL; return $dto; } @@ -104,6 +106,7 @@ public function jsonSerialize(): mixed 'MBIN_SSO_SHOW_FIRST' => $this->MBIN_SSO_SHOW_FIRST, 'MAX_IMAGE_BYTES' => $this->MAX_IMAGE_BYTES, 'MBIN_DOWNVOTES_MODE' => $this->MBIN_DOWNVOTES_MODE, + 'MBIN_NEW_USERS_NEED_APPROVAL' => $this->MBIN_NEW_USERS_NEED_APPROVAL, ]; } } diff --git a/src/DTO/UserDto.php b/src/DTO/UserDto.php index ce8e344aa..2fda3ac2f 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -49,6 +49,7 @@ class UserDto implements UserDtoInterface public ?string $totpSecret = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + public ?string $applicationText = null; #[Assert\Callback] public function validate( @@ -91,6 +92,7 @@ public static function create( ?bool $isBot = null, ?bool $isAdmin = null, ?bool $isGlobalModerator = null, + ?string $applicationText = null, ): self { $dto = new UserDto(); $dto->id = $id; @@ -107,6 +109,7 @@ public static function create( $dto->isBot = $isBot; $dto->isAdmin = $isAdmin; $dto->isGlobalModerator = $isGlobalModerator; + $dto->applicationText = $applicationText; return $dto; } diff --git a/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php b/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php new file mode 100644 index 000000000..7b6865d52 --- /dev/null +++ b/src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php @@ -0,0 +1,20 @@ +getValues()); + + return 'ENUM('.implode(', ', $values).')'; + } + + public function convertToPHPValue($value, AbstractPlatform $platform) + { + return $value; + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform) + { + if (!\in_array($value, $this->getValues())) { + throw new \InvalidArgumentException("Invalid '".$this->getName()."' value."); + } + + return $value; + } + + public function requiresSQLCommentHint(AbstractPlatform $platform): bool + { + return true; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 9417439f0..73fb52b8a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,7 @@ use App\Entity\Traits\ActivityPubActorTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\VisibilityTrait; +use App\Enums\EApplicationStatus; use App\Repository\UserRepository; use App\Service\ActivityPub\ApHttpClientInterface; use Doctrine\Common\Collections\ArrayCollection; @@ -241,6 +242,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; + #[Column(type: 'text', nullable: true)] + public string $applicationText; + + #[Column(type: 'enumApplicationStatus', nullable: false, options: ['default' => EApplicationStatus::Approved->value])] + private string $applicationStatus; + public function __construct( string $email, string $username, @@ -248,6 +255,8 @@ public function __construct( string $type, ?string $apProfileId = null, ?string $apId = null, + EApplicationStatus $applicationStatus = EApplicationStatus::Approved, + ?string $applicationText = null, ) { $this->email = $email; $this->password = $password; @@ -281,6 +290,8 @@ public function __construct( $this->lastActive = new \DateTime(); $this->createdAtTraitConstruct(); $this->oAuth2UserConsents = new ArrayCollection(); + $this->setApplicationStatus($applicationStatus); + $this->applicationText = $applicationText; } public function getId(): int @@ -897,4 +908,14 @@ public function canUpdateUser(User $actor): bool return $this->apDomain === $actor->apDomain; } } + + public function getApplicationStatus(): EApplicationStatus + { + return EApplicationStatus::getFromString($this->applicationStatus); + } + + public function setApplicationStatus(EApplicationStatus $applicationStatus): void + { + $this->applicationStatus = $applicationStatus->value; + } } diff --git a/src/Enums/EApplicationStatus.php b/src/Enums/EApplicationStatus.php new file mode 100644 index 000000000..f18291658 --- /dev/null +++ b/src/Enums/EApplicationStatus.php @@ -0,0 +1,34 @@ +value => self::Approved, + self::Rejected->value => self::Rejected, + self::Pending->value => self::Pending, + default => null, + }; + } + + /** + * @return string[] + */ + public static function getValues(): array + { + return [ + EApplicationStatus::Approved->value, + EApplicationStatus::Rejected->value, + EApplicationStatus::Pending->value, + ]; + } +} diff --git a/src/Event/User/UserApplicationApprovedEvent.php b/src/Event/User/UserApplicationApprovedEvent.php new file mode 100644 index 000000000..a735c5458 --- /dev/null +++ b/src/Event/User/UserApplicationApprovedEvent.php @@ -0,0 +1,15 @@ + 'onUserApplicationRejected', + UserApplicationApprovedEvent::class => 'onUserApplicationApproved', + ]; + } + + public function onUserApplicationApproved(UserApplicationApprovedEvent $event): void + { + $this->logger->debug('Got a UserApplicationApprovedEvent for {u}', ['u' => $event->user->username]); + $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: true)); + } + + public function onUserApplicationRejected(UserApplicationRejectedEvent $event): void + { + $this->logger->debug('Got a UserApplicationRejectedEvent for {u}', ['u' => $event->user->username]); + $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: false)); + } +} diff --git a/src/Form/SettingsType.php b/src/Form/SettingsType.php index 561450dc2..8804c3304 100644 --- a/src/Form/SettingsType.php +++ b/src/Form/SettingsType.php @@ -59,6 +59,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $dto->MBIN_DOWNVOTES_MODE => ['checked' => true], ], ]) + ->add('MBIN_NEW_USERS_NEED_APPROVAL', CheckboxType::class, ['required' => false]) ->add('submit', SubmitType::class); } diff --git a/src/Form/UserRegisterType.php b/src/Form/UserRegisterType.php index 0da509738..314246091 100644 --- a/src/Form/UserRegisterType.php +++ b/src/Form/UserRegisterType.php @@ -9,12 +9,14 @@ use App\Form\EventListener\CaptchaListener; use App\Form\EventListener\DisableFieldsOnUserEdit; use App\Form\EventListener\ImageListener; +use App\Service\SettingsManager; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -25,6 +27,7 @@ public function __construct( private readonly AddFieldsOnUserEdit $addAvatarFieldOnUserEdit, private readonly DisableFieldsOnUserEdit $disableUsernameFieldOnUserEdit, private readonly CaptchaListener $captchaListener, + private readonly SettingsManager $settingsManager, ) { } @@ -64,6 +67,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ) ->add('submit', SubmitType::class); + if ($this->settingsManager->getNewUsersNeedApproval()) { + $builder + ->add('applicationText', TextareaType::class, ['required' => true]); + } + $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->captchaListener); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); diff --git a/src/Message/UserApplicationAnswerMessage.php b/src/Message/UserApplicationAnswerMessage.php new file mode 100644 index 000000000..b86b6e116 --- /dev/null +++ b/src/Message/UserApplicationAnswerMessage.php @@ -0,0 +1,16 @@ +entityManager->flush(); // recreate a user with the same name, so this handle is blocked - $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false); + $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false, preApprove: true); $user->isDeleted = true; $user->markedForDeletionAt = null; $user->isVerified = false; diff --git a/src/MessageHandler/SendApplicationAnswerMailHandler.php b/src/MessageHandler/SendApplicationAnswerMailHandler.php new file mode 100644 index 000000000..df5792f52 --- /dev/null +++ b/src/MessageHandler/SendApplicationAnswerMailHandler.php @@ -0,0 +1,72 @@ +entityManager); + } + + public function __invoke(UserApplicationAnswerMessage $message): void + { + $this->workWrapper($message); + } + + public function doWork(MessageInterface $message): void + { + if (!($message instanceof UserApplicationAnswerMessage)) { + throw new \LogicException(); + } + $user = $this->repository->find($message->userId); + if (!$user) { + throw new UnrecoverableMessageHandlingException('User not found'); + } + + $this->sendAnswerMail($user, $message->approved); + } + + public function sendAnswerMail(User $user, bool $approved): void + { + $mail = (new TemplatedEmail()) + ->from( + new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain')) + ) + ->to($user->email); + + if ($approved) { + $mail->subject($this->translator->trans('email_application_approved_title')) + ->htmlTemplate('_email/application_approved.html.twig') + ->context(['user' => $user]); + } else { + $mail->subject($this->translator->trans('email_application_rejected_title')) + ->htmlTemplate('_email/application_rejected.html.twig') + ->context(['user' => $user]); + } + $this->mailer->send($mail); + } +} diff --git a/src/MessageHandler/SentUserConfirmationEmailHandler.php b/src/MessageHandler/SentUserConfirmationEmailHandler.php index f4e52ae6a..8d3ae9645 100644 --- a/src/MessageHandler/SentUserConfirmationEmailHandler.php +++ b/src/MessageHandler/SentUserConfirmationEmailHandler.php @@ -70,6 +70,7 @@ public function sendConfirmationEmail(User $user): void ->to($user->email) ->subject($this->translator->trans('email_confirm_title')) ->htmlTemplate('_email/confirmation_email.html.twig') + ->context(['user' => $user]) ); } catch (\Exception $e) { throw $e; diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index a7b0e29a6..12f33f848 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -12,6 +12,7 @@ use App\Entity\PostComment; use App\Entity\User; use App\Entity\UserFollow; +use App\Enums\EApplicationStatus; use App\Service\SettingsManager; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Result; @@ -252,6 +253,8 @@ public function findAllActivePaginated(int $page, bool $onlyLocal = false): Page ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('u.createdAt', 'ASC') ->getQuery(); @@ -281,6 +284,8 @@ public function findAllInactivePaginated(int $page): PagerfantaInterface ->andWhere('u.isVerified = false') ->andWhere('u.isDeleted = false') ->andWhere('u.isBanned = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->orderBy('u.createdAt', 'ASC') ->getQuery(); @@ -403,6 +408,8 @@ public function findOneByUsername(string $username): ?User { return $this->createQueryBuilder('u') ->Where('LOWER(u.username) = LOWER(:username)') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter('username', $username) ->getQuery() ->getOneOrNullResult(); @@ -412,6 +419,8 @@ public function findByUsernames(array $users): array { return $this->createQueryBuilder('u') ->where('u.username IN (?1)') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->setParameter(1, $users) ->getQuery() ->getResult(); @@ -474,6 +483,8 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr return $qb ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->orderBy('u.lastActive', 'DESC'); } @@ -521,7 +532,10 @@ private function findQueryBuilder(string $group, ?string $query, bool $needsAbou break; } - return $qb->orderBy('u.lastActive', 'DESC'); + return $qb + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) + ->orderBy('u.lastActive', 'DESC'); } public function findUsersForGroup(string $group = self::USERS_ALL, ?bool $recentlyActive = true): array @@ -576,6 +590,8 @@ public function findAdmin(): User $result = $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); if (0 === \sizeof($result)) { @@ -593,6 +609,8 @@ public function findAllAdmins(): array return $this->createQueryBuilder('u') ->andWhere("JSONB_CONTAINS(u.roles, '\"".'ROLE_ADMIN'."\"') = true") ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') + ->setParameter('status', EApplicationStatus::Approved->value) ->getQuery() ->getResult(); } @@ -606,7 +624,8 @@ public function findUsersSuggestions(string $query): array ->orWhere($qb->expr()->like('u.email', ':query')) ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') - ->setParameters(['query' => "{$query}%"]) + ->andWhere('u.applicationStatus = :status') + ->setParameters(['query' => "{$query}%", 'status' => EApplicationStatus::Approved->value]) ->setMaxResults(5) ->getQuery() ->getResult(); @@ -647,6 +666,7 @@ public function findUsersForMagazine(Magazine $magazine, ?bool $federated = fals $qb->andWhere($qb->expr()->in('u.id', $user)) ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') + ->andWhere('u.applicationStatus = :status') ->andWhere('u.visibility = :visibility') ->andWhere('u.apDeletedAt IS NULL') ->andWhere('u.apTimeoutAt IS NULL'); @@ -665,6 +685,7 @@ public function findUsersForMagazine(Magazine $magazine, ?bool $federated = fals } $qb->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ->setParameter('status', EApplicationStatus::Approved->value) ->setMaxResults($limit); try { @@ -692,6 +713,7 @@ public function findActiveUsers(?Magazine $magazine = null) $results = $this->findUsersForMagazine($magazine, null, 35, true, true); } else { $results = $this->createQueryBuilder('u') + ->andWhere('u.applicationStatus = :status') ->andWhere('u.lastActive >= :lastActive') ->andWhere('u.isBanned = false') ->andWhere('u.isDeleted = false') @@ -705,7 +727,7 @@ public function findActiveUsers(?Magazine $magazine = null) $results = $results->join('u.avatar', 'a') ->orderBy('u.lastActive', 'DESC') - ->setParameters(['lastActive' => (new \DateTime())->modify('-7 days'), 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameters(['lastActive' => (new \DateTime())->modify('-7 days'), 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'status' => EApplicationStatus::Approved->value]) ->setMaxResults(35) ->getQuery() ->getResult(); @@ -761,4 +783,21 @@ public function findAllModerators(): array ->getResult() ; } + + public function findAllSignupRequestsPaginated(int $page = 1): PagerfantaInterface + { + $query = $this->createQueryBuilder('u') + ->where('u.applicationStatus = :status') + ->andWhere('u.apId IS NULL') + ->andWhere('u.isDeleted = false') + ->andWhere('u.markedForDeletionAt IS NULL') + ->setParameter('status', EApplicationStatus::Pending->value) + ->getQuery(); + + $fanta = new Pagerfanta(new QueryAdapter($query)); + $fanta->setCurrentPage($page); + $fanta->setMaxPerPage(self::PER_PAGE); + + return $fanta; + } } diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php index 473bc536c..4955a38f5 100644 --- a/src/Security/UserChecker.php +++ b/src/Security/UserChecker.php @@ -5,6 +5,7 @@ namespace App\Security; use App\Entity\User as AppUser; +use App\Enums\EApplicationStatus; use App\Service\UserManager; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\BadCredentialsException; @@ -40,6 +41,17 @@ public function checkPreAuth(UserInterface $user): void } } + $applicationStatus = $user->getApplicationStatus(); + if (EApplicationStatus::Approved !== $applicationStatus) { + if (EApplicationStatus::Pending === $applicationStatus) { + throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_yet_approved')); + } elseif (EApplicationStatus::Rejected === $applicationStatus) { + throw new BadCredentialsException(); + } else { + throw new \LogicException("Unrecognized application status $applicationStatus->value"); + } + } + if (!$user->isVerified) { $resendEmailActivationUrl = $this->urlGenerator->generate('app_resend_email_activation'); throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_active', ['%link_target%' => $resendEmailActivationUrl])); diff --git a/src/Service/ActivityPubManager.php b/src/Service/ActivityPubManager.php index e5405cb2e..40afe6416 100644 --- a/src/Service/ActivityPubManager.php +++ b/src/Service/ActivityPubManager.php @@ -320,7 +320,8 @@ private function createUser(string $actorUrl): ?User $this->userManager->create( $this->userFactory->createDtoFromAp($actorUrl, $webfinger->getHandle()), false, - false + false, + preApprove: true, ); return $this->updateUser($actorUrl); diff --git a/src/Service/SettingsManager.php b/src/Service/SettingsManager.php index 8e3462b29..3ca809671 100644 --- a/src/Service/SettingsManager.php +++ b/src/Service/SettingsManager.php @@ -39,6 +39,7 @@ public function __construct( private readonly bool $mbinSsoOnlyMode, private readonly int $maxImageBytes, private readonly DownvotesMode $mbinDownvotesMode, + private readonly bool $mbinNewUsersNeedApproval, ) { if (!self::$dto) { $results = $this->repository->findAll(); @@ -48,6 +49,15 @@ public function __construct( $maxImageBytesEdited = $this->maxImageBytes; } + $newUsersNeedApprovalDb = $this->find($results, 'MBIN_NEW_USERS_NEED_APPROVAL'); + if ('true' === $newUsersNeedApprovalDb) { + $newUsersNeedApprovalEdited = true; + } elseif ('false' === $newUsersNeedApprovalDb) { + $newUsersNeedApprovalEdited = false; + } else { + $newUsersNeedApprovalEdited = $this->mbinNewUsersNeedApproval; + } + self::$dto = new SettingsDto( $this->kbinDomain, $this->find($results, 'KBIN_TITLE') ?? $this->kbinTitle, @@ -84,6 +94,7 @@ public function __construct( $this->find($results, 'MBIN_SSO_SHOW_FIRST', FILTER_VALIDATE_BOOLEAN) ?? false, $maxImageBytesEdited, $this->find($results, 'MBIN_DOWNVOTES_MODE') ?? $this->mbinDownvotesMode->value, + $newUsersNeedApprovalEdited, ); } } @@ -169,6 +180,11 @@ public function getDownvotesMode(): DownvotesMode return DownvotesMode::from($this->get('MBIN_DOWNVOTES_MODE')); } + public function getNewUsersNeedApproval(): bool + { + return $this->get('MBIN_NEW_USERS_NEED_APPROVAL'); + } + public function set(string $name, $value): void { self::$dto->{$name} = $value; diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index bfb7e4e48..2ba9d9ef3 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -8,6 +8,9 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\User; use App\Entity\UserFollowRequest; +use App\Enums\EApplicationStatus; +use App\Event\User\UserApplicationApprovedEvent; +use App\Event\User\UserApplicationRejectedEvent; use App\Event\User\UserBlockEvent; use App\Event\User\UserEditedEvent; use App\Event\User\UserFollowEvent; @@ -29,6 +32,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query\ResultSetMapping; use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -59,6 +63,9 @@ public function __construct( private Security $security, private CacheInterface $cache, private ReputationRepository $reputationRepository, + private SettingsManager $settingsManager, + private EventDispatcherInterface $eventDispatcher, + private LoggerInterface $logger, ) { } @@ -143,7 +150,7 @@ public function unfollow(User $follower, User $following): void $this->dispatcher->dispatch(new UserFollowEvent($follower, $following, true)); } - public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true): User + public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true, ?bool $preApprove = null): User { if ($rateLimit) { $limiter = $this->userRegisterLimiter->create($dto->ip); @@ -151,8 +158,12 @@ public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = throw new TooManyRequestsHttpException(); } } + $status = EApplicationStatus::Approved; + if (true !== $preApprove && $this->settingsManager->getNewUsersNeedApproval()) { + $status = EApplicationStatus::Pending; + } - $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId); + $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId, applicationStatus: $status, applicationText: $dto->applicationText); $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword)); if (!$dto->apId) { @@ -420,6 +431,30 @@ public function getUsersMarkedForDeletionBefore(?\DateTime $dateTime = null): ar ->getResult(); } + public function rejectUserApplication(User $user): void + { + if (EApplicationStatus::Rejected === $user->getApplicationStatus()) { + return; + } + $user->setApplicationStatus(EApplicationStatus::Rejected); + $this->entityManager->persist($user); + $this->entityManager->flush(); + $this->logger->debug('Rejecting application for {u}', ['u' => $user->username]); + $this->eventDispatcher->dispatch(new UserApplicationRejectedEvent($user)); + } + + public function approveUserApplication(User $user): void + { + if (EApplicationStatus::Approved === $user->getApplicationStatus()) { + return; + } + $user->setApplicationStatus(EApplicationStatus::Approved); + $this->entityManager->persist($user); + $this->entityManager->flush(); + $this->logger->debug('Approving application for {u}', ['u' => $user->username]); + $this->eventDispatcher->dispatch(new UserApplicationApprovedEvent($user)); + } + public function getAllInboxesOfInteractions(User $user): array { $sql = ' diff --git a/src/Twig/Extension/AdminExtension.php b/src/Twig/Extension/AdminExtension.php index cd0649c29..261c7e6ec 100644 --- a/src/Twig/Extension/AdminExtension.php +++ b/src/Twig/Extension/AdminExtension.php @@ -15,6 +15,7 @@ public function getFunctions(): array return [ new TwigFunction('is_admin_panel_page', [AdminExtensionRuntime::class, 'isAdminPanelPage']), new TwigFunction('is_tag_banned', [AdminExtensionRuntime::class, 'isTagBanned']), + new TwigFunction('do_new_users_need_approval', [AdminExtensionRuntime::class, 'doNewUsersNeedApproval']), ]; } } diff --git a/src/Twig/Runtime/AdminExtensionRuntime.php b/src/Twig/Runtime/AdminExtensionRuntime.php index 6221c2424..0d6c4cb08 100644 --- a/src/Twig/Runtime/AdminExtensionRuntime.php +++ b/src/Twig/Runtime/AdminExtensionRuntime.php @@ -5,6 +5,8 @@ namespace App\Twig\Runtime; use App\Repository\TagRepository; +use App\Repository\UserRepository; +use App\Service\SettingsManager; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Twig\Extension\RuntimeExtensionInterface; @@ -14,6 +16,8 @@ public function __construct( private Security $security, private TagRepository $tagRepository, + private SettingsManager $settingsManager, + private UserRepository $userRepository, ) { } @@ -30,4 +34,10 @@ public function isTagBanned(string $tag): bool return $hashtag->banned; } + + public function doNewUsersNeedApproval(): bool + { + // show the signup requests page even if they are deactivated if there are any remaining + return $this->settingsManager->getNewUsersNeedApproval() || $this->userRepository->findAllSignupRequestsPaginated()->count() > 0; + } } diff --git a/templates/_email/application_approved.html.twig b/templates/_email/application_approved.html.twig new file mode 100644 index 000000000..7d5dc2d92 --- /dev/null +++ b/templates/_email/application_approved.html.twig @@ -0,0 +1,19 @@ +{% extends '_email/email_base.html.twig' %} + +{%- block title -%} + {{- 'email_application_approved_title'|trans }} +{%- endblock -%} + +{% block body %} +

    + {{ 'email_application_approved_body'|trans({ + '%link%': url('app_login'), + '%siteName%': kbin_domain(), + })|raw }} +

    + {% if user.isVerified is same as false %} +

    + {{ 'email_verification_pending'|trans }} +

    + {% endif %} +{% endblock %} diff --git a/templates/_email/application_rejected.html.twig b/templates/_email/application_rejected.html.twig new file mode 100644 index 000000000..b6c2af98c --- /dev/null +++ b/templates/_email/application_rejected.html.twig @@ -0,0 +1,11 @@ +{% extends '_email/email_base.html.twig' %} + +{%- block title -%} + {{- 'email_application_rejected_title'|trans }} +{%- endblock -%} + +{% block body %} +

    + {{ 'email_application_rejected_body'|trans }} +

    +{% endblock %} diff --git a/templates/_email/confirmation_email.html.twig b/templates/_email/confirmation_email.html.twig index 6dd61d421..25ad777dd 100644 --- a/templates/_email/confirmation_email.html.twig +++ b/templates/_email/confirmation_email.html.twig @@ -10,6 +10,11 @@

    {{ 'email_verify'|trans }}

    + {% if user.getApplicationStatus() is not same as enum('App\\Enums\\EApplicationStatus').Approved %} +

    + {{ 'email_application_pending'|trans }} +

    + {% endif %}

    {{ 'email_confirm_expire'|trans }}

    Cheers!

    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/_options.html.twig b/templates/admin/_options.html.twig index 29fdb21b6..d03643605 100644 --- a/templates/admin/_options.html.twig +++ b/templates/admin/_options.html.twig @@ -41,6 +41,14 @@ {{ 'ownership_requests'|trans }}
  • + {% if do_new_users_need_approval() %} +
  • + +
  • + {% endif %}
  • diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 80dc0d6ad..6c081bd88 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -91,6 +91,10 @@ {{ form_label(form.MBIN_SSO_SHOW_FIRST, 'sso_show_first') }} {{ form_widget(form.MBIN_SSO_SHOW_FIRST) }} +
    + {{ form_label(form.MBIN_NEW_USERS_NEED_APPROVAL, 'new_users_need_approval') }} + {{ form_widget(form.MBIN_NEW_USERS_NEED_APPROVAL) }} +
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    diff --git a/templates/admin/signup_requests.html.twig b/templates/admin/signup_requests.html.twig new file mode 100644 index 000000000..b4d325adb --- /dev/null +++ b/templates/admin/signup_requests.html.twig @@ -0,0 +1,52 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'signup_requests'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-admin-federation{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'admin/_options.html.twig' %} +
    +

    {{ 'signup_requests_header'|trans }}

    +

    {{ 'signup_requests_paragraph'|trans }}

    +
    + {% if requests|length %} + {% for request in requests %} +
    +
    + {{ component('user_inline', {user: request}) }}, + {{ component('date', {date: request.createdAt}) }} +
    +
    + {{ request.applicationText }} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + {% endfor %} + {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/user/register.html.twig b/templates/user/register.html.twig index ae2561e98..e0ba605dc 100644 --- a/templates/user/register.html.twig +++ b/templates/user/register.html.twig @@ -27,6 +27,11 @@ {{ form_row(form.username, { label: 'username', }) }} + {% if do_new_users_need_approval() %} + {{ form_row(form.applicationText, { + label: 'application_text', + }) }} + {% endif %} {{ form_row(form.email, { label: 'email' }) }} diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index b75788457..fafc5d7f7 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -172,7 +172,7 @@ public static function createOAuth2ClientCredsClient(): void $userDto->email = 'test@kbin.test'; $userDto->plainPassword = hash('sha512', random_bytes(32)); $userDto->isBot = true; - $user = $userManager->create($userDto, false, false); + $user = $userManager->create($userDto, false, false, true); $client->setUser($user); $client->setDescription('An OAuth2 client for testing purposes'); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index c3eec2d90..a2a363d47 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -437,6 +437,8 @@ your_account_is_not_active: Your account has not been activated. Please check yo email for account activation instructions or
    request a new account activation email. your_account_has_been_banned: Your account has been banned +your_account_is_not_yet_approved: Your account has not been approved yet. + We will send you an email as soon as the admins have processed your signup request. toolbar.bold: Bold toolbar.italic: Italic toolbar.strikethrough: Strikethrough @@ -919,3 +921,16 @@ search_type_all: Threads + Microblogs search_type_entry: Threads search_type_post: Microblogs select_user: Choose a user +new_users_need_approval: New users have to be approved by an admin before they can log in. +signup_requests: Signup requests +application_text: Application text +signup_requests_header: Signup Requests +signup_requests_paragraph: These users would like to join your server. They cannot log in until you've approved their signup request. +flash_application_info: An admin needs to approve your account before you can log in. + You will receive an email when they processed your signup request. +email_application_approved_title: Your signup request was approved +email_application_approved_body: Your signup request was approved by the admins. You can log into the server at %siteName%. +email_application_rejected_title: Your signup request was declined +email_application_rejected_body: Thank you for your interest, but we regret to inform you that your signup request has been declined. +email_application_pending: Your account requires admin approval before you can log in. +email_verification_pending: You have to verify your email address before you can log in. From d4974b996187731dcd5e19c6618bc8877151548f Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Mon, 9 Dec 2024 17:28:27 +0100 Subject: [PATCH 15/16] Make applicationText nullable (#1276) --- src/Entity/User.php | 2 +- .../Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php | 1 + .../Api/Instance/Admin/InstanceSettingsUpdateApiTest.php | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index 73fb52b8a..3d77c7778 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -243,7 +243,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public string $type; #[Column(type: 'text', nullable: true)] - public string $applicationText; + public ?string $applicationText; #[Column(type: 'enumApplicationStatus', nullable: false, options: ['default' => EApplicationStatus::Approved->value])] private string $applicationStatus; diff --git a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php index 8a0dd605b..fac575941 100644 --- a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php @@ -36,6 +36,7 @@ class InstanceSettingsRetrieveApiTest extends WebTestCase 'MBIN_SSO_ONLY_MODE', 'MBIN_SSO_SHOW_FIRST', 'MAX_IMAGE_BYTES', + 'MBIN_NEW_USERS_NEED_APPROVAL', ]; public function testApiCannotRetrieveInstanceSettingsAnonymous(): void diff --git a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php index 8335f956c..6322737fe 100644 --- a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php +++ b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php @@ -38,6 +38,7 @@ class InstanceSettingsUpdateApiTest extends WebTestCase 'MBIN_SSO_ONLY_MODE', 'MBIN_SSO_SHOW_FIRST', 'MAX_IMAGE_BYTES', + 'MBIN_NEW_USERS_NEED_APPROVAL', ]; public function testApiCannotUpdateInstanceSettingsAnonymous(): void @@ -112,6 +113,7 @@ public function testApiCanUpdateInstanceSettings(): void 'MBIN_SSO_ONLY_MODE' => false, 'MBIN_SSO_SHOW_FIRST' => false, 'MAX_IMAGE_BYTES' => 10000, + 'MBIN_NEW_USERS_NEED_APPROVAL' => false, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); @@ -152,6 +154,7 @@ public function testApiCanUpdateInstanceSettings(): void 'MBIN_SSO_ONLY_MODE' => true, 'MBIN_SSO_SHOW_FIRST' => true, 'MAX_IMAGE_BYTES' => 30000, + 'MBIN_NEW_USERS_NEED_APPROVAL' => false, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); From 5a25fded29640e1c111d2388a97f25d49225b7be Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Tue, 10 Dec 2024 10:18:31 +0100 Subject: [PATCH 16/16] [Feature] Admin Signup Notifications (#1242) --- config/packages/doctrine.yaml | 1 + migrations/Version20241124155724.php | 32 ++++++++++++++ src/DTO/UserSettingsDto.php | 2 + src/Entity/NewSignupNotification.php | 41 ++++++++++++++++++ src/Entity/Notification.php | 1 + src/Entity/User.php | 2 + src/Form/UserSettingsType.php | 13 +++++- .../SentNewSignupNotificationMessage.php | 14 ++++++ .../SentNewSignupNotificationHandler.php | 43 +++++++++++++++++++ .../SignupNotificationManager.php | 37 ++++++++++++++++ src/Service/UserManager.php | 10 ++++- src/Service/UserSettingsManager.php | 7 ++- templates/notifications/_blocks.html.twig | 5 +++ templates/user/settings/general.html.twig | 3 ++ .../Api/User/UserRetrieveApiTest.php | 1 + translations/messages.en.yaml | 4 ++ 16 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 migrations/Version20241124155724.php create mode 100644 src/Entity/NewSignupNotification.php create mode 100644 src/Message/Notification/SentNewSignupNotificationMessage.php create mode 100644 src/MessageHandler/Notification/SentNewSignupNotificationHandler.php create mode 100644 src/Service/Notification/SignupNotificationManager.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 2f891dc79..809607c02 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -7,6 +7,7 @@ doctrine: mapping_types: user_type: string citext: citext + enumApplicationStatus: string # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) diff --git a/migrations/Version20241124155724.php b/migrations/Version20241124155724.php new file mode 100644 index 000000000..d97292c91 --- /dev/null +++ b/migrations/Version20241124155724.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE notification ADD new_user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA7C2D807B FOREIGN KEY (new_user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_BF5476CA7C2D807B ON notification (new_user_id)'); + $this->addSql('ALTER TABLE "user" ADD notify_on_user_signup BOOLEAN DEFAULT TRUE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA7C2D807B'); + $this->addSql('DROP INDEX IDX_BF5476CA7C2D807B'); + $this->addSql('ALTER TABLE notification DROP new_user_id'); + $this->addSql('ALTER TABLE "user" DROP notify_on_user_signup'); + } +} diff --git a/src/DTO/UserSettingsDto.php b/src/DTO/UserSettingsDto.php index 350d07914..16c9f3d01 100644 --- a/src/DTO/UserSettingsDto.php +++ b/src/DTO/UserSettingsDto.php @@ -30,6 +30,7 @@ public function __construct( public ?array $preferredLanguages = null, public ?string $customCss = null, public ?bool $ignoreMagazinesCustomCss = null, + public ?bool $notifyOnUserSignup = null, ) { } @@ -52,6 +53,7 @@ public function jsonSerialize(): mixed 'preferredLanguages' => $this->preferredLanguages, 'customCss' => $this->customCss, 'ignoreMagazinesCustomCss' => $this->ignoreMagazinesCustomCss, + 'notifyOnUserSignup' => $this->notifyOnUserSignup, ]; } diff --git a/src/Entity/NewSignupNotification.php b/src/Entity/NewSignupNotification.php new file mode 100644 index 000000000..f6ac16545 --- /dev/null +++ b/src/Entity/NewSignupNotification.php @@ -0,0 +1,41 @@ +newUser; + } + + public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification + { + $message = str_replace('%u%', $this->newUser->username, $trans->trans('notification_body_new_signup', locale: $locale)); + $title = $trans->trans('notification_title_new_signup', locale: $locale); + $url = $urlGenerator->generate('user_overview', ['username' => $this->newUser->username]); + $slash = $this->newUser->avatar && !str_starts_with('/', $this->newUser->avatar->filePath) ? '/' : ''; + $avatarUrl = $this->newUser->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->newUser->avatar->filePath : null; + + return new PushNotification($message, $title, actionUrl: $url, avatarUrl: $avatarUrl); + } +} diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php index 27a815628..2729531d2 100644 --- a/src/Entity/Notification.php +++ b/src/Entity/Notification.php @@ -45,6 +45,7 @@ 'report_created' => 'ReportCreatedNotification', 'report_approved' => 'ReportApprovedNotification', 'report_rejected' => 'ReportRejectedNotification', + 'new_signup' => 'NewSignupNotification', ])] abstract class Notification { diff --git a/src/Entity/User.php b/src/Entity/User.php index 3d77c7778..b53aaa499 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -153,6 +153,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public bool $notifyOnNewPostReply = true; #[Column(type: 'boolean', nullable: false)] public bool $notifyOnNewPostCommentReply = true; + #[Column(type: 'boolean', nullable: false, options: ['default' => true])] + public bool $notifyOnUserSignup = true; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $addMentionsEntries = false; #[Column(type: 'boolean', nullable: false, options: ['default' => true])] diff --git a/src/Form/UserSettingsType.php b/src/Form/UserSettingsType.php index 4901ea774..225d75276 100644 --- a/src/Form/UserSettingsType.php +++ b/src/Form/UserSettingsType.php @@ -7,6 +7,7 @@ use App\DTO\UserSettingsDto; use App\Entity\User; use App\Form\DataTransformer\FeaturedMagazinesBarTransformer; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -19,8 +20,10 @@ class UserSettingsType extends AbstractType { - public function __construct(private readonly TranslatorInterface $translator) - { + public function __construct( + private readonly TranslatorInterface $translator, + private readonly Security $security, + ) { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -109,6 +112,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ) ->add('submit', SubmitType::class); + /** @var User $user */ + $user = $this->security->getUser(); + if ($user->isAdmin()) { + $builder->add('notifyOnUserSignup', CheckboxType::class, ['required' => false]); + } + $builder->get('featuredMagazines')->addModelTransformer( new FeaturedMagazinesBarTransformer() ); diff --git a/src/Message/Notification/SentNewSignupNotificationMessage.php b/src/Message/Notification/SentNewSignupNotificationMessage.php new file mode 100644 index 000000000..7b8be5164 --- /dev/null +++ b/src/Message/Notification/SentNewSignupNotificationMessage.php @@ -0,0 +1,14 @@ +workWrapper($message); + } + + public function doWork(MessageInterface $message): void + { + if (!($message instanceof SentNewSignupNotificationMessage)) { + throw new \LogicException(); + } + $user = $this->userRepository->findOneBy(['id' => $message->userId]); + if (!$user) { + throw new UnrecoverableMessageHandlingException('user not found'); + } + $this->signupNotificationManager->sendNewSignupNotification($user); + } +} diff --git a/src/Service/Notification/SignupNotificationManager.php b/src/Service/Notification/SignupNotificationManager.php new file mode 100644 index 000000000..8b58b794b --- /dev/null +++ b/src/Service/Notification/SignupNotificationManager.php @@ -0,0 +1,37 @@ +userRepository->findAllAdmins(); + foreach ($receivers as $receiver) { + if (!$receiver->notifyOnUserSignup) { + continue; + } + $notification = new NewSignupNotification($receiver); + $notification->newUser = $newUser; + $this->entityManager->persist($notification); + $this->dispatcher->dispatch(new NotificationCreatedEvent($notification)); + } + $this->entityManager->flush(); + } +} diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index 2ba9d9ef3..0381ad0c7 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -19,6 +19,7 @@ use App\Message\ClearDeletedUserMessage; use App\Message\DeleteImageMessage; use App\Message\DeleteUserMessage; +use App\Message\Notification\SentNewSignupNotificationMessage; use App\Message\UserCreatedMessage; use App\Message\UserUpdatedMessage; use App\MessageHandler\ClearDeletedUserHandler; @@ -173,10 +174,17 @@ public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = $this->entityManager->persist($user); $this->entityManager->flush(); + if (!$dto->apId) { + try { + $this->bus->dispatch(new SentNewSignupNotificationMessage($user->getId())); + } catch (\Throwable $e) { + } + } + if ($verifyUserEmail) { try { $this->bus->dispatch(new UserCreatedMessage($user->getId())); - } catch (\Exception $e) { + } catch (\Throwable $e) { } } diff --git a/src/Service/UserSettingsManager.php b/src/Service/UserSettingsManager.php index a632ce6fc..574bdb9e5 100644 --- a/src/Service/UserSettingsManager.php +++ b/src/Service/UserSettingsManager.php @@ -32,7 +32,8 @@ public function createDto(User $user): UserSettingsDto $user->featuredMagazines, $user->preferredLanguages, $user->customCss, - $user->ignoreMagazinesCustomCss + $user->ignoreMagazinesCustomCss, + $user->notifyOnUserSignup, ); } @@ -55,6 +56,10 @@ public function update(User $user, UserSettingsDto $dto): void $user->customCss = $dto->customCss; $user->ignoreMagazinesCustomCss = $dto->ignoreMagazinesCustomCss; + if (null !== $dto->notifyOnUserSignup) { + $user->notifyOnUserSignup = $dto->notifyOnUserSignup; + } + $this->entityManager->flush(); } } diff --git a/templates/notifications/_blocks.html.twig b/templates/notifications/_blocks.html.twig index 4e9a157e4..7e597bba9 100644 --- a/templates/notifications/_blocks.html.twig +++ b/templates/notifications/_blocks.html.twig @@ -156,3 +156,8 @@ {{ 'open_report'|trans }} {% endif %} {% endblock report_approved_notification %} + +{% block new_signup %} + {{ 'notification_title_new_signup'|trans }}
    + {{ component('user_inline', { user: notification.newUser } ) }} +{% endblock %} diff --git a/templates/user/settings/general.html.twig b/templates/user/settings/general.html.twig index 5af246fa0..701aedb8a 100644 --- a/templates/user/settings/general.html.twig +++ b/templates/user/settings/general.html.twig @@ -41,6 +41,9 @@ {{ form_row(form.notifyOnNewPostCommentReply, {label: 'notify_on_new_post_comment_reply', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewEntry, {label: 'notify_on_new_entry', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.notifyOnNewPost, {label: 'notify_on_new_posts', row_attr: {class: 'checkbox'}}) }} + {% if app.user.admin %} + {{ form_row(form.notifyOnUserSignup, {label: 'notify_on_user_signup', row_attr: {class: 'checkbox'}}) }} + {% endif %}
    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
    diff --git a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php index f031c0460..695db9d07 100644 --- a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php @@ -26,6 +26,7 @@ class UserRetrieveApiTest extends WebTestCase 'preferredLanguages', 'customCss', 'ignoreMagazinesCustomCss', + 'notifyOnUserSignup', ]; public const NUM_USERS = 10; diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index a2a363d47..46f4cecd0 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -214,6 +214,7 @@ notify_on_new_post_comment_reply: Replies to my comments on any posts notify_on_new_entry: New threads (links or articles) in any magazine to which I'm subscribed notify_on_new_posts: New posts in any magazine to which I'm subscribed +notify_on_user_signup: New signups save: Save about: About old_email: Current email @@ -875,6 +876,9 @@ notification_title_message: New direct message notification_title_new_post: New Post notification_title_removed_post: A post was removed notification_title_edited_post: A post was edited +notification_title_new_signup: A new user registered +notification_body_new_signup: The user %u% registered. +notification_body2_new_signup_approval: You need to approve the request before they can log in show_related_magazines: Show random magazines show_related_entries: Show random threads show_related_posts: Show random posts