com.github.henkexbg.gallery.controller.GalleryController Maven / Gradle / Ivy
Show all versions of gallery-api Show documentation
/**
* Copyright (c) 2016 Henrik Bjerne
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:The above copyright
* notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.github.henkexbg.gallery.controller;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.io.input.BoundedInputStream;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.HandlerMapping;
import com.github.henkexbg.gallery.bean.GalleryDirectory;
import com.github.henkexbg.gallery.bean.GalleryFile;
import com.github.henkexbg.gallery.bean.GalleryFile.GalleryFileType;
import com.github.henkexbg.gallery.controller.exception.RangeException;
import com.github.henkexbg.gallery.controller.exception.ResourceNotFoundException;
import com.github.henkexbg.gallery.controller.model.GalleryDirectoryHolder;
import com.github.henkexbg.gallery.controller.model.GalleryFileHolder;
import com.github.henkexbg.gallery.controller.model.ImageFormat;
import com.github.henkexbg.gallery.controller.model.ListingContext;
import com.github.henkexbg.gallery.service.GalleryService;
import com.github.henkexbg.gallery.service.exception.NotAllowedException;
/**
* This class contains the actual web endpoints for this application. Most of
* the logic revolves around extracting parameters, calling the appropriate
* methods on {@link GalleryService} (which deals with all the business logic),
* and then returning the relevant data. Most methods deal with returning JSON
* responses, but there are also endpoints for returning images or ranged parts
* of a video.
* Actual service logic is delegated to service class. This class is more
* concerned with handling web-related things, such as taking input from the
* request, and adapting the response, including last modified checks.
*
* @author Henrik Bjerne
*
*/
@Controller
public class GalleryController {
private final Logger LOG = LoggerFactory.getLogger(getClass());
private static final String SERVICE_PATH = "/service/";
private List imageFormats;
private GalleryService galleryService;
private boolean allowCustomImageSizes = false;
private boolean separateImagesAndVideos = false;
private String mediaResourcesCacheHeader;
@Autowired
public void setGalleryService(GalleryService galleryService) {
this.galleryService = galleryService;
}
@Autowired
public void setImageFormats(List imageFormats) {
this.imageFormats = imageFormats;
}
@Value("${gallery.allowCustomImageSizes}")
public void setAllowCustomImageSizes(boolean allowCustomImageSizes) {
this.allowCustomImageSizes = allowCustomImageSizes;
}
@Value("${gallery.separateImagesAndVideos}")
public void setSeparateImagesAndVideos(boolean separateImagesAndVideos) {
this.separateImagesAndVideos = separateImagesAndVideos;
}
@Value("${gallery.mediaResourcesCacheHeader}")
public void setMediaResourcesCacheHeader(String mediaResourcesCacheHeader) {
this.mediaResourcesCacheHeader = mediaResourcesCacheHeader;
}
/**
* Retrieves the listing for a given path (which can be empty). The response can
* contain media in the shape of {@link GalleryFileHolder} instances as well as
* sub-directories.
*
* @param servletRequest Servlet request
* @param model Spring web model
*
* @return A {@link ListingContext} instance.
* @throws IOException Sub-types of this exception are thrown for different
* scenarios, and the {@link IOException} itself for generic
* errors.
*/
@RequestMapping(value = SERVICE_PATH + "**", method = RequestMethod.GET)
public @ResponseBody ListingContext getListing(HttpServletRequest servletRequest, Model model) throws IOException {
String path = extractPathFromPattern(servletRequest);
LOG.debug("Entering getListing(path={})", path);
String contextPath = servletRequest.getContextPath();
try {
ListingContext listingContext = new ListingContext();
listingContext.setAllowCustomImageSizes(allowCustomImageSizes);
listingContext.setSeparateImagesAndVideos(separateImagesAndVideos);
listingContext.setImageFormats(imageFormats);
listingContext.setVideoFormats(galleryService.getAvailableVideoModes());
if (StringUtils.isBlank(path)) {
listingContext.setDirectories(
convertToGalleryDirectoryHolders(contextPath, galleryService.getRootDirectories()));
} else {
listingContext.setCurrentPathDisplay(path);
listingContext.setPreviousPath(getPreviousPath(contextPath, path));
List directoryListing = galleryService.getDirectoryListingFiles(path);
if (directoryListing == null) {
throw new ResourceNotFoundException();
}
LOG.debug("{} media files found", directoryListing.size());
if (separateImagesAndVideos) {
List galleryImages = directoryListing.stream()
.filter(gi -> GalleryFileType.IMAGE.equals(gi.getType())).collect(Collectors.toList());
List listing = convertToGalleryFileHolders(contextPath, galleryImages);
listingContext.setImages(listing);
List galleryVideos = directoryListing.stream()
.filter(gi -> GalleryFileType.VIDEO.equals(gi.getType())).collect(Collectors.toList());
List videoHolders = convertToGalleryFileHolders(contextPath, galleryVideos);
listingContext.setVideos(videoHolders);
} else {
listingContext.setMedia(convertToGalleryFileHolders(contextPath, directoryListing));
}
listingContext.setDirectories(
convertToGalleryDirectoryHolders(contextPath, galleryService.getDirectories(path)));
}
return listingContext;
} catch (NotAllowedException noe) {
LOG.warn("Not allowing resource {}", path);
throw new ResourceNotFoundException();
} catch (FileNotFoundException fnfe) {
LOG.warn("Could not find resource {}", path);
throw new ResourceNotFoundException();
} catch (IOException ioe) {
LOG.error("Error when calling getImage", ioe);
throw ioe;
} catch (Exception e) {
LOG.error("Error when calling getListing", e);
throw e;
}
}
/**
* Requests an image with the given {@link ImageFormat}.
*
* @param request Spring request
* @param servletRequest Servlet request
* @param imageFormatCode Image format.
*
* @return The image as a stream with the appropriate response headers set or a
* not-modified response, (see
* {@link #returnResource(WebRequest, GalleryFile)}).
* @throws IOException Sub-types of this exception are thrown for different
* scenarios, and the {@link IOException} itself for generic
* errors.
*/
@RequestMapping(value = "/image/{imageFormat}/**", method = RequestMethod.GET)
public ResponseEntity getImage(WebRequest request, HttpServletRequest servletRequest,
@PathVariable(value = "imageFormat") String imageFormatCode) throws IOException {
String path = extractPathFromPattern(servletRequest);
LOG.debug("getImage(imageFormatCode={}, path={})", imageFormatCode, path);
try {
ImageFormat imageFormat = getImageFormatForCode(imageFormatCode);
if (imageFormat == null) {
throw new ResourceNotFoundException();
}
GalleryFile galleryFile = galleryService.getImage(path, imageFormat.getWidth(), imageFormat.getHeight());
return returnResource(request, galleryFile);
} catch (FileNotFoundException fnfe) {
LOG.warn("Could not find resource {}", path);
throw new ResourceNotFoundException();
} catch (NotAllowedException nae) {
LOG.warn("User was not allowed to access resource {}", path);
throw new ResourceNotFoundException();
} catch (IOException ioe) {
LOG.error("Error when calling getImage", ioe);
throw ioe;
}
}
/**
* Requests an image of a custom size. This method will return the image only if
* {@link #allowCustomImageSizes} is set to true.
*
* @param request Spring request
* @param servletRequest Servlet request
* @param width Width in pixels
* @param height Height in pixels
*
* @return The image as a stream with the appropriate response headers set or a
* not-modified response, (see
* {@link #returnResource(WebRequest, GalleryFile)}).
* @throws IOException Sub-types of this exception are thrown for different
* scenarios, and the {@link IOException} itself for generic
* errors.
*/
@RequestMapping(value = "/customImage/{width}/{height}/**", method = RequestMethod.GET)
public ResponseEntity getCustomImage(WebRequest request, HttpServletRequest servletRequest,
@PathVariable(value = "width") String width, @PathVariable(value = "height") String height)
throws IOException {
if (!allowCustomImageSizes) {
LOG.debug("Request for custom image was made despite allowCustomImageSizes being false.");
throw new ResourceNotFoundException();
}
String path = extractPathFromPattern(servletRequest);
LOG.debug("getCustomImage(width={}, height={}, path={})", width, height, path);
try {
int widthInt = Integer.parseInt(width);
int heightInt = Integer.parseInt(height);
if (widthInt <= 0 || heightInt <= 0) {
LOG.debug("Won't try to scale an image do negative dimensions...", path);
throw new ResourceNotFoundException();
}
GalleryFile galleryFile = galleryService.getImage(path, widthInt, heightInt);
return returnResource(request, galleryFile);
} catch (FileNotFoundException fnfe) {
LOG.warn("Could not find resource {}", path);
throw new ResourceNotFoundException();
} catch (NotAllowedException nae) {
LOG.warn("User was not allowed to access resource {}", path);
throw new ResourceNotFoundException();
} catch (NumberFormatException nfe) {
LOG.warn("Could not parse image dimensions {}", path);
throw new ResourceNotFoundException();
} catch (IOException ioe) {
LOG.error("Error when calling getImage", ioe);
throw ioe;
}
}
/**
* Requests a video of a certain format.
*
* @param request Spring request
* @param servletRequest Servlet request
* @param conversionFormat Video format
*
* @return The image as a stream with the appropriate response headers set or a
* not-modified response, (see
* {@link #returnResource(WebRequest, GalleryFile)}).
*
* @throws IOException Sub-types of this exception are thrown for different
* scenarios, and the {@link IOException} itself for generic
* errors.
*/
@RequestMapping(value = "/video/{conversionFormat}/**", method = RequestMethod.GET)
public ResponseEntity getVideo(WebRequest request, HttpServletRequest servletRequest,
@PathVariable(value = "conversionFormat") String conversionFormat) throws IOException {
String path = extractPathFromPattern(servletRequest);
LOG.debug("getVideo(path={}, conversionFormat={})", path, conversionFormat);
try {
// GalleryFile galleryFile = galleryService.getGalleryFile(path);
GalleryFile galleryFile = galleryService.getVideo(path, conversionFormat);
if (!GalleryFileType.VIDEO.equals(galleryFile.getType())) {
LOG.warn("File {} was not a video but {}. Throwing ResourceNotFoundException.", path,
galleryFile.getType());
throw new ResourceNotFoundException();
}
return returnResource(request, galleryFile);
} catch (FileNotFoundException fnfe) {
LOG.warn("Could not find resource {}", path);
throw new ResourceNotFoundException();
} catch (NotAllowedException nae) {
LOG.warn("User was not allowed to access resource {}", path);
throw new ResourceNotFoundException();
} catch (IOException ioe) {
LOG.error("Error when calling getVideo", ioe);
throw ioe;
}
}
/**
* Method used to return the binary of a gallery file (
* {@link GalleryFile#getActualFile()} ). This method handles 304 redirects (if
* file has not changed) and range headers if requested by browser. The range
* parts is particularly important for videos. The correct response status is
* set depending on the circumstances.
*
* NOTE: the range logic should NOT be considered a complete implementation -
* it's a bare minimum for making requests for byte ranges work.
*
* @param request Request
* @param galleryFile Gallery file
* @return The binary of the gallery file, or a 304 redirect, or a part of the
* file.
* @throws IOException If there is an issue accessing the binary file.
*/
private ResponseEntity returnResource(WebRequest request, GalleryFile galleryFile)
throws IOException {
LOG.debug("Entering returnResource()");
if (request.checkNotModified(galleryFile.getActualFile().lastModified())) {
return null;
}
File file = galleryFile.getActualFile();
String contentType = galleryFile.getContentType();
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
long[] ranges = getRangesFromHeader(rangeHeader);
long startPosition = ranges[0];
long fileTotalSize = file.length();
long endPosition = ranges[1] != 0 ? ranges[1] : fileTotalSize - 1;
long contentLength = endPosition - startPosition + 1;
LOG.debug("contentLength: {}, file length: {}", contentLength, fileTotalSize);
LOG.debug("Returning resource {} as inputstream. Start position: {}", file.getCanonicalPath(), startPosition);
InputStream boundedInputStream = new BoundedInputStream(new FileInputStream(file), endPosition + 1);
InputStream is = new BufferedInputStream(boundedInputStream, 65536);
InputStreamResource inputStreamResource = new InputStreamResource(is);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setCacheControl(mediaResourcesCacheHeader);
responseHeaders.setContentLength(contentLength);
responseHeaders.setContentType(MediaType.valueOf(contentType));
responseHeaders.add(HttpHeaders.ACCEPT_RANGES, "bytes");
if (StringUtils.isNotBlank(rangeHeader)) {
is.skip(startPosition);
String contentRangeResponseHeader = "bytes " + startPosition + "-" + endPosition + "/" + fileTotalSize;
responseHeaders.add(HttpHeaders.CONTENT_RANGE, contentRangeResponseHeader);
LOG.debug("{} was not null but {}. Adding header {} to response: {}", HttpHeaders.RANGE, rangeHeader,
HttpHeaders.CONTENT_RANGE, contentRangeResponseHeader);
}
HttpStatus status = (startPosition == 0 && contentLength == fileTotalSize) ? HttpStatus.OK
: HttpStatus.PARTIAL_CONTENT;
LOG.debug("Returning {}. Status: {}, content-type: {}, {}: {}, contentLength: {}", file, status, contentType,
HttpHeaders.CONTENT_RANGE, responseHeaders.get(HttpHeaders.CONTENT_RANGE), contentLength);
return new ResponseEntity(inputStreamResource, responseHeaders, status);
}
/**
* Extracts the request range header if present.
*
* @param rangeHeader Range header.
* @return a long[] which will always be the size 2. The first element is the
* start index, and the second the end index. If the end index is not
* set (which means till the end of the resource), 0 is returned in that
* field.
*/
private long[] getRangesFromHeader(String rangeHeader) {
LOG.debug("Range header: {}", rangeHeader);
long[] result = new long[2];
final String headerPrefix = "bytes=";
if (StringUtils.startsWith(rangeHeader, headerPrefix)) {
String[] splitRange = rangeHeader.substring(headerPrefix.length()).split("-");
try {
result[0] = Long.parseLong(splitRange[0]);
if (splitRange.length > 1) {
result[1] = Long.parseLong(splitRange[1]);
}
if (result[0] < 0 || (result[1] != 0 && result[0] > result[1])) {
throw new RangeException();
}
} catch (NumberFormatException nfe) {
throw new RangeException();
}
}
return result;
}
/**
* Helper method that retrieves the URL to the previous path if available.
* Previous in this case always means one step up in the hierarchy. It is
* assumed that this is always a directory i.e. the URL will point to the
* {@value #SERVICE_PATH} service.
*
* @param contextPath Webapp context path.
* @param path Webapp-specific path.
* @return The previous path, or null.
*/
private String getPreviousPath(String contextPath, String path) {
if (StringUtils.isBlank(path)) {
return null;
}
int lastIndexOfSlash = path.lastIndexOf('/');
if (lastIndexOfSlash == -1) {
return null;
}
return contextPath + SERVICE_PATH + path.substring(0, lastIndexOfSlash);
}
/**
* Converts a list of service layer {@link GalleryFile} objects to web model
* {@link GalleryFileHolder} objects.
*
* @param contextPath Webapp context path.
* @param galleryFiles List of gallery files.
* @return A list of gallery files holders
*/
private List convertToGalleryFileHolders(String contextPath, List galleryFiles) {
List galleryFileHolders = new ArrayList<>(galleryFiles.size());
galleryFiles.forEach(gf -> galleryFileHolders.add(convertToGalleryFileHolder(contextPath, gf)));
return galleryFileHolders;
}
/**
* Converts the provided service layer {@link GalleryFile} object to a web model
* {@link GalleryFileHolder} object. The public paths are appended to the
* appropriate services in question; a video for example is retrieved via a
* different URL than an image, and images are retrieved from different URLs
* depending on whether they are free size URLs or requested with a specific
* format.
*
* @param contextPath Webapp context path.
* @param galleryFile Gallery file.
* @return A gallery file holder
*/
private GalleryFileHolder convertToGalleryFileHolder(String contextPath, GalleryFile galleryFile) {
GalleryFileHolder galleryFileHolder = new GalleryFileHolder();
galleryFileHolder.setFilename(galleryFile.getActualFile().getName());
galleryFileHolder.setFreeSizePath(generateCustomImageUrlTemplate(contextPath, galleryFile));
galleryFileHolder.setFormatPath(generateDynamicImageUrl(contextPath, galleryFile));
if (GalleryFileType.VIDEO.equals(galleryFile.getType())) {
galleryFileHolder.setVideoPath(contextPath + "/video/{conversionFormat}/" + galleryFile.getPublicPath());
}
galleryFileHolder.setContentType(galleryFile.getContentType());
return galleryFileHolder;
}
/**
* Converts a list of the provided service layer {@link GalleryDirectory} files
* for web models {@link GalleryDirectoryHolder} objects.
*
* @param contextPath Webapp context path.
* @param galleryDirectories List of service layer directories.
* @return A list of gallery directory holders
*/
private List convertToGalleryDirectoryHolders(String contextPath,
List galleryDirectories) {
List galleryDirectoryHolders = new ArrayList<>(galleryDirectories.size());
for (GalleryDirectory oneGalleryDirectory : galleryDirectories) {
GalleryDirectoryHolder oneGalleryDirectoryHolder = new GalleryDirectoryHolder();
String onePublicPath = oneGalleryDirectory.getPublicPath();
oneGalleryDirectoryHolder.setName(oneGalleryDirectory.getName());
oneGalleryDirectoryHolder.setPath(contextPath + SERVICE_PATH + onePublicPath);
if (oneGalleryDirectory.getImage() != null) {
oneGalleryDirectoryHolder
.setImage(convertToGalleryFileHolder(contextPath, oneGalleryDirectory.getImage()));
}
galleryDirectoryHolders.add(oneGalleryDirectoryHolder);
}
return galleryDirectoryHolders;
}
/**
* Generates the URL template for a certain image format.
*
* @param contextPath Webapp context path.
* @param file Image.
* @return The URL for the image at the given image format code.
*/
private String generateDynamicImageUrl(String contextPath, GalleryFile file) {
return contextPath + "/image/{imageFormat}/" + file.getPublicPath();
}
/**
* Generates the URL template for a certain image size.
*
* @param contextPath Webapp context path.
* @param file Image.
* @return The URL template for the image at the given image format code. The
* URL will contain the placeholders {width} and {height}. The idea with
* these is that a calling entitiy should swap those values for the
* actual width/height.
*/
private String generateCustomImageUrlTemplate(String contextPath, GalleryFile file) {
return contextPath + "/customImage/{width}/{height}/" + file.getPublicPath();
}
/**
* Due to some Spring MVC oddities with pattern matching, the following method
* was put in place to correctly extract the path from the URL path (remember,
* the URL path contains more information than just the image path).
*
* @param request Request
* @return The public image path.
*/
private String extractPathFromPattern(final HttpServletRequest request) {
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
String bestMatchPattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
AntPathMatcher apm = new AntPathMatcher();
String finalPath = apm.extractPathWithinPattern(bestMatchPattern, path);
return finalPath;
}
/**
* Retrieves the image format for the image format code.
*
* @param code Code.
* @return The {@link ImageFormat}, or null.
*/
private ImageFormat getImageFormatForCode(String code) {
if (code == null || imageFormats == null) {
return null;
}
for (ImageFormat oneImageFormat : imageFormats) {
if (code.equalsIgnoreCase(oneImageFormat.getCode())) {
return oneImageFormat;
}
}
return null;
}
}