diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ac442f9..9a6b9d9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ jobs: - name: Run PHPStan run: | - docker run backend-dev \ + docker run -e APP_ENV=dev backend-dev \ sh -c "bin/console cache:warmup && ./vendor/bin/phpstan analyse --memory-limit=1G --ansi" phpunit: @@ -61,5 +61,6 @@ jobs: --network ci \ -e APP_ENV=test \ backend-dev sh -c " + php bin/console doctrine:database:create --if-not-exists && php bin/console doctrine:migrations:migrate --no-interaction && ./vendor/bin/phpunit" diff --git a/Dockerfile b/Dockerfile index d63d65e..74081c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,5 +71,20 @@ COPY meta/image/dev/run.dev /app/run CMD ["sh", "/app/run"] ################################################### -# FROM backend-base AS final -# TODO +FROM backend-base AS final + +ENV APP_ENV=prod \ + APP_DEBUG=0 + +COPY backend /app/backend + +RUN composer install --no-interaction --no-dev --optimize-autoloader --classmap-authoritative --no-scripts + +COPY --from=frontend-prod /app/frontend/build /app/static + +COPY meta/image/prod/Caddyfile.prod /etc/caddy/Caddyfile +COPY meta/image/prod/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY meta/image/prod/run.prod /app/run + +EXPOSE 80 +CMD ["sh", "/app/run"] diff --git a/backend/.env b/backend/.env index b251b88..db63ceb 100644 --- a/backend/.env +++ b/backend/.env @@ -1,17 +1,17 @@ +APP_ENV= ###> symfony/framework-bundle ### -APP_SECRET=634272ac4a9ace811b9fe1ab649d2176 +APP_SECRET= ###< symfony/framework-bundle ### -DATABASE_URL="postgresql://postgres:postgres@hyvor-service-pgsql:5432/hyvor_reader?serverVersion=16&charset=utf8" -HYVOR_FAKE=1 +DATABASE_URL= ###> symfony/messenger ### -MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +MESSENGER_TRANSPORT_DSN= ###< symfony/messenger ### ###> symfony/lock ### -# Choose one of the stores below -# postgresql+advisory://db_user:db_password@localhost/db_name -LOCK_DSN=flock +LOCK_DSN= ###< symfony/lock ### + +LOG_LEVEL= diff --git a/backend/.env.dev b/backend/.env.dev index 37430fa..4208d7a 100644 --- a/backend/.env.dev +++ b/backend/.env.dev @@ -1,7 +1,12 @@ +APP_ENV=dev -###> symfony/framework-bundle ### -APP_SECRET=634272ac4a9ace811b9fe1ab649d2176 -###< symfony/framework-bundle ### +APP_SECRET=Qgg+kph9o0lULpebas4C9xa+tvoNC5b94rCjRf/5gAY= DATABASE_URL="postgresql://postgres:postgres@hyvor-service-pgsql:5432/hyvor_reader?serverVersion=16&charset=utf8" -HYVOR_FAKE=1 \ No newline at end of file +HYVOR_FAKE=1 + +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 + +LOCK_DSN=flock + +LOG_LEVEL=debug diff --git a/backend/.env.test b/backend/.env.test index 9e7162f..caaf1d1 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -1,6 +1,9 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots +APP_SECRET=Qgg+kph9o0lULpebas4C9xa+tvoNC5b94rCjRf/5gAY= + +DATABASE_URL="postgresql://postgres:postgres@hyvor-service-pgsql:5432/hyvor_reader?serverVersion=16&charset=utf8" +LOCK_DSN=flock \ No newline at end of file diff --git a/backend/composer.json b/backend/composer.json index 624e8ab..f1adb81 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -13,8 +13,10 @@ "doctrine/doctrine-bundle": "^2.15", "doctrine/doctrine-migrations-bundle": "^3.4.2", "doctrine/orm": "^3.5.0", - "nesbot/carbon": "^3.10.1", "hyvor/internal": "^3.1.4", + "nesbot/carbon": "^3.10.1", + "phpdocumentor/reflection-docblock": "^5.6", + "phpstan/phpdoc-parser": "^2.3", "runtime/frankenphp-symfony": "^0.2.0", "symfony/console": "7.3.*", "symfony/doctrine-messenger": "7.3.*", @@ -24,8 +26,12 @@ "symfony/http-client": "7.3.*", "symfony/lock": "7.3.*", "symfony/messenger": "7.3.*", + "symfony/monolog-bundle": "^3.10", + "symfony/property-access": "7.3.*", + "symfony/property-info": "7.3.*", "symfony/runtime": "7.3.*", "symfony/scheduler": "7.3.*", + "symfony/serializer": "7.3.*", "symfony/uid": "7.3.*", "symfony/yaml": "7.3.*" }, @@ -62,13 +68,7 @@ "auto-scripts": { "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd" - }, - "post-install-cmd": [ - "@auto-scripts" - ], - "post-update-cmd": [ - "@auto-scripts" - ] + } }, "conflict": { "symfony/symfony": "*" @@ -82,6 +82,7 @@ "require-dev": { "dama/doctrine-test-bundle": "^8.3", "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan-doctrine": "^2.0", "phpunit/phpunit": "^10.0", "symfony/browser-kit": "7.3.*", "symfony/css-selector": "7.3.*", diff --git a/backend/composer.lock b/backend/composer.lock index 083ef98..19c303f 100644 --- a/backend/composer.lock +++ b/backend/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": "a79ccdc6b2c8d501d4c45f0709d3b33f", + "content-hash": "5da3a4696effcaa4dbba085cf8f6be45", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -325,16 +325,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.16.0", + "version": "2.16.2", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "cb2ad28708f870ff9534e82798c557bdc79809ba" + "reference": "1c10de0fe995f01eca6b073d1c2549ef0b603a7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/cb2ad28708f870ff9534e82798c557bdc79809ba", - "reference": "cb2ad28708f870ff9534e82798c557bdc79809ba", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/1c10de0fe995f01eca6b073d1c2549ef0b603a7f", + "reference": "1c10de0fe995f01eca6b073d1c2549ef0b603a7f", "shasum": "" }, "require": { @@ -368,11 +368,11 @@ "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.6.22", + "phpunit/phpunit": "^10.5.53", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.2", "symfony/property-info": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", @@ -427,7 +427,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.0" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.2" }, "funding": [ { @@ -443,7 +443,7 @@ "type": "tidelift" } ], - "time": "2025-09-02T17:41:12+00:00" + "time": "2025-09-10T19:14:48+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -1589,16 +1589,16 @@ }, { "name": "hyvor/internal", - "version": "3.1.4", + "version": "3.1.7", "source": { "type": "git", "url": "https://github.com/hyvor/internal.git", - "reference": "c79d05b7073b2f0542c62df6a99fe62e576f7288" + "reference": "76f233d951363187b84b8238d6bec6808f79ef0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hyvor/internal/zipball/c79d05b7073b2f0542c62df6a99fe62e576f7288", - "reference": "c79d05b7073b2f0542c62df6a99fe62e576f7288", + "url": "https://api.github.com/repos/hyvor/internal/zipball/76f233d951363187b84b8238d6bec6808f79ef0c", + "reference": "76f233d951363187b84b8238d6bec6808f79ef0c", "shasum": "" }, "require": { @@ -1657,22 +1657,22 @@ "description": "Internal Package for HYVOR Applications", "support": { "issues": "https://github.com/hyvor/internal/issues", - "source": "https://github.com/hyvor/internal/tree/3.1.4" + "source": "https://github.com/hyvor/internal/tree/3.1.7" }, - "time": "2025-08-13T22:04:57+00:00" + "time": "2025-09-27T08:16:58+00:00" }, { "name": "illuminate/collections", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "2737a0477a3f4855a71bf8f3d0b8d32596ded628" + "reference": "d47aaf15c55dd1c252688fdc7adbee129bd2ff0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/2737a0477a3f4855a71bf8f3d0b8d32596ded628", - "reference": "2737a0477a3f4855a71bf8f3d0b8d32596ded628", + "url": "https://api.github.com/repos/illuminate/collections/zipball/d47aaf15c55dd1c252688fdc7adbee129bd2ff0b", + "reference": "d47aaf15c55dd1c252688fdc7adbee129bd2ff0b", "shasum": "" }, "require": { @@ -1718,11 +1718,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-02T15:31:06+00:00" + "time": "2025-09-28T12:52:25+00:00" }, { "name": "illuminate/conditionable", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1768,16 +1768,16 @@ }, { "name": "illuminate/contracts", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "f1c4cf02c9ab81a9ce47940cf261fa2386ed6c5d" + "reference": "0bdbf0cdb5dd5739b2c8e6caf881a4114399ab15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/f1c4cf02c9ab81a9ce47940cf261fa2386ed6c5d", - "reference": "f1c4cf02c9ab81a9ce47940cf261fa2386ed6c5d", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/0bdbf0cdb5dd5739b2c8e6caf881a4114399ab15", + "reference": "0bdbf0cdb5dd5739b2c8e6caf881a4114399ab15", "shasum": "" }, "require": { @@ -1812,11 +1812,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-27T18:34:41+00:00" + "time": "2025-09-12T14:35:11+00:00" }, { "name": "illuminate/encryption", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/encryption.git", @@ -1867,16 +1867,16 @@ }, { "name": "illuminate/log", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/log.git", - "reference": "bcd4c0ac05b47c45f0693a83704b46399fec6414" + "reference": "535f80fd257318656d1d29c4e8f679d0f4b195b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/log/zipball/bcd4c0ac05b47c45f0693a83704b46399fec6414", - "reference": "bcd4c0ac05b47c45f0693a83704b46399fec6414", + "url": "https://api.github.com/repos/illuminate/log/zipball/535f80fd257318656d1d29c4e8f679d0f4b195b2", + "reference": "535f80fd257318656d1d29c4e8f679d0f4b195b2", "shasum": "" }, "require": { @@ -1916,11 +1916,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-22T14:22:42+00:00" + "time": "2025-09-21T15:11:44+00:00" }, { "name": "illuminate/macroable", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -1966,16 +1966,16 @@ }, { "name": "illuminate/support", - "version": "v12.28.1", + "version": "v12.32.5", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "487bbe527806615b818e87c364d93ba91f27db9b" + "reference": "648bbaf51b108771e0c3297dc251953fd03a4243" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/487bbe527806615b818e87c364d93ba91f27db9b", - "reference": "487bbe527806615b818e87c364d93ba91f27db9b", + "url": "https://api.github.com/repos/illuminate/support/zipball/648bbaf51b108771e0c3297dc251953fd03a4243", + "reference": "648bbaf51b108771e0c3297dc251953fd03a4243", "shasum": "" }, "require": { @@ -2041,7 +2041,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-09-03T16:23:04+00:00" + "time": "2025-09-29T18:49:19+00:00" }, { "name": "monolog/monolog", @@ -2208,16 +2208,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.2", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", "shasum": "" }, "require": { @@ -2235,13 +2235,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.75.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpunit/phpunit": "^10.5.46", - "squizlabs/php_codesniffer": "^3.13.0" + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -2309,7 +2309,7 @@ "type": "tidelift" } ], - "time": "2025-08-02T09:36:06+00:00" + "time": "2025-09-06T13:39:36+00:00" }, { "name": "nikic/php-parser", @@ -3032,16 +3032,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.53", + "version": "10.5.58", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "32768472ebfb6969e6c7399f1c7b09009723f653" + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/32768472ebfb6969e6c7399f1c7b09009723f653", - "reference": "32768472ebfb6969e6c7399f1c7b09009723f653", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e24fb46da450d8e6a5788670513c1af1424f16ca", + "reference": "e24fb46da450d8e6a5788670513c1af1424f16ca", "shasum": "" }, "require": { @@ -3062,10 +3062,10 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", + "sebastian/comparator": "^5.0.4", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", + "sebastian/exporter": "^5.1.4", "sebastian/global-state": "^6.0.2", "sebastian/object-enumerator": "^5.0.0", "sebastian/recursion-context": "^5.0.1", @@ -3113,7 +3113,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.53" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.58" }, "funding": [ { @@ -3137,7 +3137,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T14:40:06+00:00" + "time": "2025-09-28T12:04:46+00:00" }, { "name": "promphp/prometheus_client_php", @@ -3934,16 +3934,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "5.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", "shasum": "" }, "require": { @@ -3999,15 +3999,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2025-09-07T05:25:07+00:00" }, { "name": "sebastian/complexity", @@ -4200,16 +4212,16 @@ }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { @@ -4218,7 +4230,7 @@ "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -4266,15 +4278,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", @@ -4695,16 +4719,16 @@ }, { "name": "symfony/cache", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6" + "reference": "bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6", - "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6", + "url": "https://api.github.com/repos/symfony/cache/zipball/bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f", + "reference": "bf8afc8ffd4bfd3d9c373e417f041d9f1e5b863f", "shasum": "" }, "require": { @@ -4773,7 +4797,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.3.2" + "source": "https://github.com/symfony/cache/tree/v7.3.4" }, "funding": [ { @@ -4793,7 +4817,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/cache-contracts", @@ -4947,16 +4971,16 @@ }, { "name": "symfony/config", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "faef36e271bbeb74a9d733be4b56419b157762e2" + "reference": "8a09223170046d2cfda3d2e11af01df2c641e961" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/faef36e271bbeb74a9d733be4b56419b157762e2", - "reference": "faef36e271bbeb74a9d733be4b56419b157762e2", + "url": "https://api.github.com/repos/symfony/config/zipball/8a09223170046d2cfda3d2e11af01df2c641e961", + "reference": "8a09223170046d2cfda3d2e11af01df2c641e961", "shasum": "" }, "require": { @@ -5002,7 +5026,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.3.2" + "source": "https://github.com/symfony/config/tree/v7.3.4" }, "funding": [ { @@ -5022,20 +5046,20 @@ "type": "tidelift" } ], - "time": "2025-07-26T13:55:06+00:00" + "time": "2025-09-22T12:46:16+00:00" }, { "name": "symfony/console", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", "shasum": "" }, "require": { @@ -5100,7 +5124,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.3" + "source": "https://github.com/symfony/console/tree/v7.3.4" }, "funding": [ { @@ -5120,20 +5144,20 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-22T15:31:00+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "ab6c38dad5da9b15b1f7afb2f5c5814112e70261" + "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ab6c38dad5da9b15b1f7afb2f5c5814112e70261", - "reference": "ab6c38dad5da9b15b1f7afb2f5c5814112e70261", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/82119812ab0bf3425c1234d413efd1b19bb92ae4", + "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4", "shasum": "" }, "require": { @@ -5184,7 +5208,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.3.3" + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.4" }, "funding": [ { @@ -5204,7 +5228,7 @@ "type": "tidelift" } ], - "time": "2025-08-14T09:54:27+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5275,16 +5299,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "b371ded46da25415e1a3a7422e4acd2ec34214c5" + "reference": "21cd48c34a47a0d0e303a590a67c3450fde55888" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/b371ded46da25415e1a3a7422e4acd2ec34214c5", - "reference": "b371ded46da25415e1a3a7422e4acd2ec34214c5", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/21cd48c34a47a0d0e303a590a67c3450fde55888", + "reference": "21cd48c34a47a0d0e303a590a67c3450fde55888", "shasum": "" }, "require": { @@ -5364,7 +5388,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.3" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.4" }, "funding": [ { @@ -5384,20 +5408,20 @@ "type": "tidelift" } ], - "time": "2025-08-18T13:10:53+00:00" + "time": "2025-09-24T09:56:23+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "4d77230ef2d99aa630cf931621d0afb54ce10636" + "reference": "064159484ab330590b7b477f6c8835812f2e340f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/4d77230ef2d99aa630cf931621d0afb54ce10636", - "reference": "4d77230ef2d99aa630cf931621d0afb54ce10636", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/064159484ab330590b7b477f6c8835812f2e340f", + "reference": "064159484ab330590b7b477f6c8835812f2e340f", "shasum": "" }, "require": { @@ -5440,7 +5464,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.3" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.4" }, "funding": [ { @@ -5460,7 +5484,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T06:38:36+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/dotenv", @@ -5542,16 +5566,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", "shasum": "" }, "require": { @@ -5599,7 +5623,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.3.4" }, "funding": [ { @@ -5619,7 +5643,7 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/event-dispatcher", @@ -6061,16 +6085,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "19ec4ab6be90322ed190e041e2404a976ed22571" + "reference": "b13e7cec5a144c8dba6f4233a2c53c00bc29e140" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/19ec4ab6be90322ed190e041e2404a976ed22571", - "reference": "19ec4ab6be90322ed190e041e2404a976ed22571", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/b13e7cec5a144c8dba6f4233a2c53c00bc29e140", + "reference": "b13e7cec5a144c8dba6f4233a2c53c00bc29e140", "shasum": "" }, "require": { @@ -6195,7 +6219,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.3.3" + "source": "https://github.com/symfony/framework-bundle/tree/v7.3.4" }, "funding": [ { @@ -6215,20 +6239,20 @@ "type": "tidelift" } ], - "time": "2025-08-27T07:45:05+00:00" + "time": "2025-09-17T05:51:54+00:00" }, { "name": "symfony/http-client", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019" + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", + "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", "shasum": "" }, "require": { @@ -6295,7 +6319,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.3" + "source": "https://github.com/symfony/http-client/tree/v7.3.4" }, "funding": [ { @@ -6315,7 +6339,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T07:45:05+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/http-client-contracts", @@ -6397,16 +6421,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00" + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7475561ec27020196c49bb7c4f178d33d7d3dc00", - "reference": "7475561ec27020196c49bb7c4f178d33d7d3dc00", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6", + "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6", "shasum": "" }, "require": { @@ -6456,7 +6480,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.3.3" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.4" }, "funding": [ { @@ -6476,20 +6500,20 @@ "type": "tidelift" } ], - "time": "2025-08-20T08:04:18+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b" + "reference": "b796dffea7821f035047235e076b60ca2446e3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/72c304de37e1a1cec6d5d12b81187ebd4850a17b", - "reference": "72c304de37e1a1cec6d5d12b81187ebd4850a17b", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf", + "reference": "b796dffea7821f035047235e076b60ca2446e3cf", "shasum": "" }, "require": { @@ -6574,7 +6598,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.3.3" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.4" }, "funding": [ { @@ -6594,20 +6618,20 @@ "type": "tidelift" } ], - "time": "2025-08-29T08:23:45+00:00" + "time": "2025-09-27T12:32:17+00:00" }, { "name": "symfony/lock", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/lock.git", - "reference": "a78a7956599ae25176053f7a2894f8c084da977a" + "reference": "e025f32cfd1fa8e3a4485a8d810544474aac26da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/lock/zipball/a78a7956599ae25176053f7a2894f8c084da977a", - "reference": "a78a7956599ae25176053f7a2894f8c084da977a", + "url": "https://api.github.com/repos/symfony/lock/zipball/e025f32cfd1fa8e3a4485a8d810544474aac26da", + "reference": "e025f32cfd1fa8e3a4485a8d810544474aac26da", "shasum": "" }, "require": { @@ -6656,7 +6680,7 @@ "semaphore" ], "support": { - "source": "https://github.com/symfony/lock/tree/v7.3.2" + "source": "https://github.com/symfony/lock/tree/v7.3.4" }, "funding": [ { @@ -6676,7 +6700,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/messenger", @@ -6771,6 +6795,169 @@ ], "time": "2025-08-13T11:49:31+00:00" }, + { + "name": "symfony/monolog-bridge", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7acf2abe23e5019451399ba69fc8ed3d61d4d8f0", + "reference": "7acf2abe23e5019451399ba69fc8ed3d61d4d8f0", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.2", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/mailer": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-24T16:45:39+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=7.2.5", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-11-06T17:08:13+00:00" + }, { "name": "symfony/orm-pack", "version": "v2.4.1", @@ -7477,16 +7664,16 @@ }, { "name": "symfony/property-info", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970" + "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/90586acbf2a6dd13bee4f09f09111c8bd4773970", - "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970", + "url": "https://api.github.com/repos/symfony/property-info/zipball/7b6db23f23d13ada41e1cb484748a8ec028fbace", + "reference": "7b6db23f23d13ada41e1cb484748a8ec028fbace", "shasum": "" }, "require": { @@ -7543,7 +7730,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.3.1" + "source": "https://github.com/symfony/property-info/tree/v7.3.4" }, "funding": [ { @@ -7554,25 +7741,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-15T13:55:54+00:00" }, { "name": "symfony/routing", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c", + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c", "shasum": "" }, "require": { @@ -7624,7 +7815,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.2" + "source": "https://github.com/symfony/routing/tree/v7.3.4" }, "funding": [ { @@ -7644,20 +7835,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/runtime", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9" + "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/9516056d432f8acdac9458eb41b80097da7a05c9", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9", + "url": "https://api.github.com/repos/symfony/runtime/zipball/3550e2711e30bfa5d808514781cd52d1cc1d9e9f", + "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f", "shasum": "" }, "require": { @@ -7707,7 +7898,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.3.1" + "source": "https://github.com/symfony/runtime/tree/v7.3.4" }, "funding": [ { @@ -7718,12 +7909,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-09-11T15:31:28+00:00" }, { "name": "symfony/scheduler", @@ -7812,16 +8007,16 @@ }, { "name": "symfony/serializer", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "5608b04d8daaf29432d76ecc618b0fac169c2dfb" + "reference": "0df5af266c6fe9a855af7db4fea86e13b9ca3ab1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/5608b04d8daaf29432d76ecc618b0fac169c2dfb", - "reference": "5608b04d8daaf29432d76ecc618b0fac169c2dfb", + "url": "https://api.github.com/repos/symfony/serializer/zipball/0df5af266c6fe9a855af7db4fea86e13b9ca3ab1", + "reference": "0df5af266c6fe9a855af7db4fea86e13b9ca3ab1", "shasum": "" }, "require": { @@ -7891,7 +8086,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.3.3" + "source": "https://github.com/symfony/serializer/tree/v7.3.4" }, "funding": [ { @@ -7911,7 +8106,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T11:34:33+00:00" + "time": "2025-09-15T13:39:02+00:00" }, { "name": "symfony/serializer-pack", @@ -8111,16 +8306,16 @@ }, { "name": "symfony/string", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" + "reference": "f96476035142921000338bad71e5247fbc138872" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", "shasum": "" }, "require": { @@ -8135,7 +8330,6 @@ }, "require-dev": { "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -8178,7 +8372,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.3" + "source": "https://github.com/symfony/string/tree/v7.3.4" }, "funding": [ { @@ -8198,20 +8392,20 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2025-09-11T14:36:48+00:00" }, { "name": "symfony/translation", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d" + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/e0837b4cbcef63c754d89a4806575cada743a38d", - "reference": "e0837b4cbcef63c754d89a4806575cada743a38d", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", "shasum": "" }, "require": { @@ -8278,7 +8472,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.3" + "source": "https://github.com/symfony/translation/tree/v7.3.4" }, "funding": [ { @@ -8298,7 +8492,7 @@ "type": "tidelift" } ], - "time": "2025-08-01T21:02:37+00:00" + "time": "2025-09-07T11:39:36+00:00" }, { "name": "symfony/translation-contracts", @@ -8495,16 +8689,16 @@ }, { "name": "symfony/twig-bundle", - "version": "v7.3.2", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657" + "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/5d85220df4d8d79e6a9ca57eea6f70004de39657", - "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/da5c778a8416fcce5318737c4d944f6fa2bb3f81", + "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81", "shasum": "" }, "require": { @@ -8559,7 +8753,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.3.2" + "source": "https://github.com/symfony/twig-bundle/tree/v7.3.4" }, "funding": [ { @@ -8579,20 +8773,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-09-10T12:00:31+00:00" }, { "name": "symfony/type-info", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "aa64b58ed04517d4d730202dd035895743c23273" + "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/aa64b58ed04517d4d730202dd035895743c23273", - "reference": "aa64b58ed04517d4d730202dd035895743c23273", + "url": "https://api.github.com/repos/symfony/type-info/zipball/d34eaeb57f39c8a9c97eb72a977c423207dfa35b", + "reference": "d34eaeb57f39c8a9c97eb72a977c423207dfa35b", "shasum": "" }, "require": { @@ -8642,7 +8836,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.3" + "source": "https://github.com/symfony/type-info/tree/v7.3.4" }, "funding": [ { @@ -8662,7 +8856,7 @@ "type": "tidelift" } ], - "time": "2025-08-28T09:38:04+00:00" + "time": "2025-09-11T15:33:27+00:00" }, { "name": "symfony/uid", @@ -8827,16 +9021,16 @@ }, { "name": "symfony/validator", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "a2f26d7c122393db75a2d41435ad8251250f8bc6" + "reference": "5e29a348b5fac2227b6938a54db006d673bb813a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/a2f26d7c122393db75a2d41435ad8251250f8bc6", - "reference": "a2f26d7c122393db75a2d41435ad8251250f8bc6", + "url": "https://api.github.com/repos/symfony/validator/zipball/5e29a348b5fac2227b6938a54db006d673bb813a", + "reference": "5e29a348b5fac2227b6938a54db006d673bb813a", "shasum": "" }, "require": { @@ -8905,7 +9099,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.3.3" + "source": "https://github.com/symfony/validator/tree/v7.3.4" }, "funding": [ { @@ -8925,20 +9119,20 @@ "type": "tidelift" } ], - "time": "2025-08-27T11:34:33+00:00" + "time": "2025-09-24T06:32:27+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f" + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", - "reference": "34d8d4c4b9597347306d1ec8eb4e1319b1e6986f", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", + "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb", "shasum": "" }, "require": { @@ -8992,7 +9186,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.3" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.4" }, "funding": [ { @@ -9012,20 +9206,20 @@ "type": "tidelift" } ], - "time": "2025-08-13T11:49:31+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "d4dfcd2a822cbedd7612eb6fbd260e46f87b7137" + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d4dfcd2a822cbedd7612eb6fbd260e46f87b7137", - "reference": "d4dfcd2a822cbedd7612eb6fbd260e46f87b7137", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", "shasum": "" }, "require": { @@ -9073,7 +9267,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.3" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.4" }, "funding": [ { @@ -9093,7 +9287,7 @@ "type": "tidelift" } ], - "time": "2025-08-18T13:10:53+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/yaml", @@ -9695,16 +9889,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.22", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" - }, + "version": "2.1.30", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a4a7f159927983dd4f7c8020ed227d80b7f39d7d", + "reference": "a4a7f159927983dd4f7c8020ed227d80b7f39d7d", "shasum": "" }, "require": { @@ -9749,7 +9938,80 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2025-10-02T16:07:52+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "5eaf37b87288474051469aee9f937fc9d862f330" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/5eaf37b87288474051469aee9f937fc9d862f330", + "reference": "5eaf37b87288474051469aee9f937fc9d862f330", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.2", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.10" + }, + "time": "2025-10-06T10:01:02+00:00" }, { "name": "symfony/browser-kit", @@ -10054,16 +10316,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "7954e563ed14f924593169f6c4645d58d9d9ac77" + "reference": "ed77a629c13979e051b7000a317966474d566398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/7954e563ed14f924593169f6c4645d58d9d9ac77", - "reference": "7954e563ed14f924593169f6c4645d58d9d9ac77", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/ed77a629c13979e051b7000a317966474d566398", + "reference": "ed77a629c13979e051b7000a317966474d566398", "shasum": "" }, "require": { @@ -10119,7 +10381,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.3.3" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.3.4" }, "funding": [ { @@ -10139,20 +10401,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T15:15:28+00:00" + "time": "2025-09-12T12:18:52+00:00" }, { "name": "symfony/process", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1" + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/32241012d521e2e8a9d713adb0812bb773b907f1", - "reference": "32241012d521e2e8a9d713adb0812bb773b907f1", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", "shasum": "" }, "require": { @@ -10184,7 +10446,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.3" + "source": "https://github.com/symfony/process/tree/v7.3.4" }, "funding": [ { @@ -10204,7 +10466,7 @@ "type": "tidelift" } ], - "time": "2025-08-18T09:42:54+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "zenstruck/assert", @@ -10267,16 +10529,16 @@ }, { "name": "zenstruck/foundry", - "version": "v2.6.3", + "version": "v2.7.2", "source": { "type": "git", "url": "https://github.com/zenstruck/foundry.git", - "reference": "7c951226c9cb09c8d57818c0f1ec84d6d484ed57" + "reference": "2c84cb0c2e09679562cd33e90e66559dae014d7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zenstruck/foundry/zipball/7c951226c9cb09c8d57818c0f1ec84d6d484ed57", - "reference": "7c951226c9cb09c8d57818c0f1ec84d6d484ed57", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/2c84cb0c2e09679562cd33e90e66559dae014d7b", + "reference": "2c84cb0c2e09679562cd33e90e66559dae014d7b", "shasum": "" }, "require": { @@ -10306,16 +10568,19 @@ "doctrine/orm": "^2.16|^3.0", "doctrine/persistence": "^2.0|^3.0|^4.0", "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/dotenv": "^6.4|^7.0|^8.0", "symfony/framework-bundle": "^6.4|^7.0|^8.0", "symfony/maker-bundle": "^1.55", "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/runtime": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^3.4", "symfony/uid": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/yaml": "^6.4|^7.0|^8.0", + "webmozart/assert": "^1.11" }, "type": "library", "extra": { @@ -10336,7 +10601,8 @@ ], "psr-4": { "Zenstruck\\Foundry\\": "src/", - "Zenstruck\\Foundry\\Psalm\\": "utils/psalm" + "Zenstruck\\Foundry\\Psalm\\": "utils/psalm", + "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -10366,7 +10632,7 @@ ], "support": { "issues": "https://github.com/zenstruck/foundry/issues", - "source": "https://github.com/zenstruck/foundry/tree/v2.6.3" + "source": "https://github.com/zenstruck/foundry/tree/v2.7.2" }, "funding": [ { @@ -10378,7 +10644,7 @@ "type": "github" } ], - "time": "2025-08-28T05:57:45+00:00" + "time": "2025-09-25T05:36:26+00:00" }, { "name": "zenstruck/messenger-test", @@ -10455,7 +10721,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -10465,6 +10731,6 @@ "ext-iconv": "*", "ext-libxml": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 87b0947..db959f8 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -9,4 +9,5 @@ Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle::class => ['test' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/internal.php b/backend/config/packages/internal.php deleted file mode 100644 index 53e64c2..0000000 --- a/backend/config/packages/internal.php +++ /dev/null @@ -1,5 +0,0 @@ -component('reader'); -}; \ No newline at end of file diff --git a/backend/config/packages/internal.yaml b/backend/config/packages/internal.yaml new file mode 100644 index 0000000..efbad1c --- /dev/null +++ b/backend/config/packages/internal.yaml @@ -0,0 +1,3 @@ +internal: + component: 'reader' + diff --git a/backend/config/packages/monolog.php b/backend/config/packages/monolog.php new file mode 100644 index 0000000..c976a26 --- /dev/null +++ b/backend/config/packages/monolog.php @@ -0,0 +1,30 @@ +env() !== 'test') { + $monolog->handler('app') + ->type('buffer') + ->handler('final') + ->level("%env(LOG_LEVEL)%") + ->bubble(false) + ->channels()->elements(['app']); + $monolog->handler('non_app') + ->type('buffer') + ->handler('final') + ->level('error') + ->bubble(false) + ->channels()->elements(['!app']); + $monolog->handler('final') + ->type('stream') + ->path('php://stderr') + ->formatter('monolog.formatter.json'); + } else { + $monolog->handler('test') + ->type('test') + ->level('info'); + } +}; + diff --git a/backend/phpstan.dist.neon b/backend/phpstan.dist.neon index 3ba9219..795fb35 100644 --- a/backend/phpstan.dist.neon +++ b/backend/phpstan.dist.neon @@ -1,3 +1,7 @@ +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-doctrine/rules.neon + parameters: level: max paths: @@ -6,3 +10,5 @@ parameters: - public/ - src/ - tests/ + scanDirectories: + - var/cache/dev/Symfony/Config/ diff --git a/backend/phpunit.xml.dist b/backend/phpunit.xml.dist index 43bf72e..fb7d27b 100644 --- a/backend/phpunit.xml.dist +++ b/backend/phpunit.xml.dist @@ -6,7 +6,6 @@ backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php" - convertDeprecationsToExceptions="false" > @@ -23,15 +22,11 @@ - + src - - - - - + diff --git a/backend/public/index.php b/backend/public/index.php index 8797ace..ec0575f 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -4,6 +4,8 @@ require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; -return function (array $context) { - return new Kernel($context['APP_ENV'], (bool) ($context['APP_DEBUG'])); +return function (array $context): Kernel { + $env = isset($context['APP_ENV']) && is_string($context['APP_ENV']) ? $context['APP_ENV'] : 'dev'; + $debug = !empty($context['APP_DEBUG']); + return new Kernel($env, $debug); }; diff --git a/backend/src/Api/App/Controller/CollectionController.php b/backend/src/Api/App/Controller/CollectionController.php index ed7e1c3..a043705 100644 --- a/backend/src/Api/App/Controller/CollectionController.php +++ b/backend/src/Api/App/Controller/CollectionController.php @@ -5,7 +5,6 @@ use App\Api\App\Object\CollectionObject; use App\Api\App\Object\PublicationObject; use App\Service\Collection\CollectionService; - use Symfony\Component\HttpFoundation\Request; use App\Api\App\Authorization\AuthorizationListener; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,7 +12,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; - +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use App\Api\App\Input\AddCollectionInput; class CollectionController extends AbstractController { @@ -55,5 +55,18 @@ public function getCollection(string $slug, Request $request): JsonResponse ]); } + #[Route('/collections', methods: ['POST'])] + public function createCollection(Request $request, #[MapRequestPayload] AddCollectionInput $payload): JsonResponse + { + $user = AuthorizationListener::getUser($request); + + $name = trim($payload->name); + $isPublic = $payload->is_public; + + $collection = $this->collectionService->createCollection($user->id, $name, $isPublic); -} + return $this->json([ + 'collection' => new CollectionObject($collection, $user->id), + ]); + } +} diff --git a/backend/src/Api/App/Controller/PublicationController.php b/backend/src/Api/App/Controller/PublicationController.php index 1e90ad2..a6ed198 100644 --- a/backend/src/Api/App/Controller/PublicationController.php +++ b/backend/src/Api/App/Controller/PublicationController.php @@ -2,20 +2,36 @@ namespace App\Api\App\Controller; +use App\Api\App\Object\PublicationObject; +use App\Api\App\Authorization\AuthorizationListener; use App\Service\Publication\PublicationService; use App\Service\Collection\CollectionService; +use App\Service\Fetch\FetchService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Messenger\MessageBusInterface; +use Doctrine\ORM\EntityManagerInterface; +use App\Service\Fetch\Message\ProcessFeedMessage; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use App\Api\App\Input\AddPublicationInput; +use App\Service\Parser\ParserException; +use App\Service\Fetch\Exception\UnexpectedStatusCodeException; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; class PublicationController extends AbstractController { public function __construct( private readonly PublicationService $publicationService, private readonly CollectionService $collectionService, + private readonly FetchService $fetchService, + private readonly MessageBusInterface $messageBus, + private readonly EntityManagerInterface $em, ) { } @@ -40,4 +56,60 @@ public function getPublications(Request $request): JsonResponse 'publications' => $publications, ]); } -} + + #[Route('/publications', methods: ['POST'])] + public function addPublication( + #[MapRequestPayload] AddPublicationInput $payload, + Request $request + ): JsonResponse + { + $user = AuthorizationListener::getUser($request); + $collectionSlug = trim($payload->collection_slug); + $url = trim($payload->url); + + $collection = $this->collectionService->findBySlug($collectionSlug); + if (!$collection) { + throw new NotFoundHttpException('Collection not found'); + } + + if (!$this->collectionService->hasUserWriteAccess($user->id, $collection)) { + throw new AccessDeniedHttpException('Write access required'); + } + + try { + $inspection = $this->fetchService->inspectFeed($url); + } catch (UnexpectedStatusCodeException $e) { + throw new BadRequestHttpException('Feed returned HTTP ' . $e->getHttpCode()); + } catch (ParserException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (TransportExceptionInterface) { + throw new BadRequestHttpException('Could not fetch the URL'); + } + + $normalizedUrl = $inspection['final_url']; + $publication = $this->publicationService->findByUrl($normalizedUrl); + $created = false; + + if (!$publication) { + $publication = $this->publicationService->addPublication($collection, $inspection, false); + $created = true; + $attached = true; + $status = Response::HTTP_CREATED; + } else { + $attached = $this->publicationService->attachToCollectionIfMissing($publication, $collection, false); + $status = Response::HTTP_OK; + } + + if ($created || $attached) { + $publication->setIsFetching(true); + $this->em->flush(); + $this->messageBus->dispatch(new ProcessFeedMessage($publication->getId())); + } + + return $this->json([ + 'publication' => new PublicationObject($publication), + 'created' => $created, + 'attached' => $attached, + ], $status); + } +} diff --git a/backend/src/Api/App/Input/AddCollectionInput.php b/backend/src/Api/App/Input/AddCollectionInput.php new file mode 100644 index 0000000..1b76efe --- /dev/null +++ b/backend/src/Api/App/Input/AddCollectionInput.php @@ -0,0 +1,14 @@ +name = $collection->getName(); $this->slug = $collection->getSlug(); $this->is_public = $collection->isPublic(); - $this->is_owner = $currentUserId ? $collection->getHyvorUserId() === $currentUserId : false; + $this->is_owner = $currentUserId && $collection->getHyvorUserId() === $currentUserId; } } \ No newline at end of file diff --git a/backend/src/Api/App/Object/ItemObject.php b/backend/src/Api/App/Object/ItemObject.php index 4f69cf4..1023a4b 100644 --- a/backend/src/Api/App/Object/ItemObject.php +++ b/backend/src/Api/App/Object/ItemObject.php @@ -48,8 +48,8 @@ public function __construct(Item $item) $this->language = $item->getLanguage(); $publication = $item->getPublication(); - $this->publication_id = $publication?->getId(); - $this->publication_slug = $publication?->getSlug(); - $this->publication_title = $publication?->getTitle() ?? 'Untitled'; + $this->publication_id = $publication->getId(); + $this->publication_slug = $publication->getSlug(); + $this->publication_title = $publication->getTitle() ?? 'Untitled'; } } diff --git a/backend/src/Api/ExceptionListener.php b/backend/src/Api/ExceptionListener.php new file mode 100644 index 0000000..4d13885 --- /dev/null +++ b/backend/src/Api/ExceptionListener.php @@ -0,0 +1,16 @@ +collectionService->ensureUserHasDefaultCollection((new InternalFake())->user()); + $this->collectionService->ensureUserHasDefaultCollection(AuthFake::generateUser()); $createdCollections = CollectionFactory::createMany(3, function () { $publications = PublicationFactory::createMany(rand(2, 5)); diff --git a/backend/src/Entity/CollectionUser.php b/backend/src/Entity/CollectionUser.php index 480b9cf..5926c37 100644 --- a/backend/src/Entity/CollectionUser.php +++ b/backend/src/Entity/CollectionUser.php @@ -12,14 +12,14 @@ class CollectionUser { #[ORM\Id] #[ORM\GeneratedValue] - #[ORM\Column(type: 'bigint')] + #[ORM\Column(type: 'integer')] private int $id; #[ORM\ManyToOne(targetEntity: Collection::class, cascade: ['persist'])] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private Collection $collection; - #[ORM\Column(name: 'hyvor_user_id', type: 'bigint')] + #[ORM\Column(name: 'hyvor_user_id', type: 'integer')] private int $hyvorUserId; #[ORM\Column(name: 'write_access', type: 'boolean', options: ['default' => false])] diff --git a/backend/src/Entity/Item.php b/backend/src/Entity/Item.php index 6d6913d..5302adc 100644 --- a/backend/src/Entity/Item.php +++ b/backend/src/Entity/Item.php @@ -61,7 +61,7 @@ class Item #[ORM\ManyToOne(inversedBy: 'items')] #[ORM\JoinColumn(nullable: false)] - private ?Publication $publication = null; + private Publication $publication; public function __construct() { @@ -228,12 +228,12 @@ public function setLanguage(?string $language): static return $this; } - public function getPublication(): ?Publication + public function getPublication(): Publication { return $this->publication; } - public function setPublication(?Publication $publication): static + public function setPublication(Publication $publication): static { $this->publication = $publication; diff --git a/backend/src/Entity/Publication.php b/backend/src/Entity/Publication.php index 62ff025..da7fc16 100644 --- a/backend/src/Entity/Publication.php +++ b/backend/src/Entity/Publication.php @@ -243,12 +243,7 @@ public function addItem(Item $item): static public function removeItem(Item $item): static { - if ($this->items->removeElement($item)) { - // set the owning side to null (unless already changed) - if ($item->getPublication() === $this) { - $item->setPublication(null); - } - } + $this->items->removeElement($item); return $this; } diff --git a/backend/src/Factory/CollectionFactory.php b/backend/src/Factory/CollectionFactory.php index 60dcb42..8e31bbf 100644 --- a/backend/src/Factory/CollectionFactory.php +++ b/backend/src/Factory/CollectionFactory.php @@ -32,10 +32,12 @@ public static function class(): string */ protected function defaults(): array { + $user = (new InternalFake())->user(); + $hyvorUserId = is_object($user) && isset($user->id) ? (int) $user->id : 1; return [ 'name' => self::faker()->words(2, true), 'slug' => self::faker()->unique()->slug(), - 'hyvorUserId' => (new InternalFake())->user()->id, + 'hyvorUserId' => $hyvorUserId, ]; } @@ -49,6 +51,9 @@ protected function initialize(): static ; } + /** + * @return array{collection: Collection, collectionUser: \App\Entity\CollectionUser} + */ public static function createWithCollectionUser(int $hyvorUserId, string $name, bool $isPublic = false): array { @@ -58,12 +63,14 @@ public static function createWithCollectionUser(int $hyvorUserId, string $name, 'hyvorUserId' => $hyvorUserId, ]); - // TODO: add collection user factory - $collectionUser = CollectionUserFactory::createOne([ + $collectionUser = \App\Factory\CollectionUserFactory::createOne([ 'hyvorUserId' => $hyvorUserId, 'collection' => $collection, 'writeAccess' => true, ]); - + return [ + 'collection' => $collection->_real(), + 'collectionUser' => $collectionUser->_real(), + ]; } } diff --git a/backend/src/Factory/CollectionUserFactory.php b/backend/src/Factory/CollectionUserFactory.php new file mode 100644 index 0000000..cb40d08 --- /dev/null +++ b/backend/src/Factory/CollectionUserFactory.php @@ -0,0 +1,40 @@ + + */ +final class CollectionUserFactory extends PersistentProxyObjectFactory +{ + public function __construct() + { + } + + public static function class(): string + { + return CollectionUser::class; + } + + /** + * @return array + */ + protected function defaults(): array + { + return [ + 'hyvorUserId' => self::faker()->numberBetween(1, 1000), + // 'collection' must be provided by caller + 'writeAccess' => true, + ]; + } + + protected function initialize(): static + { + return $this; + } +} + + diff --git a/backend/src/InternalFake.php b/backend/src/InternalFake.php index 9097be0..efac5fc 100644 --- a/backend/src/InternalFake.php +++ b/backend/src/InternalFake.php @@ -10,17 +10,6 @@ */ class InternalFake extends BaseInternalFake { - public function user(): AuthUser - { - return AuthUser::fromArray([ - 'id' => 5, - 'username' => 'sakithb', - 'name' => 'Sakith B.', - 'email' => 'sakith@hyvor.com', - 'picture_url' => 'https://hyvor.com/avatar.jpg', - ]); - } - /** * @return array|null */ diff --git a/backend/src/Repository/CollectionUserRepository.php b/backend/src/Repository/CollectionUserRepository.php index 22b0894..30fc29d 100644 --- a/backend/src/Repository/CollectionUserRepository.php +++ b/backend/src/Repository/CollectionUserRepository.php @@ -16,20 +16,12 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, CollectionUser::class); } - /** - * @return CollectionUser|null - */ - public function findUserCollectionAccess(int $hyvorUserId, int $collectionId): ?CollectionUser + public function getCollectionUserWithAccess(int $hyvorUserId, int $collectionId): ?CollectionUser { - $result = $this->createQueryBuilder('cu') - ->andWhere('cu.hyvorUserId = :hyvorUserId') - ->andWhere('cu.collection = :collectionId') - ->setParameter('hyvorUserId', $hyvorUserId) - ->setParameter('collectionId', $collectionId) - ->getQuery() - ->getOneOrNullResult(); - - assert($result instanceof CollectionUser || $result === null); - return $result; + return $this->findOneBy([ + 'hyvorUserId' => $hyvorUserId, + 'collection' => $collectionId, + ]); } + } diff --git a/backend/src/Service/Collection/CollectionService.php b/backend/src/Service/Collection/CollectionService.php index 6b5fd2c..cdef5e0 100644 --- a/backend/src/Service/Collection/CollectionService.php +++ b/backend/src/Service/Collection/CollectionService.php @@ -20,8 +20,8 @@ public function __construct( private function getCollectionUserRepository(): CollectionUserRepository { + /** @var CollectionUserRepository $repository */ $repository = $this->em->getRepository(CollectionUser::class); - assert($repository instanceof CollectionUserRepository); return $repository; } @@ -151,7 +151,7 @@ public function hasUserReadAccess(int $hyvorUserId, Collection $collection): boo return true; } - $collectionUser = $this->getCollectionUserRepository()->findUserCollectionAccess( + $collectionUser = $this->getCollectionUserRepository()->getCollectionUserWithAccess( $hyvorUserId, $collection->getId() ); @@ -165,7 +165,7 @@ public function hasUserWriteAccess(int $hyvorUserId, Collection $collection): bo return true; } - $collectionUser = $this->getCollectionUserRepository()->findUserCollectionAccess( + $collectionUser = $this->getCollectionUserRepository()->getCollectionUserWithAccess( $hyvorUserId, $collection->getId() ); diff --git a/backend/src/Service/Fetch/FetchService.php b/backend/src/Service/Fetch/FetchService.php index 1b7f5a2..71e8a52 100644 --- a/backend/src/Service/Fetch/FetchService.php +++ b/backend/src/Service/Fetch/FetchService.php @@ -8,8 +8,13 @@ use App\Entity\Item; use App\Service\Parser\Types\Feed; use App\Service\Parser\Types\Item as ParsedItem; +use App\Service\Parser\Parser; +use App\Service\Parser\ParserException; +use App\Service\Fetch\Exception\UnexpectedStatusCodeException; use Doctrine\ORM\EntityManagerInterface; use App\Service\Item\ItemService; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; class FetchService { @@ -18,6 +23,7 @@ public function __construct( private ItemRepository $itemRepository, private EntityManagerInterface $entityManager, private ItemService $itemService, + private HttpClientInterface $httpClient, ) { } @@ -108,6 +114,62 @@ public function updateNextFetchTime(Publication $publication): void $publication->setNextFetchAt($nextFetchAt); } + /** + * @param string $url + * @param array $options + * @return array{content: string, final_url: string, status_code: int, headers: array>} + * @throws TransportExceptionInterface + */ + public function fetchFeed(string $url, array $options = []): array + { + $response = $this->httpClient->request('GET', $url, array_merge([ + 'timeout' => 10, + 'max_redirects' => 3, + ], $options)); + + $statusCode = $response->getStatusCode(); + $content = $response->getContent(false); // do not throw on non-2xx + $finalUrlInfo = $response->getInfo('url'); + $finalUrl = is_string($finalUrlInfo) ? $finalUrlInfo : $url; + /** @var array> $headers */ + $headers = $response->getHeaders(false); + + return [ + 'content' => $content, + 'final_url' => $finalUrl, + 'status_code' => $statusCode, + 'headers' => $headers, + ]; + } + + /** + * Fetch, validate status, and parse feed in one step. Throws on non-2xx or parse errors. + * + * @param string $url + * @return array{final_url: string, feed: Feed, title: string, headers: array>} + * @throws TransportExceptionInterface + * @throws UnexpectedStatusCodeException + * @throws ParserException + */ + public function inspectFeed(string $url): array + { + $result = $this->fetchFeed($url); + + if ($result['status_code'] < 200 || $result['status_code'] >= 300) { + throw new UnexpectedStatusCodeException($result['status_code']); + } + + $feed = (new Parser($result['content']))->parse(); + $title = $feed->title ?: (parse_url($result['final_url'], PHP_URL_HOST) ?: 'Untitled'); + + return [ + 'final_url' => $result['final_url'], + 'feed' => $feed, + 'title' => $title, + 'headers' => $result['headers'], + ]; + } + /** * @param Item $existingItem * @param ParsedItem $parsedItem diff --git a/backend/src/Service/Fetch/Handler/ProcessFeedHandler.php b/backend/src/Service/Fetch/Handler/ProcessFeedHandler.php index b2670f0..54c5b70 100644 --- a/backend/src/Service/Fetch/Handler/ProcessFeedHandler.php +++ b/backend/src/Service/Fetch/Handler/ProcessFeedHandler.php @@ -2,7 +2,6 @@ namespace App\Service\Fetch\Handler; -use App\Entity\Publication; use App\Entity\PublicationFetch; use App\Repository\PublicationRepository; use App\Service\Fetch\Message\ProcessFeedMessage; @@ -14,7 +13,6 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Clock\ClockAwareTrait; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use App\Service\Fetch\Exception\UnexpectedStatusCodeException; @@ -27,7 +25,6 @@ public function __construct( private FetchService $fetchService, private PublicationRepository $publicationRepository, private EntityManagerInterface $entityManager, - private HttpClientInterface $httpClient, private LoggerInterface $logger, ) { } @@ -56,13 +53,13 @@ public function __invoke(ProcessFeedMessage $message): void if ($lastModified = $publication->getConditionalGetLastModified()) { $headers['If-Modified-Since'] = $lastModified; } - $response = $this->httpClient->request('GET', $publication->getUrl(), [ - 'headers' => $headers, - 'timeout' => 30, + $fetchResponse = $this->fetchService->fetchFeed($publication->getUrl(), [ + 'headers' => $headers, + 'timeout' => 30, 'max_redirects' => 5, ]); - $statusCode = $response->getStatusCode(); + $statusCode = $fetchResponse['status_code']; $latencyMs = (int)((microtime(true) - $startTime) * 1000); if ($statusCode === 304) { @@ -83,7 +80,7 @@ public function __invoke(ProcessFeedMessage $message): void throw new UnexpectedStatusCodeException($statusCode); } - $feed = new Parser($response->getContent())->parse(); + $feed = (new Parser($fetchResponse['content']))->parse(); $result = $this->fetchService->processItems($publication, $feed); $fetch->setStatus(FetchStatusEnum::COMPLETED); @@ -93,11 +90,12 @@ public function __invoke(ProcessFeedMessage $message): void ->setUpdatedItemsCount($result['updated_items']); $publication->setLastFetchedAt($this->now()); - if (isset($response->getHeaders()['etag'][0])) { - $publication->setConditionalGetEtag($response->getHeaders()['etag'][0]); + $headers = $fetchResponse['headers']; + if (isset($headers['etag'][0])) { + $publication->setConditionalGetEtag($headers['etag'][0]); } - if (isset($response->getHeaders()['last-modified'][0])) { - $publication->setConditionalGetLastModified($response->getHeaders()['last-modified'][0]); + if (isset($headers['last-modified'][0])) { + $publication->setConditionalGetLastModified($headers['last-modified'][0]); } if ($feed->title && $publication->getTitle() !== $feed->title) { $publication->setTitle($feed->title); diff --git a/backend/src/Service/Opml/OpmlService.php b/backend/src/Service/Opml/OpmlService.php index 1bd8761..033f419 100644 --- a/backend/src/Service/Opml/OpmlService.php +++ b/backend/src/Service/Opml/OpmlService.php @@ -2,11 +2,16 @@ namespace App\Service\Opml; +use App\Service\Collection\CollectionService; +use App\Service\Fetch\FetchService; +use App\Service\Publication\PublicationService; + class OpmlService { public function __construct( - private readonly \App\Service\Collection\CollectionService $collectionService, - private readonly \App\Service\Publication\PublicationService $publicationService + private readonly CollectionService $collectionService, + private readonly PublicationService $publicationService, + private readonly FetchService $fetchService, ) { } @@ -18,16 +23,21 @@ public function import(string $content, int $hyvorUserId): void $xpath = new \DOMXPath($dom); $outlines = $xpath->query('//outline[@title and @text and not(@type)]'); + if ($outlines === false) { + return; + } foreach ($outlines as $outline) { - $collectionName = $outline->getAttribute('title'); + if (!($outline instanceof \DOMElement)) { + continue; + } + $collectionName = (string) $outline->getAttribute('title'); $collection = $this->collectionService->createCollection($hyvorUserId, $collectionName); foreach ($outline->childNodes as $child) { - if ($child->nodeType === XML_ELEMENT_NODE && $child->tagName === 'outline') { - $publicationTitle = $child->getAttribute('title'); - $publicationUrl = $child->getAttribute('xmlUrl'); - - $this->publicationService->createPublication($collection, $publicationUrl, $publicationTitle); + if ($child instanceof \DOMElement && $child->tagName === 'outline') { + $publicationUrl = (string) $child->getAttribute('xmlUrl'); + $inspection = $this->fetchService->inspectFeed($publicationUrl); + $this->publicationService->addPublication($collection, $inspection); } } } @@ -52,14 +62,14 @@ public function export(string $title, int $hyvorUserId): string $collections = $this->collectionService->getUserCollections($hyvorUserId); foreach ($collections as $collection) { $outline = $dom->createElement('outline'); - $outline->setAttribute('title', $collection->getName()); - $outline->setAttribute('text', $collection->getName()); + $outline->setAttribute('title', (string) $collection->getName()); + $outline->setAttribute('text', (string) $collection->getName()); foreach($collection->getPublications() as $publication) { $pubOutline = $dom->createElement('outline'); $pubOutline->setAttribute('type', 'rss'); - $pubOutline->setAttribute('text', $publication->getTitle()); - $pubOutline->setAttribute('title', $publication->getTitle()); + $pubOutline->setAttribute('text', (string) ($publication->getTitle() ?? '')); + $pubOutline->setAttribute('title', (string) ($publication->getTitle() ?? '')); $pubOutline->setAttribute('xmlUrl', $publication->getUrl()); $outline->appendChild($pubOutline); } @@ -69,6 +79,6 @@ public function export(string $title, int $hyvorUserId): string $opml->appendChild($body); - return $dom->saveXML(); + return (string) $dom->saveXML(); } } diff --git a/backend/src/Service/Parser/AtomParser.php b/backend/src/Service/Parser/AtomParser.php index e1b48f0..c4b75a6 100644 --- a/backend/src/Service/Parser/AtomParser.php +++ b/backend/src/Service/Parser/AtomParser.php @@ -14,7 +14,7 @@ class AtomParser implements ParserInterface public function __construct(string $content) { - if (empty($content)) { + if ($content === '') { throw new ParserException('Empty content'); } @@ -33,22 +33,22 @@ public function parse(): Feed } $title = $this->get_text_content($feedElement, 'title'); - if (empty($title)) { + if ($title === '') { throw new ParserException('Required field missing: title'); } $id = $this->get_text_content($feedElement, 'id'); - if (empty($id)) { + if ($id === '') { throw new ParserException('Required field missing: id'); } $updated = $this->get_text_content($feedElement, 'updated'); - if (empty($updated)) { + if ($updated === '') { throw new ParserException('Required field missing: updated'); } $homepageUrl = $this->get_alternate_link($feedElement); - if (empty($homepageUrl)) { + if ($homepageUrl === '') { throw new ParserException('Required field missing: link'); } @@ -83,12 +83,12 @@ public function parse(): Feed private function parse_entry(\DOMElement $entry): Item { $id = $this->get_text_content($entry, 'id'); - if (empty($id)) { + if ($id === '') { throw new ParserException('Entry must have an id'); } $url = $this->get_alternate_link($entry); - if (empty($url)) { + if ($url === '') { throw new ParserException('Entry must have an alternate link'); } @@ -97,7 +97,7 @@ private function parse_entry(\DOMElement $entry): Item $content = $this->get_content($entry); $language = $entry->getAttribute('xml:lang'); - if (empty($language)) { + if ($language === '') { $language = null; } @@ -185,7 +185,7 @@ private function get_content(\DOMElement $entry): ?string private function get_date(\DOMElement $element, string $tagName): ?\DateTimeImmutable { $date = $this->get_text_content($element, $tagName); - if (empty($date)) { + if ($date === '') { return null; } @@ -201,7 +201,7 @@ private function get_alternate_link(\DOMElement $element): string $links = $element->getElementsByTagName('link'); foreach ($links as $link) { $rel = $link->getAttribute('rel'); - if (empty($rel) || $rel === 'alternate') { + if ($rel === '' || $rel === 'alternate') { return $link->getAttribute('href'); } } @@ -215,7 +215,7 @@ private function get_self_link(\DOMElement $element): ?string $rel = $link->getAttribute('rel'); if ($rel === 'self') { $href = $link->getAttribute('href'); - return empty($href) ? null : $href; + return $href === '' ? null : $href; } } return null; diff --git a/backend/src/Service/Publication/PublicationService.php b/backend/src/Service/Publication/PublicationService.php index 2ef0d01..2892f82 100644 --- a/backend/src/Service/Publication/PublicationService.php +++ b/backend/src/Service/Publication/PublicationService.php @@ -5,12 +5,17 @@ use App\Entity\Publication; use App\Entity\Collection; use App\Api\App\Object\PublicationObject; +use App\Service\Parser\Types\Feed; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\String\Slugger\AsciiSlugger; +use App\Service\Fetch\FetchService; class PublicationService { - public function __construct(private EntityManagerInterface $em) + public function __construct( + private EntityManagerInterface $em, + private FetchService $fetchService, + ) { } @@ -19,6 +24,11 @@ public function findBySlug(string $slug): ?Publication return $this->em->getRepository(Publication::class)->findOneBy(['slug' => $slug]); } + public function findByUrl(string $url): ?Publication + { + return $this->em->getRepository(Publication::class)->findOneBy(['url' => $url]); + } + /** * @return PublicationObject[] */ @@ -32,21 +42,64 @@ public function getPublicationsFromCollection(Collection $collection): array return $publications; } - public function createPublication(Collection $collection, string $url, ?string $title = null, ?string $description = null): Publication + /** + * @param array{ + * final_url: string, + * feed: Feed, + * title: string, + * headers: array> + * } $inspection + */ + public function addPublication(Collection $collection, array $inspection, bool $flush = true): Publication { + $url = $inspection['final_url']; + $feed = $inspection['feed']; + $title = $inspection['title']; + /** @var array> $headers */ + $headers = $inspection['headers']; + $publication = new Publication(); $publication->setUrl($url); - $publication->setTitle($title); - $publication->setDescription($description); $publication->addCollection($collection); $publication->setSlug($this->generateUniqueSlug($title ?: $url)); + if (isset($headers['etag'][0])) { + $publication->setConditionalGetEtag($headers['etag'][0]); + } + if (isset($headers['last-modified'][0])) { + $publication->setConditionalGetLastModified($headers['last-modified'][0]); + } + + $this->fetchService->processItems($publication, $feed); + if ($title && $publication->getTitle() !== $title) { + $publication->setTitle($feed->title); + } + if ($feed->description && $publication->getDescription() !== $feed->description) { + $publication->setDescription($feed->description); + } + $publication->setLastFetchedAt(new \DateTimeImmutable()); + $this->fetchService->updateNextFetchTime($publication); + $this->em->persist($publication); - $this->em->flush(); + if ($flush) { + $this->em->flush(); + } return $publication; } + public function attachToCollectionIfMissing(Publication $publication, Collection $collection, bool $flush = true): bool + { + if (!$publication->getCollections()->contains($collection)) { + $publication->addCollection($collection); + if ($flush) { + $this->em->flush(); + } + return true; + } + return false; + } + private function generateUniqueSlug(string $text): string { $slugger = new AsciiSlugger(); diff --git a/backend/symfony.lock b/backend/symfony.lock index bf4528e..ee7f053 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -153,6 +153,18 @@ "config/packages/messenger.yaml" ] }, + "symfony/monolog-bundle": { + "version": "3.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "aff23899c4440dd995907613c1dd709b6f59503f" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, "symfony/phpunit-bridge": { "version": "7.2", "recipe": { diff --git a/backend/tests/Api/App/Collection/CreateCollectionsTest.php b/backend/tests/Api/App/Collection/CreateCollectionsTest.php new file mode 100644 index 0000000..135bda7 --- /dev/null +++ b/backend/tests/Api/App/Collection/CreateCollectionsTest.php @@ -0,0 +1,116 @@ +client->request('POST', '/api/app/collections', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'name' => 'My Private Collection', + 'is_public' => false, + ])); + + $response = $this->client->getResponse(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'Expected 200 OK'); + + $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($data); + $this->assertArrayHasKey('collection', $data); + + $collection = $data['collection']; + $this->assertIsArray($collection); + $this->assertSame('My Private Collection', $collection['name']); + $this->assertFalse($collection['is_public']); + $this->assertTrue($collection['is_owner']); + $this->assertArrayHasKey('slug', $collection); + $this->assertNotEmpty($collection['slug']); + } + + public function test_create_public_collection(): void + { + $this->client->request('POST', '/api/app/collections', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'name' => 'My Public Collection', + 'is_public' => true, + ])); + + $response = $this->client->getResponse(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'Expected 200 OK'); + + $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($data); + $this->assertArrayHasKey('collection', $data); + + $collection = $data['collection']; + $this->assertIsArray($collection); + $this->assertSame('My Public Collection', $collection['name']); + $this->assertTrue($collection['is_public']); + $this->assertTrue($collection['is_owner']); + $this->assertArrayHasKey('slug', $collection); + $this->assertNotEmpty($collection['slug']); + } + + public function test_create_collection_with_trimmed_name(): void + { + $this->client->request('POST', '/api/app/collections', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'name' => ' Trimmed Collection Name ', + 'is_public' => false, + ])); + + $response = $this->client->getResponse(); + + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'Expected 200 OK'); + + $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($data); + $this->assertArrayHasKey('collection', $data); + + $collection = $data['collection']; + $this->assertIsArray($collection); + $this->assertSame('Trimmed Collection Name', $collection['name']); + } + + public function test_create_collection_requires_name(): void + { + $this->client->request('POST', '/api/app/collections', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'is_public' => false, + ])); + + $response = $this->client->getResponse(); + + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode(), 'Expected 422 Unprocessable Entity'); + } + + public function test_create_collection_requires_valid_json(): void + { + $this->client->request('POST', '/api/app/collections', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], 'invalid json'); + + $response = $this->client->getResponse(); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(), 'Expected 400 Bad Request'); + } +} + + diff --git a/backend/tests/Api/App/Collection/GetCollectionBySlugTest.php b/backend/tests/Api/App/Collection/GetCollectionBySlugTest.php index 81e4d9a..3988b2e 100644 --- a/backend/tests/Api/App/Collection/GetCollectionBySlugTest.php +++ b/backend/tests/Api/App/Collection/GetCollectionBySlugTest.php @@ -18,26 +18,33 @@ class GetCollectionBySlugTest extends WebTestCase public function test_get_single_collection_with_publications(): void { $collectionService = $this->container->get(CollectionService::class); + $this->assertInstanceOf(CollectionService::class, $collectionService); $collection = $collectionService->createCollection(1, 'Reading List', false); $publication1 = PublicationFactory::createOne(['collections' => [$collection]]); $publication2 = PublicationFactory::createOne(['collections' => [$collection]]); - $this->client->request('GET', '/api/app/collections/' . $collection->getSlug()); + $this->client->request('GET', '/api/app/collections/' . (string) $collection->getSlug()); $response = $this->client->getResponse(); $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'Expected 200 OK'); - $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($data); $this->assertArrayHasKey('collection', $data); $this->assertArrayHasKey('publications', $data); + $this->assertIsArray($data['publications']); - $this->assertSame($collection->getSlug(), $data['collection']['slug']); - $this->assertTrue($data['collection']['is_owner']); + $collectionArr = $data['collection']; + $this->assertIsArray($collectionArr); + $this->assertSame($collection->getSlug(), $collectionArr['slug']); + $this->assertTrue($collectionArr['is_owner']); $this->assertCount(2, $data['publications']); - $publicationSlugs = array_map(fn(array $p) => $p['slug'], $data['publications']); + $publications = $data['publications']; + /** @var array $publications */ + $publicationSlugs = array_map(static fn(array $p): string => (string) $p['slug'], $publications); $this->assertContains($publication1->getSlug(), $publicationSlugs); $this->assertContains($publication2->getSlug(), $publicationSlugs); } diff --git a/backend/tests/Api/App/Collection/GetCollectionsTest.php b/backend/tests/Api/App/Collection/GetCollectionsTest.php index 2dd3dc4..6543b6e 100644 --- a/backend/tests/Api/App/Collection/GetCollectionsTest.php +++ b/backend/tests/Api/App/Collection/GetCollectionsTest.php @@ -17,6 +17,7 @@ class GetCollectionsTest extends WebTestCase public function test_get_collections_returns_only_current_users_collections(): void { $collectionService = $this->container->get(CollectionService::class); + $this->assertInstanceOf(CollectionService::class, $collectionService); $collection1 = $collectionService->createCollection(1, 'User Collection 1', false); $collection2 = $collectionService->createCollection(1, 'User Collection 2', false); @@ -28,11 +29,15 @@ public function test_get_collections_returns_only_current_users_collections(): v $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'Expected 200 OK'); - $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($data); $this->assertArrayHasKey('collections', $data); - $this->assertCount(2, $data['collections']); + $this->assertIsArray($data['collections']); + $collections = $data['collections']; + /** @var array $collections */ + $this->assertCount(2, $collections); - $slugs = array_map(fn(array $c) => $c['slug'], $data['collections']); + $slugs = array_map(static fn(array $c): string => (string) $c['slug'], $collections); $this->assertContains($collection1->getSlug(), $slugs); $this->assertContains($collection2->getSlug(), $slugs); $this->assertNotContains($otherCollection->getSlug(), $slugs); diff --git a/backend/tests/Api/App/Publication/AddPublicationTest.php b/backend/tests/Api/App/Publication/AddPublicationTest.php new file mode 100644 index 0000000..7091afb --- /dev/null +++ b/backend/tests/Api/App/Publication/AddPublicationTest.php @@ -0,0 +1,217 @@ + 'https://jsonfeed.org/version/1', + 'title' => 'Test Publication', + 'items' => [ + ['id' => '1', 'url' => 'https://example.com/1', 'title' => 'Item 1'] + ], + ]); + return new MockResponse($body, ['http_code' => 200, 'response_headers' => ['etag: W/"abc"']]); + } + + if (str_contains($url, 'not-a-url')) { + return new MockResponse('', ['http_code' => 404]); + } + + $default = (string) json_encode([ + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'Default Feed', + 'items' => [], + ]); + return new MockResponse($default, ['http_code' => 200]); + }); + + static::getContainer()->set(HttpClientInterface::class, $mockClient); + } + public function test_create_new_publication_and_queue_fetch(): void + { + $collection = CollectionFactory::createOne(['hyvorUserId' => 1])->_real(); + + $payload = [ + 'collection_slug' => $collection->getSlug(), + 'url' => 'https://example.com/feed.xml', + 'title' => 'Test Publication', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_CREATED, $response->getStatusCode()); + + $json = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($json); + $this->assertTrue($json['created']); + $this->assertTrue($json['attached']); + $this->assertArrayHasKey('publication', $json); + $publicationArr = $json['publication']; + $this->assertIsArray($publicationArr); + $this->assertSame('https://example.com/feed.xml', $publicationArr['url']); + $this->assertSame('Test Publication', $publicationArr['title']); + + /** @var \Zenstruck\Messenger\Test\Transport\TestTransport $transport */ + $transport = static::getContainer()->get('messenger.transport.async'); + $envelopes = iterator_to_array($transport->get()); + $this->assertCount(1, $envelopes); + $this->assertInstanceOf(ProcessFeedMessage::class, $envelopes[0]->getMessage()); + } + + public function test_attach_existing_publication_and_queue_fetch(): void + { + $collection = CollectionFactory::createOne(['hyvorUserId' => 1])->_real(); + $publication = PublicationFactory::createOne(['url' => 'https://example.com/rss'])->_real(); + + $payload = [ + 'collection_slug' => $collection->getSlug(), + 'url' => $publication->getUrl(), + 'title' => 'Existing Publication', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $json = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($json); + $this->assertFalse($json['created']); + $this->assertTrue($json['attached']); + + /** @var \Zenstruck\Messenger\Test\Transport\TestTransport $transport */ + $transport = static::getContainer()->get('messenger.transport.async'); + $envelopes = iterator_to_array($transport->get()); + $this->assertCount(1, $envelopes); + $this->assertInstanceOf(ProcessFeedMessage::class, $envelopes[0]->getMessage()); + } + + public function test_idempotent_attach_does_not_queue_fetch(): void + { + $collection = CollectionFactory::createOne(['hyvorUserId' => 1])->_real(); + $publication = PublicationFactory::createOne(['url' => 'https://example.com/idempotent', 'collections' => [$collection]])->_real(); + + $payload = [ + 'collection_slug' => $collection->getSlug(), + 'url' => $publication->getUrl(), + 'title' => 'Idempotent Publication', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $json = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($json); + $this->assertFalse($json['created']); + $this->assertFalse($json['attached']); + + /** @var \Zenstruck\Messenger\Test\Transport\TestTransport $transport */ + $transport = static::getContainer()->get('messenger.transport.async'); + $envelopes = iterator_to_array($transport->get()); + $this->assertCount(0, $envelopes); + } + + public function test_invalid_url_returns_bad_request(): void + { + $collection = CollectionFactory::createOne(['hyvorUserId' => 1])->_real(); + + $payload = [ + 'collection_slug' => $collection->getSlug(), + 'url' => 'not-a-url', + 'title' => 'Invalid URL Test', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function test_collection_not_found(): void + { + $payload = [ + 'collection_slug' => 'missing', + 'url' => 'https://example.com/feed', + 'title' => 'Collection Not Found', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function test_forbidden_without_write_access(): void + { + $collection = CollectionFactory::createOne(['hyvorUserId' => 2])->_real(); + + $payload = [ + 'collection_slug' => $collection->getSlug(), + 'url' => 'https://example.com/feed', + 'title' => 'Forbidden Test', + ]; + + $this->client->request( + 'POST', + '/api/app/publications', + server: ['CONTENT_TYPE' => 'application/json'], + content: (string) json_encode($payload) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } +} + + diff --git a/backend/tests/Case/WebTestCase.php b/backend/tests/Case/WebTestCase.php index e335c78..7244407 100644 --- a/backend/tests/Case/WebTestCase.php +++ b/backend/tests/Case/WebTestCase.php @@ -26,7 +26,9 @@ protected function setUp(): void $this->client = static::createClient(); $this->container = static::getContainer(); - $this->em = $this->container->get(EntityManagerInterface::class); + /** @var EntityManagerInterface $em */ + $em = $this->container->get(EntityManagerInterface::class); + $this->em = $em; AuthFake::enableForSymfony($this->container, ['id' => 1]); $this->client->getCookieJar()->set(new Cookie('authsess', 'test')); diff --git a/backend/tests/Service/Fetch/FetchServiceTest.php b/backend/tests/Service/Fetch/FetchServiceTest.php index a259bb5..7b47a45 100644 --- a/backend/tests/Service/Fetch/FetchServiceTest.php +++ b/backend/tests/Service/Fetch/FetchServiceTest.php @@ -22,7 +22,7 @@ protected function setUp(): void { parent::setUp(); $service = $this->container->get(FetchService::class); - assert($service instanceof FetchService); + $this->assertInstanceOf(FetchService::class, $service); $this->fetchService = $service; } @@ -97,7 +97,6 @@ public function test_processItems_adds_new_items_with_correct_values(): void $this->assertEquals(['test-tag'], $item->getTags()); $itemPublication = $item->getPublication(); - $this->assertNotNull($itemPublication, 'Item publication should not be null'); $this->assertEquals($publication->getId(), $itemPublication->getId()); } @@ -163,7 +162,6 @@ public function test_processItems_updates_existing_items_with_correct_values(): $this->assertEquals(['updated-tag'], $freshItem->getTags()); $itemPublication = $freshItem->getPublication(); - $this->assertNotNull($itemPublication, 'Item publication should not be null'); $this->assertEquals($publication->getId(), $itemPublication->getId()); } diff --git a/backend/tests/Service/Opml/OpmlServiceTest.php b/backend/tests/Service/Opml/OpmlServiceTest.php index 69df2c2..b1748ac 100644 --- a/backend/tests/Service/Opml/OpmlServiceTest.php +++ b/backend/tests/Service/Opml/OpmlServiceTest.php @@ -7,27 +7,54 @@ use App\Factory\PublicationFactory; use PHPUnit\Framework\Attributes\CoversClass; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(OpmlService::class)] class OpmlServiceTest extends KernelTestCase { private OpmlService $opmlService; - private LoggerInterface $logger; protected function setUp(): void { parent::setUp(); + $mockClient = new MockHttpClient(function (string $method, string $url, array $options = []): MockResponse { + if (str_contains($url, 'example.com/pub1')) { + $body = (string) json_encode([ + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'Publication 1', + 'items' => [ + ['id' => 'i1', 'url' => $url.'/item', 'title' => 'Imported Item'] + ], + ]); + return new MockResponse($body, ['http_code' => 200]); + } + if (str_contains($url, 'example.com/pub2')) { + $body = (string) json_encode([ + 'version' => 'https://jsonfeed.org/version/1', + 'title' => 'Publication 2', + 'items' => [ + ['id' => 'i1', 'url' => $url.'/item', 'title' => 'Imported Item'] + ], + ]); + return new MockResponse($body, ['http_code' => 200]); + } + return new MockResponse('', ['http_code' => 200]); + }); + static::getContainer()->set(HttpClientInterface::class, $mockClient); $service = $this->container->get(OpmlService::class); - assert($service instanceof OpmlService); + $this->assertInstanceOf(OpmlService::class, $service); $this->opmlService = $service; - $this->logger = static::getContainer()->get(LoggerInterface::class); } public function test_import(): void { $hyvorUserId = 1; $collectionService = $this->container->get(\App\Service\Collection\CollectionService::class); + $this->assertInstanceOf(\App\Service\Collection\CollectionService::class, $collectionService); $publicationService = $this->container->get(\App\Service\Publication\PublicationService::class); + $this->assertInstanceOf(\App\Service\Publication\PublicationService::class, $publicationService); $opmlContent = << @@ -55,15 +82,19 @@ public function test_import(): void $this->assertEquals('Test Collection 1', $collection1->getName(), 'First collection name should match'); $publications1 = $collection1->getPublications(); $this->assertCount(1, $publications1, 'First collection should have one publication'); - $this->assertEquals('Publication 1', $publications1[0]->getTitle(), 'Publication title should match'); - $this->assertEquals('http://example.com/pub1', $publications1[0]->getUrl(), 'Publication URL should match'); + $firstPublication1 = $publications1->first(); + $this->assertInstanceOf(\App\Entity\Publication::class, $firstPublication1); + $this->assertEquals('Publication 1', $firstPublication1->getTitle(), 'Publication title should match'); + $this->assertEquals('http://example.com/pub1', $firstPublication1->getUrl(), 'Publication URL should match'); $collection2 = $collections[1]; $this->assertEquals('Test Collection 2', $collection2->getName(), 'Second collection name should match'); $publications2 = $collection2->getPublications(); $this->assertCount(1, $publications2, 'Second collection should have one publication'); - $this->assertEquals('Publication 2', $publications2[0]->getTitle(), 'Publication title should match'); - $this->assertEquals('http://example.com/pub2', $publications2[0]->getUrl(), 'Publication URL should match'); + $firstPublication2 = $publications2->first(); + $this->assertInstanceOf(\App\Entity\Publication::class, $firstPublication2); + $this->assertEquals('Publication 2', $firstPublication2->getTitle(), 'Publication title should match'); + $this->assertEquals('http://example.com/pub2', $firstPublication2->getUrl(), 'Publication URL should match'); } public function test_export(): void @@ -71,8 +102,11 @@ public function test_export(): void $hyvorUserId = 1; $collectionService = $this->container->get(\App\Service\Collection\CollectionService::class); + $this->assertTrue(method_exists($collectionService, 'createCollection')); $collection1 = $collectionService->createCollection($hyvorUserId, 'Test Collection 1'); + $this->assertInstanceOf(\App\Entity\Collection::class, $collection1); $collection2 = $collectionService->createCollection($hyvorUserId, 'Test Collection 2'); + $this->assertInstanceOf(\App\Entity\Collection::class, $collection2); $publication1 = PublicationFactory::createOne(['collections' => [$collection1]]); $publication2 = PublicationFactory::createOne(['collections' => [$collection2]]); @@ -83,13 +117,15 @@ public function test_export(): void $dom = new \DOMDocument(); $dom->loadXML($opmlContent); - $this->assertEquals('2.0', $dom->documentElement->getAttribute('version'), 'OPML version should be 2.0'); + $root = $dom->documentElement; + $this->assertInstanceOf(\DOMElement::class, $root); + $this->assertEquals('2.0', $root->getAttribute('version'), 'OPML version should be 2.0'); $this->assertEquals('UTF-8', $dom->encoding, 'OPML encoding should be UTF-8'); $head = $dom->getElementsByTagName('head')->item(0); - $this->assertNotNull($head, 'OPML head element should exist'); + $this->assertInstanceOf(\DOMElement::class, $head, 'OPML head element should exist'); $titleElement = $head->getElementsByTagName('title')->item(0); - $this->assertNotNull($titleElement, 'OPML title element should exist'); + $this->assertInstanceOf(\DOMElement::class, $titleElement, 'OPML title element should exist'); $this->assertEquals($title, $titleElement->textContent, 'OPML title should match'); $body = $dom->getElementsByTagName('body')->item(0); @@ -97,22 +133,27 @@ public function test_export(): void $xpath = new \DOMXPath($dom); $outlines = $xpath->query('//outline[@title and @text and not(@type)]'); + $this->assertNotFalse($outlines); $this->assertCount(2, $outlines, 'There should be two collection outlines'); $collectionOutline1 = $outlines->item(0); + $this->assertInstanceOf(\DOMElement::class, $collectionOutline1); $this->assertEquals($collection1->getName(), $collectionOutline1->getAttribute('title'), 'First collection title should match'); $this->assertEquals($collection1->getName(), $collectionOutline1->getAttribute('text'), 'First collection text should match'); $this->assertCount(1, $collectionOutline1->getElementsByTagName('outline'), 'First collection should have one publication outline'); $publicationOutline1 = $collectionOutline1->getElementsByTagName('outline')->item(0); + $this->assertInstanceOf(\DOMElement::class, $publicationOutline1); $this->assertEquals('rss', $publicationOutline1->getAttribute('type'), 'Publication outline type should be rss'); $this->assertEquals($publication1->getTitle(), $publicationOutline1->getAttribute('title'), 'Publication title should match'); $this->assertEquals($publication1->getUrl(), $publicationOutline1->getAttribute('xmlUrl'), 'Publication URL should match'); $collectionOutline2 = $outlines->item(1); + $this->assertInstanceOf(\DOMElement::class, $collectionOutline2); $this->assertEquals($collection2->getName(), $collectionOutline2->getAttribute('title'), 'Second collection title should match'); $this->assertEquals($collection2->getName(), $collectionOutline2->getAttribute('text'), 'Second collection text should match'); $this->assertCount(1, $collectionOutline2->getElementsByTagName('outline'), 'Second collection should have one publication outline'); $publicationOutline2 = $collectionOutline2->getElementsByTagName('outline')->item(0); + $this->assertInstanceOf(\DOMElement::class, $publicationOutline2); $this->assertEquals('rss', $publicationOutline2->getAttribute('type'), 'Publication outline type should be rss'); $this->assertEquals($publication2->getTitle(), $publicationOutline2->getAttribute('title'), 'Publication title should match'); $this->assertEquals($publication2->getUrl(), $publicationOutline2->getAttribute('xmlUrl'), 'Publication URL should match'); diff --git a/compose.yaml b/compose.yaml index 12c872f..81697e5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,6 +4,7 @@ services: build: context: . target: frontend-dev + network: host volumes: - ./frontend/src:/app/frontend/src - ./frontend/static:/app/frontend/static @@ -28,6 +29,7 @@ services: build: context: . target: backend-dev + network: host volumes: - ./backend:/app/backend # - ../internal:/app/backend/vendor/hyvor/internal:ro diff --git a/frontend/src/lib/Components/DomainIcon.svelte b/frontend/src/lib/Components/DomainIcon.svelte deleted file mode 100644 index 1ca7380..0000000 --- a/frontend/src/lib/Components/DomainIcon.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -{#if iconUrl} - -{/if} - - diff --git a/frontend/src/lib/actions/collectionsActions.ts b/frontend/src/lib/actions/collectionsActions.ts new file mode 100644 index 0000000..c299ce1 --- /dev/null +++ b/frontend/src/lib/actions/collectionsActions.ts @@ -0,0 +1,19 @@ +import type { Collection } from '$lib/types'; +import api from '../api'; + +export async function getCollections() { + const res = await api.get('/collections'); + return res.collections as Collection[]; +} + +export async function getCollectionBySlug(slug: string) { + const res = await api.get(`/collections/${slug}`); + return res.collection as Collection; +} + +export async function createCollection(name: string, isPublic: boolean) { + const res = await api.post('/collections', { name, is_public: isPublic }); + return res.collection as Collection; +} + + diff --git a/frontend/src/lib/actions/initActions.ts b/frontend/src/lib/actions/initActions.ts new file mode 100644 index 0000000..17ed23f --- /dev/null +++ b/frontend/src/lib/actions/initActions.ts @@ -0,0 +1,11 @@ +import type { Collection } from '$lib/types'; +import api from '../api'; + +export async function init() { + const res = await api.get('/init'); + return { + collections: res.collections as Collection[] + }; +} + + diff --git a/frontend/src/lib/actions/publicationsActions.ts b/frontend/src/lib/actions/publicationsActions.ts new file mode 100644 index 0000000..b5af2b3 --- /dev/null +++ b/frontend/src/lib/actions/publicationsActions.ts @@ -0,0 +1,12 @@ +import type { Publication } from '$lib/types'; +import api from '../api'; + +export async function addPublication(collectionSlug: string, url: string) { + const res = await api.post('/publications', { + collection_slug: collectionSlug, + url + }); + return res.publication as Publication; +} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5b26ba9..7655beb 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,13 +19,14 @@ export default class api { body: method !== 'get' ? JSON.stringify(data) : undefined, headers: { 'Content-Type': 'application/json', + 'Accept': 'application/json', }, credentials: 'include', }); if (!response.ok) { const json = await response.json(); - throw new Error(json ? json.error : 'Unknown error'); + throw new Error(json?.message ?? 'Unknown error'); } return await response.json(); diff --git a/frontend/src/routes/app/types.ts b/frontend/src/lib/types.ts similarity index 100% rename from frontend/src/routes/app/types.ts rename to frontend/src/lib/types.ts index a2c28f4..9e9c35b 100644 --- a/frontend/src/routes/app/types.ts +++ b/frontend/src/lib/types.ts @@ -1,4 +1,3 @@ - export interface Collection { id: number; name: string; @@ -39,3 +38,4 @@ export interface Item { word_count?: number; } + diff --git a/frontend/src/routes/(marketing)/learn/[[slug]]/+page.ts b/frontend/src/routes/(marketing)/learn/[[slug]]/+page.ts index aa9d645..cb07309 100644 --- a/frontend/src/routes/(marketing)/learn/[[slug]]/+page.ts +++ b/frontend/src/routes/(marketing)/learn/[[slug]]/+page.ts @@ -24,6 +24,6 @@ export async function load({ params }) { return { slug: params.slug, - content: nav[fileName], + content: nav[fileName] ?? Index, } } \ No newline at end of file diff --git a/frontend/src/routes/app/(reader)/+layout.svelte b/frontend/src/routes/app/(reader)/+layout.svelte index b3763ed..89b07a6 100644 --- a/frontend/src/routes/app/(reader)/+layout.svelte +++ b/frontend/src/routes/app/(reader)/+layout.svelte @@ -1,7 +1,20 @@ @@ -102,11 +210,22 @@
+
+ { + showSidebarMobile = true; + }} + > + + +
{#if $loadingInit} {:else} - + {#snippet trigger()}
{$selectedCollection?.name || 'Select Collection'} @@ -114,7 +233,7 @@
{/snippet} {#snippet content()} - + {#each $collections as collection} {/each} - + + + {/snippet}
{/if} @@ -139,47 +265,58 @@
-
- {#if $loadingPublications} -
- -
- {:else} - - {#each $publications as publication} +
+
+ {#if $loadingPublications} +
+ +
+ {:else} - {/each} - {/if} + {#each $publications as publication} + + {/each} + {/if} +
+
{#if selectedItem} - {#if item.image} {/if} @@ -262,9 +395,103 @@ {/if}
+ +
{ + showSidebarMobile = false; + }} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + showSidebarMobile = false; + } + }} + >
+ { + showCreateCollectionModal = false; + }} + on:confirm={handleCreateCollection} +> + + + + + + + {#snippet footer()} + + {/snippet} + + + {@render children()} diff --git a/frontend/src/routes/app/(reader)/[collection_slug]/+layout.svelte b/frontend/src/routes/app/(reader)/[collection_slug]/+layout.svelte index 89d4513..1fb374d 100644 --- a/frontend/src/routes/app/(reader)/[collection_slug]/+layout.svelte +++ b/frontend/src/routes/app/(reader)/[collection_slug]/+layout.svelte @@ -9,6 +9,7 @@ selectedPublication, loadingPublications } from '../../appStore'; + import { toast } from '@hyvor/design/components'; let { children } = $props(); let lastFetchedSlug: string | null = null; @@ -26,7 +27,6 @@ if (!collection || collection.slug === lastFetchedSlug) return; lastFetchedSlug = collection.slug; - console.log("Fetching publications for collection:", collection.slug); loadingPublications.set(true); try { @@ -34,7 +34,7 @@ publications.set(res.publications); selectedPublication.set(null); } catch (e) { - console.error('Failed to fetch publications:', e); + toast.error(e instanceof Error ? e.message : 'Failed to fetch publications'); } finally { loadingPublications.set(false); } diff --git a/frontend/src/routes/app/(reader)/[collection_slug]/[[publication_slug]]/+page.svelte b/frontend/src/routes/app/(reader)/[collection_slug]/[[publication_slug]]/+page.svelte index 124f7db..e117f60 100644 --- a/frontend/src/routes/app/(reader)/[collection_slug]/[[publication_slug]]/+page.svelte +++ b/frontend/src/routes/app/(reader)/[collection_slug]/[[publication_slug]]/+page.svelte @@ -1,6 +1,7 @@