RepositoryDownloadController.java

/*
 * Copyright 2020 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.gringlobal.api.v1.impl;

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

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

import lombok.extern.slf4j.Slf4j;
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.gringlobal.api.exception.NotFoundElement;
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.security.access.prepost.PreAuthorize;
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.bind.annotation.RestController;
import org.springframework.web.servlet.HandlerMapping;

import io.swagger.v3.oas.annotations.tags.Tag;

/**
 * This controller servers thumbnails and files.
 *
 * @author Matija Obreza
 */
@RestController("repositoryDownload1")
@PreAuthorize("isAuthenticated()")
@Tag(name = "Repository")
@Slf4j
public class RepositoryDownloadController {

	@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) {
					throw new NotFoundElement("Thumbnail cannot be fetched", e);
				}

				response.setDateHeader(HttpHeaders.LAST_MODIFIED, repositoryFile.getLastModifiedDate().getEpochSecond());
				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, e);
			}

		} 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().getEpochSecond());
				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());
				repositoryService.streamFileBytes(repositoryFile, response.getOutputStream());
				response.getOutputStream().flush();

			} catch (final NoSuchRepositoryFileException | InvalidRepositoryPathException e) {
				log.warn("404 - No such repository file ", e);
				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().getEpochSecond() < sinceDate) {
			log.debug("Not modified since: {} < {}", repositoryFile.getLastModifiedBy(), new Date(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.getStorageFolder().equals(path.toString()) || !repositoryFile.getExtension().equals(ext)) {
			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 = RepositoryController.CONTROLLER_URL + "/download/d/**", method = { RequestMethod.GET, RequestMethod.POST })
	public void downloadFile(final HttpServletRequest request, final HttpServletResponse response) throws IOException {
		final String fullpath = ((String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)).substring((RepositoryController.CONTROLLER_URL + "/download/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 = RepositoryController.CONTROLLER_URL + "/download/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((RepositoryController.CONTROLLER_URL + "/download/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, e);
		}

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

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

		return repositoryFile;
	}
}