├── .env.dist ├── .gitignore ├── README.md ├── articles ├── 1. BOOTSTRAP.md ├── 2. BOOTSTRAP 2.md └── sitepoint-gallery-blog-1.png ├── bin ├── console └── refreshDb.sh ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── dev │ │ ├── monolog.yaml │ │ └── routing.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── prod │ │ ├── doctrine.yaml │ │ └── monolog.yaml │ ├── ramsey_uuid_doctrine.yaml │ ├── routing.yaml │ ├── security.yaml │ ├── test │ │ └── framework.yaml │ ├── translation.yaml │ └── twig.yaml ├── routes.yaml ├── routes │ ├── annotations.yaml │ └── dev │ │ └── twig.yaml └── services.yaml ├── phpunit.xml.dist ├── public ├── assets │ ├── main.css │ ├── main.css.map │ └── main.scss ├── favicon.ico └── index.php ├── scripts ├── galleries.txt ├── lazy-load-urls.txt ├── setup-supervisor.sh ├── test-homepage.sh └── test-single-gallery.sh ├── src ├── Command │ ├── GenerateGalleryImageThumbnailsCommand.php │ ├── ResizeImageWorkerCommand.php │ └── TestJobCommand.php ├── Controller │ ├── .gitignore │ ├── EditGalleryController.php │ ├── EditImageController.php │ ├── GalleryController.php │ ├── HomeController.php │ ├── ImageController.php │ ├── RegistrationController.php │ ├── SecurityController.php │ └── UploadController.php ├── DataFixtures │ └── ORM │ │ ├── .gitignore │ │ ├── LoadGalleriesData.php │ │ └── LoadUsersData.php ├── Entity │ ├── .gitignore │ ├── Gallery.php │ ├── Image.php │ └── User.php ├── Event │ └── GalleryCreatedEvent.php ├── Form │ ├── EditGalleryType.php │ ├── EditImageType.php │ ├── MarkdownType.php │ └── RegistrationFormType.php ├── Kernel.php ├── Migrations │ └── .gitignore ├── Repository │ ├── .gitignore │ └── GalleryRepository.php ├── Service │ ├── FileManager.php │ ├── GalleryEventSubscriber.php │ ├── ImageResizer.php │ ├── JobQueueFactory.php │ └── UserManager.php ├── Twig │ ├── ImageRendererExtension.php │ ├── MarkdownExtension.php │ └── SingleGalleryPageModulesTwigExtension.php └── Validation │ ├── AvailableEmailConstraint.php │ └── AvailableEmailConstraintValidator.php ├── symfony.lock ├── templates ├── base.html.twig ├── gallery │ ├── edit-gallery.html.twig │ ├── partials │ │ ├── _newest-galleries.html.twig │ │ ├── _related-galleries.html.twig │ │ └── gallery-list-item.html.twig │ ├── single-gallery.html.twig │ └── upload.html.twig ├── home.html.twig ├── image │ └── edit-image.html.twig ├── partials │ ├── header.html.twig │ └── home-galleries-lazy-load.html.twig └── security │ ├── login.html.twig │ └── registration.html.twig ├── tests ├── .gitignore └── SmokeTest.php ├── translations └── .gitignore └── var ├── demo-data └── sample-images │ ├── image1.jpeg │ ├── image10.jpeg │ ├── image11.jpeg │ ├── image12.jpg │ ├── image13.jpeg │ ├── image14.jpeg │ ├── image15.jpeg │ ├── image2.jpeg │ ├── image3.jpeg │ ├── image4.jpeg │ ├── image5.jpeg │ ├── image6.jpeg │ ├── image7.jpeg │ ├── image8.jpeg │ └── image9.jpeg └── placeholder.jpg /.env.dist: -------------------------------------------------------------------------------- 1 | # This file is a "template" of which env vars need to be defined for your application 2 | # Copy this file to .env file for development, create environment variables when deploying to production 3 | # https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration 4 | 5 | ###> symfony/framework-bundle ### 6 | APP_ENV=dev 7 | APP_DEBUG=1 8 | APP_SECRET=70e889541a109fb64cf757b4a69db191 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> doctrine/doctrine-bundle ### 12 | # Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 13 | # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" 14 | # Set "serverVersion" to your server version to avoid edge-case exceptions and extra database calls 15 | DATABASE_URL="mysql://homestead:secret@127.0.0.1:3306/blog?charset=utf8mb4&serverVersion=5.7" 16 | ###< doctrine/doctrine-bundle ### 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | .env 3 | /public/bundles/ 4 | /var/* 5 | !/var/placeholder.jpg 6 | !/var/demo-data/ 7 | !/var/demo-data/sample-images/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | /.idea 12 | /.DS_Store 13 | /**/.DS_Store 14 | 15 | ###> phpunit/phpunit ### 16 | /phpunit.xml 17 | ###< phpunit/phpunit ### 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # multi-image-gallery-blog 2 | -------------------------------------------------------------------------------- /articles/1. BOOTSTRAP.md: -------------------------------------------------------------------------------- 1 | # Bootstrapping a New Project 2 | 3 | Now and then you have to create a new project repository, run that `git init` command locally and kickoff a new awesome project. 4 | I have to admit I like the feeling of starting something new; it's like going on an adventure! 5 | 6 | Lao Tzu said: 7 | > The journey of a thousand miles begins with one step 8 | 9 | We can think about the project setup as the very first step of our thousand miles (users!) journey. We aren't sure where exactly 10 | we are going to end, but it will be fun! 11 | 12 | We also should keep in mind advice from prof. Donald Knuth: 13 | 14 | > Premature optimization is the root of all evil (or at least most of it) in programming. 15 | 16 | Our journey towards stable and robust, high-performance web app will start with the simple but functional application. 17 | We will populate the database with random content, do some benchmarks and improve performances incrementally. Every article 18 | in this series will be a checkpoint on our journey! 19 | 20 | This article will cover basics of setting up the project and organizing files for Symfony Flex project. 21 | I will also show you some tips, tricks and helper scripts I'm using for speeding up the development. 22 | 23 | ## First things first, What are we creating anyways? 24 | 25 | Before starting any project, you should have a clear vision of the final destination. 26 | Where are you headed? Who will be using your app and how? What are the main features you're building? 27 | Once you have that set, you can prepare your environment, 3rd party libraries and dive in developing the next big thing. 28 | 29 | In this series of articles, we'll be building simple image gallery blog where users can register or log in, 30 | upload images and create simple image galleries with descriptions written in markdown format. 31 | 32 | We'll be using new [Symfony Flex](https://www.sitepoint.com/symfony-flex-paving-path-faster-better-symfony/) and 33 | [Homestead](https://www.sitepoint.com/quick-tip-get-homestead-vagrant-vm-running/) 34 | (make sure you've read tutorials on them as we're not going to cover it here). 35 | All the code referenced in the article is available at [GitHub repo](https://github.com/zantolov/multi-image-gallery-blog). 36 | 37 | We are going to use Twig templating engine, Symfony forms, and Doctrine ORM with UUIDs as primary keys. 38 | Entities and routes will use annotations; we'll have simple [email/password based authentication](http://symfony.com/doc/current/security/form_login_setup.html), 39 | and we will prepare data fixtures to populate the database. 40 | 41 | After creating new project based on `symfony/skeleton` by executing command 42 | 43 | ``` 44 | composer create-project "symfony/skeleton:^3.3" multi-user-gallery-blog 45 | ``` 46 | 47 | we can require additional packages (some of them are referenced by their aliases, new feature brought by Flex): 48 | 49 | ``` 50 | composer req annotations security orm template asset validator ramsey/uuid-doctrine 51 | ``` 52 | 53 | Dependencies used only in the dev environment are required with `--dev` flag: 54 | 55 | ``` 56 | composer req --dev fzaninotto/faker doctrine/Doctrine-Fixtures-Bundle 57 | ``` 58 | 59 | Because of some of our dependencies we have to set minimum stability to `dev` in our `composer.json`. 60 | 61 | Flex is doing some serious work for us behind the scenes, and most of the libraries (or bundles) are already registered and configured 62 | with good-enough defaults! Check `config` directory for yourself. You can check all dependencies used in this project 63 | in [composer.json](https://github.com/zantolov/multi-image-gallery-blog/blob/master/composer.json) file. 64 | 65 | Routes are defined by annotations so we need to add following code to our `config/routes.yaml`: 66 | 67 | ``` 68 | controllers: 69 | resource: ../src/Controller/ 70 | type: annotation 71 | ``` 72 | 73 | ## Database, Scripts, and Fixtures 74 | 75 | Configure `DATABASE_URL` environment variable (e.g. by editing `.env` file) to setup working DB connection. 76 | DB schema can be generated from existing entities by executing: 77 | 78 | ``` 79 | ./bin/console doctrine:schema:create 80 | ``` 81 | 82 | If everything is OK, you should be able to see freshly created tables in the database 83 | (for [Gallery](https://github.com/zantolov/multi-image-gallery-blog/blob/master/src/Entity/Gallery.php), 84 | [Image](https://github.com/zantolov/multi-image-gallery-blog/blob/master/src/Entity/Image.php) and 85 | [User](https://github.com/zantolov/multi-image-gallery-blog/blob/master/src/Entity/User.php) entities). 86 | 87 | If you want to drop database schema you can run: 88 | 89 | ``` 90 | ./bin/console doctrine:schema:drop --full-database --force 91 | ``` 92 | 93 | ### Fake it until you make it! 94 | 95 | I can't imagine developing an app today without having data fixtures (i.e., scripts for seeding the DB). 96 | With few simple scripts, you can populate your database with realistic content, which is useful 97 | when it comes to rapid app development and testing, but it's also a requirement for a healthy CI pipeline. 98 | 99 | I find [Doctrine Fixtures Bundle](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html) excellent 100 | tool for handling data fixtures as it supports ordered fixtures (i.e., you can control the order of execution), 101 | sharing objects (via references) between scripts and accessing service container. 102 | 103 | Default Symfony services configuration doesn't allow public access to services as best practice is to inject all dependencies. 104 | We'll need some services in our fixtures so I'm going to make all services in `App\Services` publicly available by adding: 105 | 106 | ``` 107 | App\Service\: 108 | resource: '../src/Service/*' 109 | public: true 110 | ``` 111 | 112 | to `config/services.yaml`. 113 | 114 | I'm also using [Faker](https://github.com/fzaninotto/Faker) to get random but realistic data (names, sentences, texts, images, addresses, ... ). 115 | Take a look at the [script for seeding galleries ](https://github.com/zantolov/multi-image-gallery-blog/blob/master/src/DataFixtures/ORM/LoadGalleriesData.php) 116 | with random images to get a feeling how cool this combination is. 117 | 118 | Usually, I combine commands for dropping existing DB schema, creating new DB schema, loading data 119 | fixtures and other repetitive tasks in a single shell script e.g. `bin/refreshDb.sh` so I can easily 120 | re-generate DB schema and load dummy data: 121 | 122 | ``` 123 | # Drop schema 124 | ./bin/console doctrine:schema:drop --full-database --force 125 | 126 | # Create schema 127 | ./bin/console doctrine:schema:create 128 | 129 | # Load fixtures 130 | ./bin/console doctrine:fixtures:load -n --fixtures src/DataFixtures/ORM 131 | 132 | # Install assets 133 | ./bin/console assets:install --symlink 134 | 135 | # Clear cache 136 | ./bin/console cache:clear 137 | ``` 138 | 139 | Make sure you restrict execution of this script on production, or you are going to have serious fun at some point. 140 | 141 | After executing `bin/refreshDb.sh` you should be able to see the homepage of our blog: 142 | 143 | ![Project homepage](sitepoint-gallery-blog-1.png) 144 | 145 | *Project homepage after fixtures are loaded* 146 | 147 | One can argue that randomly generated data can't reproduce different edge cases so your CI can sometimes fail or pass 148 | depending on the data generation. It is true, and you should make sure all edge cases are covered with your fixtures. 149 | Every time you find an edge case causing a bug, make sure you add it to data fixtures. This will help you to build 150 | more robust system and prevent similar errors in the future. 151 | 152 | ## Controllers, Templates and Services 153 | 154 | Controller classes are located under `src\Controller` directory and are not extending existing `Controller` class provided by 155 | `FrameworkBundle`. 156 | All dependencies are injected through constructor making the code less coupled and easier to test. 157 | I don't like 'magic' for simple stuff! 158 | 159 | In the new Symfony directory structure, templates are located under `templates` directory. 160 | I've created master template `base.html.twig` that defines basic HTML structure and references external resources. 161 | Other templates are extending it and overriding its blocks (e.g. stylesheets, body, header, content, javascripts and other blocks). 162 | Local assets are referenced by using `asset` Twig function as it will enable us better control later (e.g. changing hosts and appending query strings for versioning). 163 | Other templates are organized in subdirectories within `src/templates` directory. 164 | 165 | Services are automatically registered and configured by default Symfony service configuration. 166 | That way it isn't needed to manually configure Twig extensions with filters for markdown support and generating URL 167 | for Image entity located under `src/Twig` directory. 168 | 169 | ## Source Code Management / Version Control 170 | 171 | Hopefully, you already know about [Git basics](https://www.sitepoint.com/git-for-beginners/) and understand how `.gitignore` works. 172 | 173 | Symfony Flex manages default project `.gitignore` by adding known bundle files and folders to the ignore list. For example, 174 | Flex would add following files to the ignore list for `symfony/framework-bundle` bundle: 175 | 176 | ``` 177 | .env 178 | /public/bundles/ 179 | /var/ 180 | /vendor/ 181 | ``` 182 | 183 | We are going to store uploaded files in `var/uploads` so we need it created first. 184 | Instead of doing it manually on every project setup, we can add command for creating all needed directories to `post-install-cmd` event in 185 | our `composer.json` scripts section and make Composer run it for us (e.g. `"mkdir -p var/uploads"`). Read more about composer scripts 186 | [here](https://getcomposer.org/doc/articles/scripts.md) and 187 | auto-scripts [here](http://fabien.potencier.org/symfony4-workflow-automation.html) 188 | 189 | We should add all other directories and files we want to be ignored to `.gitignore` but outside of commented section managed by Flex. 190 | 191 | ## Tips 192 | - Check your `Homestead.yaml` for environment variables definition. If you already have `APP_ENV` variable, 193 | Symfony won't try to load one from the `.env` file 194 | - Cache clear within your Vagrant machine can fail because of permissions. You can simply run `sudo rm -rf var/cache/*` 195 | to clear cache manually. 196 | 197 | ## Stay tuned 198 | In this article, we had successfully set a project with entities, controllers, templates and helper scripts. 199 | The database is populated with dummy content, and users are able to register and create their galleries. 200 | 201 | In the next article, I will demonstrate how to populate your database with a more massive dataset to test app's performance in a 202 | more realistic environment, how to set up a simple testing suite and simple CI based on Docker. 203 | -------------------------------------------------------------------------------- /articles/2. BOOTSTRAP 2.md: -------------------------------------------------------------------------------- 1 | # Bootstrapping a New Project - Part 2 2 | 3 | In my [previous article](#), I've demonstrated how to set up a Symfony project from scratch with Flex, 4 | create a simple set of fixtures and get the project up and running. 5 | Next step on our journey is populating the database with a somewhat realistic amount of data 6 | to test application performance. 7 | 8 | As a bonus, I will demonstrate how to setup simple PHPUnit test suite with basic [smoke tests](https://en.wikipedia.org/wiki/Smoke_testing_(software)). 9 | 10 | ## More Fake Data 11 | 12 | Once your entities are polished, and you had "That's it! I'm done!" moment, it's a perfect time for 13 | creating a more significant dataset that can be used for further testing and preparing your app for production. 14 | 15 | Simple fixtures as ones I've created in the previous article are great for the development phase where 16 | loading ~30 entities is done quickly, and it can often be repeated while changing the DB schema. 17 | 18 | Testing app performance, simulating real-world traffic and detecting bottlenecks requires bigger dataset 19 | (i.e., larger amoung of database entries and image files for this project). 20 | Generating thousands of entries takes some time (and computer resources), so I want to do it only once. 21 | 22 | I could try to increase the `COUNT` constant in our fixture classes and see what will happen: 23 | 24 | ``` 25 | // src/DataFixtures/ORM/LoadUsersData.php 26 | class LoadUsersData extends AbstractFixture implements ContainerAwareInterface, OrderedFixtureInterface 27 | { 28 | const COUNT = 500; 29 | ... 30 | } 31 | 32 | // src/DataFixtures/ORM/LoadGalleriesData.php 33 | class LoadGalleriesData extends AbstractFixture implements ContainerAwareInterface, OrderedFixtureInterface 34 | { 35 | const COUNT = 1000; 36 | ... 37 | } 38 | 39 | ``` 40 | 41 | Now, if you run `./bin/refreshDb.sh`, after some time of execution you'll probably get a not-so-nice message `PHP Fatal error: Allowed memory size of N bytes exhausted`. 42 | 43 | Apart from slow execution, every error would result in an empty database because EntityManager is flushed only at the very end of the fixture class. 44 | Additionally, Faker is downloading a random image for every gallery entry. 45 | For 1.000 galleries with 5 to 10 images per gallery that would be 5.000 - 10.000 downloads which is really slow. 46 | 47 | There are excellent resources on optimizing [Doctrine](http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/batch-processing.html) and [Symfony](https://groups.google.com/forum/#!topic/symfony-devs/0Ez-TpsC3I0) 48 | for batch processing, and I'm going to use some of these tips to optimize fixtures loading. 49 | 50 | First, I'll define a batch size of 100 galleries. 51 | After every batch, I'll flush and clear the `EntityManager` (i.e., detach persisted entities) and tell garbage collector to do its job. 52 | 53 | To track progress, I will print out some meta information (batch identifier and memory usage). 54 | 55 | **Note:** After calling `$manager->clear()` all persisted entities are now unmanaged. 56 | Entity manager doesn't know about them anymore, and you'll probably get an "entity-not-persisted" error. 57 | The key is to merge entity back to the manager `$entity = $manager->merge($entity);` 58 | 59 | Without the optimization, memory usage is increasing while running a `LoadGalleriesData` fixture class: 60 | ``` 61 | > loading [200] App\DataFixtures\ORM\LoadGalleriesData 62 | 100 Memory usage (currently) 24MB / (max) 24MB 63 | 200 Memory usage (currently) 26MB / (max) 26MB 64 | 300 Memory usage (currently) 28MB / (max) 28MB 65 | 400 Memory usage (currently) 30MB / (max) 30MB 66 | 500 Memory usage (currently) 32MB / (max) 32MB 67 | 600 Memory usage (currently) 34MB / (max) 34MB 68 | 700 Memory usage (currently) 36MB / (max) 36MB 69 | 800 Memory usage (currently) 38MB / (max) 38MB 70 | 900 Memory usage (currently) 40MB / (max) 40MB 71 | 1000 Memory usage (currently) 42MB / (max) 42MB 72 | ``` 73 | 74 | Memory usage starts at 24 MB and increases for 2 MB for every batch (100 galleries). 75 | If I tried to load 100.000 galleries, I'd need 24 MB + 999 (999 batches of 100 galleries, 99900 galleries) * 2 MB = **~2 GB of memory**. 76 | 77 | After adding `$manager->flush()` and `gc_collect_cycles()` for every batch, removing 78 | SQL logging with `$manager->getConnection()->getConfiguration()->setSQLLogger(null)` and 79 | removing entity references by commenting out `$this->addReference('gallery' . $i, $gallery);`, 80 | memory usage became somewhat constant for every batch. 81 | 82 | ``` 83 | // Define batch size outside of the for loop 84 | $batchSize = 100; 85 | 86 | ... 87 | 88 | for ($i = 1; $i <= self::COUNT; $i++) { 89 | ... 90 | 91 | // Save the batch at the end of the for loop 92 | if (($i % $batchSize) == 0 || $i == self::COUNT) { 93 | $currentMemoryUsage = round(memory_get_usage(true) / 1024); 94 | $maxMemoryUsage = round(memory_get_peak_usage(true) / 1024); 95 | echo sprintf("%s Memory usage (currently) %dKB/ (max) %dKB \n", $i, $currentMemoryUsage, $maxMemoryUsage); 96 | 97 | $manager->flush(); 98 | $manager->clear(); 99 | 100 | // here you should merge entities you're re-using with the $manager 101 | // because they aren't managed anymore after calling $manager->clear(); 102 | // e.g. if you've already loaded category or tag entities 103 | // $category = $manager->merge($category); 104 | 105 | gc_collect_cycles(); 106 | } 107 | } 108 | 109 | ``` 110 | 111 | As expected, memory usage is now stable: 112 | 113 | ``` 114 | > loading [200] App\DataFixtures\ORM\LoadGalleriesData 115 | 100 Memory usage (currently) 24MB / (max) 24MB 116 | 200 Memory usage (currently) 26MB / (max) 28MB 117 | 300 Memory usage (currently) 26MB / (max) 28MB 118 | 400 Memory usage (currently) 26MB / (max) 28MB 119 | 500 Memory usage (currently) 26MB / (max) 28MB 120 | 600 Memory usage (currently) 26MB / (max) 28MB 121 | 700 Memory usage (currently) 26MB / (max) 28MB 122 | 800 Memory usage (currently) 26MB / (max) 28MB 123 | 900 Memory usage (currently) 26MB / (max) 28MB 124 | 1000 Memory usage (currently) 26MB / (max) 28MB 125 | ``` 126 | 127 | Instead of downloading random images every time I can prepare 15 random images and update fixture script to 128 | randomly choose one of them instead of using Faker's `$faker->image()` method. 129 | 130 | I'm going to take 15 images from [Unsplash](https://unsplash.com) and save them in `var/demo-data/sample-images`. 131 | Then I will update the `LoadGalleriesData::generateRandomImage` method: 132 | 133 | ``` 134 | private function generateRandomImage($imageName) 135 | { 136 | $images = [ 137 | 'image1.jpeg', 138 | 'image10.jpeg', 139 | 'image11.jpeg', 140 | 'image12.jpg', 141 | 'image13.jpeg', 142 | 'image14.jpeg', 143 | 'image15.jpeg', 144 | 'image2.jpeg', 145 | 'image3.jpeg', 146 | 'image4.jpeg', 147 | 'image5.jpeg', 148 | 'image6.jpeg', 149 | 'image7.jpeg', 150 | 'image8.jpeg', 151 | 'image9.jpeg', 152 | ]; 153 | 154 | $sourceDirectory = $this->container->getParameter('kernel.project_dir') . '/var/demo-data/sample-images/'; 155 | $targetDirectory = $this->container->getParameter('kernel.project_dir') . '/var/uploads/'; 156 | 157 | $randomImage = $images[rand(0, count($images) - 1)]; 158 | $randomImageSourceFilePath = $sourceDirectory . $randomImage; 159 | $randomImageExtension = explode('.', $randomImage)[1]; 160 | $targetImageFilename = sha1(microtime() . rand()) . '.' . $randomImageExtension; 161 | copy($randomImageSourceFilePath, $targetDirectory . $targetImageFilename); 162 | 163 | $image = new Image( 164 | Uuid::getFactory()->uuid4(), 165 | $randomImage, 166 | $targetImageFilename 167 | ); 168 | 169 | return $image; 170 | } 171 | ``` 172 | 173 | It's a good idea to remove old files in `var/uploads` when reloading fixtures so I'm adding `rm var/uploads/*` command to `bin/refreshDb.sh` script, 174 | immediately after dropping the DB schema. 175 | 176 | Loading 500 users and 1000 galleries now takes ~7 minutes and ~28 MB of memory (peak usage). 177 | 178 | ``` 179 | Dropping database schema... 180 | Database schema dropped successfully! 181 | ATTENTION: This operation should not be executed in a production environment. 182 | 183 | Creating database schema... 184 | Database schema created successfully! 185 | > purging database 186 | > loading [100] App\DataFixtures\ORM\LoadUsersData 187 | 300 Memory usage (currently) 10MB / (max) 10MB 188 | 500 Memory usage (currently) 12MB / (max) 12MB 189 | > loading [200] App\DataFixtures\ORM\LoadGalleriesData 190 | 100 Memory usage (currently) 24MB / (max) 26MB 191 | 200 Memory usage (currently) 26MB / (max) 28MB 192 | 300 Memory usage (currently) 26MB / (max) 28MB 193 | 400 Memory usage (currently) 26MB / (max) 28MB 194 | 500 Memory usage (currently) 26MB / (max) 28MB 195 | 600 Memory usage (currently) 26MB / (max) 28MB 196 | 700 Memory usage (currently) 26MB / (max) 28MB 197 | 800 Memory usage (currently) 26MB / (max) 28MB 198 | 900 Memory usage (currently) 26MB / (max) 28MB 199 | 1000 Memory usage (currently) 26MB / (max) 28MB 200 | ``` 201 | 202 | ## Performance 203 | 204 | Homepage rendering became very slow, way too slow for production. 205 | The user can feel that app is struggling to deliver the page, probably because the app is 206 | rendering all galleries instead of a limited number. 207 | 208 | Instead of rendering all galleries at once, I could update the app to render only first 12 galleries immediately and introduce lazy load. 209 | When the user scrolls to the end of the screen, the app will fetch next 12 galleries and present them to the user. 210 | 211 | ### Performance tests 212 | To track performance optimization, I need to establish fixed set of tests that will be used to test and benchmark 213 | performance improvements relatively. 214 | 215 | I will use Siege for load testing. Here you can find more about [Siege and performance testing](https://www.sitepoint.com/web-app-performance-testing-siege-plan-test-learn/). 216 | Instead of installing Siege on my machine I will utilize [Docker](https://www.docker.com/) - a powerful container platform. 217 | Simplified, Docker containers are similar to virtual machines ([but they aren't the same thing](https://www.docker.com/what-container#comparing)). 218 | Except for building and deploying apps, Docker can be used to experiment with applications without actually installing them on your local machine. 219 | You can build your images or use images available on [Docker Hub](https://hub.docker.com/), a public registry of Docker images. 220 | It's especially useful when you want to experiment with different versions of the same software (e.g., different versions of PHP). 221 | 222 | I will use [yokogawa/siege](https://hub.docker.com/r/) image to test the app. 223 | 224 | #### Testing The Homepage 225 | 226 | Testing the homepage is not trivial since there are AJAX requests executed 227 | only when the user scrolls to the end of the page. 228 | 229 | I expect all users to land on the homepage (i.e., 100%). 230 | I guess that 50% of them would scroll down to the end and therefore request the second page of galleries. 231 | Furthermore, I guess that 30% of them would load the 3rd page, 232 | 15% would request 4th page, and 233 | 5% would request 5th page. 234 | 235 | These numbers are based on predictions, and it would be much better if I could take a 236 | look at analytics tool and get a better insight in users' behavior, but that's impossible for a brand new app. 237 | However, it's a good idea to take a look at analytics data now and then and adjust your test suite after the initial deploy. 238 | 239 | I will test the homepage (and lazy load URLs) with two tests running in parallel. 240 | First one will be testing the homepage URL only, while another one will test 241 | lazy-load endpoint URLs. 242 | 243 | File `lazy-load-urls.txt` contains a randomized list of lazily loaded pages URLs 244 | in predicted ratios: 245 | 246 | - 10 URLs for the 2nd page (50%) 247 | - 6 URLs for 3rd page (30%) 248 | - 3 URLs for 4th page (15%) 249 | - 1 URLs for 5th page (5%) 250 | 251 | ``` 252 | http://blog.app/galleries-lazy-load?page=2 253 | http://blog.app/galleries-lazy-load?page=2 254 | http://blog.app/galleries-lazy-load?page=2 255 | http://blog.app/galleries-lazy-load?page=4 256 | http://blog.app/galleries-lazy-load?page=2 257 | http://blog.app/galleries-lazy-load?page=2 258 | http://blog.app/galleries-lazy-load?page=3 259 | http://blog.app/galleries-lazy-load?page=2 260 | http://blog.app/galleries-lazy-load?page=2 261 | http://blog.app/galleries-lazy-load?page=4 262 | http://blog.app/galleries-lazy-load?page=2 263 | http://blog.app/galleries-lazy-load?page=4 264 | http://blog.app/galleries-lazy-load?page=2 265 | http://blog.app/galleries-lazy-load?page=3 266 | http://blog.app/galleries-lazy-load?page=3 267 | http://blog.app/galleries-lazy-load?page=3 268 | http://blog.app/galleries-lazy-load?page=5 269 | http://blog.app/galleries-lazy-load?page=3 270 | http://blog.app/galleries-lazy-load?page=2 271 | http://blog.app/galleries-lazy-load?page=3 272 | ``` 273 | 274 | The script for testing homepage performance will run 2 Siege processes in parallel, 275 | one against homepage and another one against a generated list of URLs. 276 | 277 | To execute a single HTTP request with Siege (in Docker), run: 278 | ``` 279 | docker run --rm -t yokogawa/siege -c1 -r1 blog.app 280 | ``` 281 | 282 | **Note:** If you aren't using Docker, you can omit the `docker run --rm -t yokogawa/siege` part and run Siege with the same arguments. 283 | 284 | To run a 1-minute test with 50 concurrent users against homepage with a 1-second delay, execute: 285 | ``` 286 | docker run --rm -t yokogawa/siege -d1 -c50 -t1M http://blog.app 287 | ``` 288 | 289 | To run a 1-minute test with 50 concurrent users against URLs in `lazy-load-urls.txt`, execute: 290 | ``` 291 | docker run --rm -v `pwd`:/var/siege:ro -t yokogawa/siege -i --file=/var/siege/lazy-load-urls.txt -d1 -c50 -t1M 292 | ``` 293 | from the directory where your `lazy-load-urls.txt` is located (that directory will be mounted as a read-only volume in Docker). 294 | 295 | Running a script `test-homepage.sh` will start 2 Siege processes (in a way suggested by this [StackOverflow answer](https://stackoverflow.com/a/5553774)) 296 | and output results from processes. 297 | 298 | I've deployed the app on a server with Nginx and with PHP-FPM 7.1 and loaded 25.000 users and 30.000 galleries. 299 | Results from load testing the app homepage are: 300 | 301 | ``` 302 | ./test-homepage.sh 303 | 304 | Transactions: 499 hits 305 | Availability: 100.00 % 306 | Elapsed time: 59.10 secs 307 | Data transferred: 1.49 MB 308 | Response time: 4.75 secs 309 | Transaction rate: 8.44 trans/sec 310 | Throughput: 0.03 MB/sec 311 | Concurrency: 40.09 312 | Successful transactions: 499 313 | Failed transactions: 0 314 | Longest transaction: 16.47 315 | Shortest transaction: 0.17 316 | 317 | Transactions: 482 hits 318 | Availability: 100.00 % 319 | Elapsed time: 59.08 secs 320 | Data transferred: 6.01 MB 321 | Response time: 4.72 secs 322 | Transaction rate: 8.16 trans/sec 323 | Throughput: 0.10 MB/sec 324 | Concurrency: 38.49 325 | Successful transactions: 482 326 | Failed transactions: 0 327 | Longest transaction: 15.36 328 | Shortest transaction: 0.15 329 | 330 | ``` 331 | 332 | Even though app availability was 100% for both homepage and lazy-load tests, response time was ~5 seconds which 333 | is not something we'd expect from a high performance app. 334 | 335 | 336 | #### Testing a Single Gallery Page 337 | 338 | Testing a single gallery page is a little bit simpler - I will run Siege against the `galleries.txt` file 339 | where I have a list of single gallery page URLs I want to test. 340 | Run the command: 341 | 342 | ``` 343 | docker run --rm -v `pwd`:/var/siege:ro -t yokogawa/siege -i --file=/var/siege/galleries.txt -d1 -c50 -t1M 344 | ``` 345 | from the directory where the `galleries.txt` file is located (that directory will be mounted as a read-only volume in Docker). 346 | 347 | Load test results for single gallery pages are somewhat better than for the homepage: 348 | 349 | ``` 350 | ./test-single-gallery.sh 351 | ** SIEGE 3.0.5 352 | ** Preparing 50 concurrent users for battle. 353 | The server is now under siege... 354 | Lifting the server siege... done. 355 | 356 | Transactions: 3589 hits 357 | Availability: 100.00 % 358 | Elapsed time: 59.64 secs 359 | Data transferred: 11.15 MB 360 | Response time: 0.33 secs 361 | Transaction rate: 60.18 trans/sec 362 | Throughput: 0.19 MB/sec 363 | Concurrency: 19.62 364 | Successful transactions: 3589 365 | Failed transactions: 0 366 | Longest transaction: 1.25 367 | Shortest transaction: 0.10 368 | ``` 369 | 370 | ## Tests, tests, tests 371 | 372 | To make sure I'm not breaking anything with improvements I implement in the future, I need at least some tests. 373 | First, I'll require PHPUnit as a dev dependency: 374 | ``` 375 | composer req --dev phpunit 376 | ``` 377 | Then I'll create simple PHPUnit configuration by copying `phpunit.xml.dist` file created by Flex to `phpunit.xml` and 378 | update environment variables (e.g., `DATABASE_URL` variable for the test environment). 379 | Also, I'm adding `phpunit.xml` to `.gitignore`. 380 | 381 | I will create basic [functional/smoke tests](https://symfony.com/doc/current/best_practices/tests.html#functional-tests) for blog homepage and single gallery pages. 382 | These tests would only assert that URLs you provide in `SmokeTest::urlProvider()` method 383 | are resulting with a successful HTTP response code (i.e., HTTP status code is 2xx or 3xx). 384 | 385 | ## Stay tuned 386 | 387 | Upcoming articles in this series will cover details about PHP and MySQL performance optimization, improving 388 | overall performance perception and other tips and tricks for better app performance. 389 | -------------------------------------------------------------------------------- /articles/sitepoint-gallery-blog-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/articles/sitepoint-gallery-blog-1.png -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | load(__DIR__.'/../.env'); 20 | } 21 | 22 | $input = new ArgvInput(); 23 | $env = $input->getParameterOption(['--env', '-e'], $_SERVER['APP_ENV'] ?? 'dev'); 24 | $debug = ($_SERVER['APP_DEBUG'] ?? true) !== '0' && !$input->hasParameterOption(['--no-debug', '']); 25 | 26 | if ($debug) { 27 | umask(0000); 28 | 29 | if (class_exists(Debug::class)) { 30 | Debug::enable(); 31 | } 32 | } 33 | 34 | $kernel = new Kernel($env, $debug); 35 | $application = new Application($kernel); 36 | $application->run($input); 37 | -------------------------------------------------------------------------------- /bin/refreshDb.sh: -------------------------------------------------------------------------------- 1 | # Drop schema 2 | ./bin/console doctrine:schema:drop --full-database --force 3 | rm -rf var/uploads/cache/* 4 | rm var/uploads/* 5 | 6 | # Create schema 7 | ./bin/console doctrine:schema:create 8 | 9 | # Load fixtures 10 | ./bin/console doctrine:fixtures:load -n \ 11 | --fixtures src/DataFixtures/ORM 12 | 13 | # Install assets 14 | ./bin/console assets:install --symlink 15 | 16 | # Clear cache 17 | ./bin/console cache:clear -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "require": { 6 | "php": "^7.0.8", 7 | "erusev/parsedown": "^1.6", 8 | "league/glide": "^1.1", 9 | "pda/pheanstalk": "^3.1", 10 | "ramsey/uuid-doctrine": "^1.4", 11 | "sensio/framework-extra-bundle": "^4.0", 12 | "symfony/asset": "^4.0", 13 | "symfony/form": "^4.0", 14 | "symfony/framework-bundle": "^4.0", 15 | "symfony/monolog-bundle": "^3.0", 16 | "symfony/orm-pack": "^1.0", 17 | "symfony/security-bundle": "^4.0", 18 | "symfony/twig-bundle": "^4.0", 19 | "symfony/validator": "^4.0", 20 | "symfony/yaml": "^4.0" 21 | }, 22 | "require-dev": { 23 | "doctrine/doctrine-fixtures-bundle": "^2.4@dev", 24 | "fzaninotto/faker": "^1.8@dev", 25 | "phpunit/phpunit": "^6.5@dev", 26 | "symfony/debug-bundle": "^4.0", 27 | "symfony/dotenv": "^4.0", 28 | "symfony/flex": "^1.0" 29 | }, 30 | "config": { 31 | "preferred-install": { 32 | "*": "dist" 33 | }, 34 | "sort-packages": true 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "App\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "App\\Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "auto-scripts": { 48 | "assets:install --symlink --relative %PUBLIC_DIR%": "symfony-cmd", 49 | "cache:clear": "symfony-cmd" 50 | }, 51 | "post-install-cmd": [ 52 | "@auto-scripts", 53 | "mkdir -p var/uploads" 54 | ], 55 | "post-update-cmd": [ 56 | "@auto-scripts" 57 | ] 58 | }, 59 | "conflict": { 60 | "symfony/symfony": "*", 61 | "symfony/twig-bundle": "<3.3", 62 | "symfony/debug": "<3.3" 63 | }, 64 | "extra": { 65 | "symfony": { 66 | "id": "01BWFSDBN3DHNTPQC7R56YDP77", 67 | "allow-contrib": false 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 7 | Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true], 8 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 9 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], 10 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], 11 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 12 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 13 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 14 | ]; 15 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /config/packages/dev/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: true 4 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(DATABASE_URL)%' 4 | types: 5 | uuid: Ramsey\Uuid\Doctrine\UuidType 6 | orm: 7 | auto_generate_proxy_classes: '%kernel.debug%' 8 | naming_strategy: doctrine.orm.naming_strategy.underscore 9 | auto_mapping: true 10 | mappings: 11 | App: 12 | is_bundle: false 13 | type: annotation 14 | dir: '%kernel.project_dir%/src/Entity' 15 | prefix: 'App\Entity\' 16 | alias: App 17 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | dir_name: '%kernel.project_dir%/src/Migrations' 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | namespace: DoctrineMigrations 6 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | secret: '%env(APP_SECRET)%' 3 | default_locale: en 4 | csrf_protection: ~ 5 | #http_method_override: true 6 | #trusted_hosts: ~ 7 | # https://symfony.com/doc/current/reference/configuration/framework.html#handler-id 8 | session: 9 | # The native PHP session handler will be used 10 | handler_id: ~ 11 | #esi: ~ 12 | #fragments: ~ 13 | php_errors: 14 | log: true 15 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | metadata_cache_driver: 4 | type: service 5 | id: doctrine.system_cache_provider 6 | query_cache_driver: 7 | type: service 8 | id: doctrine.system_cache_provider 9 | result_cache_driver: 10 | type: service 11 | id: doctrine.result_cache_provider 12 | 13 | services: 14 | doctrine.result_cache_provider: 15 | class: Symfony\Component\Cache\DoctrineProvider 16 | public: false 17 | arguments: 18 | - '@doctrine.result_cache_pool' 19 | doctrine.system_cache_provider: 20 | class: Symfony\Component\Cache\DoctrineProvider 21 | public: false 22 | arguments: 23 | - '@doctrine.system_cache_pool' 24 | 25 | framework: 26 | cache: 27 | pools: 28 | doctrine.result_cache_pool: 29 | adapter: cache.app 30 | doctrine.system_cache_pool: 31 | adapter: cache.system 32 | -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_404s: 8 | # regex: exclude all 404 errors from the logs 9 | - ^/ 10 | nested: 11 | type: stream 12 | path: "%kernel.logs_dir%/%kernel.environment%.log" 13 | level: debug 14 | console: 15 | type: console 16 | process_psr_3_messages: false 17 | channels: ["!event", "!doctrine"] 18 | -------------------------------------------------------------------------------- /config/packages/ramsey_uuid_doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | types: 4 | uuid: Ramsey\Uuid\Doctrine\UuidType 5 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: ~ 4 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | 3 | providers: 4 | db_provider: 5 | entity: 6 | class: App\Entity\User 7 | property: email 8 | 9 | encoders: 10 | App\Entity\User: 11 | algorithm: bcrypt 12 | 13 | 14 | firewalls: 15 | 16 | dev: 17 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 18 | security: false 19 | 20 | main: 21 | provider: db_provider 22 | anonymous: ~ 23 | form_login: 24 | login_path: login 25 | check_path: login 26 | require_previous_session: false 27 | 28 | logout: 29 | path: /logout 30 | target: / 31 | 32 | access_control: 33 | - { path: ^/private, roles: ROLE_USER } 34 | -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: ~ 3 | session: 4 | storage_id: session.storage.mock_file 5 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: '%locale%' 3 | translator: 4 | paths: 5 | - '%kernel.project_dir%/translations/' 6 | fallbacks: 7 | - '%locale%' 8 | -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | paths: ['%kernel.project_dir%/templates'] 3 | debug: '%kernel.debug%' 4 | strict_variables: '%kernel.debug%' 5 | form_themes: 6 | - 'bootstrap_3_layout.html.twig' 7 | -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | #index: 2 | # path: / 3 | # defaults: { _controller: 'App\Controller\DefaultController::index' } 4 | 5 | # first, run composer req annotations 6 | controllers: 7 | resource: ../src/Controller/ 8 | type: annotation 9 | -------------------------------------------------------------------------------- /config/routes/annotations.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: ../../src/Controller/ 3 | type: annotation 4 | -------------------------------------------------------------------------------- /config/routes/dev/twig.yaml: -------------------------------------------------------------------------------- 1 | _errors: 2 | resource: '@TwigBundle/Resources/config/routing/errors.xml' 3 | prefix: /_error 4 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # Put parameters here that don't need to change on each machine where the app is deployed 2 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 3 | parameters: 4 | locale: 'en' 5 | 6 | services: 7 | # default configuration for services in *this* file 8 | _defaults: 9 | # automatically injects dependencies in your services 10 | autowire: true 11 | # automatically registers your services as commands, event subscribers, etc. 12 | autoconfigure: true 13 | # this means you cannot fetch services directly from the container via $container->get() 14 | # if you need to do this, you can override this setting on individual services 15 | public: false 16 | 17 | # makes classes in src/ available to be used as services 18 | # this creates a service per class whose id is the fully-qualified class name 19 | App\: 20 | resource: '../src/*' 21 | # you can exclude directories or files 22 | # but if a service is unused, it's removed anyway 23 | exclude: '../src/{Entity,Migrations,Tests,DataFixtures}' 24 | 25 | # controllers are imported separately to make sure they 26 | # have the tag that allows actions to type-hint services 27 | App\Controller\: 28 | resource: '../src/Controller' 29 | tags: ['controller.service_arguments'] 30 | 31 | App\Service\: 32 | resource: '../src/Service/*' 33 | public: true 34 | 35 | App\Service\FileManager: 36 | public: true 37 | arguments: 38 | $path: '%kernel.project_dir%/var/uploads' 39 | 40 | App\Command\: 41 | resource: '../src/Command/*' 42 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | tests/ 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/assets/main.css: -------------------------------------------------------------------------------- 1 | /* Space out content a bit */ 2 | body { 3 | padding-top: 1.5rem; 4 | padding-bottom: 1.5rem; } 5 | 6 | /* Everything but the jumbotron gets side spacing for mobile first views */ 7 | .header, 8 | .marketing, 9 | .footer { 10 | padding-right: 1rem; 11 | padding-left: 1rem; } 12 | 13 | /* Custom page header */ 14 | .header { 15 | padding-bottom: 1rem; 16 | border-bottom: .05rem solid #e5e5e5; } 17 | 18 | /* Make the masthead heading the same height as the navigation */ 19 | .header h3 { 20 | margin-top: 0; 21 | margin-bottom: 0; 22 | line-height: 3rem; } 23 | 24 | /* Custom page footer */ 25 | .footer { 26 | padding-top: 1.5rem; 27 | color: #777; 28 | border-top: .05rem solid #e5e5e5; } 29 | 30 | /* Customize container */ 31 | @media (min-width: 48em) { 32 | .container { 33 | max-width: 46rem; } } 34 | .container-narrow > hr { 35 | margin: 2rem 0; } 36 | 37 | /* Main marketing message and sign up button */ 38 | .jumbotron { 39 | text-align: center; 40 | border-bottom: .05rem solid #e5e5e5; } 41 | 42 | .jumbotron .btn { 43 | padding: .75rem 1.5rem; 44 | font-size: 1.5rem; } 45 | 46 | /* Supporting marketing content */ 47 | .marketing { 48 | margin: 3rem 0; } 49 | 50 | .marketing p + h4 { 51 | margin-top: 1.5rem; } 52 | 53 | /* Responsive: Portrait tablets and up */ 54 | @media screen and (min-width: 48em) { 55 | /* Remove the padding we set earlier */ 56 | .header, 57 | .marketing, 58 | .footer { 59 | padding-right: 0; 60 | padding-left: 0; } 61 | 62 | /* Space out the masthead */ 63 | .header { 64 | margin-bottom: 2rem; } 65 | 66 | /* Remove the bottom border on the jumbotron for visual effect */ 67 | .jumbotron { 68 | border-bottom: 0; } } 69 | .gallery-upload__dropzone { 70 | width: 100%; 71 | min-height: 300px; 72 | margin-bottom: 15px; } 73 | 74 | .single-gallery__items-container { 75 | display: flex; 76 | flex-wrap: wrap; 77 | margin-bottom: 36px; } 78 | .single-gallery__name { 79 | font-size: 24px; } 80 | .single-gallery__description { 81 | line-height: 1.4; 82 | font-size: 14px; } 83 | .single-gallery__description h1 { 84 | font-size: 20px; } 85 | .single-gallery__description h2 { 86 | font-size: 18px; } 87 | .single-gallery__description h3, .single-gallery__description h4, .single-gallery__description h5, .single-gallery__description h6 { 88 | font-size: 16px; } 89 | .single-gallery__item { 90 | width: 200px; 91 | margin: 15px; } 92 | .single-gallery__item-image { 93 | cursor: pointer; } 94 | .single-gallery__item-description { 95 | line-height: 1.4; 96 | font-size: 14px; } 97 | .single-gallery__item-description h1 { 98 | font-size: 20px; } 99 | .single-gallery__item-description h2 { 100 | font-size: 18px; } 101 | .single-gallery__item-description h3, .single-gallery__item-description h4, .single-gallery__item-description h5, .single-gallery__item-description h6 { 102 | font-size: 16px; } 103 | .single-gallery__modal-image { 104 | max-width: 100%; } 105 | 106 | .related-galleries .card, 107 | .newest-galleries .card { 108 | width: 200px; 109 | margin: 15px; } 110 | .related-galleries .card h1, 111 | .newest-galleries .card h1 { 112 | font-size: 20px; } 113 | .related-galleries__title, 114 | .newest-galleries__title { 115 | font-size: 20px; 116 | margin-bottom: 6px; } 117 | .related-galleries__container, 118 | .newest-galleries__container { 119 | display: flex; 120 | flex-wrap: wrap; } 121 | 122 | .home__galleries-container { 123 | display: flex; 124 | flex-wrap: wrap; } 125 | .home__galleries-container .gallery { 126 | width: 200px; 127 | margin: 15px; } 128 | .home__galleries-container .gallery__link { 129 | display: block; 130 | text-decoration: none !important; } 131 | .home__galleries-container .gallery__name { 132 | font-size: 20px; } 133 | .home__galleries-container .gallery__user { 134 | font-size: 12px; } 135 | 136 | .image-edit__image img { 137 | max-width: 100%; } 138 | .image-edit__form { 139 | margin: 20px 0; } 140 | 141 | .gallery-edit__form { 142 | margin: 20px 0; } 143 | 144 | /*# sourceMappingURL=main.css.map */ 145 | -------------------------------------------------------------------------------- /public/assets/main.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAAA,6BAA6B;AAC7B,IAAK;EACH,WAAW,EAAE,MAAM;EACnB,cAAc,EAAE,MAAM;;AAGxB,2EAA2E;AAC3E;;OAEQ;EACN,aAAa,EAAE,IAAI;EACnB,YAAY,EAAE,IAAI;;AAGpB,wBAAwB;AACxB,OAAQ;EACN,cAAc,EAAE,IAAI;EACpB,aAAa,EAAE,oBAAoB;;AAGrC,iEAAiE;AACjE,UAAW;EACT,UAAU,EAAE,CAAC;EACb,aAAa,EAAE,CAAC;EAChB,WAAW,EAAE,IAAI;;AAGnB,wBAAwB;AACxB,OAAQ;EACN,WAAW,EAAE,MAAM;EACnB,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,oBAAoB;;AAGlC,yBAAyB;AACzB,wBAAyB;EACvB,UAAW;IACT,SAAS,EAAE,KAAK;AAIpB,sBAAuB;EACrB,MAAM,EAAE,MAAM;;AAGhB,+CAA+C;AAC/C,UAAW;EACT,UAAU,EAAE,MAAM;EAClB,aAAa,EAAE,oBAAoB;;AAGrC,eAAgB;EACd,OAAO,EAAE,aAAa;EACtB,SAAS,EAAE,MAAM;;AAGnB,kCAAkC;AAClC,UAAW;EACT,MAAM,EAAE,MAAM;;AAGhB,iBAAkB;EAChB,UAAU,EAAE,MAAM;;AAGpB,yCAAyC;AACzC,mCAAoC;EAClC,uCAAuC;EACvC;;SAEQ;IACN,aAAa,EAAE,CAAC;IAChB,YAAY,EAAE,CAAC;;EAEjB,4BAA4B;EAC5B,OAAQ;IACN,aAAa,EAAE,IAAI;;EAErB,iEAAiE;EACjE,UAAW;IACT,aAAa,EAAE,CAAC;AAMlB,yBAAY;EACV,KAAK,EAAE,IAAI;EACX,UAAU,EAAE,KAAK;EACjB,aAAa,EAAE,IAAI;;AAMrB,gCAAmB;EACjB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,IAAI;AAGrB,qBAAQ;EACN,SAAS,EAAE,IAAI;AAGjB,4BAAe;EACb,WAAW,EAAE,GAAG;EAChB,SAAS,EAAE,IAAI;EAEf,+BAAG;IACD,SAAS,EAAE,IAAI;EAEjB,+BAAG;IACD,SAAS,EAAE,IAAI;EAEjB,kIAAe;IACb,SAAS,EAAE,IAAI;AAInB,qBAAQ;EACN,KAAK,EAAE,KAAK;EACZ,MAAM,EAAE,IAAI;AAGd,2BAAc;EACZ,MAAM,EAAE,OAAO;AAGjB,iCAAoB;EAClB,WAAW,EAAE,GAAG;EAChB,SAAS,EAAE,IAAI;EAEf,oCAAG;IACD,SAAS,EAAE,IAAI;EAEjB,oCAAG;IACD,SAAS,EAAE,IAAI;EAEjB,sJAAe;IACb,SAAS,EAAE,IAAI;AAInB,4BAAe;EACb,SAAS,EAAE,IAAI;;AAOjB;uBAAM;EACJ,KAAK,EAAE,KAAK;EACZ,MAAM,EAAE,IAAI;EAEZ;4BAAG;IACD,SAAS,EAAE,IAAI;AAInB;wBAAS;EACP,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,GAAG;AAGpB;4BAAa;EACX,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;;AAOjB,0BAAuB;EACrB,OAAO,EAAE,IAAI;EACb,SAAS,EAAE,IAAI;EAEf,mCAAS;IACP,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,IAAI;IAEZ,yCAAQ;MACN,OAAO,EAAE,KAAK;MACd,eAAe,EAAE,eAAe;IAGlC,yCAAQ;MACN,SAAS,EAAE,IAAI;IAOjB,yCAAQ;MACN,SAAS,EAAE,IAAI;;AAWnB,sBAAI;EACF,SAAS,EAAE,IAAI;AAInB,iBAAQ;EACN,MAAM,EAAE,MAAM;;AAKhB,mBAAQ;EACN,MAAM,EAAE,MAAM", 4 | "sources": ["main.scss"], 5 | "names": [], 6 | "file": "main.css" 7 | } -------------------------------------------------------------------------------- /public/assets/main.scss: -------------------------------------------------------------------------------- 1 | /* Space out content a bit */ 2 | body { 3 | padding-top: 1.5rem; 4 | padding-bottom: 1.5rem; 5 | } 6 | 7 | /* Everything but the jumbotron gets side spacing for mobile first views */ 8 | .header, 9 | .marketing, 10 | .footer { 11 | padding-right: 1rem; 12 | padding-left: 1rem; 13 | } 14 | 15 | /* Custom page header */ 16 | .header { 17 | padding-bottom: 1rem; 18 | border-bottom: .05rem solid #e5e5e5; 19 | } 20 | 21 | /* Make the masthead heading the same height as the navigation */ 22 | .header h3 { 23 | margin-top: 0; 24 | margin-bottom: 0; 25 | line-height: 3rem; 26 | } 27 | 28 | /* Custom page footer */ 29 | .footer { 30 | padding-top: 1.5rem; 31 | color: #777; 32 | border-top: .05rem solid #e5e5e5; 33 | } 34 | 35 | /* Customize container */ 36 | @media (min-width: 48em) { 37 | .container { 38 | max-width: 46rem; 39 | } 40 | } 41 | 42 | .container-narrow > hr { 43 | margin: 2rem 0; 44 | } 45 | 46 | /* Main marketing message and sign up button */ 47 | .jumbotron { 48 | text-align: center; 49 | border-bottom: .05rem solid #e5e5e5; 50 | } 51 | 52 | .jumbotron .btn { 53 | padding: .75rem 1.5rem; 54 | font-size: 1.5rem; 55 | } 56 | 57 | /* Supporting marketing content */ 58 | .marketing { 59 | margin: 3rem 0; 60 | } 61 | 62 | .marketing p + h4 { 63 | margin-top: 1.5rem; 64 | } 65 | 66 | /* Responsive: Portrait tablets and up */ 67 | @media screen and (min-width: 48em) { 68 | /* Remove the padding we set earlier */ 69 | .header, 70 | .marketing, 71 | .footer { 72 | padding-right: 0; 73 | padding-left: 0; 74 | } 75 | /* Space out the masthead */ 76 | .header { 77 | margin-bottom: 2rem; 78 | } 79 | /* Remove the bottom border on the jumbotron for visual effect */ 80 | .jumbotron { 81 | border-bottom: 0; 82 | } 83 | } 84 | 85 | .gallery-upload { 86 | 87 | &__dropzone { 88 | width: 100%; 89 | min-height: 300px; 90 | margin-bottom: 15px; 91 | } 92 | } 93 | 94 | .single-gallery { 95 | 96 | &__items-container { 97 | display: flex; 98 | flex-wrap: wrap; 99 | margin-bottom: 36px; 100 | } 101 | 102 | &__name { 103 | font-size: 24px; 104 | } 105 | 106 | &__description { 107 | line-height: 1.4; 108 | font-size: 14px; 109 | 110 | h1 { 111 | font-size: 20px; 112 | } 113 | h2 { 114 | font-size: 18px; 115 | } 116 | h3, h4, h5, h6 { 117 | font-size: 16px; 118 | } 119 | } 120 | 121 | &__item { 122 | width: 200px; 123 | margin: 15px; 124 | } 125 | 126 | &__item-image { 127 | cursor: pointer; 128 | } 129 | 130 | &__item-description { 131 | line-height: 1.4; 132 | font-size: 14px; 133 | 134 | h1 { 135 | font-size: 20px; 136 | } 137 | h2 { 138 | font-size: 18px; 139 | } 140 | h3, h4, h5, h6 { 141 | font-size: 16px; 142 | } 143 | } 144 | 145 | &__modal-image { 146 | max-width: 100%; 147 | } 148 | 149 | } 150 | 151 | .related-galleries, 152 | .newest-galleries { 153 | .card { 154 | width: 200px; 155 | margin: 15px; 156 | 157 | h1 { 158 | font-size: 20px 159 | } 160 | } 161 | 162 | &__title { 163 | font-size: 20px; 164 | margin-bottom: 6px; 165 | } 166 | 167 | &__container { 168 | display: flex; 169 | flex-wrap: wrap; 170 | } 171 | 172 | } 173 | 174 | .home { 175 | 176 | &__galleries-container { 177 | display: flex; 178 | flex-wrap: wrap; 179 | 180 | .gallery { 181 | width: 200px; 182 | margin: 15px; 183 | 184 | &__link { 185 | display: block; 186 | text-decoration: none !important; 187 | } 188 | 189 | &__name { 190 | font-size: 20px; 191 | } 192 | 193 | &__images-count { 194 | 195 | } 196 | 197 | &__user { 198 | font-size: 12px; 199 | } 200 | 201 | } 202 | 203 | } 204 | 205 | } 206 | 207 | .image-edit { 208 | &__image { 209 | img { 210 | max-width: 100%; 211 | } 212 | } 213 | 214 | &__form { 215 | margin: 20px 0; 216 | } 217 | } 218 | 219 | .gallery-edit { 220 | &__form { 221 | margin: 20px 0; 222 | } 223 | } 224 | 225 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/public/favicon.ico -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | load(__DIR__.'/../.env'); 13 | } 14 | 15 | if ($_SERVER['APP_DEBUG'] ?? false) { 16 | umask(0000); 17 | 18 | Debug::enable(); 19 | } 20 | 21 | // Request::setTrustedProxies(['0.0.0.0/0'], Request::HEADER_FORWARDED); 22 | 23 | $kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', $_SERVER['APP_DEBUG'] ?? false); 24 | $request = Request::createFromGlobals(); 25 | $response = $kernel->handle($request); 26 | $response->send(); 27 | $kernel->terminate($request, $response); 28 | -------------------------------------------------------------------------------- /scripts/galleries.txt: -------------------------------------------------------------------------------- 1 | http://37.139.21.127/gallery/06439c6e-a03c-41bb-8a94-c58ba7253b31 2 | http://37.139.21.127/gallery/0853000d-c3e1-48ba-8d6c-6cf2ccbe821a 3 | http://37.139.21.127/gallery/0c81051d-ded4-461e-b7d4-82ee199ee48a 4 | http://37.139.21.127/gallery/0cfcbab1-3cd3-4650-b9ab-fa25286cceee 5 | http://37.139.21.127/gallery/0dec9f65-8578-4723-b241-4652c4160a2e 6 | http://37.139.21.127/gallery/1e9aca69-e14d-40b0-b979-8847270811ce 7 | http://37.139.21.127/gallery/257c3433-b650-457c-88eb-d835ff413b0e 8 | http://37.139.21.127/gallery/25c90e46-6cad-4697-8cfe-3f9df7f89d3d 9 | http://37.139.21.127/gallery/30a8582d-db98-439a-a3f1-80e32048ddda 10 | http://37.139.21.127/gallery/320e6c40-6573-4ae6-84cd-1346cb790f87 11 | http://37.139.21.127/gallery/463f72f6-4928-421b-87d0-8662003f5ba4 12 | http://37.139.21.127/gallery/485a2582-2ec4-4c92-b7cf-d9e02a4eea35 -------------------------------------------------------------------------------- /scripts/lazy-load-urls.txt: -------------------------------------------------------------------------------- 1 | http://37.139.21.127/galleries-lazy-load?page=2 2 | http://37.139.21.127/galleries-lazy-load?page=2 3 | http://37.139.21.127/galleries-lazy-load?page=2 4 | http://37.139.21.127/galleries-lazy-load?page=4 5 | http://37.139.21.127/galleries-lazy-load?page=2 6 | http://37.139.21.127/galleries-lazy-load?page=2 7 | http://37.139.21.127/galleries-lazy-load?page=3 8 | http://37.139.21.127/galleries-lazy-load?page=2 9 | http://37.139.21.127/galleries-lazy-load?page=2 10 | http://37.139.21.127/galleries-lazy-load?page=4 11 | http://37.139.21.127/galleries-lazy-load?page=2 12 | http://37.139.21.127/galleries-lazy-load?page=4 13 | http://37.139.21.127/galleries-lazy-load?page=2 14 | http://37.139.21.127/galleries-lazy-load?page=3 15 | http://37.139.21.127/galleries-lazy-load?page=3 16 | http://37.139.21.127/galleries-lazy-load?page=3 17 | http://37.139.21.127/galleries-lazy-load?page=5 18 | http://37.139.21.127/galleries-lazy-load?page=3 19 | http://37.139.21.127/galleries-lazy-load?page=2 20 | http://37.139.21.127/galleries-lazy-load?page=3 -------------------------------------------------------------------------------- /scripts/setup-supervisor.sh: -------------------------------------------------------------------------------- 1 | SCRIPT=`realpath $0` 2 | SCRIPTPATH=`dirname $SCRIPT` 3 | PROJECT_ROOT=`realpath "$SCRIPTPATH/../"` 4 | 5 | resize_worker_block="[program:resize-worker] 6 | process_name=%(program_name)s_%(process_num)02d 7 | command=php $PROJECT_ROOT/bin/console app:resize-image-worker 8 | autostart=true 9 | autorestart=true 10 | numprocs=5 11 | stderr_logfile = $PROJECT_ROOT/var/log/resize-worker-stderr.log 12 | stdout_logfile = $PROJECT_ROOT/var/log/resize-worker-stdout.log" 13 | 14 | echo "$resize_worker_block" > "/etc/supervisor/conf.d/resize-worker.conf" 15 | supervisorctl reread 16 | supervisorctl update 17 | -------------------------------------------------------------------------------- /scripts/test-homepage.sh: -------------------------------------------------------------------------------- 1 | docker run --rm -t yokogawa/siege -d1 -c50 -t1M http://37.139.21.127/ & docker run --rm -v `pwd`:/var/siege:ro -t yokogawa/siege -i --file=/var/siege/lazy-load-urls.txt -d1 -c50 -t1M && fg -------------------------------------------------------------------------------- /scripts/test-single-gallery.sh: -------------------------------------------------------------------------------- 1 | docker run --rm -v `pwd`:/var/siege:ro -t yokogawa/siege -i --file=/var/siege/galleries.txt -d1 -c50 -t1M -------------------------------------------------------------------------------- /src/Command/GenerateGalleryImageThumbnailsCommand.php: -------------------------------------------------------------------------------- 1 | setName('app:generate-gallery-thumbs') 21 | ->addArgument('id', InputArgument::REQUIRED); 22 | } 23 | 24 | protected function execute(InputInterface $input, OutputInterface $output) 25 | { 26 | $id = $input->getArgument('id'); 27 | 28 | /** @var Gallery $gallery */ 29 | $gallery = $this->getContainer()->get('doctrine')->getRepository(Gallery::class)->find($id); 30 | if (empty($gallery)) { 31 | throw new \Exception('Gallery not found'); 32 | } 33 | 34 | $imageResizer = $this->getContainer()->get(ImageResizer::class); 35 | $fileManager = $this->getContainer()->get(FileManager::class); 36 | 37 | foreach ($gallery->getImages() as $image) { 38 | $fullPath = $fileManager->getFilePath($image->getFilename()); 39 | if (empty($fullPath)) { 40 | $output->writeln("Full path for image with ID {$image->getId()} is empty"); 41 | continue; 42 | } 43 | 44 | $cachedPaths = []; 45 | foreach ($imageResizer->getSupportedWidths() as $width) { 46 | $cachedPaths[$width] = $imageResizer->getResizedPath($fullPath, $width, true); 47 | } 48 | $output->writeln("Thumbnails generated for image {$image->getId()}"); 49 | $output->writeln(json_encode($cachedPaths, JSON_PRETTY_PRINT)); 50 | } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Command/ResizeImageWorkerCommand.php: -------------------------------------------------------------------------------- 1 | setName('app:resize-image-worker'); 21 | } 22 | 23 | protected function execute(InputInterface $input, OutputInterface $output) 24 | { 25 | $this->output = $output; 26 | $output->writeln(sprintf('Started worker')); 27 | 28 | $queue = $this->getContainer() 29 | ->get(JobQueueFactory::class) 30 | ->createQueue() 31 | ->watch(JobQueueFactory::QUEUE_IMAGE_RESIZE); 32 | 33 | $job = $queue->reserve(60 * 5); 34 | 35 | if (false === $job) { 36 | $this->output->writeln('Timed out'); 37 | 38 | return; 39 | } 40 | 41 | try { 42 | $this->resizeImage($job->getData()); 43 | $queue->delete($job); 44 | } catch (\Exception $e) { 45 | $queue->bury($job); 46 | throw $e; 47 | } 48 | } 49 | 50 | protected function resizeImage(string $imageId) 51 | { 52 | /** @var Image $image */ 53 | $image = $this->getContainer()->get('doctrine') 54 | ->getManager() 55 | ->getRepository(Image::class) 56 | ->find($imageId); 57 | 58 | if (empty($image)) { 59 | $this->output->writeln("Image with ID $imageId not found"); 60 | } 61 | 62 | $imageResizer = $this->getContainer()->get(ImageResizer::class); 63 | $fileManager = $this->getContainer()->get(FileManager::class); 64 | 65 | $fullPath = $fileManager->getFilePath($image->getFilename()); 66 | if (empty($fullPath)) { 67 | $this->output->writeln("Full path for image with ID $imageId is empty"); 68 | 69 | return; 70 | } 71 | 72 | $cachedPaths = []; 73 | foreach ($imageResizer->getSupportedWidths() as $width) { 74 | $cachedPaths[$width] = $imageResizer->getResizedPath($fullPath, $width, true); 75 | } 76 | 77 | $this->output->writeln("Thumbnails generated for image $imageId"); 78 | $this->output->writeln(json_encode($cachedPaths, JSON_PRETTY_PRINT)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Command/TestJobCommand.php: -------------------------------------------------------------------------------- 1 | setName('app:test-jobs') 20 | ->addArgument( 21 | 'mode', 22 | InputArgument::REQUIRED, 23 | 'test worker mode (1 for worker, 2 for new task)' 24 | ); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output) 28 | { 29 | $this->output = $output; 30 | $mode = $input->getArgument('mode'); 31 | 32 | if ($mode != 1 && $mode != 2) { 33 | throw new \Exception('Invalid mode, use 1 or 2'); 34 | } 35 | 36 | if ($mode == 1) { 37 | $this->startWorker(); 38 | } 39 | 40 | if ($mode == 2) { 41 | $this->generateTestJob(); 42 | } 43 | } 44 | 45 | protected function startWorker() 46 | { 47 | $this->output->writeln('Starting a worker'); 48 | 49 | $queue = $this->getContainer() 50 | ->get(JobQueueFactory::class) 51 | ->createQueue() 52 | ->watch('test'); 53 | 54 | $job = $queue->reserve(60 * 5); 55 | 56 | if (false === $job) { 57 | $this->output->writeln('Timed out'); 58 | 59 | return; 60 | } 61 | 62 | $jobPayload = $job->getData(); 63 | $this->output->writeln("Got a new job: $jobPayload"); 64 | $queue->delete($job); 65 | } 66 | 67 | protected function generateTestJob() 68 | { 69 | $queue = $this->getContainer() 70 | ->get(JobQueueFactory::class) 71 | ->createQueue() 72 | ->useTube('test'); 73 | 74 | $jobPayload = sprintf("Job %s at %s", rand(), date('d.m.Y. H:i:s')); 75 | $queue->put($jobPayload); 76 | $this->output->writeln("Added a new job: $jobPayload"); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/src/Controller/.gitignore -------------------------------------------------------------------------------- /src/Controller/EditGalleryController.php: -------------------------------------------------------------------------------- 1 | em = $em; 49 | $this->formFactory = $formFactory; 50 | $this->flashBag = $flashBag; 51 | $this->router = $router; 52 | $this->twig = $twig; 53 | $this->userManager = $userManager; 54 | } 55 | 56 | /** 57 | * @Route("/gallery/{id}/delete", name="gallery.delete") 58 | */ 59 | public function deleteImageAction(Request $request, $id) 60 | { 61 | $gallery = $this->em->getRepository(Gallery::class)->find($id); 62 | if (empty($gallery)) { 63 | throw new NotFoundHttpException('Gallery not found'); 64 | } 65 | 66 | $currentUser = $this->userManager->getCurrentUser(); 67 | if (empty($currentUser) || false === $gallery->isOwner($currentUser)) { 68 | throw new AccessDeniedHttpException(); 69 | } 70 | 71 | $this->em->remove($gallery); 72 | $this->em->flush(); 73 | 74 | $this->flashBag->add('success', 'Gallery deleted'); 75 | 76 | return new RedirectResponse($this->router->generate('home')); 77 | } 78 | 79 | /** 80 | * @Route("/gallery/{id}/edit", name="gallery.edit") 81 | */ 82 | public function editGalleryAction(Request $request, $id) 83 | { 84 | $gallery = $this->em->getRepository(Gallery::class)->find($id); 85 | if (empty($gallery)) { 86 | throw new NotFoundHttpException('Gallery not found'); 87 | } 88 | 89 | $currentUser = $this->userManager->getCurrentUser(); 90 | if (empty($currentUser) || false === $gallery->isOwner($currentUser)) { 91 | throw new AccessDeniedHttpException(); 92 | } 93 | 94 | $galleryDto = [ 95 | 'name' => $gallery->getName(), 96 | 'description' => $gallery->getDescription(), 97 | ]; 98 | 99 | $form = $this->formFactory->create(EditGalleryType::class, $galleryDto); 100 | $form->handleRequest($request); 101 | 102 | if ($form->isSubmitted() && $form->isValid()) { 103 | $gallery->setDescription($form->get('description')->getData()); 104 | $gallery->setName($form->get('name')->getData()); 105 | $this->em->flush(); 106 | 107 | $this->flashBag->add('success', 'Gallery updated'); 108 | 109 | return new RedirectResponse($this->router->generate('gallery.edit', ['id' => $gallery->getId()])); 110 | } 111 | 112 | $view = $this->twig->render('gallery/edit-gallery.html.twig', [ 113 | 'gallery' => $gallery, 114 | 'form' => $form->createView(), 115 | ]); 116 | 117 | return new Response($view); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/Controller/EditImageController.php: -------------------------------------------------------------------------------- 1 | em = $em; 50 | $this->formFactory = $formFactory; 51 | $this->flashBag = $flashBag; 52 | $this->router = $router; 53 | $this->twig = $twig; 54 | $this->userManager = $userManager; 55 | } 56 | 57 | 58 | /** 59 | * @Route("/image/{id}/delete", name="image.delete") 60 | */ 61 | public function deleteImageAction(Request $request, $id) 62 | { 63 | $image = $this->em->getRepository(Image::class)->find($id); 64 | if (empty($image)) { 65 | throw new NotFoundHttpException('Image not found'); 66 | } 67 | 68 | $currentUser = $this->userManager->getCurrentUser(); 69 | if (empty($currentUser) || false === $image->canEdit($currentUser)) { 70 | throw new AccessDeniedHttpException(); 71 | } 72 | 73 | /** @var Gallery $gallery */ 74 | $gallery = $image->getGallery(); 75 | $this->em->remove($image); 76 | $this->em->flush(); 77 | 78 | $this->flashBag->add('success', 'Image deleted'); 79 | 80 | return new RedirectResponse($this->router->generate('gallery.single-gallery', ['id' => $gallery->getId()])); 81 | } 82 | 83 | /** 84 | * @Route("/image/{id}/edit", name="image.edit") 85 | */ 86 | public function editImageAction(Request $request, $id) 87 | { 88 | $image = $this->em->getRepository(Image::class)->find($id); 89 | if (empty($image)) { 90 | throw new NotFoundHttpException('Image not found'); 91 | } 92 | 93 | $currentUser = $this->userManager->getCurrentUser(); 94 | if (empty($currentUser) || false === $image->canEdit($currentUser)) { 95 | throw new AccessDeniedHttpException(); 96 | } 97 | 98 | $imageDto = [ 99 | 'originalFilename' => $image->getOriginalFilename(), 100 | 'description' => $image->getDescription(), 101 | ]; 102 | 103 | $form = $this->formFactory->create(EditImageType::class, $imageDto); 104 | $form->handleRequest($request); 105 | 106 | if ($form->isSubmitted() && $form->isValid()) { 107 | $image->setDescription($form->get('description')->getData()); 108 | $image->setOriginalFilename($form->get('originalFilename')->getData()); 109 | $this->em->flush(); 110 | 111 | $this->flashBag->add('success', 'Image updated'); 112 | 113 | return new RedirectResponse($this->router->generate('image.edit', ['id' => $image->getId()])); 114 | } 115 | 116 | $view = $this->twig->render('image/edit-image.html.twig', [ 117 | 'image' => $image, 118 | 'form' => $form->createView(), 119 | ]); 120 | 121 | return new Response($view); 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/Controller/GalleryController.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 27 | $this->em = $em; 28 | $this->userManager = $userManager; 29 | } 30 | 31 | /** 32 | * @Route("/gallery/{id}", name="gallery.single-gallery") 33 | */ 34 | public function homeAction($id) 35 | { 36 | $gallery = $this->em->getRepository(Gallery::class)->find($id); 37 | if (empty($gallery)) { 38 | throw new NotFoundHttpException(); 39 | } 40 | 41 | $canEdit = false; 42 | $currentUser = $this->userManager->getCurrentUser(); 43 | if (!empty($currentUser)) { 44 | $canEdit = $gallery->isOwner($currentUser); 45 | } 46 | 47 | $view = $this->twig->render('gallery/single-gallery.html.twig', [ 48 | 'gallery' => $gallery, 49 | 'canEdit' => $canEdit, 50 | ]); 51 | 52 | return new Response($view); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Controller/HomeController.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 26 | $this->em = $em; 27 | } 28 | 29 | /** 30 | * @Route("", name="home") 31 | */ 32 | public function homeAction() 33 | { 34 | $galleries = $this->em->getRepository(Gallery::class)->findBy([], ['createdAt' => 'DESC'], self::PER_PAGE); 35 | $view = $this->twig->render('home.html.twig', [ 36 | 'galleries' => $galleries, 37 | ]); 38 | 39 | return new Response($view); 40 | } 41 | 42 | /** 43 | * @Route("/galleries-lazy-load", name="home.lazy-load") 44 | */ 45 | public function homeGalleriesLazyLoadAction(Request $request) 46 | { 47 | $page = $request->get('page', null); 48 | if (empty($page)) { 49 | return new JsonResponse([ 50 | 'success' => false, 51 | 'msg' => 'Page param is required', 52 | ]); 53 | } 54 | 55 | $offset = ($page - 1) * self::PER_PAGE; 56 | $galleries = $this->em->getRepository(Gallery::class)->findBy([], ['createdAt' => 'DESC'], 12, $offset); 57 | 58 | $view = $this->twig->render('partials/home-galleries-lazy-load.html.twig', [ 59 | 'galleries' => $galleries, 60 | ]); 61 | 62 | return new JsonResponse([ 63 | 'success' => true, 64 | 'data' => $view, 65 | ]); 66 | } 67 | 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/Controller/ImageController.php: -------------------------------------------------------------------------------- 1 | em = $em; 29 | $this->fileManager = $fileManager; 30 | $this->imageResizer = $imageResizer; 31 | } 32 | 33 | /** 34 | * @Route("/image/{id}/raw", name="image.serve") 35 | */ 36 | public function serveImageAction(Request $request, $id) 37 | { 38 | $idFragments = explode('--', $id); 39 | $id = $idFragments[0]; 40 | $size = (int)$idFragments[1] ?? null; 41 | 42 | if (false === is_null($size) && false === $this->imageResizer->isSupportedSize($size)) { 43 | throw new NotFoundHttpException('Image not found'); 44 | } 45 | 46 | $image = $this->em->getRepository(Image::class)->find($id); 47 | if (empty($image)) { 48 | throw new NotFoundHttpException('Image not found'); 49 | } 50 | 51 | if (false === is_null($size)) { 52 | return $this->renderResizedImage($image, $size); 53 | } 54 | 55 | return $this->renderRawImage($image); 56 | } 57 | 58 | private function buildImageResponse(string $path, string $filename, int $cacheTtl) 59 | { 60 | $response = new BinaryFileResponse($path); 61 | $response->headers->set('Content-type', mime_content_type($path)); 62 | $response->headers->set( 63 | 'Content-Disposition', 64 | 'inline; filename="' . $filename . '";' 65 | ); 66 | 67 | $response->setTtl($cacheTtl); 68 | 69 | if ($cacheTtl === -1) { 70 | // Prevent caching 71 | $response->setPrivate(); 72 | $response->setMaxAge(0); 73 | $response->headers->addCacheControlDirective('s-maxage', 0); 74 | $response->headers->addCacheControlDirective('must-revalidate', true); 75 | $response->headers->addCacheControlDirective('no-store', true); 76 | $response->setExpires(new \DateTime('-1 year')); 77 | } else { 78 | $response->setTtl($cacheTtl); 79 | $response->headers->addCacheControlDirective('must-revalidate', true); 80 | } 81 | 82 | return $response; 83 | } 84 | 85 | private function renderRawImage(Image $image) 86 | { 87 | $fullPath = $this->fileManager->getFilePath($image->getFilename()); 88 | 89 | return $this->buildImageResponse($fullPath, $image->getOriginalFilename(), 1209600); 90 | } 91 | 92 | private function renderResizedImage(Image $image, int $size) 93 | { 94 | $fullPath = $this->fileManager->getFilePath($image->getFilename()); 95 | 96 | try { 97 | $fullPath = $this->imageResizer->getResizedPath($fullPath, $size); 98 | } catch (\Exception $e) { 99 | throw new NotFoundHttpException('Image not found'); 100 | } 101 | 102 | // Image hasn't been resized yet, render placeholder without cache ttl 103 | if (true === is_null($fullPath)) { 104 | $fullPath = $this->fileManager->getPlaceholderImagePath(); 105 | 106 | return $this->buildImageResponse($fullPath, 'placeholder.jpg', -1); 107 | } 108 | 109 | return $this->buildImageResponse($fullPath, 'placeholder.jpg', 1209600); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/Controller/RegistrationController.php: -------------------------------------------------------------------------------- 1 | formFactory = $formFactory; 42 | $this->twig = $twig; 43 | $this->flashBag = $flashBag; 44 | $this->router = $router; 45 | $this->userManager = $userManager; 46 | } 47 | 48 | private function createRegistrationForm(Request $request) 49 | { 50 | $form = $this->formFactory->create(RegistrationFormType::class); 51 | $form->handleRequest($request); 52 | 53 | return $form; 54 | } 55 | 56 | /** 57 | * @Route("/register", name="register") 58 | */ 59 | public function registerAction(Request $request) 60 | { 61 | $form = $this->createRegistrationForm($request); 62 | 63 | if ($form->isSubmitted() && $form->isValid()) { 64 | return $this->handleRegistrationFormSubmission($form, $request); 65 | } 66 | 67 | $view = $this->twig->render('security/registration.html.twig', [ 68 | 'form' => $form->createView(), 69 | ]); 70 | 71 | return new Response($view); 72 | } 73 | 74 | private function handleRegistrationFormSubmission(FormInterface $form, Request $request) 75 | { 76 | $data = $form->getData(); 77 | $user = $this->userManager->register($data); 78 | $this->userManager->login($user, $request); 79 | $this->flashBag->add('success', 'You\'ve been registered successfully'); 80 | 81 | return new RedirectResponse($this->router->generate('home')); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/Controller/SecurityController.php: -------------------------------------------------------------------------------- 1 | getLastAuthenticationError(); 19 | 20 | // last username entered by the user 21 | $lastUsername = $authUtils->getLastUsername(); 22 | 23 | return $this->render('security/login.html.twig', [ 24 | 'last_username' => $lastUsername, 25 | 'error' => $error, 26 | ]); 27 | } 28 | 29 | /** 30 | * @Route("/logout", name="logout") 31 | */ 32 | public function logoutAction() 33 | { 34 | // Stub used only for generating routes 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Controller/UploadController.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 55 | $this->flashBag = $flashBag; 56 | $this->router = $router; 57 | $this->fileManager = $fileManager; 58 | $this->em = $em; 59 | $this->userManager = $userManager; 60 | $this->eventDispatcher = $eventDispatcher; 61 | } 62 | 63 | /** 64 | * @Route("/private/upload", name="upload") 65 | */ 66 | public function renderUploadScreenAction(Request $request) 67 | { 68 | $view = $this->twig->render('gallery/upload.html.twig'); 69 | 70 | return new Response($view); 71 | } 72 | 73 | /** 74 | * @Route("/private/upload-process", name="upload.process") 75 | */ 76 | public function processUploadAction(Request $request) 77 | { 78 | // @todo access control 79 | // @todo input validation 80 | 81 | $gallery = new Gallery(Uuid::getFactory()->uuid4()); 82 | $gallery->setName($request->get('name', null)); 83 | $gallery->setDescription($request->get('description', null)); 84 | $gallery->setUser($this->userManager->getCurrentUser()); 85 | $files = $request->files->get('file'); 86 | 87 | /** @var UploadedFile $file */ 88 | foreach ($files as $file) { 89 | $filename = $file->getClientOriginalName(); 90 | $filepath = Uuid::getFactory()->uuid4()->toString() . '.' . $file->getClientOriginalExtension(); 91 | $movedFile = $this->fileManager->upload($file, $filepath); 92 | 93 | $image = new Image( 94 | Uuid::getFactory()->uuid4(), 95 | $filename, 96 | $filepath 97 | ); 98 | 99 | $gallery->addImage($image); 100 | } 101 | 102 | $this->em->persist($gallery); 103 | $this->em->flush(); 104 | 105 | $this->eventDispatcher->dispatch( 106 | GalleryCreatedEvent::class, 107 | new GalleryCreatedEvent($gallery->getId()) 108 | ); 109 | 110 | $this->flashBag->add('success', 'Gallery created! Images are now being processed.'); 111 | 112 | return new JsonResponse([ 113 | 'success' => true, 114 | 'redirectUrl' => $this->router->generate( 115 | 'gallery.single-gallery', 116 | ['id' => $gallery->getId()] 117 | ), 118 | ]); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/DataFixtures/ORM/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/src/DataFixtures/ORM/.gitignore -------------------------------------------------------------------------------- /src/DataFixtures/ORM/LoadGalleriesData.php: -------------------------------------------------------------------------------- 1 | getConnection()->getConfiguration()->setSQLLogger(null); 27 | $imageResizer = $this->container->get(ImageResizer::class); 28 | $fileManager = $this->container->get(FileManager::class); 29 | 30 | $faker = Factory::create(); 31 | $batchSize = 100; 32 | 33 | for ($i = 1; $i <= self::COUNT; $i++) { 34 | $gallery = new Gallery(Uuid::getFactory()->uuid4()); 35 | $gallery->setName($faker->sentence); 36 | 37 | $description = <<sentence()} 39 | 40 | {$faker->realText()} 41 | MD; 42 | 43 | 44 | $gallery->setDescription($description); 45 | $gallery->setUser($this->getReference('user' . (rand(1, LoadUsersData::COUNT)))); 46 | // $this->addReference('gallery' . $i, $gallery); 47 | 48 | for ($j = 1; $j <= rand(5, 10); $j++) { 49 | $filename = $faker->word . '.jpg'; 50 | $image = $this->generateRandomImage($filename); 51 | 52 | $description = <<sentence()} 54 | 55 | {$faker->realText()} 56 | MD; 57 | 58 | $image->setDescription($description); 59 | $gallery->addImage($image); 60 | $manager->persist($image); 61 | 62 | $fullPath = $fileManager->getFilePath($image->getFilename()); 63 | if (false === empty($fullPath)) { 64 | foreach ($imageResizer->getSupportedWidths() as $width) { 65 | $imageResizer->getResizedPath($fullPath, $width, true); 66 | } 67 | } 68 | } 69 | 70 | $manager->persist($gallery); 71 | 72 | if (($i % $batchSize) == 0 || $i == self::COUNT) { 73 | $currentMemoryUsage = round(memory_get_usage(true) / 1024 / 1024); 74 | $maxMemoryUsage = round(memory_get_peak_usage(true) / 1024 / 1024); 75 | echo sprintf("%s Memory usage (currently) %dMB / (max) %dMB\n", 76 | $i, 77 | $currentMemoryUsage, 78 | $maxMemoryUsage 79 | ); 80 | 81 | $manager->flush(); 82 | $manager->clear(); 83 | 84 | // here you should merge entities you're re-using with the $manager 85 | // because they aren't managed anymore after calling $manager->clear(); 86 | // e.g. if you've already loaded category or tag entities 87 | // $category1 = $manager->merge($category1); 88 | 89 | gc_collect_cycles(); 90 | } 91 | } 92 | 93 | $manager->flush(); 94 | } 95 | 96 | 97 | private function generateRandomImage($imageName) 98 | { 99 | $images = [ 100 | 'image1.jpeg', 101 | 'image10.jpeg', 102 | 'image11.jpeg', 103 | 'image12.jpg', 104 | 'image13.jpeg', 105 | 'image14.jpeg', 106 | 'image15.jpeg', 107 | 'image2.jpeg', 108 | 'image3.jpeg', 109 | 'image4.jpeg', 110 | 'image5.jpeg', 111 | 'image6.jpeg', 112 | 'image7.jpeg', 113 | 'image8.jpeg', 114 | 'image9.jpeg', 115 | ]; 116 | 117 | $sourceDirectory = $this->container->getParameter('kernel.project_dir') . '/var/demo-data/sample-images/'; 118 | $targetDirectory = $this->container->getParameter('kernel.project_dir') . '/var/uploads/'; 119 | 120 | $randomImage = $images[rand(0, count($images) - 1)]; 121 | $randomImageSourceFilePath = $sourceDirectory . $randomImage; 122 | $randomImageExtension = explode('.', $randomImage)[1]; 123 | $targetImageFilename = sha1(microtime() . rand()) . '.' . $randomImageExtension; 124 | copy($randomImageSourceFilePath, $targetDirectory . $targetImageFilename); 125 | 126 | $image = new Image( 127 | Uuid::getFactory()->uuid4(), 128 | $imageName, 129 | $targetImageFilename 130 | ); 131 | 132 | return $image; 133 | } 134 | 135 | public function getOrder() 136 | { 137 | return 200; 138 | } 139 | 140 | public function setContainer(ContainerInterface $container = null) 141 | { 142 | $this->container = $container; 143 | } 144 | } -------------------------------------------------------------------------------- /src/DataFixtures/ORM/LoadUsersData.php: -------------------------------------------------------------------------------- 1 | container->get(UserManager::class); 23 | $batchSize = 300; 24 | 25 | $encoder = $this->container->get('security.password_encoder'); 26 | $user = $userManager->createUser(); 27 | $encodedPassword = $encoder->encodePassword($user, '123456'); 28 | 29 | for ($i = 1; $i <= self::COUNT; $i++) { 30 | $user = $userManager->createUser(); 31 | $user->setEmail(sprintf('user%s@mailinator.com', $i)); 32 | $user->setRoles(['ROLE_USER']); 33 | $user->setPassword($encodedPassword); 34 | // $userManager->update($user); 35 | $manager->persist($user); 36 | $this->addReference('user' . $i, $user); 37 | 38 | if (($i % $batchSize) == 0 || $i == self::COUNT) { 39 | $currentMemoryUsage = round(memory_get_usage(true) / 1024 / 1024); 40 | $maxMemoryUsage = round(memory_get_peak_usage(true) / 1024 / 1024); 41 | echo sprintf("%s Memory usage (currently) %dMB / (max) %dMB\n", 42 | $i, 43 | $currentMemoryUsage, 44 | $maxMemoryUsage 45 | ); 46 | 47 | $manager->flush(); 48 | $manager->clear(); 49 | 50 | gc_collect_cycles(); 51 | } 52 | 53 | } 54 | 55 | $manager->flush(); 56 | } 57 | 58 | public function getOrder() 59 | { 60 | return 100; 61 | } 62 | 63 | public function setContainer(ContainerInterface $container = null) 64 | { 65 | $this->container = $container; 66 | } 67 | } -------------------------------------------------------------------------------- /src/Entity/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/src/Entity/.gitignore -------------------------------------------------------------------------------- /src/Entity/Gallery.php: -------------------------------------------------------------------------------- 1 | id = $id; 62 | $this->images = new ArrayCollection(); 63 | $this->createdAt = new DateTime(); 64 | } 65 | 66 | /** 67 | * @return Uuid 68 | */ 69 | public function getId() 70 | { 71 | return $this->id; 72 | } 73 | 74 | /** 75 | * @return string 76 | */ 77 | public function getName() 78 | { 79 | return $this->name; 80 | } 81 | 82 | /** 83 | * @param string $name 84 | */ 85 | public function setName($name) 86 | { 87 | $this->name = $name; 88 | } 89 | 90 | /** 91 | * @return string 92 | */ 93 | public function getDescription() 94 | { 95 | return $this->description; 96 | } 97 | 98 | /** 99 | * @param string $description 100 | */ 101 | public function setDescription($description) 102 | { 103 | $this->description = $description; 104 | } 105 | 106 | /** 107 | * @return Collection 108 | */ 109 | public function getImages() 110 | { 111 | return $this->images; 112 | } 113 | 114 | public function addImage(Image $image) 115 | { 116 | if (false === $this->images->contains($image)) { 117 | $image->setGallery($this); 118 | $this->images->add($image); 119 | } 120 | } 121 | 122 | public function removeImage(Image $image) 123 | { 124 | if (true === $this->images->contains($image)) { 125 | $this->images->removeElement($image); 126 | } 127 | } 128 | 129 | /** 130 | * @return User 131 | */ 132 | public function getUser(): User 133 | { 134 | return $this->user; 135 | } 136 | 137 | /** 138 | * @param User $user 139 | */ 140 | public function setUser(User $user) 141 | { 142 | $this->user = $user; 143 | } 144 | 145 | public function isOwner(User $user) 146 | { 147 | return $this->user === $user; 148 | } 149 | 150 | /** 151 | * @return DateTime 152 | */ 153 | public function getCreatedAt(): DateTime 154 | { 155 | return $this->createdAt; 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/Entity/Image.php: -------------------------------------------------------------------------------- 1 | id = $id; 52 | $this->originalFilename = $originalFilename; 53 | $this->filename = $filename; 54 | } 55 | 56 | /** 57 | * @return Uuid 58 | */ 59 | public function getId() 60 | { 61 | return $this->id; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getOriginalFilename() 68 | { 69 | return $this->originalFilename; 70 | } 71 | 72 | /** 73 | * @param string $originalFilename 74 | */ 75 | public function setOriginalFilename(string $originalFilename) 76 | { 77 | $this->originalFilename = $originalFilename; 78 | } 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function getFilename() 84 | { 85 | return $this->filename; 86 | } 87 | 88 | /** 89 | * @return string 90 | */ 91 | public function getDescription() 92 | { 93 | return $this->description; 94 | } 95 | 96 | /** 97 | * @param string $description 98 | */ 99 | public function setDescription($description) 100 | { 101 | $this->description = $description; 102 | } 103 | 104 | /** 105 | * @return Gallery 106 | */ 107 | public function getGallery() 108 | { 109 | return $this->gallery; 110 | } 111 | 112 | /** 113 | * @param Gallery $gallery 114 | */ 115 | public function setGallery(Gallery $gallery) 116 | { 117 | $this->gallery = $gallery; 118 | } 119 | 120 | public function canEdit(User $user) 121 | { 122 | return $this->getGallery()->isOwner($user); 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | isActive = true; 52 | $this->id = $id; 53 | } 54 | 55 | public function getUsername() 56 | { 57 | return $this->getEmail(); 58 | } 59 | 60 | public function getEmail() 61 | { 62 | return $this->email; 63 | } 64 | 65 | public function getSalt() 66 | { 67 | return null; 68 | } 69 | 70 | public function getPassword() 71 | { 72 | return $this->password; 73 | } 74 | 75 | public function getRoles() 76 | { 77 | return ['ROLE_USER']; 78 | } 79 | 80 | public function eraseCredentials() 81 | { 82 | } 83 | 84 | /** @see \Serializable::serialize() */ 85 | public function serialize() 86 | { 87 | return serialize([ 88 | $this->id, 89 | $this->email, 90 | $this->password, 91 | ]); 92 | } 93 | 94 | /** @see \Serializable::unserialize() */ 95 | public function unserialize($serialized) 96 | { 97 | list ( 98 | $this->id, 99 | $this->email, 100 | $this->password, 101 | ) = unserialize($serialized); 102 | } 103 | 104 | /** 105 | * @param mixed $password 106 | */ 107 | public function setPassword($password) 108 | { 109 | $this->password = $password; 110 | } 111 | 112 | /** 113 | * @param mixed $email 114 | */ 115 | public function setEmail($email) 116 | { 117 | $this->email = $email; 118 | } 119 | 120 | /** 121 | * @param array $roles 122 | */ 123 | public function setRoles(array $roles) 124 | { 125 | $this->roles = $roles; 126 | } 127 | 128 | /** 129 | * @param mixed $plainPassword 130 | */ 131 | public function setPlainPassword($plainPassword) 132 | { 133 | $this->plainPassword = $plainPassword; 134 | } 135 | 136 | /** 137 | * @return mixed 138 | */ 139 | public function getPlainPassword() 140 | { 141 | return $this->plainPassword; 142 | } 143 | 144 | } -------------------------------------------------------------------------------- /src/Event/GalleryCreatedEvent.php: -------------------------------------------------------------------------------- 1 | galleryId = $galleryId; 15 | } 16 | 17 | public function getGalleryId(): string 18 | { 19 | return $this->galleryId; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Form/EditGalleryType.php: -------------------------------------------------------------------------------- 1 | add('name', TextType::class, [ 16 | 'label' => 'Name', 17 | 'attr' => ['placeholder' => 'Name'], 18 | 'constraints' => [ 19 | new NotBlank(), 20 | ], 21 | ]) 22 | ->add('description', MarkdownType::class, [ 23 | 'label' => 'Description', 24 | 'required' => false, 25 | 'constraints' => [ 26 | new NotBlank(), 27 | ], 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Form/EditImageType.php: -------------------------------------------------------------------------------- 1 | add('originalFilename', TextType::class, [ 16 | 'label' => 'File name', 17 | 'attr' => ['placeholder' => 'File name'], 18 | 'constraints' => [ 19 | new NotBlank(), 20 | ], 21 | ]) 22 | ->add('description', MarkdownType::class, [ 23 | 'label' => 'Description', 24 | 'required' => false, 25 | 'constraints' => [ 26 | new NotBlank(), 27 | ], 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Form/MarkdownType.php: -------------------------------------------------------------------------------- 1 | setDefaults([ 13 | 'compound' => false, 14 | 'attr' => [ 15 | 'class' => 'markdown-textarea', 16 | ], 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Form/RegistrationFormType.php: -------------------------------------------------------------------------------- 1 | add('email', EmailType::class, [ 21 | 'label' => 'Email', 22 | 'attr' => ['placeholder' => 'Email'], 23 | 'constraints' => [ 24 | new NotBlank(), 25 | new Email(), 26 | new AvailableEmailConstraint(), 27 | ], 28 | ]) 29 | ->add('password', RepeatedType::class, [ 30 | 'type' => PasswordType::class, 31 | 'invalid_message' => 'The password fields must match.', 32 | 'options' => ['attr' => ['class' => 'password-field']], 33 | 'required' => true, 34 | 'first_options' => [ 35 | 'label' => 'Password', 36 | 'attr' => [ 37 | 'placeholder' => 'Password', 38 | ], 39 | 'constraints' => [ 40 | new NotBlank(), 41 | new Length(['min' => 6, 'max' => 35]), 42 | ], 43 | ], 44 | 'second_options' => [ 45 | 'label' => 'Repeat Password', 46 | 'attr' => [ 47 | 'placeholder' => 'Repeat password', 48 | ], 49 | ], 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | environment; 20 | } 21 | 22 | public function getLogDir(): string 23 | { 24 | return dirname(__DIR__).'/var/log'; 25 | } 26 | 27 | public function registerBundles() 28 | { 29 | $contents = require dirname(__DIR__).'/config/bundles.php'; 30 | foreach ($contents as $class => $envs) { 31 | if (isset($envs['all']) || isset($envs[$this->environment])) { 32 | yield new $class(); 33 | } 34 | } 35 | } 36 | 37 | protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader) 38 | { 39 | $confDir = dirname(__DIR__).'/config'; 40 | $loader->load($confDir.'/packages/*'.self::CONFIG_EXTS, 'glob'); 41 | if (is_dir($confDir.'/packages/'.$this->environment)) { 42 | $loader->load($confDir.'/packages/'.$this->environment.'/**/*'.self::CONFIG_EXTS, 'glob'); 43 | } 44 | $loader->load($confDir.'/services'.self::CONFIG_EXTS, 'glob'); 45 | $loader->load($confDir.'/services_'.$this->environment.self::CONFIG_EXTS, 'glob'); 46 | } 47 | 48 | protected function configureRoutes(RouteCollectionBuilder $routes) 49 | { 50 | $confDir = dirname(__DIR__).'/config'; 51 | if (is_dir($confDir.'/routes/')) { 52 | $routes->import($confDir.'/routes/*'.self::CONFIG_EXTS, '/', 'glob'); 53 | } 54 | if (is_dir($confDir.'/routes/'.$this->environment)) { 55 | $routes->import($confDir.'/routes/'.$this->environment.'/**/*'.self::CONFIG_EXTS, '/', 'glob'); 56 | } 57 | $routes->import($confDir.'/routes'.self::CONFIG_EXTS, '/', 'glob'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Migrations/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/src/Migrations/.gitignore -------------------------------------------------------------------------------- /src/Repository/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/src/Repository/.gitignore -------------------------------------------------------------------------------- /src/Repository/GalleryRepository.php: -------------------------------------------------------------------------------- 1 | repository = $em->getRepository(Gallery::class); 17 | } 18 | 19 | public function findNewest($limit = 5) 20 | { 21 | return $this->repository->findBy([], ['createdAt' => 'DESC'], $limit); 22 | } 23 | 24 | public function findRelated(Gallery $gallery, $limit = 5) 25 | { 26 | return $this->repository->findBy(['user' => $gallery->getUser()], ['createdAt' => 'DESC'], $limit); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Service/FileManager.php: -------------------------------------------------------------------------------- 1 | path = $path; 19 | } 20 | 21 | public function getUploadsDirectory() 22 | { 23 | return $this->path; 24 | } 25 | 26 | public function upload(UploadedFile $file, $filename) 27 | { 28 | $file = $file->move($this->getUploadsDirectory(), $filename); 29 | 30 | return $file; 31 | } 32 | 33 | public function getFilePath($filename) 34 | { 35 | return $this->getUploadsDirectory() . DIRECTORY_SEPARATOR . $filename; 36 | } 37 | 38 | public function getPlaceholderImagePath() 39 | { 40 | return $this->getUploadsDirectory() . '/../placeholder.jpg'; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Service/GalleryEventSubscriber.php: -------------------------------------------------------------------------------- 1 | jobQueueFactory = $jobQueueFactory; 23 | $this->entityManager = $entityManager; 24 | } 25 | 26 | public static function getSubscribedEvents() 27 | { 28 | return [ 29 | GalleryCreatedEvent::class => 'onGalleryCreated', 30 | ]; 31 | } 32 | 33 | public function onGalleryCreated(GalleryCreatedEvent $event) 34 | { 35 | $queue = $this->jobQueueFactory 36 | ->createQueue() 37 | ->useTube(JobQueueFactory::QUEUE_IMAGE_RESIZE); 38 | 39 | $gallery = $this->entityManager 40 | ->getRepository(Gallery::class) 41 | ->find($event->getGalleryId()); 42 | 43 | if (empty($gallery)) { 44 | return; 45 | } 46 | 47 | /** @var Image $image */ 48 | foreach ($gallery->getImages() as $image) { 49 | $queue->put($image->getId()); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Service/ImageResizer.php: -------------------------------------------------------------------------------- 1 | server = $server = Glide\ServerFactory::create([ 21 | 'source' => $fm->getUploadsDirectory(), 22 | 'cache' => $fm->getUploadsDirectory() . '/' . self::CACHE_DIR, 23 | ]); 24 | } 25 | 26 | private function getGlide() 27 | { 28 | return $this->server; 29 | } 30 | 31 | public function getSupportedWidths() 32 | { 33 | return [ 34 | self::SIZE_1120, 35 | self::SIZE_720, 36 | self::SIZE_400, 37 | self::SIZE_250, 38 | ]; 39 | } 40 | 41 | public function isSupportedSize(int $size) 42 | { 43 | return in_array($size, $this->getSupportedWidths()); 44 | } 45 | 46 | public function getResizedPath(string $fullPath, int $size, $resizeIfDoesntExist = false) 47 | { 48 | if (false === is_readable($fullPath)) { 49 | throw new \Exception(); 50 | } 51 | 52 | if (false === $this->isSupportedSize($size)) { 53 | throw new \Exception(); 54 | } 55 | 56 | $info = pathinfo($fullPath); 57 | $fileName = $info['filename'] . '.' . $info['extension']; 58 | 59 | $params = ['w' => $size]; 60 | 61 | $cacheFileExists = $this->getGlide()->cacheFileExists($fileName, $params); 62 | 63 | if (true === $cacheFileExists) { 64 | $cachePath = $this->getGlide()->getCachePath($fileName, $params); 65 | $cachePath = sprintf("/%s/%s", self::CACHE_DIR, $cachePath); 66 | $filePath = str_replace($fileName, $cachePath, $fullPath); 67 | 68 | return $filePath; 69 | } 70 | 71 | if (true === $resizeIfDoesntExist) { 72 | $cachePath = $this->getGlide()->makeImage($fileName, $params); 73 | $cachePath = sprintf("/%s/%s", self::CACHE_DIR, $cachePath); 74 | $filePath = str_replace($fileName, $cachePath, $fullPath); 75 | 76 | return $filePath; 77 | } 78 | 79 | return null; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Service/JobQueueFactory.php: -------------------------------------------------------------------------------- 1 | host, $this->port); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Service/UserManager.php: -------------------------------------------------------------------------------- 1 | encoder = $encoder; 46 | $this->em = $em; 47 | $this->repository = $em->getRepository(User::class); 48 | $this->tokenStorage = $tokenStorage; 49 | $this->session = $session; 50 | $this->eventDispatcher = $eventDispatcher; 51 | } 52 | 53 | public function getCurrentUser() 54 | { 55 | $token = $this->tokenStorage->getToken(); 56 | 57 | if (empty($token)) { 58 | return null; 59 | } 60 | 61 | $user = $token->getUser(); 62 | 63 | if (!($user instanceof User)) { 64 | return null; 65 | } 66 | 67 | return $user; 68 | } 69 | 70 | public function createUser() 71 | { 72 | $uuid = Uuid::getFactory()->uuid4(); 73 | 74 | return new User($uuid); 75 | } 76 | 77 | public function update(User $user) 78 | { 79 | $password = $this->encoder->encodePassword($user, $user->getPlainPassword()); 80 | $user->setPassword($password); 81 | } 82 | 83 | public function save(User $user) 84 | { 85 | $this->em->persist($user); 86 | $this->em->flush(); 87 | } 88 | 89 | public function register(array $data) 90 | { 91 | $user = $this->createUser(); 92 | $user->setEmail($data['email']); 93 | $user->setPlainPassword($data['password']); 94 | $this->update($user); 95 | $this->save($user); 96 | 97 | return $user; 98 | } 99 | 100 | public function login(User $user, Request $request) 101 | { 102 | $token = new UsernamePasswordToken($user, null, 'main', $user->getRoles()); 103 | $this->tokenStorage->setToken($token); 104 | $this->session->set('_security_main', serialize($token)); 105 | $event = new InteractiveLoginEvent($request, $token); 106 | $this->eventDispatcher->dispatch(SecurityEvents::INTERACTIVE_LOGIN, $event); 107 | } 108 | 109 | public function findByEmail($email) 110 | { 111 | return $this->repository->findOneBy(['email' => $email]); 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/Twig/ImageRendererExtension.php: -------------------------------------------------------------------------------- 1 | router = $router; 20 | } 21 | 22 | public function getFilters() 23 | { 24 | return [ 25 | new Twig_SimpleFilter('getImageUrl', [$this, 'getImageUrl']), 26 | new Twig_SimpleFilter('getImageSrcset', [$this, 'getImageSrcset']), 27 | ]; 28 | } 29 | 30 | public function getImageUrl(Image $image, $size = null) 31 | { 32 | return $this->router->generate('image.serve', [ 33 | 'id' => $image->getId() . (($size) ? '--' . $size : ''), 34 | ], RouterInterface::ABSOLUTE_URL); 35 | } 36 | 37 | 38 | public function getImageSrcset(Image $image) 39 | { 40 | $sizes = [ 41 | ImageResizer::SIZE_1120, 42 | ImageResizer::SIZE_720, 43 | ImageResizer::SIZE_400, 44 | ImageResizer::SIZE_250, 45 | ]; 46 | 47 | $string = ''; 48 | foreach ($sizes as $size) { 49 | $string .= $this->router->generate('image.serve', [ 50 | 'id' => $image->getId() . '--' . $size, 51 | ], RouterInterface::ABSOLUTE_URL) . ' ' . $size . 'w, '; 52 | } 53 | $string = trim($string, ', '); 54 | 55 | return html_entity_decode($string); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/Twig/MarkdownExtension.php: -------------------------------------------------------------------------------- 1 | text($markdown); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Twig/SingleGalleryPageModulesTwigExtension.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 20 | $this->repository = $repository; 21 | } 22 | 23 | public function getFunctions() 24 | { 25 | return [ 26 | new \Twig_Function('renderRelatedGalleries', [$this, 'renderRelatedGalleries'], ['is_safe' => ['html']]), 27 | new \Twig_Function('renderNewestGalleries', [$this, 'renderNewestGalleries'], ['is_safe' => ['html']]), 28 | ]; 29 | } 30 | 31 | public function renderRelatedGalleries(Gallery $gallery, int $limit = 5) 32 | { 33 | return $this->twig->render('gallery/partials/_related-galleries.html.twig', [ 34 | 'galleries' => $this->repository->findRelated($gallery, $limit), 35 | ]); 36 | } 37 | 38 | public function renderNewestGalleries(int $limit = 5) 39 | { 40 | return $this->twig->render('gallery/partials/_newest-galleries.html.twig', [ 41 | 'galleries' => $this->repository->findNewest($limit), 42 | ]); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/Validation/AvailableEmailConstraint.php: -------------------------------------------------------------------------------- 1 | userManager = $userManager; 21 | } 22 | 23 | public function validate($value, Constraint $constraint) 24 | { 25 | if (empty($value)) { 26 | return; 27 | } 28 | 29 | $user = $this->userManager->findByEmail($value); 30 | if (false === empty($user)) { 31 | $this->context->addViolation($constraint->message); 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "doctrine/annotations": { 3 | "version": "1.0", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes", 6 | "branch": "master", 7 | "version": "1.0", 8 | "ref": "cb4152ebcadbe620ea2261da1a1c5a9b8cea7672" 9 | } 10 | }, 11 | "doctrine/cache": { 12 | "version": "1.8.x-dev" 13 | }, 14 | "doctrine/collections": { 15 | "version": "1.6.x-dev" 16 | }, 17 | "doctrine/common": { 18 | "version": "2.9.x-dev" 19 | }, 20 | "doctrine/data-fixtures": { 21 | "version": "1.3.x-dev" 22 | }, 23 | "doctrine/dbal": { 24 | "version": "2.7.x-dev" 25 | }, 26 | "doctrine/doctrine-bundle": { 27 | "version": "1.6", 28 | "recipe": { 29 | "repo": "github.com/symfony/recipes", 30 | "branch": "master", 31 | "version": "1.6", 32 | "ref": "44d3aa7752dd46f77ba11af2297a25e1dedfb4d0" 33 | } 34 | }, 35 | "doctrine/doctrine-cache-bundle": { 36 | "version": "1.3.x-dev" 37 | }, 38 | "doctrine/doctrine-fixtures-bundle": { 39 | "version": "2.4.x-dev" 40 | }, 41 | "doctrine/doctrine-migrations-bundle": { 42 | "version": "1.2", 43 | "recipe": { 44 | "repo": "github.com/symfony/recipes", 45 | "branch": "master", 46 | "version": "1.2", 47 | "ref": "c1431086fec31f17fbcfe6d6d7e92059458facc1" 48 | } 49 | }, 50 | "doctrine/inflector": { 51 | "version": "1.3.x-dev" 52 | }, 53 | "doctrine/instantiator": { 54 | "version": "1.2.x-dev" 55 | }, 56 | "doctrine/lexer": { 57 | "version": "1.0.x-dev" 58 | }, 59 | "doctrine/migrations": { 60 | "version": "v1.7.x-dev" 61 | }, 62 | "doctrine/orm": { 63 | "version": "2.6.x-dev" 64 | }, 65 | "erusev/parsedown": { 66 | "version": "1.6.4" 67 | }, 68 | "fzaninotto/faker": { 69 | "version": "1.8-dev" 70 | }, 71 | "guzzlehttp/psr7": { 72 | "version": "1.4-dev" 73 | }, 74 | "intervention/image": { 75 | "version": "2.4.1" 76 | }, 77 | "jdorn/sql-formatter": { 78 | "version": "1.3.x-dev" 79 | }, 80 | "league/flysystem": { 81 | "version": "1.1-dev" 82 | }, 83 | "league/glide": { 84 | "version": "1.2.2" 85 | }, 86 | "monolog/monolog": { 87 | "version": "1.x-dev" 88 | }, 89 | "myclabs/deep-copy": { 90 | "version": "1.x-dev" 91 | }, 92 | "ocramius/proxy-manager": { 93 | "version": "2.1.x-dev" 94 | }, 95 | "paragonie/random_compat": { 96 | "version": "v2.0.11" 97 | }, 98 | "pda/pheanstalk": { 99 | "version": "3.1-dev" 100 | }, 101 | "phar-io/manifest": { 102 | "version": "1.0.x-dev" 103 | }, 104 | "phar-io/version": { 105 | "version": "1.0.1" 106 | }, 107 | "phpdocumentor/reflection-common": { 108 | "version": "1.0.x-dev" 109 | }, 110 | "phpdocumentor/reflection-docblock": { 111 | "version": "4.x-dev" 112 | }, 113 | "phpdocumentor/type-resolver": { 114 | "version": "0.4.0" 115 | }, 116 | "phpspec/prophecy": { 117 | "version": "1.7.x-dev" 118 | }, 119 | "phpunit/php-code-coverage": { 120 | "version": "5.2.x-dev" 121 | }, 122 | "phpunit/php-file-iterator": { 123 | "version": "1.4.x-dev" 124 | }, 125 | "phpunit/php-text-template": { 126 | "version": "1.2.1" 127 | }, 128 | "phpunit/php-timer": { 129 | "version": "1.0-dev" 130 | }, 131 | "phpunit/php-token-stream": { 132 | "version": "2.0.x-dev" 133 | }, 134 | "phpunit/phpunit": { 135 | "version": "4.7", 136 | "recipe": { 137 | "repo": "github.com/symfony/recipes", 138 | "branch": "master", 139 | "version": "4.7", 140 | "ref": "07e681480e205e17703679882f4c1d1b71bcc4d5" 141 | } 142 | }, 143 | "phpunit/phpunit-mock-objects": { 144 | "version": "5.0.x-dev" 145 | }, 146 | "psr/cache": { 147 | "version": "1.0.x-dev" 148 | }, 149 | "psr/container": { 150 | "version": "1.0.x-dev" 151 | }, 152 | "psr/http-message": { 153 | "version": "1.0.x-dev" 154 | }, 155 | "psr/log": { 156 | "version": "1.0.x-dev" 157 | }, 158 | "psr/simple-cache": { 159 | "version": "1.0.x-dev" 160 | }, 161 | "ramsey/uuid": { 162 | "version": "3.x-dev" 163 | }, 164 | "ramsey/uuid-doctrine": { 165 | "version": "1.3", 166 | "recipe": { 167 | "repo": "github.com/symfony/recipes-contrib", 168 | "branch": "master", 169 | "version": "1.3", 170 | "ref": "f7895777cd673fcefbc321a9063077a24e5df646" 171 | } 172 | }, 173 | "sebastian/code-unit-reverse-lookup": { 174 | "version": "1.0.x-dev" 175 | }, 176 | "sebastian/comparator": { 177 | "version": "2.1.x-dev" 178 | }, 179 | "sebastian/diff": { 180 | "version": "2.0-dev" 181 | }, 182 | "sebastian/environment": { 183 | "version": "3.1.x-dev" 184 | }, 185 | "sebastian/exporter": { 186 | "version": "3.1.x-dev" 187 | }, 188 | "sebastian/global-state": { 189 | "version": "2.0-dev" 190 | }, 191 | "sebastian/object-enumerator": { 192 | "version": "3.0.x-dev" 193 | }, 194 | "sebastian/object-reflector": { 195 | "version": "1.1-dev" 196 | }, 197 | "sebastian/recursion-context": { 198 | "version": "3.0.x-dev" 199 | }, 200 | "sebastian/resource-operations": { 201 | "version": "1.0.x-dev" 202 | }, 203 | "sebastian/version": { 204 | "version": "2.0.x-dev" 205 | }, 206 | "sensio/framework-extra-bundle": { 207 | "version": "4.0", 208 | "recipe": { 209 | "repo": "github.com/symfony/recipes", 210 | "branch": "master", 211 | "version": "4.0", 212 | "ref": "aaddfdf43cdecd4cf91f992052d76c2cadc04543" 213 | } 214 | }, 215 | "symfony/asset": { 216 | "version": "3.4.x-dev" 217 | }, 218 | "symfony/cache": { 219 | "version": "4.1-dev" 220 | }, 221 | "symfony/config": { 222 | "version": "4.1-dev" 223 | }, 224 | "symfony/console": { 225 | "version": "3.3", 226 | "recipe": { 227 | "repo": "github.com/symfony/recipes", 228 | "branch": "master", 229 | "version": "3.3", 230 | "ref": "9f94d3ea453cd8a3b95db7f82592d7344fe3a76a" 231 | } 232 | }, 233 | "symfony/debug": { 234 | "version": "4.1-dev" 235 | }, 236 | "symfony/debug-bundle": { 237 | "version": "3.3", 238 | "recipe": { 239 | "repo": "github.com/symfony/recipes", 240 | "branch": "master", 241 | "version": "3.3", 242 | "ref": "de31e687f3964939abd1f66817bd96ed34bc2eee" 243 | } 244 | }, 245 | "symfony/dependency-injection": { 246 | "version": "4.1-dev" 247 | }, 248 | "symfony/doctrine-bridge": { 249 | "version": "4.1-dev" 250 | }, 251 | "symfony/dotenv": { 252 | "version": "3.4.x-dev" 253 | }, 254 | "symfony/event-dispatcher": { 255 | "version": "4.1-dev" 256 | }, 257 | "symfony/filesystem": { 258 | "version": "4.1-dev" 259 | }, 260 | "symfony/finder": { 261 | "version": "4.1-dev" 262 | }, 263 | "symfony/flex": { 264 | "version": "1.0", 265 | "recipe": { 266 | "repo": "github.com/symfony/recipes", 267 | "branch": "master", 268 | "version": "1.0", 269 | "ref": "e921bdbfe20cdefa3b82f379d1cd36df1bc8d115" 270 | } 271 | }, 272 | "symfony/form": { 273 | "version": "4.1-dev" 274 | }, 275 | "symfony/framework-bundle": { 276 | "version": "3.3", 277 | "recipe": { 278 | "repo": "github.com/symfony/recipes", 279 | "branch": "master", 280 | "version": "3.3", 281 | "ref": "c0fdace641ed81c02668d6bfc0fc76a2b39ee7c9" 282 | } 283 | }, 284 | "symfony/http-foundation": { 285 | "version": "4.1-dev" 286 | }, 287 | "symfony/http-kernel": { 288 | "version": "4.1-dev" 289 | }, 290 | "symfony/inflector": { 291 | "version": "4.1-dev" 292 | }, 293 | "symfony/intl": { 294 | "version": "4.1-dev" 295 | }, 296 | "symfony/monolog-bridge": { 297 | "version": "4.1-dev" 298 | }, 299 | "symfony/monolog-bundle": { 300 | "version": "3.1", 301 | "recipe": { 302 | "repo": "github.com/symfony/recipes", 303 | "branch": "master", 304 | "version": "3.1", 305 | "ref": "c24944bd87dacf0bb8fa218dc21e4a70fff56882" 306 | } 307 | }, 308 | "symfony/options-resolver": { 309 | "version": "4.1-dev" 310 | }, 311 | "symfony/orm-pack": { 312 | "version": "v1.0.4" 313 | }, 314 | "symfony/polyfill-intl-icu": { 315 | "version": "1.6-dev" 316 | }, 317 | "symfony/polyfill-mbstring": { 318 | "version": "1.6-dev" 319 | }, 320 | "symfony/polyfill-php72": { 321 | "version": "1.6-dev" 322 | }, 323 | "symfony/property-access": { 324 | "version": "4.1-dev" 325 | }, 326 | "symfony/routing": { 327 | "version": "3.3", 328 | "recipe": { 329 | "repo": "github.com/symfony/recipes", 330 | "branch": "master", 331 | "version": "3.3", 332 | "ref": "a249484db698d1a847a30291c8f732414ac47e25" 333 | } 334 | }, 335 | "symfony/security": { 336 | "version": "4.1-dev" 337 | }, 338 | "symfony/security-bundle": { 339 | "version": "3.3", 340 | "recipe": { 341 | "repo": "github.com/symfony/recipes", 342 | "branch": "master", 343 | "version": "3.3", 344 | "ref": "85834af1496735f28d831489d12ab1921a875e0d" 345 | } 346 | }, 347 | "symfony/translation": { 348 | "version": "3.3", 349 | "recipe": { 350 | "repo": "github.com/symfony/recipes", 351 | "branch": "master", 352 | "version": "3.3", 353 | "ref": "124be9a2b7cc035fd8c56f4fd2e19c907c1f3fb8" 354 | } 355 | }, 356 | "symfony/twig-bridge": { 357 | "version": "4.1-dev" 358 | }, 359 | "symfony/twig-bundle": { 360 | "version": "3.3", 361 | "recipe": { 362 | "repo": "github.com/symfony/recipes", 363 | "branch": "master", 364 | "version": "3.3", 365 | "ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f" 366 | } 367 | }, 368 | "symfony/validator": { 369 | "version": "3.4.x-dev" 370 | }, 371 | "symfony/var-dumper": { 372 | "version": "4.1-dev" 373 | }, 374 | "symfony/yaml": { 375 | "version": "3.4.x-dev" 376 | }, 377 | "theseer/tokenizer": { 378 | "version": "1.1.0" 379 | }, 380 | "twig/twig": { 381 | "version": "2.x-dev" 382 | }, 383 | "webmozart/assert": { 384 | "version": "1.3-dev" 385 | }, 386 | "zendframework/zend-code": { 387 | "version": "3.3-dev" 388 | }, 389 | "zendframework/zend-eventmanager": { 390 | "version": "3.3-dev" 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Sitepoint Image Blog!{% endblock %} 8 | 9 | {% block stylesheets %} 10 | 11 | 14 | 15 | 16 | 17 | 18 | {% endblock %} 19 | 20 | 21 | 22 | {% block body %} 23 | 24 | {% block header %} 25 | {% include 'partials/header.html.twig' %} 26 | {% endblock header %} 27 | 28 | {% block flashMessages %} 29 | {% set flashes = app.flashes(['warning', 'info','notice', 'error', 'success']) %} 30 | {% if flashes|length > 0 %} 31 |
32 | {% for label, messages in flashes %} 33 | {% for message in messages %} 34 |
35 | {{ message }} 36 |
37 | {% endfor %} 38 | {% endfor %} 39 |
40 | {% endif %} 41 | {% endblock %} 42 | 43 | {% block content %} 44 | {% endblock content %} 45 | 46 | {% endblock %} 47 | 48 | {% block javascripts %} 49 | 50 | 51 | 54 | 57 | 60 | 61 | 62 | 71 | 72 | {% endblock %} 73 | 74 | -------------------------------------------------------------------------------- /templates/gallery/edit-gallery.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 | 29 | 30 |
31 | 32 | {% endblock %} 33 | 34 | {% block javascripts %} 35 | {{ parent() }} 36 | 51 | {% endblock %} -------------------------------------------------------------------------------- /templates/gallery/partials/_newest-galleries.html.twig: -------------------------------------------------------------------------------- 1 |
2 | 3 |
Newest galleries
4 | 5 |
6 | {% for gallery in galleries %} 7 |
8 | {% include 'gallery/partials/gallery-list-item.html.twig' %} 9 |
10 | {% endfor %} 11 |
12 |
-------------------------------------------------------------------------------- /templates/gallery/partials/_related-galleries.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/gallery/partials/gallery-list-item.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/gallery/single-gallery.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 | 68 | 69 | 70 |
71 | 72 | 85 | 86 | {% endblock %} 87 | 88 | {% block javascripts %} 89 | {{ parent() }} 90 | 91 | 105 | {% endblock %} -------------------------------------------------------------------------------- /templates/gallery/upload.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 |

Upload images

7 | 8 | 28 | 29 | 30 |
31 | {% endblock %} 32 | 33 | {% block javascripts %} 34 | {{ parent() }} 35 | 98 | {% endblock %} -------------------------------------------------------------------------------- /templates/home.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |
8 | 9 |
10 | {% include 'partials/home-galleries-lazy-load.html.twig' %} 11 |
12 | 13 | 18 | 19 |
20 | 21 |
22 | 23 | {% endblock %} 24 | 25 | 26 | {% block javascripts %} 27 | {{ parent() }} 28 | 29 | 61 | {% endblock %} -------------------------------------------------------------------------------- /templates/image/edit-image.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 | 16 | 17 |
18 |
19 | {{ image.originalFilename }} 20 |
21 | 22 |
23 | 24 | {{ form_start(form) }} 25 | {{ form_widget(form) }} 26 | 27 | 30 | 31 | {{ form_end(form) }} 32 | 33 |
34 | 35 |
36 | 38 | Delete image 39 | 40 |
41 | 42 |
43 | 44 |
45 | 46 | {% endblock %} 47 | 48 | {% block javascripts %} 49 | {{ parent() }} 50 | 65 | {% endblock %} -------------------------------------------------------------------------------- /templates/partials/header.html.twig: -------------------------------------------------------------------------------- 1 |
2 | {% if app.user %} 3 |
4 | {{ app.user.email }} 5 |
6 | {% endif %} 7 | 8 |
9 | 33 |

34 | 35 | Sitepoint Image Blog 36 | 37 |

38 |
39 |
-------------------------------------------------------------------------------- /templates/partials/home-galleries-lazy-load.html.twig: -------------------------------------------------------------------------------- 1 | {% for gallery in galleries %} 2 | 3 | 27 | {% endfor %} -------------------------------------------------------------------------------- /templates/security/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | {% if error %} 7 |
8 | {{ error.message }} 9 |
10 | {#
{{ error.messageKey|trans(error.messageData, 'security') }}
#} 11 | {% endif %} 12 | 13 | {% if not app.user %} 14 | 15 |
16 |
17 | 18 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 | 30 |
31 | {% else %} 32 | 33 | Hello {{ app.user.email }} 34 | 35 | {% endif %} 36 |
37 | 38 | {% endblock %} -------------------------------------------------------------------------------- /templates/security/registration.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 | 7 |

Registration

8 | 9 | {{ form_start(form) }} 10 | {{ form_widget(form) }} 11 | 12 | 15 | 16 | {{ form_end(form) }} 17 | 18 |
19 | 20 | {% endblock %} -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/tests/.gitignore -------------------------------------------------------------------------------- /tests/SmokeTest.php: -------------------------------------------------------------------------------- 1 | request('GET', $url); 22 | 23 | $this->assertTrue($client->getResponse()->isSuccessful()); 24 | } 25 | 26 | public function urlProvider() 27 | { 28 | $client = self::createClient(); 29 | $this->container = $client->getContainer(); 30 | 31 | $urls = [ 32 | ['/'], 33 | ]; 34 | 35 | $urls += $this->getGalleriesUrls(); 36 | 37 | return $urls; 38 | } 39 | 40 | private function getGalleriesUrls() 41 | { 42 | $router = $this->container->get('router'); 43 | $doctrine = $this->container->get('doctrine'); 44 | $galleries = $doctrine->getRepository(Gallery::class)->findBy([], null, 5); 45 | 46 | $urls = []; 47 | 48 | /** @var Gallery $gallery */ 49 | foreach ($galleries as $gallery) { 50 | $urls[] = [ 51 | '/' . $router->generate('gallery.single-gallery', ['id' => $gallery->getId()], 52 | RouterInterface::RELATIVE_PATH), 53 | ]; 54 | } 55 | 56 | return $urls; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/translations/.gitignore -------------------------------------------------------------------------------- /var/demo-data/sample-images/image1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image1.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image10.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image11.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image11.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image12.jpg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image13.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image13.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image14.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image15.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image15.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image2.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image3.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image4.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image5.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image6.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image7.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image8.jpeg -------------------------------------------------------------------------------- /var/demo-data/sample-images/image9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/demo-data/sample-images/image9.jpeg -------------------------------------------------------------------------------- /var/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitepoint-editors/multi-image-gallery-blog/d873d16c06ffde2d9b30383583cf7626be316068/var/placeholder.jpg --------------------------------------------------------------------------------