diff --git a/.gitignore b/.gitignore index 7d5655849..7a7fd634d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /public/build/fonts/glyphicons-* /public/build/images/glyphicons-* +/public/uploads/ ###> symfony/framework-bundle ### /.env.local diff --git a/assets/scss/app.scss b/assets/scss/app.scss index a30457c42..e18250758 100644 --- a/assets/scss/app.scss +++ b/assets/scss/app.scss @@ -358,3 +358,9 @@ body#blog_search .post-metadata { font-size: 16px; margin-bottom: 8px; } + +/* Page: 'User edit' + ------------------------------------------------------------------------- */ +body#user_edit #main form .form-group .thumbnail { + max-width: 150px; +} diff --git a/composer.lock b/composer.lock index 8fd3a440a..d6d32369f 100644 --- a/composer.lock +++ b/composer.lock @@ -1,7 +1,7 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], "content-hash": "c6aef712856c7e6e6751e63d193256fe", diff --git a/config/services.yaml b/config/services.yaml index ff788dbee..99adc0041 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,6 +5,7 @@ parameters: # This parameter defines the codes of the locales (languages) enabled in the application app_locales: en|fr|de|es|cs|nl|ru|uk|ro|pt_BR|pl|it|ja|id|ca|sl|hr|zh_CN|bg|tr|lt app.notifications.email_sender: anonymous@example.com + app.uploader_directory: '%kernel.project_dir%/public/uploads' services: # default configuration for services in *this* file @@ -32,3 +33,10 @@ services: # 'arguments' key and define the arguments just below the service class App\EventSubscriber\CommentNotificationSubscriber: $sender: '%app.notifications.email_sender%' + + App\EventSubscriber\UploadSubscriber: + tags: + - { name: doctrine.event_subscriber, connection: default } + + App\Utils\FileUploader: + $uploadPath: '%app.uploader_directory%' diff --git a/data/database.sqlite b/data/database.sqlite index 1ff16420e..44eb43a10 100644 Binary files a/data/database.sqlite and b/data/database.sqlite differ diff --git a/data/database_test.sqlite b/data/database_test.sqlite index 1bdf92e07..6549739fa 100644 Binary files a/data/database_test.sqlite and b/data/database_test.sqlite differ diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 48018fb13..6844cc044 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -50,4 +50,4 @@ "/build/admin.css": "sha384-NMnc2b6jJOZO4QiUk4TgitF+ipl41B2+0TFmAwGqpQ6cinY6Q6mb1watPtAAWkAZ", "/build/search.js": "sha384-R/BW5h0YHjTYt/CVNZNOPDSuk0IQsvSSQZeP9hPpBtEkI4pBWMkBX4CAQnQQkFvt" } -} \ No newline at end of file +} diff --git a/public/build/manifest.json b/public/build/manifest.json index 36bed35a4..54ff840ee 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -37,4 +37,4 @@ "build/images/fa-regular-400.svg": "/build/images/fa-regular-400.95f13e0b.svg", "build/images/fa-solid-900.svg": "/build/images/fa-solid-900.6ed5e3bc.svg", "build/images/glyphicons-halflings-regular.svg": "/build/images/glyphicons-halflings-regular.89889688.svg" -} \ No newline at end of file +} diff --git a/src/Annotation/Uploadable.php b/src/Annotation/Uploadable.php new file mode 100644 index 000000000..06310fd28 --- /dev/null +++ b/src/Annotation/Uploadable.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Annotation; + +use Doctrine\Common\Annotations\Annotation; + +/** + * This file defined an annotation to target a class. + * + * @Annotation + * @Target("CLASS") + * + * @author Romain Monteil + */ +class Uploadable +{ +} diff --git a/src/Annotation/UploadableField.php b/src/Annotation/UploadableField.php new file mode 100644 index 000000000..64a4362cc --- /dev/null +++ b/src/Annotation/UploadableField.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Annotation; + +use Doctrine\Common\Annotations\Annotation\Target; +use Doctrine\Common\Annotations\AnnotationException; + +/** + * This file defined an annotation to target a property on a class. + * + * @Annotation + * @Target("PROPERTY") + * + * @author Romain Monteil + */ +class UploadableField +{ + private $filename; + + private $path; + + public function __construct(array $options) + { + if (!isset($options['filename'])) { + throw new AnnotationException('Attribute "filename" is required for UploadableField annotation.'); + } + + if (!isset($options['path'])) { + throw new AnnotationException('Attribute "path" is required for UploadableField annotation.'); + } + + $this->filename = $options['filename']; + $this->path = $options['path']; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getPath(): string + { + return $this->path; + } +} diff --git a/src/Annotation/UploadableReader.php b/src/Annotation/UploadableReader.php new file mode 100644 index 000000000..dd95226ce --- /dev/null +++ b/src/Annotation/UploadableReader.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Annotation; + +use Doctrine\Common\Annotations\Reader; + +/** + * @author Romain Monteil + */ +class UploadableReader +{ + private $reader; + + public function __construct(Reader $reader) + { + $this->reader = $reader; + } + + public function isUploadable($entity): bool + { + $reflection = new \ReflectionClass(\get_class($entity)); + + return null !== $this->reader->getClassAnnotation($reflection, Uploadable::class); + } + + public function getUploadableFields($entity): array + { + $reflection = new \ReflectionClass(\get_class($entity)); + + $properties = []; + if ($this->isUploadable($entity)) { + foreach ($reflection->getProperties() as $property) { + $propertyAnnotation = $this->reader->getPropertyAnnotation($property, UploadableField::class); + if (null !== $propertyAnnotation) { + $properties[$property->getName()] = $propertyAnnotation; + } + } + } + + return $properties; + } +} diff --git a/src/Entity/Interfaces/TimestampableEntityInterface.php b/src/Entity/Interfaces/TimestampableEntityInterface.php new file mode 100644 index 000000000..10c245e2f --- /dev/null +++ b/src/Entity/Interfaces/TimestampableEntityInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity\Interfaces; + +/** + * This interface allow us to mark an Entity as Timestampable. + * + * @author Romain Monteil + */ +interface TimestampableEntityInterface +{ + public function getCreatedAt(): ?\DateTimeInterface; + + public function setCreatedAt(); + + public function getUpdatedAt(): ?\DateTimeInterface; + + public function setUpdatedAt(); +} diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 1a3cd5e41..c00a2ba82 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -11,14 +11,21 @@ namespace App\Entity; +use App\Annotation\Uploadable; +use App\Annotation\UploadableField; +use App\Entity\Interfaces\TimestampableEntityInterface; +use App\Entity\Traits\TimestampableEntityTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\Validator\Constraints as Assert; /** * @ORM\Entity(repositoryClass="App\Repository\PostRepository") * @ORM\Table(name="symfony_demo_post") + * @ORM\HasLifecycleCallbacks() + * @Uploadable() * * Defines the properties of the Post entity to represent the blog posts. * @@ -31,8 +38,10 @@ * @author Javier Eguiluz * @author Yonel Ceruto */ -class Post +class Post implements TimestampableEntityInterface { + use TimestampableEntityTrait; + /** * Use constants to define configuration options that rarely change instead * of specifying them under parameters section in config/services.yaml file. @@ -90,6 +99,27 @@ class Post */ private $publishedAt; + /** + * @var string + * + * @ORM\Column(type="text") + */ + private $thumbnail; + + /** + * @Assert\Image( + * mimeTypes={ + * "image/jpeg", + * "image/png" + * }, + * maxSize="1M", + * maxWidth="1000", + * maxHeight="500" + * ) + * @UploadableField(filename="thumbnail", path="posts") + */ + private $thumbnailFile; + /** * @var User * @@ -173,6 +203,32 @@ public function setPublishedAt(\DateTime $publishedAt): void $this->publishedAt = $publishedAt; } + public function getThumbnail(): ?string + { + return $this->thumbnail; + } + + public function setThumbnail(string $thumbnail): void + { + $this->thumbnail = $thumbnail; + } + + public function getThumbnailFile(): ?File + { + return $this->thumbnailFile; + } + + public function setThumbnailFile(File $thumbnailFile): void + { + $this->thumbnailFile = $thumbnailFile; + + // the thumbnailFile field not being mapped with ORM, we force the change of a property that is mapped + // so that the change of this field is taken into account when updating the entity. + if (null !== $thumbnailFile) { + $this->updatedAt = new \DateTime(); + } + } + public function getAuthor(): ?User { return $this->author; diff --git a/src/Entity/Traits/TimestampableEntityTrait.php b/src/Entity/Traits/TimestampableEntityTrait.php new file mode 100644 index 000000000..c26b687e2 --- /dev/null +++ b/src/Entity/Traits/TimestampableEntityTrait.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity\Traits; + +use Doctrine\ORM\Mapping as ORM; + +/** + * This trait allow us to add methods needed by the TimestampableEntityInterface. + * By doing son we don't have to redefine the methods necessary for the + * TimestampableEntityInterface interface in each entity. + * + * @author Romain Monteil + */ +trait TimestampableEntityTrait +{ + /** + * @var \DateTimeInterface|null + * + * @ORM\Column( + * name="created_at", + * type="datetime", + * nullable=true + * ) + */ + private $createdAt; + + /** + * @var \DateTimeInterface|null + * + * @ORM\Column( + * name="updated_at", + * type="datetime", + * nullable=true + * ) + */ + private $updatedAt; + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + /** + * @ORM\PrePersist + */ + public function setCreatedAt(): self + { + $this->createdAt = new \DateTime(); + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + /** + * @ORM\PrePersist + * @ORM\PreUpdate + */ + public function setUpdatedAt(): self + { + $this->updatedAt = new \DateTime(); + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 3a4beecf7..58e28d3da 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -11,13 +11,20 @@ namespace App\Entity; +use App\Annotation\Uploadable; +use App\Annotation\UploadableField; +use App\Entity\Interfaces\TimestampableEntityInterface; +use App\Entity\Traits\TimestampableEntityTrait; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints as Assert; /** * @ORM\Entity(repositoryClass="App\Repository\UserRepository") * @ORM\Table(name="symfony_demo_user") + * @ORM\HasLifecycleCallbacks() + * @Uploadable() * * Defines the properties of the User entity to represent the application users. * See https://symfony.com/doc/current/book/doctrine.html#creating-an-entity-class @@ -28,8 +35,10 @@ * @author Ryan Weaver * @author Javier Eguiluz */ -class User implements UserInterface, \Serializable +class User implements UserInterface, TimestampableEntityInterface, \Serializable { + use TimestampableEntityTrait; + /** * @var int * @@ -71,6 +80,27 @@ class User implements UserInterface, \Serializable */ private $password; + /** + * @var string + * + * @ORM\Column(type="string") + */ + private $avatar; + + /** + * @Assert\Image( + * mimeTypes={ + * "image/jpeg", + * "image/png" + * }, + * maxSize="500k", + * maxWidth="500", + * maxHeight="500" + * ) + * @UploadableField(filename="avatar", path="users") + */ + private $avatarFile; + /** * @var array * @@ -123,6 +153,32 @@ public function setPassword(string $password): void $this->password = $password; } + public function getAvatar(): ?string + { + return $this->avatar; + } + + public function setAvatar(string $avatar) + { + $this->avatar = $avatar; + } + + public function getAvatarFile(): ?File + { + return $this->avatarFile; + } + + public function setAvatarFile(?File $avatarFile) + { + $this->avatarFile = $avatarFile; + + // the avatarFile field not being mapped with ORM, we force the change of a property that is mapped + // so that the change of this field is taken into account when updating the entity. + if (null !== $avatarFile) { + $this->updatedAt = new \DateTime(); + } + } + /** * Returns the roles or permissions granted to the user for security. */ diff --git a/src/EventSubscriber/UploadSubscriber.php b/src/EventSubscriber/UploadSubscriber.php new file mode 100644 index 000000000..9c7f0301b --- /dev/null +++ b/src/EventSubscriber/UploadSubscriber.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\EventSubscriber; + +use App\Annotation\UploadableReader; +use App\Utils\FileUploader; +use Doctrine\Common\EventSubscriber; +use Doctrine\Common\Persistence\Event\LifecycleEventArgs; + +/** + * Class UploadSubscriber. + * + * @author Romain Monteil + */ +class UploadSubscriber implements EventSubscriber +{ + private $reader; + + private $uploader; + + public function __construct(UploadableReader $reader, FileUploader $uploader) + { + $this->reader = $reader; + $this->uploader = $uploader; + } + + /** + * Returns an array of events this subscriber wants to listen to. + * + * @return string[] + */ + public function getSubscribedEvents(): array + { + return [ + 'prePersist', + 'preUpdate', + 'postLoad', + 'postRemove', + ]; + } + + public function prePersist(LifecycleEventArgs $args): void + { + $this->preEvent($args); + } + + public function preUpdate(LifecycleEventArgs $args): void + { + $this->preEvent($args); + } + + public function postLoad(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + foreach ($this->reader->getUploadableFields($entity) as $property => $annotation) { + $this->uploader->setFileFromFilename($entity, $property, $annotation); + } + } + + public function postRemove(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + foreach ($this->reader->getUploadableFields($entity) as $property => $annotation) { + $this->uploader->removeFile($entity, $annotation); + } + } + + private function preEvent(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + foreach ($this->reader->getUploadableFields($entity) as $property => $annotation) { + $this->uploader->uploadFile($entity, $property, $annotation); + } + } +} diff --git a/src/Form/PostType.php b/src/Form/PostType.php index 0abd4a058..04fc15a73 100644 --- a/src/Form/PostType.php +++ b/src/Form/PostType.php @@ -15,6 +15,7 @@ use App\Form\Type\DateTimePickerType; use App\Form\Type\TagsInputType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -64,6 +65,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'label.tags', 'required' => false, ]) + ->add('thumbnail_file', FileType::class, [ + 'label' => 'label.thumbnail', + 'help' => 'help.post_thumbnail', + 'required' => false, + ]) ; } diff --git a/src/Form/UserType.php b/src/Form/UserType.php index 835d6ad57..bf7d62925 100644 --- a/src/Form/UserType.php +++ b/src/Form/UserType.php @@ -14,6 +14,7 @@ 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\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -50,6 +51,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('email', EmailType::class, [ 'label' => 'label.email', ]) + ->add('avatar_file', FileType::class, [ + 'label' => 'label.avatar', + 'help' => 'help.user_avatar', + 'required' => false, + ]) ; } diff --git a/src/Utils/FileUploader.php b/src/Utils/FileUploader.php new file mode 100644 index 000000000..43804eccb --- /dev/null +++ b/src/Utils/FileUploader.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Utils; + +use App\Annotation\UploadableField; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\PropertyAccess\PropertyAccess; + +/** + * Class FileUploader. + * + * @author Romain Monteil + */ +class FileUploader +{ + private $uploadPath; + + private $accessor; + + public function __construct(string $uploadPath) + { + $this->uploadPath = $uploadPath; + $this->accessor = PropertyAccess::createPropertyAccessor(); + } + + public function uploadFile($entity, string $property, UploadableField $annotation): void + { + $uploadedFile = $this->accessor->getValue($entity, $property); + if ($uploadedFile instanceof UploadedFile) { + $this->removeFile($entity, $annotation); + + $filename = $this->generateUniqueFileName().'.'.$uploadedFile->guessExtension(); + + $file = $uploadedFile->move($this->getTargetDirectory($annotation), $filename); + $this->accessor->setValue($entity, $annotation->getFilename(), $file->getFilename()); + } elseif ($uploadedFile instanceof File) { + $this->accessor->setValue($entity, $annotation->getFilename(), $uploadedFile->getFilename()); + } + } + + public function setFileFromFilename($entity, string $property, UploadableField $annotation): void + { + $file = $this->getFileFromFilename($entity, $annotation); + if ($file instanceof File) { + $this->accessor->setValue($entity, $property, $file); + } + } + + public function removeFile($entity, UploadableField $annotation): void + { + $file = $this->getFileFromFilename($entity, $annotation); + if ($file instanceof File) { + unlink($file->getRealPath()); + } + } + + public function getTargetDirectory(UploadableField $annotation): string + { + return $this->uploadPath.\DIRECTORY_SEPARATOR.$annotation->getPath(); + } + + private function generateUniqueFileName(): string + { + // md5() reduces the similarity of the file names generated by uniqid(), which is based on timestamps + return md5(uniqid('', true)); + } + + private function getFileFromFilename($entity, UploadableField $annotation): ?File + { + $filename = $this->accessor->getValue($entity, $annotation->getFilename()); + if (!empty($filename)) { + $filePath = $this->getTargetDirectory($annotation).\DIRECTORY_SEPARATOR.$filename; + if (is_file($filePath)) { + return new File($filePath); + } + } + + return null; + } +} diff --git a/templates/admin/blog/show.html.twig b/templates/admin/blog/show.html.twig index 93fc3c94b..110a414cb 100644 --- a/templates/admin/blog/show.html.twig +++ b/templates/admin/blog/show.html.twig @@ -3,6 +3,10 @@ {% block body_id 'admin_post_show' %} {% block main %} + {% if post.thumbnail is defined and post.thumbnail is not empty %} + + {% endif %} +

{{ post.title }}

-

- - {{ post.title }} - -

+ + {% if post.thumbnail is defined and post.thumbnail is not empty %} + + {% endif %} + +

{{ post.title }}

+

{{ post.title }}

{{ 'title.edit_user'|trans }}

{{ form_start(form) }} - {{ form_widget(form) }} + {{ form_errors(form) }} + + {{ form_row(form.username) }} + {{ form_row(form.fullName) }} + {{ form_row(form.email) }} + +
+ {% if app.user.avatar is defined and app.user.avatar is not empty %} +
+ {{ 'label.avatar'|trans }} +
+ {% endif %} + {{ form_label(form.avatar_file) }} + {{ form_widget(form.avatar_file) }} + {{ form_help(form.avatar_file) }} +