RepositoryDownloadController.java

/*
 * Copyright 2017 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **/

package org.genesys.server.mvc;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.security.SecurityContextUtil;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.model.RepositoryFile;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.server.exception.NotFoundElement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
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.servlet.HandlerMapping;

@Controller
public class RepositoryDownloadController extends BaseController {

	public static final Logger LOG = LoggerFactory.getLogger(RepositoryDownloadController.class);

	@Autowired
	private RepositoryService repositoryService;

	private void downloadFile(final Path path, final String name, final String ext, final HttpServletResponse response, HttpServletRequest request) throws IOException {

		boolean noCache = "no-cache".equalsIgnoreCase(request.getHeader(HttpHeaders.CACHE_CONTROL)) 
				|| "no-cache".equalsIgnoreCase(request.getHeader(HttpHeaders.PRAGMA));

		String extension = StringUtils.removeStartIgnoreCase(ext, ".");

		if (path.startsWith(RepositoryService.THUMB_PATH)  && (extension.equals(RepositoryService.THUMB_EXT_JPG) || extension.equals(RepositoryService.THUMB_EXT_WEBP))) {
			final String filename = name + ext;
			if (LOG.isDebugEnabled()) {
				LOG.debug("_thumb path={} filename={}", path, filename);
			}

			try {
				final RepositoryFile repositoryFile = this.repositoryService.getFile(UUID.fromString(path.getFileName().toString()));

				// check Request Cache headers (Modified-Since, ETag)
				if (! noCache && clientCacheValid(repositoryFile, request, response)) {
					LOG.debug("Client cache is valid.");
					return;
				}

				byte[] data;
				try {
					data = repositoryService.getThumbnail(path, name, extension, repositoryFile);
				} catch (Exception e) {
					response.reset();
					throw new NotFoundElement("Thumbnail cannot be fetched", e);
				}

				response.setDateHeader(HttpHeaders.LAST_MODIFIED, repositoryFile.getLastModifiedDate().toEpochMilli());
				response.setHeader(HttpHeaders.ETAG, repositoryFile.getSha1Sum());
				if (extension.equals(RepositoryService.THUMB_EXT_JPG)) {
					response.setContentType(RepositoryService.THUMB_CONTENT_TYPE_JPG);
				} else {
					response.setContentType(RepositoryService.THUMB_CONTENT_TYPE_WEBP);
				}

				if (SecurityContextUtil.anyoneHasPermission(repositoryFile, "READ")) {
					// Cache for 30days
					response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=2592000, s-maxage=2592000, public, no-transform");
				} else {
					// Cache for 24hrs
					response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=86400, s-maxage=86400, private, no-transform");
				}

				// We're writing bytes directly
				response.setContentLength(data.length);
				response.getOutputStream().write(data);
				response.getOutputStream().flush();

			} catch (NoSuchRepositoryFileException e) {
				throw new NotFoundElement("No file for thumb " + name);
			}

		} else {
			// Regular repository file
			try {
				UUID uuid = null;
				try {
					uuid = UUID.fromString(name);
				} catch (final IllegalArgumentException e) {
					LOG.debug("404 - UUID in wrong format.");
//					throw new NotFoundElement("No such thing", e);
				}
				final RepositoryFile repositoryFile = uuid != null
						? this.repositoryService.getFile(uuid)
								: this.repositoryService.getFile(path, name + ext);

				sanityCheck(path, ext, repositoryFile);

				// check Request Cache headers (Modified-Since, ETag)
				if (! noCache && clientCacheValid(repositoryFile, request, response)) {
					LOG.debug("Client cache is valid.");
					return;
				}

				if (SecurityContextUtil.anyoneHasPermission(repositoryFile, "READ")) {
					// Cache for 30days
					response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=2592000, s-maxage=2592000, public, no-transform");
				} else {
					// Cache for 24hrs
					response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=86400, s-maxage=86400, private, no-transform");
				}
				response.setHeader(HttpHeaders.PRAGMA, "");
				response.setDateHeader(HttpHeaders.LAST_MODIFIED, repositoryFile.getLastModifiedDate().toEpochMilli());
				response.setHeader(HttpHeaders.ETAG, repositoryFile.getSha1Sum());
				response.setContentType(repositoryFile.getContentType());
				response.addHeader("Content-Disposition", String.format("attachment; filename=\"%s\"", repositoryFile.getOriginalFilename()));

				response.setContentLength(repositoryFile.getSize());
				try {
					repositoryService.streamFileBytes(repositoryFile, response.getOutputStream());
				} catch (Exception e) {
					LOG.warn("Error streaming bytes: {}", e.getMessage());
					response.reset();
					throw new NotFoundElement(e.getMessage());
				}
				response.getOutputStream().flush();

			} catch (final NoSuchRepositoryFileException | InvalidRepositoryPathException e) {
				if (LOG.isDebugEnabled()) {
					LOG.warn("404 - No such repository file: {}", e.getMessage(), e);
				} else {
					LOG.warn("404 - No such repository file: {}", e.getMessage());
				}
				throw new NotFoundElement("No such thing", e);
			}
		}
	}

	private boolean clientCacheValid(RepositoryFile repositoryFile, HttpServletRequest request, HttpServletResponse response) throws IOException {
		if (repositoryFile.getSha1Sum().equals(request.getHeader(HttpHeaders.IF_NONE_MATCH))) {
			LOG.debug("ETag matches");
			response.setStatus(HttpStatus.NOT_MODIFIED.value());
			response.flushBuffer();
			return true;
		}
		long sinceDate = request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
		if (sinceDate >= -1 && repositoryFile.getLastModifiedDate().toEpochMilli() < sinceDate) {
			LOG.debug("Not modified since: {} < {}", repositoryFile.getLastModifiedBy(), Instant.ofEpochMilli(sinceDate));
			response.setStatus(HttpStatus.NOT_MODIFIED.value());
			response.flushBuffer();
			return true;
		}
		return false;
	}

	private void sanityCheck(final Path path, final String ext, final RepositoryFile repositoryFile) {
		if (repositoryFile == null) {
			throw new NotFoundElement("No such thing");
		}

		if (!repositoryFile.getExtension().equals(ext) || (!repositoryFile.getStorageFolder().equals(path.toString()) && !repositoryFile.getFolder().getFolderPath().equals(path))) {
			LOG.warn("{}!={}", repositoryFile.getStorageFolder(), path);
			LOG.warn("{}!={}", repositoryFile.getExtension(), ext);
			throw new NotFoundElement("No such thing");
		}
	}

	/**
	 * Serve the bytes of the repository object
	 */
	@RequestMapping(value = "/repository/d/**", method = RequestMethod.GET)
	public void downloadFile(final HttpServletRequest request, final HttpServletResponse response) throws IOException {
		final String fullpath = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).substring("/repository/d".length());
		if (LOG.isTraceEnabled()) {
			LOG.trace("Fullname: {}", fullpath);
		}

		final String ext = fullpath.substring(fullpath.lastIndexOf("."));
		final String name = fullpath.substring(fullpath.lastIndexOf("/") + 1, fullpath.lastIndexOf("."));
		final String path = fullpath.substring(0, fullpath.lastIndexOf('/'));

		if (LOG.isDebugEnabled()) {
			LOG.debug("{} {}", path, name + ext);

			// Enumeration<String> headerNames = request.getHeaderNames();
			// while (headerNames.hasMoreElements()) {
			// String headerName = headerNames.nextElement();
			// LOG.debug(">> {}: {}", headerName, request.getHeader(headerName));
			// }
		}

		downloadFile(Paths.get(path), name, ext, response, request);
	}

	/**
	 * Return repository object metadata
	 */
	@RequestMapping(value = "/repository/d/**", method = RequestMethod.GET, params = { "metadata" }, produces = MediaType.APPLICATION_JSON_VALUE)
	public @ResponseBody RepositoryFile getMetadata(final HttpServletRequest request) throws IOException, NoSuchRepositoryFileException {

		final String fullpath = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
		LOG.debug("Fullname: {}", fullpath);

		String path;
		String uuid;
		String ext;
		try {
			ext = fullpath.substring(fullpath.lastIndexOf("."));
			uuid = fullpath.substring(fullpath.lastIndexOf("/") + 1, fullpath.lastIndexOf("."));
			path = fullpath.substring("/repository/d".length(), fullpath.lastIndexOf("/"));
			if (LOG.isDebugEnabled()) {
				LOG.debug("{} {}", path, uuid + ext);
			}
		} catch (ArrayIndexOutOfBoundsException e) {
			// fullpath.lastIndexOf may return -1, causing AIOBE
			throw new NotFoundElement("No such resource " + fullpath);
		}

		final RepositoryFile repositoryFile = this.repositoryService.getFile(UUID.fromString(uuid));

		sanityCheck(Paths.get(path), ext, repositoryFile);

		return repositoryFile;
	}
}