From 15b632f48e074a23878ab3f211c8f6051df269cb Mon Sep 17 00:00:00 2001 From: Slava Semushin Date: Tue, 28 Feb 2017 21:21:18 +0100 Subject: [PATCH] Add support for generating image previews. --- .travis.yml | 2 +- NEWS.txt | 1 + docker/Dockerfile | 6 +- pom.xml | 7 +++ src/main/java/ru/mystamps/web/Url.java | 5 +- .../mystamps/web/config/ServicesConfig.java | 1 + .../mystamps/web/config/StrategiesConfig.java | 3 +- .../web/controller/ImageController.java | 22 +++++++ .../ru/mystamps/web/dao/ImageDataDao.java | 2 +- .../web/dao/dto/AddImageDataDbDto.java | 1 + .../web/dao/impl/JdbcImageDataDao.java | 10 +++- .../DatabaseImagePersistenceStrategy.java | 26 +++++++- .../FilesystemImagePersistenceStrategy.java | 57 ++++++++++++++++-- .../web/service/ImagePersistenceStrategy.java | 2 + .../web/service/ImagePreviewStrategy.java | 22 +++++++ .../ru/mystamps/web/service/ImageService.java | 1 + .../web/service/ImageServiceImpl.java | 48 +++++++++++++++ .../ThumbnailatorImagePreviewStrategy.java | 57 ++++++++++++++++++ .../service/TimedImagePreviewStrategy.java | 60 +++++++++++++++++++ .../CreateImagePreviewException.java | 34 +++++++++++ .../mystamps/web/support/togglz/Features.java | 4 ++ .../resources/application-travis.properties | 1 + src/main/resources/liquibase/version/0.4.xml | 1 + .../version/0.4/2017-05-11--image_preview.xml | 27 +++++++++ .../sql/image_dao_queries.properties | 5 +- .../webapp/WEB-INF/views/series/info.html | 2 +- ...atabaseImagePersistenceStrategyTest.groovy | 7 ++- ...esystemImagePersistenceStrategyTest.groovy | 3 +- .../web/service/ImageServiceImplTest.groovy | 40 ++++++++++++- .../web/tests/cases/WhenUserAddSeries.java | 4 +- .../roles/mystamps-app/tasks/main.yml | 9 +++ .../templates/application-prod.properties | 1 + 32 files changed, 448 insertions(+), 23 deletions(-) create mode 100644 src/main/java/ru/mystamps/web/service/ImagePreviewStrategy.java create mode 100644 src/main/java/ru/mystamps/web/service/ThumbnailatorImagePreviewStrategy.java create mode 100644 src/main/java/ru/mystamps/web/service/TimedImagePreviewStrategy.java create mode 100644 src/main/java/ru/mystamps/web/service/exception/CreateImagePreviewException.java create mode 100644 src/main/resources/liquibase/version/0.4/2017-05-11--image_preview.xml diff --git a/.travis.yml b/.travis.yml index 53029b403d..81952f4bf9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: before_script: - if [ "$SPRING_PROFILES_ACTIVE" = 'travis' ]; then mysql -u travis -e 'CREATE DATABASE mystamps CHARACTER SET utf8;'; - mkdir -p /tmp/uploads; + mkdir -p /tmp/uploads /tmp/preview; if [ "$TRAVIS_BRANCH" = 'prod' ]; then pip install --user ansible==2.1.1.0; fi; diff --git a/NEWS.txt b/NEWS.txt index 34c3da4e80..b177cae705 100644 --- a/NEWS.txt +++ b/NEWS.txt @@ -13,6 +13,7 @@ - (functionality) add possibility for adding series sales - (functionality) add interface for viewing series sales (contributed by Sergey Chechenev) - (functionality) name of category/country in Russian now are optional fields +- (functionality) preview now is generated after uploading an image 0.3 - (functionality) implemented possibility to user to add series to his collection diff --git a/docker/Dockerfile b/docker/Dockerfile index cf9868dae1..c9193e6e97 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -16,12 +16,12 @@ ENV SPRING_PROFILES_ACTIVE=test # See also: https://docs.docker.com/engine/reference/builder/#run RUN mkdir /data \ && useradd mystamps --home-dir /data/mystamps --create-home --comment 'MyStamps' \ - && mkdir /data/uploads /data/heap-dumps \ - && chown mystamps:mystamps /data/uploads /data/heap-dumps + && mkdir /data/uploads /data/preview /data/heap-dumps \ + && chown mystamps:mystamps /data/uploads /data/preview /data/heap-dumps # Creates mount points and marks them as holding externally mounted volumes from native host. # See also: https://docs.docker.com/engine/reference/builder/#volume -VOLUME /data/uploads /data/heap-dumps +VOLUME /data/uploads /data/preview /data/heap-dumps # Sets the user name to use when running the image and for any subsequent RUN, CMD and ENTRYPOINT instructions. # See also: https://docs.docker.com/engine/reference/builder/#user diff --git a/pom.xml b/pom.xml index 7cacc229c8..fa373a9a3a 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,12 @@ ${javax.validation.version} + + net.coobird + thumbnailator + ${thumbnailator.version} + + org.apache.commons commons-lang3 @@ -521,6 +527,7 @@ 3.1.7 2.19.1 6.8.8 + 0.4.8 3.0.2.RELEASE diff --git a/src/main/java/ru/mystamps/web/Url.java b/src/main/java/ru/mystamps/web/Url.java index d8af9ae75a..6af2cf2001 100644 --- a/src/main/java/ru/mystamps/web/Url.java +++ b/src/main/java/ru/mystamps/web/Url.java @@ -64,7 +64,8 @@ public final class Url { public static final String INFO_COLLECTION_PAGE = "/collection/{slug}"; - public static final String GET_IMAGE_PAGE = "/image/{id}"; + public static final String GET_IMAGE_PAGE = "/image/{id}"; + public static final String GET_IMAGE_PREVIEW_PAGE = "/image/preview/{id}"; public static final String FORBIDDEN_PAGE = "/error/403"; public static final String NOT_FOUND_PAGE = "/error/404"; @@ -145,6 +146,7 @@ public static Map asMap(boolean serveContentFromSingleHost) { map.put("SELECTIZE_CSS", SELECTIZE_CSS); map.put("SELECTIZE_JS", SELECTIZE_JS); map.put("GET_IMAGE_PAGE", GET_IMAGE_PAGE); + map.put("GET_IMAGE_PREVIEW_PAGE", GET_IMAGE_PREVIEW_PAGE); map.put("FAVICON_ICO", FAVICON_ICO); map.put("MAIN_CSS", MAIN_CSS); map.put("CATALOG_UTILS_JS", CATALOG_UTILS_JS); @@ -153,6 +155,7 @@ public static Map asMap(boolean serveContentFromSingleHost) { } else { // Use separate domain for our own resources map.put("GET_IMAGE_PAGE", STATIC_RESOURCES_URL + GET_IMAGE_PAGE); + map.put("GET_IMAGE_PREVIEW_PAGE", STATIC_RESOURCES_URL + GET_IMAGE_PREVIEW_PAGE); map.put("FAVICON_ICO", STATIC_RESOURCES_URL + FAVICON_ICO); map.put("MAIN_CSS", STATIC_RESOURCES_URL + MAIN_CSS); map.put("CATALOG_UTILS_JS", STATIC_RESOURCES_URL + CATALOG_UTILS_JS); diff --git a/src/main/java/ru/mystamps/web/config/ServicesConfig.java b/src/main/java/ru/mystamps/web/config/ServicesConfig.java index 550fbe70f9..e9deec8e57 100644 --- a/src/main/java/ru/mystamps/web/config/ServicesConfig.java +++ b/src/main/java/ru/mystamps/web/config/ServicesConfig.java @@ -80,6 +80,7 @@ public CronService getCronService() { public ImageService getImageService() { return new ImageServiceImpl( strategiesConfig.getImagePersistenceStrategy(), + new TimedImagePreviewStrategy(new ThumbnailatorImagePreviewStrategy()), daoConfig.getImageDao() ); } diff --git a/src/main/java/ru/mystamps/web/config/StrategiesConfig.java b/src/main/java/ru/mystamps/web/config/StrategiesConfig.java index b999345e1a..717b11cc9e 100644 --- a/src/main/java/ru/mystamps/web/config/StrategiesConfig.java +++ b/src/main/java/ru/mystamps/web/config/StrategiesConfig.java @@ -57,7 +57,8 @@ class FsStrategiesConfig implements StrategiesConfig { @Override public ImagePersistenceStrategy getImagePersistenceStrategy() { return new FilesystemImagePersistenceStrategy( - env.getRequiredProperty("app.upload.dir") + env.getRequiredProperty("app.upload.dir"), + env.getRequiredProperty("app.preview.dir") ); } diff --git a/src/main/java/ru/mystamps/web/controller/ImageController.java b/src/main/java/ru/mystamps/web/controller/ImageController.java index 0fa1634652..f36033ddbc 100644 --- a/src/main/java/ru/mystamps/web/controller/ImageController.java +++ b/src/main/java/ru/mystamps/web/controller/ImageController.java @@ -60,5 +60,27 @@ public void getImage(@PathVariable("id") Integer imageId, HttpServletResponse re response.getOutputStream().write(image.getData()); } + @GetMapping(Url.GET_IMAGE_PREVIEW_PAGE) + public void getImagePreview(@PathVariable("id") Integer imageId, HttpServletResponse response) + throws IOException { + + if (imageId == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + ImageDto image = imageService.getOrCreatePreview(imageId); + if (image == null) { + // return original image when error has occurred + getImage(imageId, response); + return; + } + + response.setContentType("image/" + image.getType().toLowerCase(Locale.ENGLISH)); + response.setContentLength(image.getData().length); + + response.getOutputStream().write(image.getData()); + } + } diff --git a/src/main/java/ru/mystamps/web/dao/ImageDataDao.java b/src/main/java/ru/mystamps/web/dao/ImageDataDao.java index dd49a9f855..84ca881ee3 100644 --- a/src/main/java/ru/mystamps/web/dao/ImageDataDao.java +++ b/src/main/java/ru/mystamps/web/dao/ImageDataDao.java @@ -21,6 +21,6 @@ import ru.mystamps.web.dao.dto.DbImageDto; public interface ImageDataDao { - DbImageDto findByImageId(Integer imageId); + DbImageDto findByImageId(Integer imageId, boolean preview); Integer add(AddImageDataDbDto imageData); } diff --git a/src/main/java/ru/mystamps/web/dao/dto/AddImageDataDbDto.java b/src/main/java/ru/mystamps/web/dao/dto/AddImageDataDbDto.java index 1717238da1..e8332e8e8b 100644 --- a/src/main/java/ru/mystamps/web/dao/dto/AddImageDataDbDto.java +++ b/src/main/java/ru/mystamps/web/dao/dto/AddImageDataDbDto.java @@ -25,4 +25,5 @@ public class AddImageDataDbDto { private Integer imageId; private byte[] content; + private boolean preview; } diff --git a/src/main/java/ru/mystamps/web/dao/impl/JdbcImageDataDao.java b/src/main/java/ru/mystamps/web/dao/impl/JdbcImageDataDao.java index 9a2c661d33..b43a85f953 100644 --- a/src/main/java/ru/mystamps/web/dao/impl/JdbcImageDataDao.java +++ b/src/main/java/ru/mystamps/web/dao/impl/JdbcImageDataDao.java @@ -17,7 +17,6 @@ */ package ru.mystamps.web.dao.impl; -import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -48,11 +47,15 @@ public class JdbcImageDataDao implements ImageDataDao { private String addImageDataSql; @Override - public DbImageDto findByImageId(Integer imageId) { + public DbImageDto findByImageId(Integer imageId, boolean preview) { + Map params = new HashMap<>(); + params.put("image_id", imageId); + params.put("preview", preview); + try { return jdbcTemplate.queryForObject( findByImageIdSql, - Collections.singletonMap("image_id", imageId), + params, RowMappers::forDbImageDto ); } catch (EmptyResultDataAccessException ignored) { @@ -65,6 +68,7 @@ public Integer add(AddImageDataDbDto imageData) { Map params = new HashMap<>(); params.put("image_id", imageData.getImageId()); params.put("content", imageData.getContent()); + params.put("preview", imageData.isPreview()); KeyHolder holder = new GeneratedKeyHolder(); diff --git a/src/main/java/ru/mystamps/web/service/DatabaseImagePersistenceStrategy.java b/src/main/java/ru/mystamps/web/service/DatabaseImagePersistenceStrategy.java index ebe4eded87..64dd14ba68 100644 --- a/src/main/java/ru/mystamps/web/service/DatabaseImagePersistenceStrategy.java +++ b/src/main/java/ru/mystamps/web/service/DatabaseImagePersistenceStrategy.java @@ -53,6 +53,7 @@ public void save(MultipartFile file, ImageInfoDto image) { AddImageDataDbDto imageData = new AddImageDataDbDto(); imageData.setImageId(image.getId()); imageData.setContent(file.getBytes()); + imageData.setPreview(false); Integer id = imageDataDao.add(imageData); LOG.info("Image #{}: meta data has been saved to #{}", image.getId(), id); @@ -63,9 +64,21 @@ public void save(MultipartFile file, ImageInfoDto image) { } } + @Override + public void savePreview(byte[] data, ImageInfoDto image) { + AddImageDataDbDto imageData = new AddImageDataDbDto(); + imageData.setImageId(image.getId()); + imageData.setContent(data); + imageData.setPreview(true); + + imageDataDao.add(imageData); + + LOG.info("Image #{}: preview has been saved", image.getId()); + } + @Override public ImageDto get(ImageInfoDto image) { - DbImageDto imageDto = imageDataDao.findByImageId(image.getId()); + DbImageDto imageDto = imageDataDao.findByImageId(image.getId(), false); if (imageDto == null) { LOG.warn("Image #{}: content not found", image.getId()); return null; @@ -74,4 +87,15 @@ public ImageDto get(ImageInfoDto image) { return imageDto; } + @Override + public ImageDto getPreview(ImageInfoDto image) { + DbImageDto imageDto = imageDataDao.findByImageId(image.getId(), true); + if (imageDto == null) { + LOG.info("Image #{}: preview not found", image.getId()); + return null; + } + + return imageDto; + } + } diff --git a/src/main/java/ru/mystamps/web/service/FilesystemImagePersistenceStrategy.java b/src/main/java/ru/mystamps/web/service/FilesystemImagePersistenceStrategy.java index 4512da6bab..9aac42dfb0 100644 --- a/src/main/java/ru/mystamps/web/service/FilesystemImagePersistenceStrategy.java +++ b/src/main/java/ru/mystamps/web/service/FilesystemImagePersistenceStrategy.java @@ -40,9 +40,11 @@ public class FilesystemImagePersistenceStrategy implements ImagePersistenceStrat LoggerFactory.getLogger(FilesystemImagePersistenceStrategy.class); private final File storageDir; + private final File previewDir; - public FilesystemImagePersistenceStrategy(String storageDir) { + public FilesystemImagePersistenceStrategy(String storageDir, String previewDir) { this.storageDir = new File(storageDir); + this.previewDir = new File(previewDir); } @PostConstruct @@ -54,11 +56,29 @@ public void init() { } else if (!storageDir.canWrite()) { LOG.warn( + // TODO(java9): log also user: ProcessHandle.current().info().user() "Directory '{}' exists but isn't writable for the current user! " + "Image uploading won't work.", storageDir ); } + + LOG.info("Image previews will be saved into {} directory", previewDir); + + if (!previewDir.exists()) { // NOPMD: ConfusingTernary (it's ok for me) + LOG.warn( + "Directory '{}' doesn't exist! Image preview generation won't work", + previewDir + ); + + } else if (!previewDir.canWrite()) { + // TODO(java9): log also user: ProcessHandle.current().info().user() + LOG.warn( + "Directory '{}' exists but isn't writable for the current user! " + + "Image preview generation won't work", + previewDir + ); + } } @Override @@ -74,9 +94,27 @@ public void save(MultipartFile file, ImageInfoDto image) { } } + @Override + public void savePreview(byte[] data, ImageInfoDto image) { + try { + Path dest = generateFilePath(previewDir, image); + writeToFile(data, dest); + + LOG.info("Image preview data has been written into file {}", dest); + + } catch (IOException ex) { + throw new ImagePersistenceException(ex); + } + } + @Override public ImageDto get(ImageInfoDto image) { - return get(storageDir, image); + return get(storageDir, image, true); + } + + @Override + public ImageDto getPreview(ImageInfoDto image) { + return get(previewDir, image, false); } // protected to allow spying @@ -93,6 +131,11 @@ protected void writeToFile(MultipartFile file, Path dest) throws IOException { Files.copy(file.getInputStream(), dest); } + // protected to allow spying + protected void writeToFile(byte[] data, Path dest) throws IOException { + Files.write(dest, data); + } + // protected to allow spying protected boolean exists(Path path) { return Files.exists(path); @@ -103,10 +146,16 @@ protected byte[] toByteArray(Path dest) throws IOException { return Files.readAllBytes(dest); } - private ImageDto get(File dir, ImageInfoDto image) { + private ImageDto get(File dir, ImageInfoDto image, boolean logWarning) { Path dest = generateFilePath(dir, image); if (!exists(dest)) { - LOG.warn("Found image without content: #{} ({} doesn't exist)", image.getId(), dest); + if (logWarning) { + LOG.warn( + "Image #{}: content not found ({} doesn't exist)", + image.getId(), + dest + ); + } return null; } diff --git a/src/main/java/ru/mystamps/web/service/ImagePersistenceStrategy.java b/src/main/java/ru/mystamps/web/service/ImagePersistenceStrategy.java index f7cbfaf2fc..12292de776 100644 --- a/src/main/java/ru/mystamps/web/service/ImagePersistenceStrategy.java +++ b/src/main/java/ru/mystamps/web/service/ImagePersistenceStrategy.java @@ -24,5 +24,7 @@ public interface ImagePersistenceStrategy { void save(MultipartFile file, ImageInfoDto image); + void savePreview(byte[] data, ImageInfoDto image); ImageDto get(ImageInfoDto image); + ImageDto getPreview(ImageInfoDto image); } diff --git a/src/main/java/ru/mystamps/web/service/ImagePreviewStrategy.java b/src/main/java/ru/mystamps/web/service/ImagePreviewStrategy.java new file mode 100644 index 0000000000..278b9807b3 --- /dev/null +++ b/src/main/java/ru/mystamps/web/service/ImagePreviewStrategy.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2009-2017 Slava Semushin + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package ru.mystamps.web.service; + +public interface ImagePreviewStrategy { + byte[] createPreview(byte[] image); +} diff --git a/src/main/java/ru/mystamps/web/service/ImageService.java b/src/main/java/ru/mystamps/web/service/ImageService.java index 144ea9bc6b..1569603b04 100644 --- a/src/main/java/ru/mystamps/web/service/ImageService.java +++ b/src/main/java/ru/mystamps/web/service/ImageService.java @@ -26,6 +26,7 @@ public interface ImageService { Integer save(MultipartFile file); ImageDto get(Integer imageId); + ImageDto getOrCreatePreview(Integer imageId); void addToSeries(Integer seriesId, Integer imageId); List findBySeriesId(Integer seriesId); } diff --git a/src/main/java/ru/mystamps/web/service/ImageServiceImpl.java b/src/main/java/ru/mystamps/web/service/ImageServiceImpl.java index a6b8fa0119..d533e768bc 100644 --- a/src/main/java/ru/mystamps/web/service/ImageServiceImpl.java +++ b/src/main/java/ru/mystamps/web/service/ImageServiceImpl.java @@ -33,10 +33,13 @@ import lombok.RequiredArgsConstructor; import ru.mystamps.web.dao.ImageDao; +import ru.mystamps.web.dao.dto.DbImageDto; import ru.mystamps.web.dao.dto.ImageDto; import ru.mystamps.web.dao.dto.ImageInfoDto; +import ru.mystamps.web.service.exception.CreateImagePreviewException; import ru.mystamps.web.service.exception.ImagePersistenceException; import ru.mystamps.web.support.spring.security.HasAuthority; +import ru.mystamps.web.support.togglz.Features; import static org.apache.commons.lang3.StringUtils.substringAfter; import static org.apache.commons.lang3.StringUtils.substringBefore; @@ -46,6 +49,7 @@ public class ImageServiceImpl implements ImageService { private static final Logger LOG = LoggerFactory.getLogger(ImageServiceImpl.class); private final ImagePersistenceStrategy imagePersistenceStrategy; + private final ImagePreviewStrategy imagePreviewStrategy; private final ImageDao imageDao; @Override @@ -94,6 +98,33 @@ public ImageDto get(Integer imageId) { return imagePersistenceStrategy.get(image); } + @Override + @Transactional + public ImageDto getOrCreatePreview(Integer imageId) { + Validate.isTrue(imageId != null, "Image id must be non null"); + Validate.isTrue(imageId > 0, "Image id must be greater than zero"); + + if (!Features.SHOW_IMAGES_PREVIEW.isActive()) { + return null; + } + + ImageInfoDto previewInfo = new ImageInfoDto(imageId, "jpeg"); + + // NB: the race between getPreview() and createReview() is possible. + // If this happens, the last request will overwrite the first. + ImageDto image = imagePersistenceStrategy.getPreview(previewInfo); + if (image != null) { + return image; + } + + image = get(imageId); + if (image == null) { + return null; + } + + return createPreview(previewInfo, image.getData()); + } + @Override @Transactional @PreAuthorize(HasAuthority.CREATE_SERIES) @@ -114,6 +145,23 @@ public List findBySeriesId(Integer seriesId) { return imageDao.findBySeriesId(seriesId); } + private ImageDto createPreview(ImageInfoDto previewInfo, byte[] image) { + try { + byte[] preview = imagePreviewStrategy.createPreview(image); + + imagePersistenceStrategy.savePreview(preview, previewInfo); + + return new DbImageDto("jpeg", preview); + + } catch (CreateImagePreviewException | ImagePersistenceException ex) { + LOG.warn( + String.format("Image #%d: couldn't create/save preview", previewInfo.getId()), + ex + ); + return null; + } + } + private static String extractExtensionFromContentType(String contentType) { // "image/jpeg; charset=UTF-8" -> "jpeg" return substringBefore(substringAfter(contentType, "/"), ";"); diff --git a/src/main/java/ru/mystamps/web/service/ThumbnailatorImagePreviewStrategy.java b/src/main/java/ru/mystamps/web/service/ThumbnailatorImagePreviewStrategy.java new file mode 100644 index 0000000000..b06fd67b76 --- /dev/null +++ b/src/main/java/ru/mystamps/web/service/ThumbnailatorImagePreviewStrategy.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2009-2017 Slava Semushin + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package ru.mystamps.web.service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import net.coobird.thumbnailator.Thumbnails; + +import ru.mystamps.web.service.exception.CreateImagePreviewException; + +public class ThumbnailatorImagePreviewStrategy implements ImagePreviewStrategy { + + private static final int WIDTH = 250; + private static final int HEIGHT = 250; + + // The value could be between 0.0 and 1.0 where 0.0 indicates the minimum quality + // and 1.0 indicates the maximum quality. + private static final double QUALITY = 0.5; + + @Override + public byte[] createPreview(byte[] image) { + try { + ByteArrayOutputStream resultStream = new ByteArrayOutputStream(); + + Thumbnails.of(new ByteArrayInputStream(image)) + .size(WIDTH, HEIGHT) + .outputFormat("JPEG") + .outputQuality(QUALITY) + .toOutputStream(resultStream); + return resultStream.toByteArray(); + + } catch (IOException + | IllegalArgumentException + | IllegalStateException + | NullPointerException ex) { // NOPMD: AvoidCatchingNPE (Thumbnails.of() could throw it) + throw new CreateImagePreviewException("Can't create preview for an image", ex); + } + } + +} diff --git a/src/main/java/ru/mystamps/web/service/TimedImagePreviewStrategy.java b/src/main/java/ru/mystamps/web/service/TimedImagePreviewStrategy.java new file mode 100644 index 0000000000..824b7ccec0 --- /dev/null +++ b/src/main/java/ru/mystamps/web/service/TimedImagePreviewStrategy.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2009-2017 Slava Semushin + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package ru.mystamps.web.service; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.time.StopWatch; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class TimedImagePreviewStrategy implements ImagePreviewStrategy { + private static final Logger LOG = LoggerFactory.getLogger(TimedImagePreviewStrategy.class); + + private final ImagePreviewStrategy strategy; + + @Override + public byte[] createPreview(byte[] image) { + // Why we don't use Spring's StopWatch? + // 1) because its javadoc says that it's not intended for production + // 2) because we don't want to have strong dependencies on the Spring Framework + StopWatch timer = new StopWatch(); + + // start() and stop() may throw IllegalStateException and in this case it's possible + // that we won't generate anything or lose already generated result. I don't want to + // make method body too complicated by adding many try/catches and I believe that such + // exception will never happen because it would mean that we're using API in a wrong way. + timer.start(); + byte[] result = strategy.createPreview(image); + timer.stop(); + + LOG.debug( + "Image preview has been generated in {} msecs: {} -> {} bytes", + timer.getTime(), + // let's hope that it won't throw IllegalStateException :) + ArrayUtils.getLength(image), + ArrayUtils.getLength(result) + ); + + return result; + } + +} diff --git a/src/main/java/ru/mystamps/web/service/exception/CreateImagePreviewException.java b/src/main/java/ru/mystamps/web/service/exception/CreateImagePreviewException.java new file mode 100644 index 0000000000..5503629751 --- /dev/null +++ b/src/main/java/ru/mystamps/web/service/exception/CreateImagePreviewException.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2009-2017 Slava Semushin + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package ru.mystamps.web.service.exception; + +public class CreateImagePreviewException extends RuntimeException { + + public CreateImagePreviewException(String message) { + super(message); + } + + public CreateImagePreviewException(String message, Throwable cause) { + super(message, cause); + } + + public CreateImagePreviewException(Throwable cause) { + super(cause); + } + +} diff --git a/src/main/java/ru/mystamps/web/support/togglz/Features.java b/src/main/java/ru/mystamps/web/support/togglz/Features.java index 01548b7ce9..2873a892a1 100644 --- a/src/main/java/ru/mystamps/web/support/togglz/Features.java +++ b/src/main/java/ru/mystamps/web/support/togglz/Features.java @@ -56,6 +56,10 @@ public enum Features implements Feature { @EnabledByDefault SHOW_PURCHASES_AND_SALES, + @Label("/series/{id}: show images preview") + @EnabledByDefault + SHOW_IMAGES_PREVIEW, + @Label("/series/{id}: possibility of user to add series purchases and sales") @EnabledByDefault ADD_PURCHASES_AND_SALES, diff --git a/src/main/resources/application-travis.properties b/src/main/resources/application-travis.properties index faf28b47b0..2ab637f8fb 100644 --- a/src/main/resources/application-travis.properties +++ b/src/main/resources/application-travis.properties @@ -35,6 +35,7 @@ logging.level.org.springframework.web.servlet.handler.SimpleUrlHandlerMapping: W logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: WARN app.upload.dir: /tmp/uploads +app.preview.dir: /tmp/preview # Full list of autoconfiguration classes: # http://docs.spring.io/spring-boot/docs/1.4.x/reference/html/auto-configuration-classes.html diff --git a/src/main/resources/liquibase/version/0.4.xml b/src/main/resources/liquibase/version/0.4.xml index 917df2bf46..3b24e10c93 100644 --- a/src/main/resources/liquibase/version/0.4.xml +++ b/src/main/resources/liquibase/version/0.4.xml @@ -26,5 +26,6 @@ + diff --git a/src/main/resources/liquibase/version/0.4/2017-05-11--image_preview.xml b/src/main/resources/liquibase/version/0.4/2017-05-11--image_preview.xml new file mode 100644 index 0000000000..7497cdd8a8 --- /dev/null +++ b/src/main/resources/liquibase/version/0.4/2017-05-11--image_preview.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/sql/image_dao_queries.properties b/src/main/resources/sql/image_dao_queries.properties index 94e034593f..1574a71d82 100644 --- a/src/main/resources/sql/image_dao_queries.properties +++ b/src/main/resources/sql/image_dao_queries.properties @@ -20,17 +20,20 @@ SELECT d.content AS data \ FROM images_data d \ JOIN images i \ ON i.id = d.image_id \ - WHERE d.image_id = :image_id + WHERE d.image_id = :image_id \ + AND d.preview = :preview image_data.add = \ INSERT \ INTO images_data \ ( image_id \ , content \ + , preview \ ) \ VALUES \ ( :image_id \ , :content \ + , :preview \ ) image.add = \ diff --git a/src/main/webapp/WEB-INF/views/series/info.html b/src/main/webapp/WEB-INF/views/series/info.html index a56bbbe1dd..20f816500d 100644 --- a/src/main/webapp/WEB-INF/views/series/info.html +++ b/src/main/webapp/WEB-INF/views/series/info.html @@ -111,7 +111,7 @@ id="series-image-1" src="../../../../../test/resources/test.png" th:id="|series-image-${iter.count}|" - th:src="@{${GET_IMAGE_PAGE}(id=${imageId})}" /> + th:src="@{${GET_IMAGE_PREVIEW_PAGE}(id=${imageId})}" /> diff --git a/src/test/groovy/ru/mystamps/web/service/DatabaseImagePersistenceStrategyTest.groovy b/src/test/groovy/ru/mystamps/web/service/DatabaseImagePersistenceStrategyTest.groovy index bd86f10d0c..d0683dba5f 100644 --- a/src/test/groovy/ru/mystamps/web/service/DatabaseImagePersistenceStrategyTest.groovy +++ b/src/test/groovy/ru/mystamps/web/service/DatabaseImagePersistenceStrategyTest.groovy @@ -92,12 +92,15 @@ class DatabaseImagePersistenceStrategyTest extends Specification { 1 * imageDataDao.findByImageId({ Integer imageId -> assert imageId == expectedImageId return true + }, { Boolean preview -> + assert preview == false + return true }) } def "get() should return null when image data dao returned null"() { given: - imageDataDao.findByImageId(_ as Integer) >> null + imageDataDao.findByImageId(_ as Integer, _ as Boolean) >> null when: ImageDto result = strategy.get(imageInfoDto) then: @@ -108,7 +111,7 @@ class DatabaseImagePersistenceStrategyTest extends Specification { given: ImageDto expectedImageDto = TestObjects.createDbImageDto() and: - imageDataDao.findByImageId(_ as Integer) >> expectedImageDto + imageDataDao.findByImageId(_ as Integer, _ as Boolean) >> expectedImageDto when: ImageDto result = strategy.get(imageInfoDto) then: diff --git a/src/test/groovy/ru/mystamps/web/service/FilesystemImagePersistenceStrategyTest.groovy b/src/test/groovy/ru/mystamps/web/service/FilesystemImagePersistenceStrategyTest.groovy index d541fc9a02..b970dd200a 100644 --- a/src/test/groovy/ru/mystamps/web/service/FilesystemImagePersistenceStrategyTest.groovy +++ b/src/test/groovy/ru/mystamps/web/service/FilesystemImagePersistenceStrategyTest.groovy @@ -30,13 +30,14 @@ import java.nio.file.Path @SuppressWarnings(['ClassJavadoc', 'MethodName', 'NoDef', 'NoTabCharacter', 'TrailingWhitespace']) class FilesystemImagePersistenceStrategyTest extends Specification { private static final STORAGE_DIR = File.separator + 'tmp' + private static final PREVIEW_DIR = File.separator + 'tmp' private final MultipartFile multipartFile = Mock() private final ImageInfoDto imageInfoDto = TestObjects.createImageInfoDto() private final Path mockFile = Mock(Path) private final ImagePersistenceStrategy strategy = - Spy(FilesystemImagePersistenceStrategy, constructorArgs:[STORAGE_DIR]) + Spy(FilesystemImagePersistenceStrategy, constructorArgs:[STORAGE_DIR, PREVIEW_DIR]) // // Tests for save() diff --git a/src/test/groovy/ru/mystamps/web/service/ImageServiceImplTest.groovy b/src/test/groovy/ru/mystamps/web/service/ImageServiceImplTest.groovy index 1e4f9d3fbb..341f5b5d49 100644 --- a/src/test/groovy/ru/mystamps/web/service/ImageServiceImplTest.groovy +++ b/src/test/groovy/ru/mystamps/web/service/ImageServiceImplTest.groovy @@ -32,9 +32,11 @@ class ImageServiceImplTest extends Specification { private final ImageDao imageDao = Mock() private final MultipartFile multipartFile = Mock() + private final ImagePreviewStrategy imagePreviewStrategy = Mock() private final ImagePersistenceStrategy imagePersistenceStrategy = Mock() - private final ImageService service = new ImageServiceImpl(imagePersistenceStrategy, imageDao) + private final ImageService service = + new ImageServiceImpl(imagePersistenceStrategy, imagePreviewStrategy, imageDao) def setup() { multipartFile.size >> 1024L @@ -222,6 +224,42 @@ class ImageServiceImplTest extends Specification { image == null } + // + // Tests for getOrCreatePreview() + // + + @Unroll + def "getOrCreatePreview() should throw exception if image id is #imageId"(Integer imageId) { + when: + service.getOrCreatePreview(imageId) + then: + thrown IllegalArgumentException + where: + imageId | _ + null | _ + -1 | _ + 0 | _ + } + + @SuppressWarnings(['ClosureAsLastMethodParameter', 'UnnecessaryReturnKeyword']) + def "getOrCreatePreview() should pass argument to strategy and return result from it"() { + given: + Integer expectedImageId = 7 + String expectedImageType = 'jpeg' + and: + ImageDto expectedImageDto = TestObjects.createDbImageDto() + when: + ImageDto actualImageDto = service.getOrCreatePreview(expectedImageId) + then: + 1 * imagePersistenceStrategy.getPreview({ ImageInfoDto passedImage -> + assert passedImage?.id == expectedImageId + assert passedImage?.type == expectedImageType + return true + }) >> expectedImageDto + and: + actualImageDto == expectedImageDto + } + // // Tests for addToSeries() // diff --git a/src/test/java/ru/mystamps/web/tests/cases/WhenUserAddSeries.java b/src/test/java/ru/mystamps/web/tests/cases/WhenUserAddSeries.java index 66a3c66bbf..046d2ca3a6 100644 --- a/src/test/java/ru/mystamps/web/tests/cases/WhenUserAddSeries.java +++ b/src/test/java/ru/mystamps/web/tests/cases/WhenUserAddSeries.java @@ -245,7 +245,7 @@ public void shouldCreateSeriesWithOnlyRequiredFieldsFilled() { String expectedCategoryName = validCategoryName; String expectedQuantity = "2"; String expectedPageUrl = Url.INFO_SERIES_PAGE.replace("{id}", "\\d+"); - String expectedImageUrl = Url.SITE + Url.GET_IMAGE_PAGE.replace("{id}", "\\d+"); + String expectedImageUrl = Url.SITE + Url.GET_IMAGE_PREVIEW_PAGE.replace("{id}", "\\d+"); page.fillCategory(expectedCategoryName); page.fillQuantity(expectedQuantity); @@ -270,7 +270,7 @@ public void shouldCreateSeriesWithOnlyRequiredFieldsFilled() { @Test(groups = "logic", dependsOnGroups = { "std", "valid", "invalid", "misc" }) public void shouldCreateSeriesWithAllFieldsFilled() { String expectedPageUrl = Url.INFO_SERIES_PAGE.replace("{id}", "\\d+"); - String expectedImageUrl = Url.SITE + Url.GET_IMAGE_PAGE.replace("{id}", "\\d+"); + String expectedImageUrl = Url.SITE + Url.GET_IMAGE_PREVIEW_PAGE.replace("{id}", "\\d+"); String expectedQuantity = "3"; String day = "8"; String month = "9"; diff --git a/vagrant/provisioning/roles/mystamps-app/tasks/main.yml b/vagrant/provisioning/roles/mystamps-app/tasks/main.yml index f3f3e7ae8d..13d69e33f6 100644 --- a/vagrant/provisioning/roles/mystamps-app/tasks/main.yml +++ b/vagrant/provisioning/roles/mystamps-app/tasks/main.yml @@ -68,6 +68,15 @@ state: directory when: profile == 'prod' +- name: Creating /data/preview + file: + path: /data/preview + owner: mystamps + group: mystamps + mode: '0755' + state: directory + when: profile == 'prod' + - name: Creating /data/mystamps/config file: path: /data/mystamps/config diff --git a/vagrant/provisioning/roles/mystamps-app/templates/application-prod.properties b/vagrant/provisioning/roles/mystamps-app/templates/application-prod.properties index 382d2dbbef..0112c9161a 100644 --- a/vagrant/provisioning/roles/mystamps-app/templates/application-prod.properties +++ b/vagrant/provisioning/roles/mystamps-app/templates/application-prod.properties @@ -35,6 +35,7 @@ logging.level.: INFO logging.level.ru.mystamps: DEBUG app.upload.dir: /data/uploads +app.preview.dir: /data/preview server.session.cookie.secure: true