diff --git a/.env b/.env index 5c5c85f..e0f36bf 100644 --- a/.env +++ b/.env @@ -42,3 +42,9 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages ###< symfony/messenger ### + +###> symfony/lock ### +# Choose one of the stores below +# postgresql+advisory://db_user:db_password@localhost/db_name +LOCK_DSN=flock +###< symfony/lock ### diff --git a/TODO b/TODO index 7d3b7fd..dbf4f6e 100644 --- a/TODO +++ b/TODO @@ -2,6 +2,7 @@ accent color #d43934 add dates and author to pages +blog use turbo make unit tests diff --git a/assets/app.js b/assets/app.js index 48617e4..0bb46da 100644 --- a/assets/app.js +++ b/assets/app.js @@ -10,22 +10,58 @@ import 'fork-awesome/scss/fork-awesome.scss' import './styles/app.scss' import $ from 'jquery' import 'bootstrap' + +// Dropzone stuff move to component +import {Dropzone} from 'dropzone' + +// TODO handle error (Chapter 26) +const formElement = $('#dropzoneForm') +if (formElement) { + const previewContent = $('#preview-content').html() + console.log(previewContent) + const dropzone = new Dropzone('#dropzoneForm', { + acceptedFiles: '.jpg, .jpeg, .png', + maxFiles: 1, + init: function () { + this.hiddenFileInput.removeAttribute('multiple') + this.on('maxfilesexceeded', (file) => { + this.removeAllFiles() + this.addFile(file) + }) + this.on('error', (file, data) => { + console.log('error'); + if (data.detail) { + this.emit('error', file, data.detail) + } + + }) + } + }) + console.log('filename', previewContent) + const mockFile = { name: previewContent } + console.log('file', mockFile) + dropzone.displayExistingFile(mockFile) +} + +// End Dropzone stuff move to component + //import './js/index' // needed for legacy code //global.$ = $ if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') { - console.log('🎉 Dark mode is supported') + console.log('🎉 Dark mode is supported') } $(document).ready(() => { - console.log('ready') - $('#toggleSidebar').on('click', () => { - const toggleIcon = $('#toggleIcon') - toggleIcon.toggleClass('fa fa-lg fa-fw fa-caret-square-o-left') - toggleIcon.toggleClass('fa fa-lg fa-fw fa-caret-square-o-right') - $('#sidebar').toggleClass('active') - }) + console.log('ready') + $('#toggleSidebar').on('click', () => { + const toggleIcon = $('#toggleIcon') + toggleIcon.toggleClass('fa fa-lg fa-fw fa-caret-square-o-left') + toggleIcon.toggleClass('fa fa-lg fa-fw fa-caret-square-o-right') + $('#sidebar').toggleClass('active') + }) }) + // start the Stimulus application //import './bootstrap' diff --git a/assets/js/components/_avatarDropzone.js b/assets/js/components/_avatarDropzone.js new file mode 100644 index 0000000..597a6db --- /dev/null +++ b/assets/js/components/_avatarDropzone.js @@ -0,0 +1 @@ +i \ No newline at end of file diff --git a/assets/styles/app.css b/assets/styles/app.css deleted file mode 100644 index cb33b13..0000000 --- a/assets/styles/app.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: lightgray; -} diff --git a/assets/styles/app.scss b/assets/styles/app.scss index ad39446..fbf9f2f 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -18,13 +18,17 @@ $primary: #FF8040; $jet-black: #0e0e10; -$body-color: #3f3f3f; -$list-group-bg: $body-color; -$list-group-hover-bg: #232323; -$list-group-active-bg: #232323; -$list-group-action-active-bg: #232323; -@import '~bootstrap'; + +$body-color: #9f9f9f; +$list-group: #232323; +$list-group-bg: $list-group; +$list-group-hover-bg: darken($list-group, 10%); +$list-group-active-bg: $list-group; +$list-group-action-active-bg: $list-group; + +@import 'bootstrap/scss/bootstrap'; +@import 'dropzone/dist/dropzone'; @import './components/sidebar'; @@ -36,7 +40,16 @@ html, body { background: #0e0e10; } +// Dropzone +.dropzone .dz-preview.dz-image-preview { + background: transparent !important; +} + +.article-author-img { + width: 80px; + margin: 15px; +} /// diff --git a/composer.json b/composer.json index 1195f29..b632310 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "easycorp/easyadmin-bundle": "^4.0", "knplabs/knp-time-bundle": "^1.18", "league/commonmark": "^2.3", + "liip/imagine-bundle": "^2.9", "nelmio/cors-bundle": "^2.2", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.4", @@ -39,6 +40,7 @@ "symfony/property-access": "6.1.*", "symfony/property-info": "6.1.*", "symfony/proxy-manager-bridge": "6.1.*", + "symfony/rate-limiter": "6.1.*", "symfony/runtime": "6.1.*", "symfony/security-bundle": "6.1.*", "symfony/security-csrf": "6.1.*", diff --git a/composer.lock b/composer.lock index 1bae413..ccaa754 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": "a389aa46db42a7da38ebb6610fb97a52", + "content-hash": "1c940d128112ec366d0f81bc6eccee45", "packages": [ { "name": "dflydev/dot-access-data", @@ -1689,6 +1689,68 @@ ], "time": "2022-10-17T19:48:16+00:00" }, + { + "name": "imagine/imagine", + "version": "1.3.2", + "source": { + "type": "git", + "url": "https://github.com/php-imagine/Imagine.git", + "reference": "ae864f26afbf8859ebd2e2b9df92d77ee175dc13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/ae864f26afbf8859ebd2e2b9df92d77ee175dc13", + "reference": "ae864f26afbf8859ebd2e2b9df92d77ee175dc13", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" + }, + "suggest": { + "ext-exif": "to read EXIF metadata", + "ext-gd": "to use the GD implementation", + "ext-gmagick": "to use the Gmagick implementation", + "ext-imagick": "to use the Imagick implementation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Imagine\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bulat Shakirzyanov", + "email": "mallluhuct@gmail.com", + "homepage": "http://avalanche123.com" + } + ], + "description": "Image processing for PHP 5.3", + "homepage": "http://imagine.readthedocs.org/", + "keywords": [ + "drawing", + "graphics", + "image manipulation", + "image processing" + ], + "support": { + "issues": "https://github.com/php-imagine/Imagine/issues", + "source": "https://github.com/php-imagine/Imagine/tree/1.3.2" + }, + "time": "2022-04-01T11:58:30+00:00" + }, { "name": "knplabs/knp-time-bundle", "version": "v1.20.0", @@ -2016,6 +2078,108 @@ ], "time": "2021-08-14T12:15:32+00:00" }, + { + "name": "liip/imagine-bundle", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/liip/LiipImagineBundle.git", + "reference": "ba164fef7be638f28d298f9c89b5a8364c3e0a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/ba164fef7be638f28d298f9c89b5a8364c3e0a4d", + "reference": "ba164fef7be638f28d298f9c89b5a8364c3e0a4d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "imagine/imagine": "^1.2.4", + "php": "^7.1|^8.0", + "symfony/filesystem": "^3.4|^4.4|^5.3|^6.0", + "symfony/finder": "^3.4|^4.4|^5.3|^6.0", + "symfony/framework-bundle": "^3.4.23|^4.4|^5.3|^6.0", + "symfony/mime": "^4.4|^5.3|^6.0", + "symfony/options-resolver": "^3.4|^4.4|^5.3|^6.0", + "symfony/process": "^3.4|^4.4|^5.3|^6.0", + "twig/twig": "^1.44|^2.9|^3.0" + }, + "require-dev": { + "amazonwebservices/aws-sdk-for-php": "^1.0", + "aws/aws-sdk-php": "^2.4", + "doctrine/cache": "^1.11|^2.0", + "doctrine/persistence": "^1.3|^2.0", + "enqueue/enqueue-bundle": "^0.9|^0.10", + "ext-gd": "*", + "league/flysystem": "^1.0|^2.0|^3.0", + "phpstan/phpstan": "^0.12.64", + "psr/cache": "^1.0|^2.0|^3.0", + "psr/log": "^1.0", + "symfony/browser-kit": "^3.4|^4.4|^5.3|^6.0", + "symfony/cache": "^3.4|^4.4|^5.3|^6.0", + "symfony/console": "^3.4|^4.4|^5.3|^6.0", + "symfony/dependency-injection": "^3.4|^4.4|^5.3|^6.0", + "symfony/form": "^3.4|^4.4|^5.3|^6.0", + "symfony/messenger": "^4.4|^5.3|^6.0", + "symfony/phpunit-bridge": "^5.3", + "symfony/templating": "^3.4|^4.4|^5.3|^6.0", + "symfony/validator": "^3.4|^4.4|^5.3|^6.0", + "symfony/yaml": "^3.4|^4.4|^5.3|^6.0" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "required for mongodb components", + "amazonwebservices/aws-sdk-for-php": "required to use AWS version 1 cache resolver", + "aws/aws-sdk-php": "required to use AWS version 2/3 cache resolver", + "doctrine/mongodb-odm": "required to use mongodb-backed doctrine components", + "enqueue/enqueue-bundle": "^0.9 add if you like to process images in background", + "ext-exif": "required to read EXIF metadata from images", + "ext-gd": "required to use gd driver", + "ext-gmagick": "required to use gmagick driver", + "ext-imagick": "required to use imagick driver", + "ext-mongodb": "required for mongodb components", + "league/flysystem": "required to use FlySystem data loader or cache resolver", + "monolog/monolog": "A psr/log compatible logger is required to enable logging", + "symfony/messenger": "If you like to process images in background", + "symfony/templating": "required to use deprecated Templating component instead of Twig" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Liip\\ImagineBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Liip and other contributors", + "homepage": "https://github.com/liip/LiipImagineBundle/contributors" + } + ], + "description": "This bundle provides an image manipulation abstraction toolkit for Symfony-based projects.", + "homepage": "http://liip.ch", + "keywords": [ + "bundle", + "image", + "imagine", + "liip", + "manipulation", + "photos", + "pictures", + "symfony", + "transformation" + ], + "support": { + "issues": "https://github.com/liip/LiipImagineBundle/issues", + "source": "https://github.com/liip/LiipImagineBundle/tree/2.9.0" + }, + "time": "2022-10-06T06:33:35+00:00" + }, { "name": "monolog/monolog", "version": "3.2.0", @@ -5076,6 +5240,83 @@ ], "time": "2022-10-23T10:33:34+00:00" }, + { + "name": "symfony/lock", + "version": "v6.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "98d6c4b6608d15e403a228c15afb4f3e71b22a57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/98d6c4b6608d15e403a228c15afb4f3e71b22a57", + "reference": "98d6c4b6608d15e403a228c15afb4f3e71b22a57", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/dbal": "<2.13" + }, + "require-dev": { + "doctrine/dbal": "^2.13|^3.0", + "predis/predis": "~1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v6.1.7" + }, + "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": "2022-10-28T16:23:08+00:00" + }, { "name": "symfony/mailer", "version": "v6.1.7", @@ -6495,6 +6736,76 @@ ], "time": "2022-03-02T13:21:45+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v6.1.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "9e75706446f7c3686773c408f422ffb5ec4ba32b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/9e75706446f7c3686773c408f422ffb5ec4ba32b", + "reference": "9e75706446f7c3686773c408f422ffb5ec4ba32b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/lock": "^5.4|^6.0", + "symfony/options-resolver": "^5.4|^6.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v6.1.3" + }, + "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": "2022-07-20T13:46:29+00:00" + }, { "name": "symfony/routing", "version": "v6.1.7", diff --git a/config/bundles.php b/config/bundles.php index dd71193..08dbdfd 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -18,4 +18,5 @@ return [ Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], + Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], ]; diff --git a/config/packages/liip_imagine.yaml b/config/packages/liip_imagine.yaml new file mode 100644 index 0000000..79bd742 --- /dev/null +++ b/config/packages/liip_imagine.yaml @@ -0,0 +1,24 @@ +# Documentation on how to configure the bundle can be found at: https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html +liip_imagine: + # valid drivers options include "gd" or "gmagick" or "imagick" + driver: "gd" + + filter_sets: + squared_thumbnail_small: + filters: + thumbnail: + size: [100, 100] + mode: outbound + allow_upscale: true + squared_thumbnail_medium: + filters: + thumbnail: + size: [200, 200] + mode: outbound + allow_upscale: true + squared_thumbnail_large: + filters: + thumbnail: + size: [400, 400] + mode: outbound + allow_upscale: true \ No newline at end of file diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b7ad3e4..da3b26e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -28,6 +28,8 @@ security: path: security_logout switch_user: true + login_throttling: true + remember_me: secret: '%kernel.secret%' signature_properties: [password] diff --git a/config/routes/liip_imagine.yaml b/config/routes/liip_imagine.yaml new file mode 100644 index 0000000..201cbd5 --- /dev/null +++ b/config/routes/liip_imagine.yaml @@ -0,0 +1,2 @@ +_liip_imagine: + resource: "@LiipImagineBundle/Resources/config/routing.yaml" diff --git a/config/services.yaml b/config/services.yaml index 73b35dd..112efda 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,6 +11,9 @@ services: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + # bind + # $var: 'content' + # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: diff --git a/package.json b/package.json index 0e0ac3e..74f8f8f 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@typescript-eslint/eslint-plugin-tslint": "^5.26.0", "axios": "^0.27.1", "bootstrap": "^5.2.2", - "bootswatch": "^5.2.2", + "dropzone": "^6.0.0-beta.2", "eslint": "^8.15.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.26.0", diff --git a/public/uploads/avatars/24_logo_bg_90x90.png b/public/uploads/avatars/24_logo_bg_90x90.png deleted file mode 100644 index b00fda4..0000000 Binary files a/public/uploads/avatars/24_logo_bg_90x90.png and /dev/null differ diff --git a/public/uploads/avatars/tracer_schmolle.png b/public/uploads/avatars/tracer_schmolle.png deleted file mode 100644 index a3147c8..0000000 Binary files a/public/uploads/avatars/tracer_schmolle.png and /dev/null differ diff --git a/src/Controller/Admin/ProjectsCrudController.php b/src/Controller/Admin/ProjectsCrudController.php index 6173a60..dfbae28 100644 --- a/src/Controller/Admin/ProjectsCrudController.php +++ b/src/Controller/Admin/ProjectsCrudController.php @@ -4,6 +4,8 @@ namespace App\Controller\Admin; use App\Entity\Projects; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; +use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; +use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; @@ -20,11 +22,13 @@ class ProjectsCrudController extends AbstractCrudController yield IdField::new(propertyName: 'id') ->onlyOnIndex(); yield TextField::new(propertyName: 'name'); - yield TextField::new(propertyName: 'description'); + yield AssociationField::new('developer'); yield TextField::new(propertyName: 'description'); yield ImageField::new(propertyName: 'teaserImage') ->setBasePath(path: 'uploads/projects') ->setUploadDir(uploadDirPath: 'public/uploads/projects') ->setUploadedFileNamePattern(patternOrCallable: '[timestamp]-[slug].[extension]'); + yield Field::new('createdAt') + ->hideOnForm(); } } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 448d76b..d9e0c0d 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -87,18 +87,7 @@ class SecurityController extends AbstractController } // no else, we already confirmed in the form itself $entityManager->persist(entity: $user); $entityManager->flush(); - - // generate a signed url and email it to the user - $this->sendEmailConfirmation(verifyEmailRouteName: 'security_verify_email', user: $user, - email: (new TemplatedEmail()) - ->from(new Address(address: 'info@24unix.net', name: '24unix.net')) - ->to($user->getEmail()) - ->subject(subject: 'Please Confirm your Email') - ->htmlTemplate(template: '@default/security/mail/registration.html.twig') - ->context(context: [ - 'username' => $user->getUsername() - ]) - ); + $this->generateSignedUrlAndEmailToTheUser($user); return $this->render(view: '@default/security/registration_finished.html.twig'); } @@ -127,7 +116,7 @@ class SecurityController extends AbstractController try { $this->handleEmailConfirmation(request: $request, user: $user); } catch (VerifyEmailExceptionInterface $exception) { - $this->addFlash(type: 'verify_email_error', message: $translator->trans(id: $exception->getReason(), parameters: [], domain: 'VerifyEmailBundle')); + $this->addFlash(type: 'error', message: $translator->trans(id: $exception->getReason(), parameters: [], domain: 'VerifyEmailBundle')); return $this->redirectToRoute(route: 'app_main'); } @@ -315,11 +304,48 @@ class SecurityController extends AbstractController */ public function handleEmailConfirmation(Request $request, User /*UserInterface*/ $user): void { - $this->verifyEmailHelper->validateEmailConfirmation(signedUrl: $request->getUri(), userId: $user->getId(), userEmail: $user->getEmail()); + $this->verifyEmailHelper->validateEmailConfirmation(signedUrl: $request->getUri(), userId: $user->getId(), userEmail: $user->getEmail()); + $user->setIsVerified(isVerified: true); + $this->entityManager->persist(entity: $user); + $this->entityManager->flush(); + } - $user->setIsVerified(isVerified: true); + /** + * @param mixed $user + * @return void + */ + public function generateSignedUrlAndEmailToTheUser(mixed $user): void + { + $this->sendEmailConfirmation(verifyEmailRouteName: 'security_verify_email', user: $user, + email: (new TemplatedEmail()) + ->from(new Address(address: 'info@24unix.net', name: '24unix.net')) + ->to($user->getEmail()) + ->subject(subject: 'Please Confirm your Email') + ->htmlTemplate(template: '@default/security/mail/registration.html.twig') + ->context(context: [ + 'username' => $user->getUsername() + ]) + ); + } - $this->entityManager->persist(entity: $user); - $this->entityManager->flush(); + + #[Route('/security/resend/verify_email', name: 'security_resend_verify_email')] + public function resendVerifyEmail(Request $request, UserRepository $userRepository) + { + + if ($request->isMethod('POST')) { + + $email = $request->getSession()->get('non_verified_email'); + $user = $userRepository->findOneBy(['email' => $email]); + if (!$user) { + throw $this->createNotFoundException('user not found for email'); + } + + $this->generateSignedUrlAndEmailToTheUser(user: $user); + $this->addFlash('success', 'eMail has been sent.'); + + return $this->redirectToRoute('app_main'); + } + return $this->render('@default/security/resend_activation.html.twig'); } } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index afad2ef..9f20143 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -6,13 +6,18 @@ use App\Entity\User; use App\Form\EditProfileFormType; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; +use Sunrise\Slugger\Slugger; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Validator\Constraints\File; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Validator\ValidatorInterface; /** * Class UserController. @@ -56,10 +61,6 @@ class UserController extends BaseController return $this->redirectToRoute(route: 'app_main'); }; - $user = $form->getData(); - // hash the plain password - - return $this->renderForm(view: '@default/user/edit_profile.html.twig', parameters: [ 'user' => $user, 'userForm' => $form @@ -93,4 +94,48 @@ class UserController extends BaseController 'users' => $users, ]); } + + // TODO move to a helper class + function humanFilesize($bytes, $decimals = 2) + { + $sz = 'BKMGTP'; + $factor = floor((strlen($bytes) - 1) / 3); + return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor]; + } + + + #[Route(path: '/user/upload/avatar/{id}', name: 'user_upload_avatar')] + public function uploadAvatar( + Request $request, + UserRepository $userRepository, + EntityManagerInterface $entityManager, + ValidatorInterface $validator, + int $id) + { + $user = $userRepository->find($id); + + if (!$user) { + return $this->json('User not found.', 404); + } + + $postMaxSize = UploadedFile::getMaxFilesize(); + $contentLength = $request->headers->get('Content-length'); + + if ($contentLength > $postMaxSize) { + return $this->json('File is bigger than the allowed ' . $this->humanFilesize($postMaxSize) . ' Bytes.', 400); + } + + $uploadedAvatar = $request->files->get('file'); + $destination = $this->getParameter(name: 'kernel.project_dir') . '/public/uploads/avatars'; + $originalFilename = pathinfo($uploadedAvatar->getClientOriginalName(), PATHINFO_FILENAME); + $slugger = new Slugger(); + $cleanFilename = $slugger->slugify($originalFilename); + $newFilename = $cleanFilename . '-' . uniqid() . '.' . $uploadedAvatar->guessExtension(); + $uploadedAvatar->move($destination, $newFilename); + $user->setAvatar($newFilename); + $entityManager->persist(entity: $user); + $entityManager->flush(); + + return $this->json(data: 'OK', status: 201); + } } diff --git a/src/Entity/Projects.php b/src/Entity/Projects.php index b29bc24..7012762 100644 --- a/src/Entity/Projects.php +++ b/src/Entity/Projects.php @@ -2,15 +2,14 @@ namespace App\Entity; -use ApiPlatform\Core\Annotation\ApiResource; use App\Repository\ProjectsRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Stringable; #[ORM\Entity(repositoryClass: ProjectsRepository::class)] -#[ApiResource] -class Projects implements \Stringable +class Projects implements Stringable { #[ORM\Id] #[ORM\GeneratedValue] @@ -33,7 +32,7 @@ class Projects implements \Stringable private ?string $teaserImage = null; #[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'projects')] - private $developer; + private Collection $developer; public function __construct() { diff --git a/src/EventSubscriber/CheckVerifiedUserSubscriber.php b/src/EventSubscriber/CheckVerifiedUserSubscriber.php new file mode 100644 index 0000000..ec3cd38 --- /dev/null +++ b/src/EventSubscriber/CheckVerifiedUserSubscriber.php @@ -0,0 +1,61 @@ +<?php + +namespace App\EventSubscriber; + +use App\Exception\UserNotVerifiedException; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Event\CheckPassportEvent; +use Symfony\Component\Security\Http\Event\LoginFailureEvent; + +class CheckVerifiedUserSubscriber implements EventSubscriberInterface +{ + + public function __construct(private readonly RouterInterface $router) + { + // empty body + } + + + public function onCheckPassport(CheckPassportEvent $event) + { + $passport = $event->getPassport(); + /* + * var User $user + */ + $user = $passport->getUser(); + + if (!$user->isVerified()) { + throw new UserNotVerifiedException(); + } + } + + + public function onValidationFailure(LoginFailureEvent $failureEvent) + { + if (!$failureEvent->getException() instanceof UserNotVerifiedException) { + return; + } + + $request = $failureEvent->getRequest(); + $email = $failureEvent->getPassport()->getUser()->getEmail(); + $request->getSession()->set('non_verified_email', $email); + + $response = new RedirectResponse( + $this->router->generate('security_resend_verify_email') + ); + $failureEvent->setResponse($response); + } + + + public static function getSubscribedEvents(): array + { + return [ + CheckPassportEvent::class => ['onCheckPassport', -10], + LoginFailureEvent::class => 'onValidationFailure' + ]; + } +} \ No newline at end of file diff --git a/src/Exception/UserNotVerifiedException.php b/src/Exception/UserNotVerifiedException.php index afc4460..bed0169 100644 --- a/src/Exception/UserNotVerifiedException.php +++ b/src/Exception/UserNotVerifiedException.php @@ -2,62 +2,10 @@ namespace App\Exception; -use JetBrains\PhpStorm\ArrayShape; + use Symfony\Component\Security\Core\Exception\AuthenticationException; -use function is_array; class UserNotVerifiedException extends AuthenticationException { - private ?string $identifier = null; - - /** - * {@inheritdoc} - */ - public function getMessageKey(): string - { - return 'User is not verified.'; - } - - /** - * Get the user identifier (e.g. username or email address). - */ - public function getUserIdentifier(): ?string - { - return $this->identifier; - } - - /** - * Set the user identifier (e.g. username or email address). - */ - public function setUserIdentifier(string $identifier): void - { - $this->identifier = $identifier; - } - - /** - * {@inheritdoc} - */ - #[ArrayShape(shape: ['{{ username }}' => "null|string", '{{ user_identifier }}' => "null|string"])] - public function getMessageData(): array - { - return ['{{ username }}' => $this->identifier, '{{ user_identifier }}' => $this->identifier]; - } - - /** - * {@inheritdoc} - */ - public function __serialize(): array - { - return [$this->identifier, parent::__serialize()]; - } - - /** - * {@inheritdoc} - */ - public function __unserialize(array $data): void - { - [$this->identifier, $parentData] = $data; - $parentData = is_array(value: $parentData) ? $parentData : unserialize(data: $parentData); - parent::__unserialize(data: $parentData); - } -} + // empty body +} \ No newline at end of file diff --git a/src/Form/EditProfileFormType.php b/src/Form/EditProfileFormType.php index 65d98c4..a60f68b 100644 --- a/src/Form/EditProfileFormType.php +++ b/src/Form/EditProfileFormType.php @@ -5,10 +5,12 @@ namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\Image; use Symfony\Component\Validator\Constraints\Length; class EditProfileFormType extends AbstractType @@ -27,7 +29,7 @@ class EditProfileFormType extends AbstractType 'options' => ['attr' => ['class' => 'password-field', 'autocomplete' => 'off']], 'required' => false, 'first_options' => ['label' => 'Password'], - 'second_options' => ['label' => 'Repeat Password (only needed if you want to update the password'], + 'second_options' => ['label' => 'Repeat Password (only needed if you want to update the password)'], 'constraints' => [new Length(exactly: ['min' => 6])] ]) ; diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index 6f72500..8a47316 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -9,6 +9,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; @@ -51,10 +52,6 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator throw new UserNotFoundException(); } - if (!$user->isVerified()) { - throw new UserNotVerifiedException(); - } - return $user; }), diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php new file mode 100644 index 0000000..4995c9e --- /dev/null +++ b/src/Twig/AppExtension.php @@ -0,0 +1,27 @@ +<?php + +namespace App\Twig; + +use Psr\Container\ContainerInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +class AppExtension extends AbstractExtension +{ +// public function __construct(private ContainerInterface $container) +// { +// } + + public function getFunctions(): array + { + return [ + new TwigFunction('avatar_asset', [$this, 'getAvatarPath']) + ]; + } + + public function getAvatarPath(string $path): string + { + return '/uploads/avatars/' . $path; + } + +} \ No newline at end of file diff --git a/symfony.lock b/symfony.lock index 6b8f720..687a9a1 100644 --- a/symfony.lock +++ b/symfony.lock @@ -47,6 +47,19 @@ "knplabs/knp-time-bundle": { "version": "v1.20.0" }, + "liip/imagine-bundle": { + "version": "2.9", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.8", + "ref": "d1227d002b70d1a1f941d91845fcd7ac7fbfc929" + }, + "files": [ + "config/packages/liip_imagine.yaml", + "config/routes/liip_imagine.yaml" + ] + }, "nelmio/cors-bundle": { "version": "2.2", "recipe": { @@ -138,6 +151,18 @@ "src/Kernel.php" ] }, + "symfony/lock": { + "version": "6.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.2", + "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e" + }, + "files": [ + "config/packages/lock.yaml" + ] + }, "symfony/mailer": { "version": "6.1", "recipe": { diff --git a/templates/themes/default/_header.html.twig b/templates/themes/default/_header.html.twig index 8e916d3..de4e893 100644 --- a/templates/themes/default/_header.html.twig +++ b/templates/themes/default/_header.html.twig @@ -21,8 +21,15 @@ {% if is_granted('ROLE_USER') %} <li class="nav-item dropdown me-auto"> <button type="button" id="navbar-dropdown" data-bs-target="#dropdown-menu" data-bs-toggle="dropdown" - class="btn btn-primary dropdown-toggle button-login"> - {{ app.user.username }} + class="btn btn-outline-dark dropdown-toggle button-login"> + {% if app.user.avatar %} + <img class="rounded-circle" + width="50px" + src="{{ avatar_asset(app.user.avatar)|imagine_filter('squared_thumbnail_small') }}" + alt="profile image"/> + {% else %} + {{ app.user.username }} + {% endif %} </button> <div class="dropdown-menu dropdown-menu-dark dropdown-menu-end" id="dropdown-menu" diff --git a/templates/themes/default/base.html.twig b/templates/themes/default/base.html.twig index 00dcbd6..2036b29 100644 --- a/templates/themes/default/base.html.twig +++ b/templates/themes/default/base.html.twig @@ -70,20 +70,22 @@ </nav> <div class="container-fluid" id="content"> - {% for message in app.flashes('success') %} - <div class="alert alert-success"> - {{ message }} - </div> - {% endfor %} - - {% for message in app.flashes('error') %} - <div class="alert alert-danger"> - {{ message }} - </div> - {% endfor %} <div class="col m-3" id="main_content"> + + {% for message in app.flashes('success') %} + <div class="alert alert-success"> + {{ message }} + </div> + {% endfor %} + + {% for message in app.flashes('error') %} + <div class="alert alert-danger"> + {{ message }} + </div> + {% endfor %} + {% block body %} <div class="m-5"> <h1 class="title-show">Quote of the Moment</h1> diff --git a/templates/themes/default/projects/index.html.twig b/templates/themes/default/projects/index.html.twig index fa6ca7e..003002d 100644 --- a/templates/themes/default/projects/index.html.twig +++ b/templates/themes/default/projects/index.html.twig @@ -32,10 +32,14 @@ {% for developer in project.developer %} <a class="align-left blog-details" href="{{ path('app_profile', { 'username':developer.username }) }}"> - <img class="article-author-img rounded-circle" - src="{{ asset('build/images/tracer_schmolle.png') }}" - alt="profile"></a> - <a href="{{ path('app_profile', { 'username':developer.username }) }}">{{ developer.username }}</a> + {% if developer.avatar is not null %} + <img class="rounded-circle mt-5 mb-4 ms-5" + src="{{ avatar_asset(developer.avatar)|imagine_filter('squared_thumbnail_small') }}" alt="profile image"/> + <br> + {% endif %} + <div class="ms-5 mb-4"> + <a href="{{ path('app_profile', { 'username':developer.username }) }}">{{ developer.username }}</a> + </div> {% endfor %} </div> diff --git a/templates/themes/default/security/resend_activation.html.twig b/templates/themes/default/security/resend_activation.html.twig new file mode 100644 index 0000000..c0e89c2 --- /dev/null +++ b/templates/themes/default/security/resend_activation.html.twig @@ -0,0 +1,20 @@ +{% extends '@default/base.html.twig' %} + +{% block title %}Verify eMail{% endblock %} + +{% block body %} + <div class="container"> + <div class="row"> + <div class="login-form bg-dark mt-4 p-4"> + <h1 class="h3 mb-3 font-weight-normal">Verify your Email</h1> + <p> + A verification email was sent - please check it to enable your + account before logging in. + </p> + <form method="POST"> + <button type="submit" class="btn btn-primary">Re-send Email</button> + </form> + </div> + </div> + </div> +{% endblock %} diff --git a/templates/themes/default/user/edit_profile.html.twig b/templates/themes/default/user/edit_profile.html.twig index 0a2aca7..e1e28c1 100644 --- a/templates/themes/default/user/edit_profile.html.twig +++ b/templates/themes/default/user/edit_profile.html.twig @@ -7,31 +7,42 @@ {% block body %} <div class="container box rounded bg-dark mt-5 mb-5"> + <div class="row"> <div class="col-md-3 border-right"> <div class="d-flex flex-column align-items-center text-center p-3 py-5"> - <img class="rounded-circle mt-5" - width="150px" - src=" {{ asset('build/images/tracer_schmolle150x150.png') }}" alt="profile image"> - + {% if user.avatar is not null %} + <img class="rounded-circle mt-5 mb-4" + src="{{ avatar_asset(user.avatar)|imagine_filter('squared_thumbnail_small') }}" + alt="profile image"/> + {% endif %} + {# {{ form_row(userForm.avatarName, { 'label': false }) }} #} + <form + action="{{ path('user_upload_avatar', { id: user.id}) }}" + method="POST" + enctype="multipart/form-data" + class="dropzone" id="dropzoneForm"> + </form> + <div id="preview-content">{{ avatar_asset(user.avatar)|imagine_filter('squared_thumbnail_small') }}</div> <span class="font-weight-bold">{{ user.username }}</span> <span class="text-white-50"><span class="fa fa-lg fa-envelope me-1"></span>{{ user.email }}</span> </div> </div> - <div class="col-md-5 border-right"> - {{ form_start(userForm) }} + <div class="col-md-8 border-right"> <div class="p-3 py-5"> <div class="d-flex justify-content-between align-items-center mb-3"> <h4 class="text-right">User Profile</h4> </div> <div class="row mt-2"> + {{ form_start(userForm) }} {{ form_row(userForm.username) }} {{ form_row(userForm.firstName) }} {{ form_row(userForm.lastName) }} {{ form_row(userForm.email) }} {{ form_row(userForm.newPassword.first) }} {{ form_row(userForm.newPassword.second) }} - {{ form_rest(userForm) }} + {{ form_rest(userForm) }} + {{ form_end(userForm) }} </div> <div class="mb-5 text-center float-end"> <button class="btn btn-primary profile-button" type="submit">Save Profile</button> @@ -41,5 +52,4 @@ </div> </div> - {% endblock %} diff --git a/templates/themes/default/user/show_profile.html.twig b/templates/themes/default/user/show_profile.html.twig index fb7f1e6..abf06e0 100644 --- a/templates/themes/default/user/show_profile.html.twig +++ b/templates/themes/default/user/show_profile.html.twig @@ -10,9 +10,10 @@ <div class="row"> <div class="col-md-3 border-right"> <div class="d-flex flex-column align-items-center text-center p-3 py-5"> - <img class="rounded-circle mt-5" - width="150px" - src=" {{ asset('/uploads/avatars/' ~ user.avatar) }}" alt="profile image"> + {% if user.avatar is not null %} + <img class="rounded-circle mt-5 mb-4" + src="{{ avatar_asset(user.avatar)|imagine_filter('squared_thumbnail_small') }}" alt="profile image"/> + {% endif %} <span class="font-weight-bold">{{ user.username }}</span> {% if is_granted('ROLE_ADMIN') %} <span class="font-weight-bold"> diff --git a/webpack.config.js b/webpack.config.js index 50d207a..379811b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -17,7 +17,7 @@ Encore .splitEntryChunks() // will require an extra script tag for runtime.js // but, you probably want this, unless you're building a single-page app - .enableSingleRuntimeChunk() + .disableSingleRuntimeChunk() .cleanupOutputBeforeBuild() .enableBuildNotifications() .enableSourceMaps(!Encore.isProduction()) diff --git a/yarn.lock b/yarn.lock index 545474c..aca37f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1043,6 +1043,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== +"@swc/helpers@^0.2.13": + version "0.2.14" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.2.14.tgz#20288c3627442339dd3d743c944f7043ee3590f0" + integrity sha512-wpCQMhf5p5GhNg2MmGKXzUNwxe7zRiCsmqYsamez2beP7mKPCSiu+BjZcdN95yYSzO857kr0VfQewmGpS77nqA== + "@symfony/stimulus-bridge@^3.0.0": version "3.2.1" resolved "https://registry.yarnpkg.com/@symfony/stimulus-bridge/-/stimulus-bridge-3.2.1.tgz#b9c261ad72830fd17898cf27c97862d1cc15b46a" @@ -1748,11 +1753,6 @@ bootstrap@^5.2.2: resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.2.2.tgz#834e053eed584a65e244d8aa112a6959f56e27a0" integrity sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ== -bootswatch@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/bootswatch/-/bootswatch-5.2.2.tgz#4d3d15dffd8de16112b64fa37c1164cca1543110" - integrity sha512-ByybawTUbMzzsdIb+5lYjme088UBYtNBhJCBt4W77PKG57fzjd2Y11rdGDgHgFGXVTMD2Oaci3/qJon4L0ceTQ== - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2333,6 +2333,14 @@ domutils@^2.5.2, domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" +dropzone@^6.0.0-beta.2: + version "6.0.0-beta.2" + resolved "https://registry.yarnpkg.com/dropzone/-/dropzone-6.0.0-beta.2.tgz#098be8fa84bdc08674cf0b74f4c889e2679083d6" + integrity sha512-k44yLuFFhRk53M8zP71FaaNzJYIzr99SKmpbO/oZKNslDjNXQsBTdfLs+iONd0U0L94zzlFzRnFdqbLcs7h9fQ== + dependencies: + "@swc/helpers" "^0.2.13" + just-extend "^5.0.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -3528,6 +3536,11 @@ json5@^2.1.2, json5@^2.2.1: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== +just-extend@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-5.1.1.tgz#4f33b1fc719964f816df55acc905776694b713ab" + integrity sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ== + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"