diff --git a/.gitignore b/.gitignore index 4268f09..e750698 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ ###< symfony/asset-mapper ### /assets/styles/app.tailwind.css +/public/uploads/ diff --git a/Dockerfile b/Dockerfile index 009ede2..6aeb0c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,22 @@ RUN set -eux; \ ENV COMPOSER_ALLOW_SUPERUSER=1 ###> recipes ### +###> symfony/panther ### +# Chromium and ChromeDriver +ENV PANTHER_NO_SANDBOX 1 +# Not mandatory, but recommended +ENV PANTHER_CHROME_ARGUMENTS='--disable-dev-shm-usage' +RUN apt-get update && \ + apt-get install -y --no-install-recommends chromium-driver chromium && \ + rm -rf /var/lib/apt/lists/* + +# Firefox and geckodriver +#ARG GECKODRIVER_VERSION=0.29.0 +#RUN apk add --no-cache firefox +#RUN wget -q https://github.com/mozilla/geckodriver/releases/download/v$GECKODRIVER_VERSION/geckodriver-v$GECKODRIVER_VERSION-linux64.tar.gz; \ +# tar -zxf geckodriver-v$GECKODRIVER_VERSION-linux64.tar.gz -C /usr/bin; \ +# rm geckodriver-v$GECKODRIVER_VERSION-linux64.tar.gz +###< symfony/panther ### ###> doctrine/doctrine-bundle ### RUN install-php-extensions pdo_pgsql ###< doctrine/doctrine-bundle ### diff --git a/Drag and drop a file or click to browse, b/Drag and drop a file or click to browse, deleted file mode 100644 index e69de29..0000000 diff --git a/[ b/[ deleted file mode 100644 index e69de29..0000000 diff --git a/compose.override.yaml b/compose.override.yaml index 177a547..15030bc 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -26,5 +26,5 @@ services: ###> doctrine/doctrine-bundle ### database: ports: - - "5432" + - "5432:5432" ###< doctrine/doctrine-bundle ### diff --git a/composer.json b/composer.json index 54489a1..240b98e 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "symfony/mime": "7.0.*", "symfony/monolog-bundle": "^3.0", "symfony/runtime": "7.0.*", + "symfony/security-bundle": "7.0.*", "symfony/security-csrf": "7.0.*", "symfony/stimulus-bundle": "^2.16", "symfony/twig-bundle": "7.0.*", @@ -78,7 +79,8 @@ ], "post-update-cmd": [ "@auto-scripts" - ] + ], + "test": "php bin/phpunit" }, "conflict": { "symfony/symfony": "*" @@ -91,14 +93,17 @@ } }, "require-dev": { + "dbrekelmans/bdi": "^1.3", "doctrine/doctrine-fixtures-bundle": "^3.5", "phpunit/phpunit": "^9.5", "symfony/browser-kit": "7.0.*", "symfony/css-selector": "7.0.*", "symfony/debug-bundle": "7.0.*", + "symfony/panther": "^2.1", "symfony/phpunit-bridge": "^7.0", "symfony/stopwatch": "7.0.*", "symfony/web-profiler-bundle": "7.0.*", + "zenstruck/browser": "^1.8", "zenstruck/foundry": "^1.36" } } diff --git a/composer.lock b/composer.lock index 1d967cb..3bc79f6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4f23cfb5246b05667b6ae81f259a610", + "content-hash": "93d79ec939cf0c840da056ec1d8622cb", "packages": [ { "name": "babdev/pagerfanta-bundle", @@ -5654,6 +5654,117 @@ ], "time": "2024-01-23T15:02:46+00:00" }, + { + "name": "symfony/security-bundle", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "5d620bd5493d62d8016b2383d8690fade66163c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/5d620bd5493d62d8016b2383d8690fade66163c1", + "reference": "5d620bd5493d62d8016b2383d8690fade66163c1", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/password-hasher": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/console": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-client": "<6.4", + "symfony/ldap": "<6.4", + "symfony/serializer": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/ldap": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "twig/twig": "^3.0.4", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1", + "web-token/jwt-signature-algorithm-eddsa": "^3.1", + "web-token/jwt-signature-algorithm-hmac": "^3.1", + "web-token/jwt-signature-algorithm-none": "^3.1", + "web-token/jwt-signature-algorithm-rsa": "^3.1" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SecurityBundle\\": "" + }, + "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 a tight integration of the Security component into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-bundle/tree/v7.0.5" + }, + "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": "2024-03-02T12:46:12+00:00" + }, { "name": "symfony/security-core", "version": "v7.0.3", @@ -5806,6 +5917,93 @@ ], "time": "2024-01-23T15:02:46+00:00" }, + { + "name": "symfony/security-http", + "version": "v7.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "f3a70a937128f47366821a9f4b5dbfaa0ba9c862" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/f3a70a937128f47366821a9f4b5dbfaa0ba9c862", + "reference": "f3a70a937128f47366821a9f4b5dbfaa0ba9c862", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/clock": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "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 Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v7.0.4" + }, + "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": "2024-02-26T07:52:39+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.4.1", @@ -6924,6 +7122,121 @@ } ], "packages-dev": [ + { + "name": "behat/mink", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/minkphp/Mink.git", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/minkphp/Mink/zipball/d8527fdf8785aad38455fb426af457ab9937aece", + "reference": "d8527fdf8785aad38455fb426af457ab9937aece", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/css-selector": "^4.4 || ^5.0 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^8.5.22 || ^9.5.11", + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "suggest": { + "behat/mink-browserkit-driver": "fast headless driver for any app without JS emulation", + "behat/mink-selenium2-driver": "slow, but JS-enabled driver for any app (requires Selenium2)", + "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)", + "dmore/chrome-mink-driver": "fast and JS-enabled driver for any app (requires chromium or google chrome)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Mink\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Browser controller/emulator abstraction for PHP", + "homepage": "https://mink.behat.org/", + "keywords": [ + "browser", + "testing", + "web" + ], + "support": { + "issues": "https://github.com/minkphp/Mink/issues", + "source": "https://github.com/minkphp/Mink/tree/v1.11.0" + }, + "time": "2023-12-09T11:23:23+00:00" + }, + { + "name": "dbrekelmans/bdi", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/dbrekelmans/bdi.git", + "reference": "46e5f8ec09bac842ab569c02e64476408aa46ef8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dbrekelmans/bdi/zipball/46e5f8ec09bac842ab569c02e64476408aa46ef8", + "reference": "46e5f8ec09bac842ab569c02e64476408aa46ef8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "ext-zlib": "*", + "php": "^8.1" + }, + "bin": [ + "bdi", + "bdi.phar" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniël Brekelmans", + "homepage": "https://github.com/dbrekelmans" + }, + { + "name": "Contributors", + "homepage": "https://github.com/dbrekelmans/bdi/graphs/contributors" + } + ], + "description": "PHAR distribution of dbrekelmans/browser-driver-installer.", + "homepage": "https://github.com/dbrekelmans/bdi", + "keywords": [ + "browser-driver-installer" + ], + "support": { + "source": "https://github.com/dbrekelmans/bdi/tree/1.3.0" + }, + "time": "2024-02-22T15:29:35+00:00" + }, { "name": "doctrine/data-fixtures", "version": "1.7.0", @@ -7402,6 +7715,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.15.1", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/cd52d9342c5aa738c2e75a67e47a1b6df97154e8", + "reference": "cd52d9342c5aa738c2e75a67e47a1b6df97154e8", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.1" + }, + "time": "2023-10-20T12:21:20+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.31", @@ -9061,6 +9440,95 @@ ], "time": "2024-02-12T11:15:03+00:00" }, + { + "name": "symfony/panther", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/panther.git", + "reference": "ef9a6f2393ac9679af03a93d3f508e4aa65c15b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/panther/zipball/ef9a6f2393ac9679af03a93d3f508e4aa65c15b5", + "reference": "ef9a6f2393ac9679af03a93d3f508e4aa65c15b5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": ">=8.0", + "php-webdriver/webdriver": "^1.8.2", + "symfony/browser-kit": "^5.3 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.3 || ^6.0 || ^7.0", + "symfony/deprecation-contracts": "^2.4 || ^3", + "symfony/dom-crawler": "^5.3 || ^6.0 || ^7.0", + "symfony/http-client": "^5.3 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.3 || ^6.0 || ^7.0", + "symfony/process": "^5.3 || ^6.0 || ^7.0" + }, + "require-dev": { + "symfony/css-selector": "^5.3 || ^6.0 || ^7.0", + "symfony/framework-bundle": "^5.3 || ^6.0 || ^7.0", + "symfony/mime": "^5.3 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^5.3 || ^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Panther\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com", + "homepage": "https://dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A browser testing and web scraping library for PHP and Symfony.", + "homepage": "https://dunglas.fr", + "keywords": [ + "e2e", + "scraping", + "selenium", + "symfony", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/symfony/panther/issues", + "source": "https://github.com/symfony/panther/tree/v2.1.1" + }, + "funding": [ + { + "url": "https://www.panthera.org/donate", + "type": "custom" + }, + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/panther", + "type": "tidelift" + } + ], + "time": "2023-12-03T22:17:31+00:00" + }, { "name": "symfony/phpunit-bridge", "version": "v7.0.4", @@ -9332,6 +9800,81 @@ ], "time": "2023-12-02T09:08:04+00:00" }, + { + "name": "zenstruck/browser", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/browser.git", + "reference": "c3a7328df5c2e3750f3aaef3be4805c2021dd6a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/browser/zipball/c3a7328df5c2e3750f3aaef3be4805c2021dd6a7", + "reference": "c3a7328df5c2e3750f3aaef3be4805c2021dd6a7", + "shasum": "" + }, + "require": { + "behat/mink": "^1.8", + "php": ">=8.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "zenstruck/assert": "^1.1", + "zenstruck/callback": "^1.4.2" + }, + "require-dev": { + "dbrekelmans/bdi": "^1.0", + "justinrainbow/json-schema": "^5.2", + "mtdowling/jmespath.php": "^2.6", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5|^10.4", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/panther": "^1.1|^2.0.1", + "symfony/phpunit-bridge": "^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "zenstruck/foundry": "^1.30" + }, + "suggest": { + "justinrainbow/json-schema": "Json schema validator. Needed to use Json::assertMatchesSchema().", + "mtdowling/jmespath.php": "PHP implementation for JMESPath. Needed to use Json assertions." + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "A fluent interface for your Symfony functional tests.", + "homepage": "https://github.com/zenstruck/browser", + "keywords": [ + "dev", + "symfony", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/browser/issues", + "source": "https://github.com/zenstruck/browser/tree/v1.8.1" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + } + ], + "time": "2024-02-21T15:32:44+00:00" + }, { "name": "zenstruck/callback", "version": "v1.5.0", diff --git a/config/bundles.php b/config/bundles.php index 5c7ee03..28b98da 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -16,4 +16,5 @@ BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\UX\Dropzone\DropzoneBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..983c946 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,62 @@ +security: + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + lazy: true + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + logout: + path: app_logout + # where to redirect after logout + # target: app_any_route + remember_me: + secret: '%kernel.secret%' # required + lifetime: 604800 # 1 week in seconds + + # activate different ways to authenticate + # https://symfony.com/doc/current/security.html#the-firewall + + # https://symfony.com/doc/current/security/impersonating_user.html + # switch_user: true + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/$, roles: PUBLIC_ACCESS } + - { path: ^/login$, roles: PUBLIC_ACCESS } + - { path: ^/blog/[^/]+/?$, roles: PUBLIC_ACCESS, methods: GET } + - { path: ^/tag/?$, roles: PUBLIC_ACCESS, methods: GET} + - { path: ^/tag/[^/]+/?$, roles: PUBLIC_ACCESS, methods: GET} + + # Deny access to all other routes for anonymous users + - { path: ^/, roles: ROLE_USER } + +when@test: + security: + password_hashers: + # By default, password hashers are resource intensive and take time. This is + # important to generate secure password hashes. In tests however, secure hashes + # are not important, waste resources and increase test times. The following + # reduces the work factor to the lowest possible values. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 511b047..3f795d9 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -4,6 +4,3 @@ twig: when@test: twig: strict_variables: true - - globals: - banner_directory: '%banner_directory%' diff --git a/config/routes/security.yaml b/config/routes/security.yaml new file mode 100644 index 0000000..f853be1 --- /dev/null +++ b/config/routes/security.yaml @@ -0,0 +1,3 @@ +_security_logout: + resource: security.route_loader.logout + type: service diff --git a/migrations/Version20240322224248.php b/migrations/Version20240322224248.php new file mode 100644 index 0000000..d99caa2 --- /dev/null +++ b/migrations/Version20240322224248.php @@ -0,0 +1,34 @@ +addSql('CREATE SEQUENCE blog_post_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE tag_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE "user" (id INT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)'); + } + + public function down(Schema $schema): void + { + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP SEQUENCE blog_post_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE tag_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE "user_id_seq" CASCADE'); + $this->addSql('DROP TABLE "user"'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c76a655..5e7edb4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,6 +15,10 @@ + + + + @@ -34,5 +38,7 @@ + + diff --git a/public/uploads/banners/sl6nzpvll6o0c44g4-65fb51b1e43df.jpg b/public/uploads/banners/sl6nzpvll6o0c44g4-65fb51b1e43df.jpg deleted file mode 100644 index 0e8cfde..0000000 Binary files a/public/uploads/banners/sl6nzpvll6o0c44g4-65fb51b1e43df.jpg and /dev/null differ diff --git a/src/Command/AddUserCommand.php b/src/Command/AddUserCommand.php new file mode 100644 index 0000000..0c28e42 --- /dev/null +++ b/src/Command/AddUserCommand.php @@ -0,0 +1,51 @@ +addArgument('email', InputArgument::REQUIRED, 'The email of the user') + ->addArgument('password', InputArgument::REQUIRED, 'The password of the user') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $email = $input->getArgument('email'); + $password = $input->getArgument('password'); + + $user = new User(); + $user->setEmail($email); + $user->setPassword($this->passwordHasher->hashPassword($user, $password)); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $io->success(sprintf('User %s has been successfully created.', $email)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/BlogPostController.php b/src/Controller/BlogPostController.php index 517d8c0..521ec4f 100644 --- a/src/Controller/BlogPostController.php +++ b/src/Controller/BlogPostController.php @@ -5,6 +5,7 @@ use App\Entity\BlogPost; use App\Form\BlogPostType; use App\Repository\BlogPostRepository; +use App\Service\UploaderHelper; use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Pagerfanta; @@ -39,34 +40,19 @@ public function index(BlogPostRepository $blogPostRepository, Request $request): } #[Route('blog/new', name: 'app_blog_post_new', methods: ['GET', 'POST'])] - public function new(Request $request, EntityManagerInterface $entityManager, SluggerInterface $slugger): Response - { + public function new( + Request $request, + EntityManagerInterface $entityManager, + SluggerInterface $slugger, + UploaderHelper $uploaderHelper, + ): Response { $blogPost = new BlogPost(); + $form = $this->createForm(BlogPostType::class, $blogPost); $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - - $bannerFile = $form->get('banner')->getData(); - if ($bannerFile) { - $originalFilename = pathinfo($bannerFile->getClientOriginalName(), PATHINFO_FILENAME); - // this is needed to safely include the file name as part of the URL - $safeFilename = $slugger->slug($originalFilename); - $newFilename = $safeFilename.'-'.uniqid().'.'.$bannerFile->guessExtension(); - - // Move the file to the directory where brochures are stored - try { - $bannerFile->move( - $this->getParameter('banner_directory'), - $newFilename - ); - } catch (FileException $e) { - // ... handle exception if something happens during file upload - } - - // updates the 'brochureFilename' property to store the PDF file name - // instead of its contents - $blogPost->setBanner($newFilename); + if ($bannerFile = $form->get('banner')->getData()) { + $blogPost->setBanner($uploaderHelper->uploadFile($bannerFile, $this->getParameter('banner_directory'))); } $entityManager->persist($blogPost); @@ -90,12 +76,26 @@ public function show(BlogPost $blogPost): Response } #[Route('blog/{id}/edit', name: 'app_blog_post_edit', methods: ['GET', 'POST'])] - public function edit(Request $request, BlogPost $blogPost, EntityManagerInterface $entityManager): Response + public function edit(Request $request, BlogPost $blogPost, EntityManagerInterface $entityManager, UploaderHelper $uploaderHelper): Response { $form = $this->createForm(BlogPostType::class, $blogPost); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + if ($form->has('delete_banner') && $form->get('delete_banner')->getData()) { + if ($blogPost->getBanner()) { + unlink($this->getParameter('banner_directory').'/'.$blogPost->getBanner()); + $blogPost->setBanner(null); + } + } + + if ($bannerFile = $form->get('banner')->getData()) { + if ($blogPost->getBanner()) { + unlink($this->getParameter('banner_directory').'/'.$blogPost->getBanner()); + } + + $blogPost->setBanner($uploaderHelper->uploadFile($bannerFile, $this->getParameter('banner_directory'))); + } $entityManager->flush(); return $this->redirectToRoute('app_blog_post_index', [], Response::HTTP_SEE_OTHER); @@ -111,6 +111,10 @@ public function edit(Request $request, BlogPost $blogPost, EntityManagerInterfac public function delete(Request $request, BlogPost $blogPost, EntityManagerInterface $entityManager): Response { if ($this->isCsrfTokenValid('delete'.$blogPost->getId(), $request->request->get('_token'))) { + if ($blogPost->getBanner()) { + unlink($this->getParameter('banner_directory').'/'.$blogPost->getBanner()); + } + $entityManager->remove($blogPost); $entityManager->flush(); } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..76bf5c4 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +getLastAuthenticationError(); + + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', [ + 'last_username' => $lastUsername, + 'error' => $error, + ]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 589aa78..34b1b9f 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -3,6 +3,7 @@ namespace App\DataFixtures; use App\Factory\BlogPostFactory; +use App\Factory\UserFactory; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; @@ -10,6 +11,7 @@ class AppFixtures extends Fixture { public function load(ObjectManager $manager): void { + UserFactory::createOne(); BlogPostFactory::createMany(1000); } } diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..f68dd04 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,109 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private string $password; + + public function getId(): int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + * + * @return list + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php new file mode 100644 index 0000000..f6581ed --- /dev/null +++ b/src/Factory/UserFactory.php @@ -0,0 +1,74 @@ + + * + * @method User|Proxy create(array|callable $attributes = []) + * @method static User|Proxy createOne(array $attributes = []) + * @method static User|Proxy find(object|array|mixed $criteria) + * @method static User|Proxy findOrCreate(array $attributes) + * @method static User|Proxy first(string $sortedField = 'id') + * @method static User|Proxy last(string $sortedField = 'id') + * @method static User|Proxy random(array $attributes = []) + * @method static User|Proxy randomOrCreate(array $attributes = []) + * @method static UserRepository|RepositoryProxy repository() + * @method static User[]|Proxy[] all() + * @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static User[]|Proxy[] createSequence(iterable|callable $sequence) + * @method static User[]|Proxy[] findBy(array $attributes) + * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static User[]|Proxy[] randomSet(int $number, array $attributes = []) + */ +final class UserFactory extends ModelFactory +{ + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services + * + * @todo inject services if required + */ + public function __construct(private UserPasswordHasherInterface $passwordHasher) + { + parent::__construct(); + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories + * + * @todo add your default values here + */ + protected function getDefaults(): array + { + return [ + 'email' => 'test@mail.com', + 'password' => '1234', + 'roles' => [], + ]; + } + + /** + * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization + */ + protected function initialize(): self + { + return $this + ->afterInstantiate(function(User $user) { + $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword())); + }) + ; + } + + protected static function getClass(): string + { + return User::class; + } +} diff --git a/src/Form/BlogPostType.php b/src/Form/BlogPostType.php index cfb3877..f2f0d49 100644 --- a/src/Form/BlogPostType.php +++ b/src/Form/BlogPostType.php @@ -6,6 +6,7 @@ use App\Entity\Tag; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -16,6 +17,7 @@ class BlogPostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + // check if form is create ore edit $builder ->add('title') ->add('banner', DropzoneType::class, [ @@ -45,6 +47,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, ]) ; + + // Conditionally add the field only for the edit form + if ($options['data']?->getBanner() !== null) { + $builder->add('delete_banner', CheckboxType::class, [ + 'mapped' => false, + 'required' => false, + ]); + } } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..4b954ba --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,40 @@ + + * + * @method User|null find($id, $lockMode = null, $lockVersion = null) + * @method User|null findOneBy(array $criteria, array $orderBy = null) + * @method User[] findAll() + * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Service/UploaderHelper.php b/src/Service/UploaderHelper.php new file mode 100644 index 0000000..c528bd2 --- /dev/null +++ b/src/Service/UploaderHelper.php @@ -0,0 +1,35 @@ +getClientOriginalName(), PATHINFO_FILENAME); + // this is needed to safely include the file name as part of the URL + $safeFilename = $this->slugger->slug($originalFilename); + $newFilename = $safeFilename.'-'.uniqid().'.'.$file->guessExtension(); + + // Move the file to the directory where brochures are stored + try { + $file->move( + $fileDirectory, + $newFilename + ); + } catch (FileException $e) { + + } + + return $newFilename; + } + +} diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php new file mode 100644 index 0000000..9a7d2a7 --- /dev/null +++ b/src/Twig/AppExtension.php @@ -0,0 +1,29 @@ + ['html']]), + ]; + } + + public function getUploadedAssetPath(string $path): string + { + return 'uploads/banners/' . $path; + } +} diff --git a/symfony.lock b/symfony.lock index d74a655..efc113b 100644 --- a/symfony.lock +++ b/symfony.lock @@ -167,6 +167,15 @@ "config/packages/monolog.yaml" ] }, + "symfony/panther": { + "version": "2.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "292a3236e81f55b545b09b9954ab097248972ab3" + } + }, "symfony/phpunit-bridge": { "version": "7.0", "recipe": { @@ -195,6 +204,19 @@ "config/routes.yaml" ] }, + "symfony/security-bundle": { + "version": "7.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "2ae08430db28c8eb4476605894296c82a642028f" + }, + "files": [ + "config/packages/security.yaml", + "config/routes/security.yaml" + ] + }, "symfony/stimulus-bundle": { "version": "2.16", "recipe": { diff --git a/templates/base.html.twig b/templates/base.html.twig index 7ef4bf9..3d7729e 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -21,7 +21,11 @@
Home About - Login + {% if app.user %} + Logout + {% else %} + Login + {% endif %}
diff --git a/templates/blog_post/_form.html.twig b/templates/blog_post/_form.html.twig index 3c7eb75..674dc4a 100644 --- a/templates/blog_post/_form.html.twig +++ b/templates/blog_post/_form.html.twig @@ -5,6 +5,14 @@ {{ form_widget(form.title, {'attr': {'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 p-2'}}) }} + {% if blog_post.banner %} +
+ {{ blog_post.title }} + Delete banner: {{ form_widget(form.delete_banner) }} +
+ {% endif %} + +
{{ form_widget(form.banner) }}
diff --git a/templates/blog_post/index.html.twig b/templates/blog_post/index.html.twig index ebb161e..8929093 100644 --- a/templates/blog_post/index.html.twig +++ b/templates/blog_post/index.html.twig @@ -3,9 +3,13 @@ {% block title %}BlogPost index{% endblock %} {% block body %} -
- Create new -
+ + {% if is_granted('IS_AUTHENTICATED_FULLY') %} +
+ Create new +
+ {% endif %} + {% for blog_post in pager %}
@@ -13,17 +17,18 @@

{{ blog_post.title }}

- - Edit - + {% if is_granted('IS_AUTHENTICATED_FULLY') %} + + Edit + + {% endif %} {% if blog_post.banner %} {% endif %} @@ -67,9 +72,12 @@ {% set left = 1 %} {% endif %} - {% for i in range(left, right) %} - {{ i }} - {% endfor %} + {% if pager.hasNextPage or pager.hasPreviousPage %} + {% for i in range(left, right) %} + {{ i }} + {% endfor %} + {% endif %} + {% if pager.hasNextPage %} Next diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000..9bb43ca --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,44 @@ +{% extends 'base.html.twig' %} + +{% block title %}Log in!{% endblock %} + +{% block body %} +
+
+

Please sign in

+
+ {% if error %} + + {% endif %} + + {% if app.user %} +
+ You are logged in as {{ app.user.userIdentifier }}, Logout +
+ {% endif %} + +
+ +{# #} + +
+
+ + +
+ +
+ +
+ + + + +
+
+
+{% endblock %} diff --git a/tests/AppBrowser.php b/tests/AppBrowser.php new file mode 100644 index 0000000..5aee69f --- /dev/null +++ b/tests/AppBrowser.php @@ -0,0 +1,18 @@ +client()->waitFor('html[aria-busy="true"]'); + + return $this; + } +} diff --git a/tests/AppPantherTestCase.php b/tests/AppPantherTestCase.php new file mode 100644 index 0000000..c35f369 --- /dev/null +++ b/tests/AppPantherTestCase.php @@ -0,0 +1,18 @@ +parentPantherBrowser($options, $kernelOptions, $managerOptions); + } +} diff --git a/tests/Browser/AuthenticationExtension.php b/tests/Browser/AuthenticationExtension.php new file mode 100644 index 0000000..f847985 --- /dev/null +++ b/tests/Browser/AuthenticationExtension.php @@ -0,0 +1,41 @@ +visit('/login') + ->fillField('username', $username) + ->fillField('password', $password) + ->click('Sign in'); + } + + public function logout(): self + { + return $this->visit('/logout'); + } + + public function assertLoggedIn(): self + { + $this->assertSee('Logout'); + + return $this; + } + + public function assertLoggedInAs(string $user): self + { + $this->assertSee($user); + + return $this; + } + + public function assertNotLoggedIn(): self + { + $this->assertSee('Login'); + + return $this; + } +} diff --git a/tests/Functional/Blog/CreateBlogPostTest.php b/tests/Functional/Blog/CreateBlogPostTest.php index 59af04b..51a49c6 100644 --- a/tests/Functional/Blog/CreateBlogPostTest.php +++ b/tests/Functional/Blog/CreateBlogPostTest.php @@ -2,7 +2,31 @@ namespace Tests\Functional\Blog; -class CreateBlogPostTest +use App\Factory\UserFactory; +use Tests\AppPantherTestCase; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +class CreateBlogPostTest extends AppPantherTestCase { + use ResetDatabase; + use Factories; + + public function testItWorks(): void + { + UserFactory::createOne(); + + // test there are no articles + $this->browser() + ->visit('/') + ->assertSee('No Articles found 😞') + ; + // login user + $this->pantherBrowser() + ->loginAs('test@mail.com', '1234') + ->waitForPageLoad() + ->assertOn('/') + ->assertLoggedIn(); + } } diff --git a/tests/Functional/Blog/DeleteBlogPostTest.php b/tests/Functional/Blog/DeleteBlogPostTest.php deleted file mode 100644 index d12ab6f..0000000 --- a/tests/Functional/Blog/DeleteBlogPostTest.php +++ /dev/null @@ -1,8 +0,0 @@ -