bjerne.gallery.controller.GalleryController.java Source code

Java tutorial

Introduction

Here is the source code for bjerne.gallery.controller.GalleryController.java

Source

/**
 * 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 bjerne.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.Map;
import java.util.TreeMap;
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.Required;
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 bjerne.gallery.controller.exception.RangeException;
import bjerne.gallery.controller.exception.ResourceNotFoundException;
import bjerne.gallery.controller.model.GalleryFileHolder;
import bjerne.gallery.controller.model.ListingContext;
import bjerne.gallery.service.GalleryService;
import bjerne.gallery.service.bean.GalleryFile;
import bjerne.gallery.service.bean.GalleryFile.GalleryFileType;
import bjerne.gallery.service.exception.NotAllowedException;

/**
 * This class receives requests where part of the path in the request URL is the
 * path of the resource (at least in a virtual sense).<br>
 * 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 DIR_LISTING_PREFIX = "/service/";

    private String thumbnailImageFormatCode;

    private List<ImageFormat> imageFormats;

    private GalleryService galleryService;

    private boolean allowCustomImageSizes = false;

    @Required
    public void setGalleryService(GalleryService galleryService) {
        this.galleryService = galleryService;
    }

    public void setImageFormats(List<ImageFormat> imageFormats) {
        this.imageFormats = imageFormats;
    }

    public void setThumbnailImageFormatCode(String thumbnailImageFormatCode) {
        this.thumbnailImageFormatCode = thumbnailImageFormatCode;
    }

    public void setAllowCustomImageSizes(boolean allowCustomImageSizes) {
        this.allowCustomImageSizes = allowCustomImageSizes;
    }

    /**
     * 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.
     * 
     * @return A {@link ListingContext} instance.
     */
    @RequestMapping(value = "/service/**", 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();
            if (StringUtils.isBlank(path)) {
                listingContext.setDirectories(
                        generateUrlsFromDirectoryPaths(path, contextPath, galleryService.getRootDirectories()));
            } else {
                listingContext.setCurrentPathDisplay(path);
                listingContext.setPreviousPath(getPreviousPath(contextPath, path));
                List<GalleryFile> directoryListing = galleryService.getDirectoryListingFiles(path);
                if (directoryListing == null) {
                    throw new ResourceNotFoundException();
                }
                LOG.debug(directoryListing.size() + " media files found");
                List<GalleryFile> galleryImages = directoryListing.stream()
                        .filter(gi -> GalleryFileType.IMAGE.equals(gi.getType())).collect(Collectors.toList());
                List<GalleryFileHolder> listing = convertToGalleryFileHolders(contextPath, galleryImages);
                listingContext.setImages(listing);
                List<GalleryFile> galleryVideos = directoryListing.stream()
                        .filter(gi -> GalleryFileType.VIDEO.equals(gi.getType())).collect(Collectors.toList());
                List<GalleryFileHolder> videoHolders = convertToGalleryFileHolders(contextPath, galleryVideos);
                listingContext.setVideos(videoHolders);
                listingContext.setDirectories(
                        generateUrlsFromDirectoryPaths(path, contextPath, galleryService.getDirectories(path)));
            }
            listingContext.setVideoFormats(galleryService.getAvailableVideoModes());
            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;
        }
    }

    /**
     * Requests an image with the given {@link ImageFormat}.
     * 
     * @return The image as a stream with the appropriate response headers set
     *         or a not-modified response, (see
     *         {@link #returnImage(WebRequest, File)}).
     */
    @RequestMapping(value = "/image/{imageFormat}/**", method = RequestMethod.GET)
    public ResponseEntity<InputStreamResource> 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.
     * 
     * @return The image as a stream with the appropriate response headers set
     *         or a not-modified response, (see
     *         {@link #returnImage(WebRequest, File)}).
     */
    @RequestMapping(value = "/customImage/{width}/{height}/**", method = RequestMethod.GET)
    public ResponseEntity<InputStreamResource> 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;
        }
    }

    @RequestMapping(value = "/video/{conversionFormat}/**", method = RequestMethod.GET)
    public ResponseEntity<InputStreamResource> 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.
     * <p>
     * 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<InputStreamResource> 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.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>(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 #DIR_LISTING_PREFIX} 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 + DIR_LISTING_PREFIX + path.substring(0, lastIndexOfSlash);
    }

    /**
     * Will return each directory as a map entry. The key will be a nicer
     * display name of the directory (just the directory name without the path).<br>
     * The value will be the public directory path (such as returned from the
     * {@link GalleryService}) prepended by the context path and service path.
     * 
     * @param currentPath
     *            Webapp-specific path.
     * @param contextPath
     *            Webapp context path.
     * @param directoryPaths
     *            Directory paths.
     * @return A Map where each key is a directory name for display, and each
     *         value is the URL for that directory listing.
     */
    private Map<String, String> generateUrlsFromDirectoryPaths(String currentPath, String contextPath,
            List<String> directoryPaths) {
        Map<String, String> urls = new TreeMap<>();
        for (String oneDir : directoryPaths) {
            String oneDirName = StringUtils.isNotBlank(currentPath) && StringUtils.startsWith(oneDir, currentPath)
                    ? oneDir.substring(currentPath.length() + 1)
                    : oneDir;
            urls.put(oneDirName, contextPath + DIR_LISTING_PREFIX + oneDir);
        }
        return urls;
    }

    /**
     * Converts the provided 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<GalleryFileHolder> convertToGalleryFileHolders(String contextPath,
            List<GalleryFile> galleryFiles) {
        List<GalleryFileHolder> galleryFileHolders = new ArrayList<>();
        for (GalleryFile oneGalleryFile : galleryFiles) {
            GalleryFileHolder oneGalleryFileHolder = new GalleryFileHolder();
            oneGalleryFileHolder.setFilename(oneGalleryFile.getActualFile().getName());
            if (GalleryFileType.IMAGE.equals(oneGalleryFile.getType())) {
                oneGalleryFileHolder.setMediaPath(generateCustomImageUrlTemplate(contextPath, oneGalleryFile));
                oneGalleryFileHolder.setThumbnailPath(
                        generateDynamicImageUrl(contextPath, thumbnailImageFormatCode, oneGalleryFile));
            } else {
                oneGalleryFileHolder
                        .setMediaPath(contextPath + "/video/{conversionFormat}/" + oneGalleryFile.getPublicPath());
            }
            oneGalleryFileHolder.setContentType(oneGalleryFile.getContentType());
            galleryFileHolders.add(oneGalleryFileHolder);
        }
        return galleryFileHolders;
    }

    /**
     * Generates the URL for a certain image format.
     * 
     * @param contextPath
     *            Webapp context path.
     * @param imageFormatCode
     *            Image format code.
     * @param file
     *            Image.
     * @return The URL for the image at the given image format code.
     */
    private String generateDynamicImageUrl(String contextPath, String imageFormatCode, GalleryFile file) {
        return contextPath + "/image/" + imageFormatCode + "/" + 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;
    }

}