RepositoryServiceImpl.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.genesys.filerepository.service.impl;

import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

import javax.imageio.ImageIO;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.tika.Tika;
import org.genesys.blocks.security.SecurityContextUtil;
import org.genesys.blocks.security.model.AclAwareModel;
import org.genesys.blocks.security.model.AclObjectIdentity;
import org.genesys.blocks.security.persistence.AclObjectIdentityPersistence;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.filerepository.FolderNotEmptyException;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.NoSuchRepositoryFolderException;
import org.genesys.filerepository.ReferencedRepositoryFileException;
import org.genesys.filerepository.ThumbnailException;
import org.genesys.filerepository.model.ImageGallery;
import org.genesys.filerepository.model.QRepositoryFile;
import org.genesys.filerepository.model.QRepositoryFolder;
import org.genesys.filerepository.model.RepositoryFile;
import org.genesys.filerepository.model.RepositoryFolder;
import org.genesys.filerepository.model.RepositoryImage;
import org.genesys.filerepository.persistence.ImageGalleryPersistence;
import org.genesys.filerepository.persistence.RepositoryFilePersistence;
import org.genesys.filerepository.persistence.RepositoryFolderRepository;
import org.genesys.filerepository.persistence.RepositoryImagePersistence;
import org.genesys.filerepository.service.BytesStorageService;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.filerepository.service.ThumbnailGenerator;
import org.genesys.filerepository.service.VirusFoundException;
import org.genesys.filerepository.service.VirusScanner;
import org.hibernate.Hibernate;
import org.hibernate.proxy.HibernateProxy;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.drew.imaging.ImageMetadataReader;
import com.drew.metadata.Metadata;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.querydsl.core.types.dsl.BooleanExpression;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

/**
 * The RepositoryServiceImpl implementation.
 *
 * @author Matija Obreza
 */
@Service
@Slf4j
public class RepositoryServiceImpl implements RepositoryService, InitializingBean {

	@Autowired
	private JdbcTemplate jdbcTemplate;

	/** The folder repository. */
	@Autowired
	private RepositoryFolderRepository folderRepository;

	/** The repository file persistence. */
	@Autowired
	private RepositoryFilePersistence repositoryFilePersistence;

	/** The repository image persistence. */
	@Autowired
	private RepositoryImagePersistence repositoryImagePersistence;

	/** The image gallery persistence. */
	@Autowired
	private ImageGalleryPersistence imageGalleryPersistence;

	/** The bytes storage service. */
	@Autowired
	private BytesStorageService bytesStorageService;

	/** Thumbnail generator. */
	@Autowired
	private ThumbnailGenerator thumbnailGenerator;

	/** The ACL service */
	@Autowired
	private CustomAclService aclService;

	@Autowired(required = false)
	private TaskExecutor taskExecutor;

	@Autowired
	private AclObjectIdentityPersistence aclObjectIdentityPersistence;

	/** The blacklist file names regexp */
	@Value("${repository.blacklist.filename}")
	private String blacklistFileNameRegex;

	/** The blacklist folder names regexp */
	@Value("${repository.blacklist.foldername}")
	private String blacklistFolderNameRegex;

	/** The pattern of blacklist file names */
	private Pattern blacklistFileNamePattern;

	/** The pattern of blacklist folder names */
	private Pattern blacklistFolderNamePattern;

	private int[] thumbnailSizes = { 200 };

	@Autowired(required = false)
	private VirusScanner virusScanner;

	/*
	 * (non-Javadoc)
	 * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
	 */
	@Override
	public void afterPropertiesSet() throws Exception {
		blacklistFileNamePattern = Pattern.compile(blacklistFileNameRegex, Pattern.CASE_INSENSITIVE);
		blacklistFolderNamePattern = Pattern.compile(blacklistFolderNameRegex, Pattern.CASE_INSENSITIVE);
	}

	private <T extends RepositoryFile> T lazyLoad(T repositoryFile) {
		if (repositoryFile != null) {
			repositoryFile.getFolder().getId();
		}
		return repositoryFile;
	}

	/**
	 * Just in case.
	 */
	@Transactional
	public void addMissingHashSums() {
		QRepositoryFile qRepositoryFile = QRepositoryFile.repositoryFile;
		var filesMissingHashSums = this.repositoryFilePersistence
			.findAll(qRepositoryFile.sha1Sum.isNull().or(qRepositoryFile.md5Sum.isNull()).or(qRepositoryFile.sha384.isNull()));
		
		for (final RepositoryFile repositoryFile : filesMissingHashSums) {
			log.debug("Updating SHA-1 and MD5 for file uuid={}", repositoryFile.getUuid());
			try {
				if (repositoryFile.getSize() > 5 * 1000 * 2000) {
					log.info("File {} has size={}. Using a temporary file to calculate digests.", repositoryFile.getFilename(), repositoryFile.getSize());
					bytesStorageService.get(repositoryFile.storagePath(), stream -> {
						File tempFile = null;
						try {
							tempFile = File.createTempFile("repo", ".bin"); // Create a temporary file 
							try (OutputStream tempFileStream = new BufferedOutputStream(
								Files.newOutputStream(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), 8 * 1000)
							) {
								log.debug("Using temporary file {}", tempFile.getAbsolutePath());
								stream.transferTo(tempFileStream);
							}
							updateDigests(repositoryFile, tempFile);
							if (tempFile.exists()) {
								tryDelete(tempFile);
							}
						} catch (IOException e) {
							log.warn("Failed to generate hash sums: {}", e.getMessage());
						} finally {
							if (tempFile != null && tempFile.exists()) {
								tryDelete(tempFile);
							}
						}
					});

				} else {
					// Handle files < 5Mb in memory
					final byte[] bytes = getFileBytes(repositoryFile);
					repositoryFile.setSha1Sum(DigestUtils.sha1Hex(bytes));
					repositoryFile.setSha384(DigestUtils.sha384(bytes));
					repositoryFile.setMd5Sum(DigestUtils.md5Hex(bytes));
				}
				repositoryFilePersistence.save(repositoryFile);
			} catch (final IOException e) {
				log.warn("Failed to generate hash sums: {}", e.getMessage());
			}
		}
	}

	/** 
	 * {@inheritDoc}
	 * Tranfer stream to a temporary file and upload that.
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("isAuthenticated()")
	public <T extends RepositoryFile> T addFile(Path repositoryPath, String originalFilename, String contentType, InputStream inputStream, T metaData) throws InvalidRepositoryPathException, InvalidRepositoryFileDataException, IOException {

		if (inputStream == null) {
			throw new InvalidRepositoryFileDataException("No bytes provided");
		}

		File tempFile = File.createTempFile("repo-", ".upload");

		try {
			try (OutputStream temp = new BufferedOutputStream(Files.newOutputStream(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), 8 * 1000)) {
				log.debug("Using temporary file {}", tempFile.getAbsolutePath());
				inputStream.transferTo(temp);
			}
			return addFile(repositoryPath, originalFilename, contentType, tempFile, metaData);
		} finally {
			if (tempFile.exists()) {
				tryDelete(tempFile);
			}
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("isAuthenticated()")
	public <T extends RepositoryFile> T addFile(final Path repositoryPath, String originalFilename, String contentType, final byte[] bytes, final T metaData)
			throws InvalidRepositoryPathException, InvalidRepositoryFileDataException, IOException {

		if (bytes == null) {
			throw new InvalidRepositoryFileDataException("No bytes provided");
		}

		File tempFile = File.createTempFile("repo-", ".upload");

		try {
			try (OutputStream temp = new BufferedOutputStream(Files.newOutputStream(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), 8 * 1000)) {
				log.debug("Using temporary file {}", tempFile.getAbsolutePath());
				temp.write(bytes);
			}
			return addFile(repositoryPath, originalFilename, contentType, tempFile, metaData);
		} finally {
			if (tempFile.exists()) {
				tryDelete(tempFile);
			}
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("isAuthenticated()")
	public <T extends RepositoryFile> T addFile(final Path repositoryPath, String originalFilename, String contentType, final File fileWithData, final T metaData)
			throws InvalidRepositoryPathException, InvalidRepositoryFileDataException, IOException {

		if (fileWithData == null || !fileWithData.exists()) {
			throw new InvalidRepositoryFileDataException("No bytes provided");
		}

		PathValidator.checkValidPath(repositoryPath);

		RepositoryFolder repositoryFolder = ensureFolder(repositoryPath);

		if (metaData != null) {
			if (StringUtils.isNotBlank(metaData.getOriginalFilename())) {
				originalFilename = metaData.getOriginalFilename();
			}
			if (StringUtils.isNotBlank(metaData.getContentType())) {
				contentType = metaData.getContentType();
			}
		}

		contentType = detectContentType(contentType, fileWithData);
		// User can override the content type, so we can do it here too.
		if (StringUtils.equals("text/plain", contentType)) {
			if (originalFilename.endsWith(".css")) {
				contentType = "text/css";
			} else if (originalFilename.endsWith(".js")) {
				contentType = "application/javascript";
			}
		}

		if (contentType.isEmpty()) {
			throw new InvalidRepositoryFileDataException("Content type not provided and could not be detected");
		}
		if ((originalFilename == null)) {
			throw new InvalidRepositoryFileDataException();
		}
		if (blacklistFileNamePattern.matcher(originalFilename).find()) {
			throw new InvalidRepositoryPathException("Invalid file name: " + originalFilename);
		}

		RepositoryFile repositoryFile = null;
		if (metaData instanceof RepositoryImage || contentType.startsWith("image/")) {
			log.trace("It's an image! contentType={}", contentType);
			repositoryFile = new RepositoryImage();
		} else {
			repositoryFile = new RepositoryFile();
		}

		if (metaData != null) {
			metaData.copyMetaData(repositoryFile);
		}

		updateDigests(repositoryFile, fileWithData);
		repositoryFile.setSize((int) fileWithData.length());
		repositoryFile.setFolder(repositoryFolder);
		repositoryFile.setOriginalFilename(originalFilename);
		repositoryFile.setContentType(contentType);

		if (repositoryFile instanceof RepositoryImage) {
			fillImageProperties((RepositoryImage) repositoryFile, fileWithData);
			repositoryFile = repositoryImagePersistence.save((RepositoryImage) repositoryFile);
		} else {
			repositoryFile = repositoryFilePersistence.save(repositoryFile);
		}

		try {
			bytesStorageService.upsert(repositoryFile.storagePath(), fileWithData);
		} catch (final IOException e) {
			log.debug("Failed to upload bytes", e);
			throw e;
		}

		if (repositoryFile instanceof RepositoryImage) {
			generateThumbnails((RepositoryImage) repositoryFile);
		}

		return (T) repositoryFile;
	}

	/**
	 * Update repositoryFile digests by streaming the file through DigestUtils
	 */
	private void updateDigests(RepositoryFile repositoryFile, File fileWithData) throws IOException {
		// Calculate SHA-1 and MD5 sums
		MessageDigest sha1 = DigestUtils.getSha1Digest();
		MessageDigest sha384 = DigestUtils.getSha384Digest();
		MessageDigest md5= DigestUtils.getMd5Digest();
		byte[] buff = new byte[8*1000];
		try (InputStream tempFileStream = new BufferedInputStream(Files.newInputStream(fileWithData.toPath()), 8 * 1000)) {
			for (int len = tempFileStream.read(buff); len > 0; len = tempFileStream.read(buff)) {
				sha1.update(buff, 0, len);
				sha384.update(buff, 0, len);
				md5.update(buff, 0, len);
			}
			repositoryFile.setSha1Sum(Hex.encodeHexString(sha1.digest()));
			repositoryFile.setSha384(sha384.digest());
			repositoryFile.setMd5Sum(Hex.encodeHexString(md5.digest()));
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("isAuthenticated()")
	public RepositoryImage addImage(final Path repositoryPath, String originalFilename, String contentType, final byte[] bytes, final RepositoryImage metaData)
			throws InvalidRepositoryPathException, InvalidRepositoryFileDataException, IOException {
		return addFile(repositoryPath, originalFilename, contentType, bytes, metaData);
	}

	/**
	 * Ensure thumbnails exist for the {@link RepositoryImage}
	 *
	 * @param repositoryImage the repository image
	 */
	@Override
	public void ensureThumbnails(final RepositoryImage repositoryImage) {
		if (taskExecutor != null) {
			taskExecutor.execute(() -> generateThumbnails(repositoryImage));
		} else {
			generateThumbnails(repositoryImage);
		}
	}

	/**
	 * Ensure thumbnails of configured sizes for the repository image.
	 * Thumbnail images are generated in descending order by size (largest thumbnail
	 * first) and thumbnails of smaller sizes are generated from the previous
	 * thumbnail.
	 * 
	 * @param repositoryImage the repository image
	 */
	private void generateThumbnails(final RepositoryImage repositoryImage) {
		if (repositoryImage.getSize() > 10 * 1000 * 1000) {
			log.info("Not generating thumbnails for large image {} with size {}b", repositoryImage.getOriginalFilename(), repositoryImage.getSize());
			return;
		}

		try {
			byte[][] webpCache = new byte[thumbnailSizes.length][];

			for (int i = thumbnailSizes.length - 1; i >= 0; i--) {
				final int cachePos = thumbnailSizes.length - i - 1;
				log.debug("Making webp thumbnail #{} size={} cachePos={}", i, thumbnailSizes[i], cachePos);
				webpCache[cachePos] = ensureThumbnail(thumbnailSizes[i], thumbnailSizes[i], RepositoryService.THUMB_EXT_WEBP, repositoryImage, () -> {
					if (cachePos > 0 && webpCache[cachePos - 1] != null) {
						log.debug("Using cached image bytes for {} at {}.", repositoryImage.getStoragePath(), cachePos - 1);
						return webpCache[cachePos - 1];
					} else {
						log.debug("Must load image bytes for {}. Nothing cached yet.", repositoryImage.getStoragePath());
						return bytesStorageService.get(repositoryImage.storagePath());
					}
				});
			}

			final byte[][] jpgCache = new byte[thumbnailSizes.length][];

			for (int i = thumbnailSizes.length - 1; i >= 0; i--) {
				final int cachePos = thumbnailSizes.length - i - 1;
				log.debug("Making jpg thumbnail #{} size={} cachePos={}", i, thumbnailSizes[i], cachePos);
				jpgCache[cachePos] = ensureThumbnail(thumbnailSizes[i], thumbnailSizes[i], RepositoryService.THUMB_EXT_JPG, repositoryImage, () -> {
					if (cachePos > 0 && jpgCache[cachePos - 1] != null) {
						log.debug("Using cached image bytes for {} at {}.", repositoryImage.getStoragePath(), cachePos - 1);
						return jpgCache[cachePos - 1];
					} else {
						log.debug("Must load image bytes for {}. Nothing cached yet.", repositoryImage.getStoragePath());
						return bytesStorageService.get(repositoryImage.storagePath());
					}
				});
			}
		} catch (final Exception e) {
			log.error("Error generating thumbnail for {}: {}", repositoryImage, e.getMessage(), e);
		}
	}

	/**
	 * Ensure thumbnail.
	 *
	 * @param width the width
	 * @param height the height
	 * @param repositoryImage the repository image
	 *
	 * @throws IOException Signals that an I/O exception has occurred.
	 * @throws InvalidRepositoryPathException if path is messed up
	 */
	private byte[] ensureThumbnail(final Integer width, final Integer height, final String extension, final RepositoryImage repositoryImage, final IImageBytesProvider loader) throws IOException, InvalidRepositoryPathException {
		final String filename = getThumbnailFilename(width, height, extension);

		if (!bytesStorageService.exists(getFullThumbnailsPath(repositoryImage).resolve(filename))) {

			log.debug("Generating new thumbnail width={} height={} for image={}", width, height, repositoryImage.getUuid());

			try {
				final byte[] thumbnailBytes = thumbnailGenerator.createThumbnail(width, height, extension, loader.getImageBytes());

				log.debug("Persisting new thumbnail width={} height={} for image={}", width, height, repositoryImage.getUuid());

				bytesStorageService.upsert(getFullThumbnailsPath(repositoryImage).resolve(filename), thumbnailBytes);
				return thumbnailBytes;
			} catch (Throwable e) {
				log.warn("Error generating thumbnail: {}", e.getMessage());
			}
		}
		return null;
	}

	/**
	 * Path to /_thumbs/{UUID#short}/{UUID}.
	 *
	 * @param image the image
	 * @return the full thumbnails path
	 */
	public static Path getFullThumbnailsPath(final RepositoryImage image) {
		return Paths.get(THUMB_PATH, image.getThumbnailPath());
	}

	/**
	 * Gets the thumbnail filename.
	 *
	 * @param width the width
	 * @param height the height
	 * @param extension the file extension of the thumbnail image
	 * @return the thumbnail filename
	 */
	public static String getThumbnailFilename(final Integer width, final Integer height, final String extension) {
		final StringBuilder sb = new StringBuilder();

		if (width != null) {
			sb.append(width);
		}
		sb.append('x');
		if (height != null) {
			sb.append(height);
		}
		sb.append('.').append(extension);
		return sb.toString();
	}

	/**
	 * Load image to get pixel width and height.
	 *
	 * @param repositoryImage the repository image
	 * @param bytes the bytes
	 * @throws IOException When things go wrong
	 */
	private void fillImageProperties(final RepositoryImage repositoryImage, final File file) {
		if (file.exists() && file.length() < 10 * 1000 * 1000) {
			try {
				final BufferedImage imageData = ImageIO.read(file);
				if (imageData != null) {
					repositoryImage.setWidth(imageData.getWidth());
					repositoryImage.setHeight(imageData.getHeight());
				}
			} catch (Throwable e) {
				log.warn("Could not read image metadata: {}", e.getMessage());
				repositoryImage.setWidth(0);
				repositoryImage.setHeight(0);
			}
		} else {
			try {
				Metadata metadata = ImageMetadataReader.readMetadata(file);
				ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
				repositoryImage.setWidth(directory.getInt(ExifSubIFDDirectory.TAG_IMAGE_WIDTH));
				repositoryImage.setHeight(directory.getInt(ExifSubIFDDirectory.TAG_IMAGE_HEIGHT));
			} catch (Throwable e) {
				log.warn("Could not read metadata: {}", e.getMessage());
				repositoryImage.setWidth(0);
				repositoryImage.setHeight(0);
			}
		}
	}

	/*
	 * (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#getFile(java
	 * .util.UUID)
	 */
	@SuppressWarnings("unchecked")
	@Override
	@Transactional(readOnly = true)
	@PostAuthorize("hasRole('ADMINISTRATOR') or hasPermission(returnObject, 'read')")
	public <T extends RepositoryFile> T getFile(final UUID fileUuid) throws NoSuchRepositoryFileException {
		RepositoryFile file = repositoryFilePersistence.findByUuid(fileUuid);
		if (file != null) {
			return lazyLoad((T) file);
		} else {
			throw new NoSuchRepositoryFileException("No such file in repository");
		}
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#getFile(java.util.UUID,
	 * int)
	 */
	@SuppressWarnings("unchecked")
	@Override
	@Transactional(readOnly = true)
	@PostAuthorize("hasRole('ADMINISTRATOR') or hasPermission(returnObject, 'read')")
	public <T extends RepositoryFile> T getFile(UUID fileUuid, int version) throws NoSuchRepositoryFileException {
		RepositoryFile file = repositoryFilePersistence.findByUuidAndVersion(fileUuid, version);
		if (file != null) {
			return (T) file;
		} else {
			throw new NoSuchRepositoryFileException("No such file in repository");
		}
	}

	/*
	 * (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#getFile(java.lang.
	 * String, java.lang.String)
	 */
	@SuppressWarnings("unchecked")
	@Override
	@Transactional(readOnly = true)
	@PostAuthorize("hasRole('ADMINISTRATOR') or hasPermission(returnObject, 'read')")
	public <T extends RepositoryFile> T getFile(final Path path, final String originalFilename) throws NoSuchRepositoryFileException, InvalidRepositoryPathException {
		PathValidator.checkValidPath(path);

		RepositoryFolder folder = folderRepository.findByPath(path.toString());
		if (folder == null) {
			throw new NoSuchRepositoryFileException("No file at path=" + path + " originalFilename=" + originalFilename);
		}

		final RepositoryFile repositoryFile = repositoryFilePersistence.findByFolderAndOriginalFilename(folder, originalFilename);
		if (repositoryFile == null) {
			throw new NoSuchRepositoryFileException("No file at path=" + path + " originalFilename=" + originalFilename);
		}

		return (T) repositoryFile;
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#getFileBytes(org.genesys
	 * .filerepository.model.RepositoryFile)
	 */
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'read')")
	public byte[] getFileBytes(final RepositoryFile repositoryFile) throws IOException {
		return bytesStorageService.get(repositoryFile.storagePath());
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'read')")
	public void streamFileBytes(RepositoryFile repositoryFile, OutputStream outputStream) throws IOException {

		final var th = new ThrowableHolder<IOException>();
		bytesStorageService.get(repositoryFile.storagePath(), (inputStream) -> {
			try (inputStream) {
				inputStream.transferTo(outputStream);
			} catch (IOException e) {
				th.throwable = e;
			}
		});

		if (th.throwable != null) {
			log.warn("Streaming bytes from storage threw {}", th.throwable, th.throwable);
			throw th.throwable;
		}
	}

	@Override
	@Transactional(readOnly = true)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFolder, 'read')")
	public OutputStream getFolderAsZip(RepositoryFolder repositoryFolder, OutputStream outputStream, int maxFilesLimit) throws InvalidRepositoryPathException, IOException{
		var folders = getAllSubFolders(repositoryFolder, new ArrayList<>());
		folders.add(0, repositoryFolder);
		log.info("Found {} folders including {}", folders.size(), repositoryFolder.getFolderPath());
		var fileCounter = repositoryFilePersistence.count(QRepositoryFile.repositoryFile.folder.in(folders));
		log.warn("Folder {} has {} files to download", repositoryFolder.getPath(), fileCounter);
		if (fileCounter >= maxFilesLimit) {
			throw new IOException("File count exceeds ZIP file limit of " + maxFilesLimit);
		}
		return getFolderAsZip(repositoryFolder, outputStream);
	}

	@Override
	@Transactional(readOnly = true)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFolder, 'read')")
	public OutputStream getFolderAsZip(RepositoryFolder repositoryFolder, OutputStream outputStream) throws InvalidRepositoryPathException, IOException{
		log.warn("Downloading {} folder as zip archive", repositoryFolder.getPath());
		final ZipOutputStream zos = new ZipOutputStream(outputStream);
		writeToZip(zos, repositoryFolder, Paths.get(""));
		zos.finish();
		zos.flush();
		return zos;
	}

	private List<RepositoryFolder> getAllSubFolders(RepositoryFolder root, List<RepositoryFolder> folders) throws InvalidRepositoryPathException {
		var subFolders = getFolders(root.getFolderPath(), Sort.unsorted());
		folders.addAll(subFolders);
		for (var subFolder : subFolders) {
			getAllSubFolders(subFolder, folders);
		}
		return folders;
	}

	private void writeToZip(final ZipOutputStream zos, RepositoryFolder folder, Path zipPath) throws InvalidRepositoryPathException, IOException {
		List<RepositoryFile> files = getFiles(folder.getFolderPath(), Sort.unsorted());
		log.info("Writing {} files from folder {}", files.size(), folder.getPath());
		for (RepositoryFile file : files) {
			final ZipEntry fileEntry = new ZipEntry(zipPath.resolve(file.getOriginalFilename()).toString());
			fileEntry.setComment(file.getDescription());
			fileEntry.setTime(System.currentTimeMillis());
			zos.putNextEntry(fileEntry);

			try (var proxy = new OutputStream() {
				@Override
				public void write(int b) throws IOException {
					zos.write(b);
				}
				public void write(byte[] b) throws IOException {
					zos.write(b);
				};
				public void write(byte[] b, int off, int len) throws IOException {
					zos.write(b, off, len);
					zos.flush();
				};
				public void close() throws IOException {
					zos.flush();
				};
			}) {
				// Stream repository bytes to proxy which writes them directly to ZipOutputStream
				streamFileBytes(file, proxy);
			}
			zos.flush();
			zos.closeEntry();
		}
		List<RepositoryFolder> subFolders = getFolders(folder.getFolderPath(), Sort.unsorted());
		for (RepositoryFolder subFolder: subFolders) {
			final ZipEntry folderEntry = new ZipEntry(zipPath.resolve(subFolder.getName()).toString().concat("/"));
			folderEntry.setComment(subFolder.getDescription());
			folderEntry.setTime(System.currentTimeMillis());
			zos.putNextEntry(folderEntry);
			zos.flush();
			zos.closeEntry();
			writeToZip(zos, subFolder, zipPath.resolve(subFolder.getName()));
		}
	}

	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	@Transactional(rollbackFor = Throwable.class)
	public List<RepositoryFile> extractZip(final RepositoryFile repositoryFile) throws NoSuchRepositoryFileException, InvalidRepositoryFileDataException, IOException {
		assert (repositoryFile != null && repositoryFile.getId() != null);
		RepositoryFile file = repositoryFilePersistence.findById(repositoryFile.getId()).orElseThrow(() -> new NoSuchRepositoryFileException());
		if (! StringUtils.equalsIgnoreCase(file.getExtension(), ".zip")) {
			throw new InvalidRepositoryFileDataException("File extension must be .zip for extracting: ".concat(repositoryFile.getOriginalFilename()));
		}

		byte[] fileBytes = getFileBytes(file);
		Path repositoryFilePath = Paths.get(file.getFolder().getPath());
		try (final ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(fileBytes))) {

			List<RepositoryFile> savedFiles = new ArrayList<>();
	
			var zipEntry = zip.getNextEntry();
			while (zipEntry != null) {
				if (zipEntry.isDirectory()) {
					try {
						ensureFolder(repositoryFilePath.resolve(zipEntry.getName()));
					} catch (InvalidRepositoryPathException e) {
						log.warn("Ignoring invalid path in archive: {}", e.getMessage());
					}
				} else {
					Path zipFilePath = Paths.get(zipEntry.getName());
					Path repositoryPath = repositoryFilePath;
					if (zipFilePath.getParent() != null) {
						repositoryPath = repositoryFilePath.resolve(zipFilePath.getParent());
					}
	
					String contentType = URLConnection.getFileNameMap().getContentTypeFor(zipFilePath.getFileName().toString());
	
					try {
						var savedFile = addFile(repositoryPath, zipFilePath.getFileName().toString(), contentType, zip.readAllBytes(), null);
						savedFiles.add(savedFile);
					} catch (InvalidRepositoryPathException e) {
						log.warn("Ignoring invalid path in archive: {}", e.getMessage());
					}
				}
				zipEntry = zip.getNextEntry();
			}
	
			return savedFiles;

		} catch (IOException e) {
			log.error("Could not extract zip", e);
			throw new InvalidRepositoryFileDataException("Could not extract data from zip archive: ".concat(e.getMessage()), e);
		}
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'read')")
	public <T extends RepositoryFile> void scanBytes(T repositoryFile) throws VirusFoundException, IOException {
		if (virusScanner != null) {
			var throwableHolder = new ThrowableHolder<VirusFoundException>();
			bytesStorageService.get(repositoryFile.storagePath(), inputStream -> {
				try {
					virusScanner.scan(inputStream);
				} catch (VirusFoundException e) {
					throwableHolder.throwable = e;
					log.warn("Virus found in repository file uuid={} path={}/{}", repositoryFile.getUuid(), repositoryFile.getFolder().getPath(), repositoryFile.getOriginalFilename());
				}
			});
			if (throwableHolder.throwable != null) {
				throw throwableHolder.throwable;
			}
		}
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#getFiles(java.nio.file.
	 * Path)
	 */
	@Override
	@Transactional(readOnly = true)
	public List<RepositoryFile> getFiles(Path folderPath, final Sort sort) throws InvalidRepositoryPathException {
		RepositoryFolder folder = getFolder(folderPath);
		if (folder == null) {
			return Collections.emptyList();
		}
		return (List<RepositoryFile>) repositoryFilePersistence.findAll(QRepositoryFile.repositoryFile.folder.eq(folder), sort);
	}

	/* (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#listFiles(java.nio.file.Path, org.springframework.data.domain.Pageable)
	 */
	@Override
	@Transactional(readOnly = true)
	public Page<RepositoryFile> listFiles(Path folderPath, Pageable page) throws InvalidRepositoryPathException {
		RepositoryFolder folder = getFolder(folderPath);
		if (folder == null) {
			return new PageImpl<>(Collections.emptyList(), page, 0);
		}
		return repositoryFilePersistence.findAll(QRepositoryFile.repositoryFile.folder.eq(folder), page);
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#getFolders(java.nio.file
	 * .Path)
	 */
	@Override
	@Transactional(readOnly = true)
	public List<RepositoryFolder> getFolders(Path root, final Sort sort) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(root);
		if (root == null || "/".equals(root.normalize().toAbsolutePath().toString())) {
			return (List<RepositoryFolder>) folderRepository.findAll(QRepositoryFolder.repositoryFolder.parent.isNull(), sort);
		} else {
			RepositoryFolder parent = getFolder(root);
			return parent == null ? Collections.emptyList() :
				// Load child folders
				(List<RepositoryFolder>) folderRepository.findAll(QRepositoryFolder.repositoryFolder.parent.eq(parent), sort);
		}
	}

	/* (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#listFolders(java.nio.file.Path, org.springframework.data.domain.Pageable)
	 */
	@Override
	@Transactional(readOnly = true)
	public Page<RepositoryFolder> listFolders(Path root, Pageable page) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(root);
		if ("/".equals(root.normalize().toAbsolutePath().toString())) {
			return folderRepository.findAll(QRepositoryFolder.repositoryFolder.parent.isNull(), page);
		} else {
			RepositoryFolder parent = getFolder(root);
			return parent == null ? new PageImpl<>(Collections.emptyList(), page, 0) :
				// Load child folders
				folderRepository.findAll(QRepositoryFolder.repositoryFolder.parent.eq(parent), page);
		}
	}

	@Override
	@Transactional(readOnly = true)
	public RepositoryFolder getFolder(UUID uuid) {
		return folderRepository.findByUuid(uuid);
	}

	@Override
	@Transactional(readOnly = true)
	public RepositoryFolder getFolder(Path folderPath) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(folderPath);
		return folderRepository.findByPath(folderPath.normalize().toAbsolutePath().toString());
	}

	/* (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#updateFolder(org.genesys.filerepository.model.RepositoryFolder)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	public RepositoryFolder updateFolder(RepositoryFolder folder) throws NoSuchRepositoryFolderException {
		RepositoryFolder databaseFolder = folderRepository.findByUuidAndVersion(folder.getUuid(), folder.getVersion());
		if (databaseFolder == null) {
			throw new NoSuchRepositoryFolderException("Folder version doesn't match or folder not found");
		}
		databaseFolder.setTitle(folder.getTitle());
		databaseFolder.setDescription(folder.getDescription());
		return folderRepository.save(databaseFolder);
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(readOnly = true)
	public Stream<RepositoryFile> streamFiles(Path root, final Sort sort) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(root);
		BooleanExpression q = QRepositoryFile.repositoryFile.folder.in(listPathsRecursively(root));
		return StreamSupport.stream(repositoryFilePersistence.findAll(q, sort).spliterator(), false);
	}

	/** {@inheritDoc} */
	@SuppressWarnings("unchecked")
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T updateMetadata(final T repositoryFile) throws NoSuchRepositoryFileException {
		RepositoryFile rf = repositoryFilePersistence.findByUuidAndVersion(repositoryFile.getUuid(), repositoryFile.getVersion());
		if (rf == null) {
			throw new NoSuchRepositoryFileException();
		}
		
		rf.apply(repositoryFile);

		if (rf instanceof RepositoryImage) {
			return (T) lazyLoad(repositoryImagePersistence.save((RepositoryImage) rf));
		} else {
			return (T) lazyLoad(repositoryFilePersistence.save(rf));
		}
	}

	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryImage, 'write')")
	public RepositoryImage updateImageMetadata(RepositoryImage repositoryImage) throws NoSuchRepositoryFileException {
		return updateMetadata(repositoryImage);
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T updateBytes(T repositoryFile, String contentType, InputStream inputStream)
			throws NoSuchRepositoryFileException, IOException {
		
		File tempFile = File.createTempFile("repo-", ".upload");

		try {
			try (OutputStream temp = new BufferedOutputStream(Files.newOutputStream(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), 8 * 1000)) {
				log.debug("Using temporary file {}", tempFile.getAbsolutePath());
				inputStream.transferTo(temp);
			}
			return updateBytes(repositoryFile, contentType, tempFile);
		} finally {
			if (tempFile.exists()) {
				tryDelete(tempFile);
			}
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T updateBytes(final T repositoryFile, String contentType, final byte[] bytes) throws NoSuchRepositoryFileException, IOException {

		File tempFile = File.createTempFile("repo-", ".upload");

		try {
			try (OutputStream temp = new BufferedOutputStream(Files.newOutputStream(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), 8 * 1000)) {
				log.debug("Using temporary file {}", tempFile.getAbsolutePath());
				temp.write(bytes);
			}
			return updateBytes(repositoryFile, contentType, tempFile);
		} finally {
			if (tempFile.exists()) {
				tryDelete(tempFile);
			}
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryImage, 'write')")
	public RepositoryImage updateImageBytes(RepositoryImage repositoryImage, String contentType, byte[] bytes)
			throws NoSuchRepositoryFileException, IOException {
		return updateBytes(repositoryImage, contentType, bytes);
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T updateBytes(T repositoryFile, String contentType, File fileWithBytes)
			throws NoSuchRepositoryFileException, IOException {

		T storedFile = getFile(repositoryFile.getUuid(), repositoryFile.getVersion());

		// Update metadata
		storedFile.apply(repositoryFile);

		contentType = detectContentType(contentType, fileWithBytes);
		storedFile.setContentType(contentType);

		// Calculate SHA-1 and MD5 sums
		log.debug("updateBytes length={}", fileWithBytes.length());
		updateDigests(storedFile, fileWithBytes);
		var fileLength = fileWithBytes.length();
		storedFile.setSize((int) fileLength);
		log.debug("updateBytes length={} repoFile.size={}", fileLength, storedFile.getSize());

		if (storedFile instanceof RepositoryImage) {
			fillImageProperties((RepositoryImage) storedFile, fileWithBytes);
		}

		bytesStorageService.upsert(storedFile.storagePath(), fileWithBytes);

		if (storedFile instanceof RepositoryImage) {
			return (T) lazyLoad(repositoryFilePersistence.save((RepositoryImage) storedFile));
		} else {
			return lazyLoad(repositoryFilePersistence.save(storedFile));
		}
	}

	/**
	 * Update content type if necessary.
	 *
	 * @param contentType the content type
	 * @param bytes the bytes
	 * @param originalFilename 
	 * @return the string
	 */
	private String detectContentType(final String contentType, @NonNull final File file) {
		if (!file.exists()) {
			return contentType;
		}
		try {
			if (contentType == null) {
				return new Tika().detect(file);
			} else {
				final String detectedContentType = new Tika().detect(file);
				if (! detectedContentType.equals(contentType)) {
					log.debug("Content-Type provided={} detected={}", contentType, detectedContentType);
				}
				return contentType;
			}
		} catch (IOException e) {
			log.info("Could not detect content type: {}", e.getMessage());
			return contentType;
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(propagation = Propagation.MANDATORY, noRollbackFor = { ReferencedRepositoryFileException.class, NoSuchRepositoryFileException.class }, rollbackFor = IOException.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'delete')")
	public <T extends RepositoryFile> T removeFileIfPossible(T repositoryFile) throws ReferencedRepositoryFileException, NoSuchRepositoryFileException, IOException {
		if (repositoryFile == null) {
			throw new NoSuchRepositoryFileException();
		}
		// ensure repositoryFile isn't proxy
		if (repositoryFile instanceof HibernateProxy) {
			repositoryFile = (T) Hibernate.unproxy(repositoryFile);
		}

		// Try to delete the record
		if (repositoryFile instanceof RepositoryImage) {
			try {
				jdbcTemplate.execute("delete from repository_image where id = " + repositoryFile.getId());
				jdbcTemplate.execute("delete from repository_file where id = " + repositoryFile.getId());
				log.debug("repository_image and repository_file removed!");
			} catch (DataIntegrityViolationException e) {
				log.debug("Err: {}", e.getMessage(), e);
				throw new ReferencedRepositoryFileException("Could not delete repositoryFile", e);
			}
		} else {
			try {
				jdbcTemplate.execute("delete from repository_file where id = " + repositoryFile.getId());
				log.debug("repository_file removed!");
			} catch (DataIntegrityViolationException e) {
				log.debug("Err: {}", e.getMessage(), e);
				throw new ReferencedRepositoryFileException("Could not delete repositoryFile", e);
			}
		}

		if (repositoryFile instanceof RepositoryImage) {
			_removeImage((RepositoryImage) repositoryFile);
		}

		// Delete the bytes
		bytesStorageService.remove(repositoryFile.storagePath());

		return repositoryFile;
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'delete')")
	public <T extends RepositoryFile> T removeFile(T repositoryFile) throws NoSuchRepositoryFileException, IOException {
		if (repositoryFile == null) {
			throw new NoSuchRepositoryFileException();
		}

		// ensure repositoryFile isn't proxy
		if (repositoryFile instanceof HibernateProxy) {
			repositoryFile = (T) Hibernate.unproxy(repositoryFile);
		}

		// Try to delete the record
		if (repositoryFile instanceof RepositoryImage) {
			repositoryImagePersistence.delete((RepositoryImage) repositoryFile);
		} else {
			repositoryFilePersistence.delete(repositoryFile);
		}

		if (repositoryFile instanceof RepositoryImage) {
			_removeImage((RepositoryImage) repositoryFile);
		}

		// Delete the bytes
		bytesStorageService.remove(repositoryFile.storagePath());

		return repositoryFile;
	}

	private void _removeImage(final RepositoryImage repositoryImage) throws NoSuchRepositoryFileException, IOException {
		// Remove thumbnails
		Path path = getFullThumbnailsPath(repositoryImage);
		try {
			List<String> thumbnails = bytesStorageService.listFiles(path);
			for (String thumb : thumbnails) {
				log.debug("Removing thumbnail at {}", path.resolve(thumb));
				try {
					bytesStorageService.remove(path.resolve(Path.of(thumb).getFileName()));
				} catch (IOException e) {
					// Log error on thumbnails, don't throw exception
					log.warn("Trouble removing thumbnail: {}", e.getMessage());
				}
			}
		} catch (Exception e) {
			log.warn("Trouble removing thumbnails: {}", e.getMessage());
		}
	}

	/** {@inheritDoc} */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryImage, 'delete')")
	public RepositoryImage removeImage(RepositoryImage repositoryImage) throws NoSuchRepositoryFileException, IOException {
		return removeFile(repositoryImage);
	}

	/*
	 * (non-Javadoc)
	 * @see org.genesys.filerepository.service.RepositoryService#moveFile(org
	 * .genesys2.server.filerepository.model .RepositoryFile, java.lang.String)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T moveFile(final T repositoryFile, final Path destination) throws NoSuchRepositoryFileException, InvalidRepositoryPathException {

		if (repositoryFile == null) {
			throw new NoSuchRepositoryFileException();
		}

		if (destination == null) {
			throw new InvalidRepositoryPathException("destination cannot be null");
		}

		PathValidator.checkValidPath(destination);

		RepositoryFolder repositoryFolder = ensureFolder(destination);
		repositoryFile.setFolder(repositoryFolder);

		if (repositoryFile instanceof RepositoryImage) {
			return (T) lazyLoad(repositoryImagePersistence.save((RepositoryImage) repositoryFile));
		} else {
			return (T) lazyLoad(repositoryFilePersistence.save(repositoryFile));
		}
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#moveAndRenameFile(org.
	 * genesys.filerepository.model.RepositoryFile, java.lang.String)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#repositoryFile, 'write')")
	public <T extends RepositoryFile> T moveAndRenameFile(T repositoryFile, final Path fullPath) throws InvalidRepositoryPathException, InvalidRepositoryFileDataException {
		if (fullPath == null) {
			throw new NullPointerException("Full path cannot be null");
		}

		// ensure repositoryFile isn't proxy
		if (repositoryFile instanceof HibernateProxy) {
			repositoryFile = (T) Hibernate.unproxy(repositoryFile);
		}

		log.info("Moving name={} to {}", repositoryFile.getFilename(), fullPath);

		final Path path = fullPath.normalize().toAbsolutePath();
		PathValidator.checkValidPath(path.getParent().toString());

		if (path.getFileName().toString().trim().isEmpty()) {
			throw new InvalidRepositoryFileDataException("File name cannot be blank");
		}

		repositoryFile.setFolder(ensureFolder(path.getParent()));
		repositoryFile.setOriginalFilename(path.getFileName().toString());

		if (repositoryFile instanceof RepositoryImage) {
			return (T) lazyLoad(repositoryImagePersistence.save((RepositoryImage) repositoryFile));
		} else {
			return (T) lazyLoad(repositoryFilePersistence.save(repositoryFile));
		}
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#listImages(java.lang.
	 * String)
	 */
	@Override
	@Transactional(readOnly = true)
	public List<RepositoryImage> listImages(final Path repositoryPath, final Sort sort) {
		return repositoryImagePersistence.findByFolder(folderRepository.findByPath(repositoryPath.normalize().toAbsolutePath().toString()), sort);
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#hasPath(java.nio.file.
	 * Path)
	 */
	@Override
	@Transactional(readOnly = true)
	public boolean hasPath(final Path path) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(path);
		final String p = path.normalize().toAbsolutePath().toString();
		PathValidator.checkValidPath(p);
		log.trace("Is directory path={}", p);
		return folderRepository.exists(QRepositoryFolder.repositoryFolder.path.eq(p));
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#listPaths(java.lang.
	 * String)
	 */
	@Override
	@Transactional(readOnly = true)
	public List<RepositoryFolder> listPathsRecursively(final Path prefix) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(prefix);
		ArrayList<RepositoryFolder> folders = new ArrayList<>();

		RepositoryFolder rootFolder = folderRepository.findByPath(prefix.normalize().toAbsolutePath().toString());
		if (rootFolder != null) {
			folders.add(rootFolder);
			addAllSubfolders(rootFolder, folders);
		}
		return folders;
	}

	/**
	 * Recursively Adds the all subfolders to the list
	 *
	 * @param folder the root folder
	 * @param folders the folders
	 */
	private void addAllSubfolders(RepositoryFolder folder, ArrayList<RepositoryFolder> folders) {
		if (folder != null && folder.getChildren() != null) {
			for (RepositoryFolder ch : folder.getChildren()) {
				folders.add(ch);
				addAllSubfolders(ch, folders);
			}
		}
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#renamePath(java.lang.
	 * String, java.lang.String)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	public RepositoryFolder renamePath(final Path currentPath, final Path newPath) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(currentPath);
		PathValidator.checkValidPath(newPath);

		final Path cPath = currentPath.normalize().toAbsolutePath();
		final Path nPath = newPath.normalize().toAbsolutePath();
		PathValidator.checkValidPath(cPath.toString());
		PathValidator.checkValidPath(nPath.toString());

		RepositoryFolder source = folderRepository.findByPath(cPath.toString());
		if (source == null) {
			throw new InvalidRepositoryPathException("Folder for rename doesn't exist. Path was " + cPath);
		} else if (!SecurityContextUtil.hasRole("ADMINISTRATOR") && !SecurityContextUtil.hasPermission(source, "MANAGE")) {
			throw new AccessDeniedException("No MANAGE permission on " + cPath);
		}

		RepositoryFolder target = folderRepository.findByPath(nPath.toString());
		if (target != null) {
			throw new InvalidRepositoryPathException("Folder with this name already exists. Path was " + nPath);
		}

		var reversedPaths = listPathsRecursively(cPath);
		Collections.reverse(reversedPaths);
		for (final RepositoryFolder folder : reversedPaths) {
			final Path folderPath = folder.getFolderPath();
			final Path relative = cPath.relativize(folderPath);

			log.debug("Base={} rfPath={} relative={} to={}", cPath, folderPath, relative, nPath.resolve(relative));

			final RepositoryFolder newFolder = ensureFolder(nPath.resolve(relative).toAbsolutePath());
			copySecurityData(folder, newFolder);

			for (final RepositoryFile repositoryFile : folder.getFiles()) {
				repositoryFile.setFolder(newFolder);
				repositoryFilePersistence.save(repositoryFile);
			}

			final ImageGallery imageGallery = folder.getGallery();
			if (imageGallery != null) {
				imageGallery.setFolder(newFolder);
				imageGallery.setPath(newFolder.getPath());
				imageGalleryPersistence.save(imageGallery);
			}

			log.debug("Deleting folder {}", folder);
			folderRepository.delete(folder);
		}

		return folderRepository.findByPath(nPath.toString());
	}

	private void copySecurityData(AclAwareModel source, AclAwareModel target) {
		AclObjectIdentity sourceAclObjectIdentity = aclService.getObjectIdentity(source);
		AclObjectIdentity targetAclObjectIdentity = aclService.getObjectIdentity(target);
		if (sourceAclObjectIdentity != null && targetAclObjectIdentity != null) {
			targetAclObjectIdentity.setOwnerSid(sourceAclObjectIdentity.getOwnerSid());
			aclObjectIdentityPersistence.save(targetAclObjectIdentity);
		} else {
			log.debug("Can't change owner source={}/{} target={}/{}", source, sourceAclObjectIdentity, target, targetAclObjectIdentity);
		}
		// Add permissions defined on source (if any) to target
		aclService.getPermissions(source).forEach(sourceSidPermission -> {
			log.debug("Copying permission {} to {}", sourceSidPermission, target);
			aclService.setPermissions(target, sourceSidPermission.sid, sourceSidPermission);
		});
	}

	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public RepositoryFolder ensureFolder(Path folderPath, AclAwareModel parentEntity) throws InvalidRepositoryPathException {
		assert(folderPath != null);
		assert(parentEntity != null);

		PathValidator.checkValidPath(folderPath);
		folderPath = folderPath.normalize().toAbsolutePath();
		PathValidator.checkValidPath(folderPath.toString());

		if ("/".equals(folderPath.toString())) {
			// Root folder
			if (! SecurityContextUtil.hasRole("ADMINISTRATOR")) {
				// Only administrator can create folders on root
				throw new AccessDeniedException("No WRITE permission on /");
			}
			return null;
		} else {
			String folderNameToCheck = folderPath.getFileName().toString();
			if (blacklistFolderNamePattern.matcher(folderNameToCheck).find()) {
				throw new InvalidRepositoryPathException("Invalid folder name: " + folderNameToCheck);
			}
			PathValidator.checkValidFolderName(folderNameToCheck);
		}

		RepositoryFolder folder = folderRepository.findByPath(folderPath.toString());

		if (folder == null) {
			// Create new folder
			folder = new RepositoryFolder();
			folder.setName(folderPath.getFileName().toString());
			// This checks permissions on parent, but we're running as ADMIN
			folder.setParent(ensureFolder(folderPath.getParent()));
			folder.setParentOid(aclService.getObjectIdentity(parentEntity));
			if (folder.getParentOid() == null) {
				throw new RuntimeException("Parent entity does not have an ACL OID: " + parentEntity);
			}
			folder = folderRepository.save(folder);

		} else {
			// Update parent OID
			folder.setParentOid(aclService.getObjectIdentity(parentEntity));
			folder = folderRepository.save(folder);
		}

		return folder;
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#addFolder(java.nio.file.
	 * Path)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	public RepositoryFolder ensureFolder(Path folderPath) throws InvalidRepositoryPathException {
		PathValidator.checkValidPath(folderPath);
		folderPath = folderPath.normalize().toAbsolutePath();
		PathValidator.checkValidPath(folderPath.toString());
		boolean hasRoleAdministrator = SecurityContextUtil.hasRole("ADMINISTRATOR");
		if ("/".equals(folderPath.toString())) {
			// Root folder
			if (! hasRoleAdministrator) {
				// Only administrator can create folders on root
				throw new AccessDeniedException("No WRITE permission on /");
			}
			return null;
		} else {
			String folderNameToCheck = folderPath.getFileName().toString();
			if (blacklistFolderNamePattern.matcher(folderNameToCheck).find()) {
				throw new InvalidRepositoryPathException("Invalid folder name: " + folderNameToCheck);
			}
			PathValidator.checkValidFolderName(folderNameToCheck);
		}

		RepositoryFolder folder = folderRepository.findByPath(folderPath.toString());

		if (folder == null) {
			folder = new RepositoryFolder();
			folder.setName(folderPath.getFileName().toString());
			if (folderPath.getParent() != null) {
				// This will assure permissions on existing parent folder
				folder.setParent(ensureFolder(folderPath.getParent()));
			}
			folderRepository.save(folder);
		} else {
			// Assure permissions on folder if it already exists
			if (! hasRoleAdministrator && ! SecurityContextUtil.hasPermission(folder, "WRITE")) {
				throw new AccessDeniedException("No WRITE permission on " + folder.getPath());
			}
		}

		return folder;
	}

	/*
	 * (non-Javadoc)
	 * @see
	 * org.genesys.filerepository.service.RepositoryService#deleteFolder(java.nio.
	 * file.Path)
	 */
	@Override
	@Transactional(rollbackFor = Throwable.class)
	public RepositoryFolder deleteFolder(Path path) throws FolderNotEmptyException, InvalidRepositoryPathException {
		PathValidator.checkValidPath(path);
		final Path cPath = path.normalize().toAbsolutePath();

		RepositoryFolder folder = folderRepository.findByPath(cPath.toString());
		if (folder != null) {
			if (!folder.getChildren().isEmpty() || repositoryFilePersistence.countByFolder(folder) > 0) {
				throw new FolderNotEmptyException("Folder " + path.toString() + " is not empty.");
			}

			folderRepository.delete(folder);
		}
		return folder;
	}

	/** {@inheritDoc} */
	@Override
	public byte[] getThumbnail(final Path path, String name, String extension, RepositoryFile repositoryFile) throws Exception {
		assert (repositoryFile instanceof RepositoryImage);
		String filename = name.concat(".").concat(extension);
		var errorFilename = RepositoryServiceImpl.getFullThumbnailsPath((RepositoryImage) repositoryFile).resolve(filename + "-err");
		try {
			var data = bytesStorageService.get(path.resolve(filename));
			if (data == null) {
				var size = Integer.parseInt(name.split("x")[0]);
				if (Arrays.stream(thumbnailSizes).noneMatch(availableSize -> availableSize == size)) {
					throw new InvalidDataAccessApiUsageException("Size of thumbnail is invalid: ".concat(name));
				}

				// TODO Re-enable
//				if (bytesStorageService.exists(errorFilename)) {
//					throw new ThumbnailException(String.format("Thumbnail cannot be generated for image: %s. Filename: %s", repositoryFile.getUuid().toString(), filename));
//				}

				var imageBytes = getFileBytes(repositoryFile);
				if (imageBytes == null) {
					throw new NoSuchRepositoryFileException("No file for uuid: ".concat(repositoryFile.getUuid().toString()));
				}

				data = thumbnailGenerator.createThumbnail(size, size, extension, imageBytes);
				try {
					bytesStorageService.remove(errorFilename);
				} catch (Throwable e) {
					// Can't remove error file
				}
				bytesStorageService.upsert(RepositoryServiceImpl.getFullThumbnailsPath((RepositoryImage) repositoryFile).resolve(filename), data);
			}
			return data;
		} catch (NoSuchRepositoryFileException e) {
			log.warn(e.getMessage());
			throw e;
		} catch (InvalidDataAccessApiUsageException e) {
			log.warn(e.getMessage());
			throw new ThumbnailException(e.getMessage(), e);
		} catch (Exception e) {
			log.warn("Could not generate thumbnail: {}", e.getMessage());
//			bytesStorageService.upsert(errorFilename, e.getMessage().getBytes());
			throw e;
		}
	}

	/**
	 * Sets the thumbnail sizes.
	 *
	 * @param thumbnailSizes the new thumbnail sizes
	 */
	public void setThumbnailSizes(int[] thumbnailSizes) {
		Arrays.sort(thumbnailSizes);
		this.thumbnailSizes = thumbnailSizes;
	}


	/**
	 * Helper to retrieve image bytes (and use loaded bytes when possible).
	 */
	public interface IImageBytesProvider {
		/**
		 * Get image bytes.
		 * @throws IOException when things go wrong
		 * @return the bytes from storage
		 */
		byte[] getImageBytes() throws IOException;
	}

	/**
	 * Internal holder class for a Throwable in a callback transaction model.
	 */
	private static class ThrowableHolder<T extends Throwable> {
		public T throwable;
	}

	private static void tryDelete(File target) {
		log.debug("DELETING {}", target.getAbsolutePath());
		try {
			if (!target.delete()) {
				log.debug("{} is not deleted", target.getAbsolutePath());
			}
		} catch (Exception e) {
			log.debug("{} is not deleted", target.getAbsolutePath());
		}
	}
}