dk.dma.msinm.common.repo.RepositoryService.java Source code

Java tutorial

Introduction

Here is the source code for dk.dma.msinm.common.repo.RepositoryService.java

Source

/* Copyright (c) 2011 Danish Maritime Authority
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this library.  If not, see <http://www.gnu.org/licenses/>.
 */
package dk.dma.msinm.common.repo;

import dk.dma.msinm.common.MsiNmApp;
import dk.dma.msinm.common.settings.annotation.Setting;
import dk.dma.msinm.common.util.WebUtils;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.FileCleanerCleanup;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.io.FileCleaningTracker;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang.StringUtils;
import org.jboss.ejb3.annotation.SecurityDomain;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.slf4j.Logger;

import javax.annotation.PostConstruct;
import javax.annotation.security.PermitAll;
import javax.annotation.security.RolesAllowed;
import javax.ejb.Lock;
import javax.ejb.LockType;
import javax.ejb.Schedule;
import javax.ejb.Singleton;
import javax.inject.Inject;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.EntityTag;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.UUID;

/**
 * A repository service.<br>
 * Streams files from the repository and facilitates uploading files to the repository.
 * <p>
 *     The repository is public in as much as everybody can download all files.<br>
 *
 *     However only "admin" users can upload files to the entire repository.<br>
 *
 *     Registered users, with the "user" role, can upload files to a sub-root of
 *     the repository, the {@code repoTempRoot}. Files upload to the this part
 *     of the repository will be deleted after 24 hours.
 * </p>
 */
@javax.ws.rs.Path("/repo")
@Singleton
@Lock(LockType.READ)
@SecurityDomain("msinm-policy")
@PermitAll
public class RepositoryService {

    @Context
    ServletContext servletContext;

    @Inject
    @Setting(value = "repoRootPath", defaultValue = "${user.home}/.msinm/repo", substituteSystemProperties = true)
    Path repoRoot;

    @Inject
    @Setting(value = "repoCacheTimeoutMinutes", defaultValue = "5")
    Long cacheTimeout;

    @Inject
    Logger log;

    @Inject
    FileTypes fileTypes;

    @Inject
    ThumbnailService thumbnailService;

    @Inject
    MsiNmApp app;

    /**
     * Initializes the repository
     */
    @PostConstruct
    public void init() {
        // Create the repo root directory
        if (!Files.exists(getRepoRoot())) {
            try {
                Files.createDirectories(getRepoRoot());
            } catch (IOException e) {
                log.error("Error creating repository dir " + getRepoRoot(), e);
            }
        }

        // Create the repo "temp" root directory
        if (!Files.exists(getTempRepoRoot())) {
            try {
                Files.createDirectories(getTempRepoRoot());
            } catch (IOException e) {
                log.error("Error creating repository dir " + getTempRepoRoot(), e);
            }
        }
    }

    /**
     * Returns the repository root
     * @return the repository root
     */
    public Path getRepoRoot() {
        return repoRoot;
    }

    /**
     * Returns the repository "temp" root
     * @return the repository "temp" root
     */
    public Path getTempRepoRoot() {
        return getRepoRoot().resolve("temp");
    }

    /**
     * Creates a URI from the repo file
     * @param repoFile the repo file
     * @return the URI for the file
     */
    public String getRepoUri(Path repoFile) {
        Path filePath = getRepoRoot().relativize(repoFile);
        return "/rest/repo/file/" + WebUtils.encodeURI(filePath.toString().replace('\\', '/'));
    }

    /**
     * Creates a path from the repo file relative to the repo root
     * @param repoFile the repo file
     * @return the path for the file
     */
    public String getRepoPath(Path repoFile) {
        Path filePath = getRepoRoot().relativize(repoFile);
        return filePath.toString().replace('\\', '/');
    }

    /**
     * Creates two levels of sub-folders within the {@code rootFolder} based on
     * a MD5 hash of the {@code target}.
     * If the sub-folder does not exist, it is created.
     *
     * @param rootFolder the root folder within the repository root
     * @param target the target name used for the hash
     * @param includeTarget whether to create a sub-folder for the target or not
     * @return the sub-folder associated with the target
     */
    public Path getHashedSubfolder(String rootFolder, String target, boolean includeTarget) throws IOException {
        byte[] bytes = target.getBytes("utf-8");

        // MD5 hash the ID
        MessageDigest md = null;
        try {
            md = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new IOException("This should never happen");
        }
        md.update(bytes);
        bytes = md.digest();
        String hash = String.valueOf(Integer.toHexString(bytes[0] & 0xff));
        while (hash.length() < 2) {
            hash = "0" + hash;
        }

        Path folder = getRepoRoot();

        // Add the root folder
        if (StringUtils.isNotBlank(rootFolder)) {
            folder = folder.resolve(rootFolder);
        }

        // Add two hashed sub-folder levels
        folder = folder.resolve(hash.substring(0, 1)).resolve(hash.substring(0, 2));

        // Check if we should create a sub-folder for the target as well
        if (includeTarget) {
            folder = folder.resolve(target);
        }

        // Create the folder if it does not exist
        if (!Files.exists(folder)) {
            Files.createDirectories(folder);
        }
        return folder;
    }

    /**
     * Streams the file specified by the path
     * @param path the path
     * @param request the servlet request
     * @return the response
     */
    @GET
    @javax.ws.rs.Path("/file/{file:.+}")
    public Response streamFile(@PathParam("file") String path, @Context Request request) throws IOException {

        Path f = repoRoot.resolve(path);

        if (Files.notExists(f) || Files.isDirectory(f)) {
            log.warn("Failed streaming file: " + f);
            throw new WebApplicationException(404);
        }

        // Set expiry to cacheTimeout minutes
        Date expirationDate = new Date(System.currentTimeMillis() + 1000L * 60L * cacheTimeout);

        String mt = fileTypes.getContentType(f);

        // Check for an ETag match
        EntityTag etag = new EntityTag("" + Files.getLastModifiedTime(f).toMillis() + "_" + Files.size(f), true);
        Response.ResponseBuilder responseBuilder = request.evaluatePreconditions(etag);
        if (responseBuilder != null) {
            // Etag match
            log.trace("File unchanged. Return code 304");
            return responseBuilder.expires(expirationDate).build();
        }

        log.trace("Streaming file: " + f);
        return Response.ok(f.toFile(), mt).expires(expirationDate).tag(etag).build();
    }

    /**
     * Deletes the file specified by the path
     * @param path the path
     * @return the response
     */
    @DELETE
    @javax.ws.rs.Path("/file/{file:.+}")
    //@RolesAllowed({ "editor" })
    public String deleteFile(@PathParam("file") String path) throws IOException {

        // TODO: The @RolesAllowed annotation has been commented out for now, because deleting
        //       a file with a space in the file name would yield an unauthorized-exception. WTF!!!
        //       Consider checking the role programmatically via the UserService...

        Path f = repoRoot.resolve(path);

        if (Files.notExists(f) || Files.isDirectory(f)) {
            log.warn("Failed deleting file: " + f);
            throw new WebApplicationException(404);
        }

        Files.delete(f);
        log.info("Deleted file " + f);

        return "OK";
    }

    /**
     * Returns the thumbnail to use for the file specified by the path
     * @param path the path
     * @param size the icon size, either 32, 64 or 128
     * @return the thumbnail to use for the file specified by the path
     */
    @GET
    @javax.ws.rs.Path("/thumb/{file:.+}")
    public Response getThumbnail(@PathParam("file") String path, @QueryParam("size") @DefaultValue("64") int size)
            throws IOException, URISyntaxException {

        IconSize iconSize = IconSize.getIconSize(size);
        Path f = repoRoot.resolve(path);

        if (Files.notExists(f) || Files.isDirectory(f)) {
            log.warn("Failed streaming file: " + f);
            throw new WebApplicationException(404);
        }

        // Check if we can generate a thumbnail for image files
        String thumbUri = null;
        Path thumbFile = thumbnailService.getThumbnail(f, iconSize);
        if (thumbFile != null) {
            thumbUri = app.getBaseUri() + getRepoUri(thumbFile);
        } else {
            // Fall back to file type icons
            thumbUri = app.getBaseUri() + "/" + fileTypes.getIcon(f, iconSize);
        }

        log.trace("Redirecting to thumbnail: " + thumbUri);
        return Response.temporaryRedirect(new URI(thumbUri)).build();
    }

    /**
     * Returns a list of files in the folder specified by the path
     * @param path the path
     * @return the list of files in the folder specified by the path
     */
    @GET
    @javax.ws.rs.Path("/list/{folder:.+}")
    @Produces("application/json;charset=UTF-8")
    @NoCache
    public List<RepoFileVo> listFiles(@PathParam("folder") String path) throws IOException {

        List<RepoFileVo> result = new ArrayList<>();
        Path folder = repoRoot.resolve(path);

        if (Files.exists(folder) && Files.isDirectory(folder)) {

            // Filter out directories, hidden files, thumbnails and map images
            DirectoryStream.Filter<Path> filter = file -> Files.isRegularFile(file)
                    && !file.getFileName().toString().startsWith(".")
                    && !file.getFileName().toString().matches(".+_thumb_\\d{1,3}\\.\\w+") && // Thumbnails
                    !file.getFileName().toString().matches("map_\\d{1,3}\\.png"); // Map image

            try (DirectoryStream<Path> stream = Files.newDirectoryStream(folder, filter)) {
                stream.forEach(f -> {
                    RepoFileVo vo = new RepoFileVo();
                    vo.setName(f.getFileName().toString());
                    vo.setPath(WebUtils.encodeURI(path + "/" + f.getFileName().toString()));
                    vo.setDirectory(Files.isDirectory(f));
                    try {
                        vo.setUpdated(new Date(Files.getLastModifiedTime(f).toMillis()));
                        vo.setSize(Files.size(f));
                    } catch (Exception e) {
                        log.trace("Error reading file attribute for " + f);
                    }
                    result.add(vo);
                });
            }
        }
        return result;
    }

    /**
     * Handles upload of files
     *
     * @param path the folder to upload to
     * @param request the request
     */
    @POST
    @javax.ws.rs.Path("/upload/{folder:.+}")
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces("application/json;charset=UTF-8")
    @RolesAllowed({ "editor" })
    public List<String> uploadFile(@PathParam("folder") String path, @Context HttpServletRequest request)
            throws FileUploadException, IOException {

        Path folder = repoRoot.resolve(path);

        if (Files.exists(folder) && !Files.isDirectory(folder)) {
            log.warn("Failed streaming file to folder: " + folder);
            throw new WebApplicationException("Invalid upload folder: " + path, 403);

        } else if (Files.notExists(folder)) {
            try {
                Files.createDirectories(folder);
            } catch (IOException e) {
                log.error("Error creating repository folder " + folder, e);
                throw new WebApplicationException("Invalid upload folder: " + path, 403);
            }
        }

        List<String> result = new ArrayList<>();
        FileItemFactory factory = newDiskFileItemFactory(servletContext);
        ServletFileUpload upload = new ServletFileUpload(factory);
        List<FileItem> items = upload.parseRequest(request);

        for (FileItem item : items) {
            if (!item.isFormField()) {
                // Argh - IE includes the path in the item.getName()!
                String fileName = Paths.get(item.getName()).getFileName().toString();
                File destFile = getUniqueFile(folder, fileName).toFile();
                log.info("File " + fileName + " is uploaded to " + destFile);
                try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(destFile))) {
                    InputStream in = new BufferedInputStream(item.getInputStream());
                    byte[] buffer = new byte[1024];
                    int len = in.read(buffer);
                    while (len != -1) {
                        out.write(buffer, 0, len);
                        len = in.read(buffer);
                    }
                    out.flush();
                }

                // Return the repo-relative path as a result
                result.add(Paths.get(path, fileName).toString());
            }
        }

        return result;
    }

    /**
     * Returns a unique file name in the given folder.
     * If the given file name is not unique, a new is constructed
     * by adding a number to the file name
     * @param folder the folder
     * @param name the file name
     * @return the new unique file
     */
    private Path getUniqueFile(Path folder, String name) {
        Path file = folder.resolve(name);
        if (Files.exists(file)) {
            for (int x = 2; true; x++) {
                String fileName = FilenameUtils.removeExtension(name) + " " + x + "."
                        + FilenameUtils.getExtension(name);
                file = folder.resolve(fileName);
                if (!Files.exists(file)) {
                    break;
                }
            }
        }
        return file;
    }

    /**
     * Returns a new unique "temp" directory. Please note, the directory has not yet been created.
     *
     * @return a new unique "temp" directory
     */
    @GET
    @javax.ws.rs.Path("/new-temp-dir")
    @Produces("application/json;charset=UTF-8")
    public RepoFileVo getNewTempDir() {

        // Construct a unique directory name
        String name = UUID.randomUUID().toString();// test "f823b7d5-9559-4a76-b3a3-6d32f2bf55f2";

        RepoFileVo dir = new RepoFileVo();
        dir.setName(name);
        dir.setPath(WebUtils.encodeURI("temp/" + name));
        dir.setDirectory(true);
        return dir;
    }

    /**
     * Handles upload of files to the "temp" repo root.
     * Only the "user" role is required to upload to the "temp" root
     *
     * @param path the folder to upload to
     * @param request the request
     */
    @POST
    @javax.ws.rs.Path("/upload-temp/{folder:.+}")
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces("application/json;charset=UTF-8")
    @RolesAllowed({ "user" })
    public List<String> uploadTempFile(@PathParam("folder") String path, @Context HttpServletRequest request)
            throws FileUploadException, IOException {

        // Check that the specified folder is indeed under the "temp" root
        Path folder = repoRoot.resolve(path);
        if (!folder.toAbsolutePath().startsWith(getTempRepoRoot().toAbsolutePath())) {
            log.warn("Failed streaming file to temp root folder: " + folder);
            throw new WebApplicationException("Invalid upload folder: " + path, 403);
        }

        return uploadFile(path, request);
    }

    /**
     * Creates a new DiskFileItemFactory. See:
     * http://commons.apache.org/proper/commons-fileupload/using.html
     * @return the new DiskFileItemFactory
     */
    public static DiskFileItemFactory newDiskFileItemFactory(ServletContext servletContext) {
        FileCleaningTracker fileCleaningTracker = FileCleanerCleanup.getFileCleaningTracker(servletContext);
        DiskFileItemFactory factory = new DiskFileItemFactory(DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD, null);
        factory.setFileCleaningTracker(fileCleaningTracker);
        return factory;
    }

    /**
     * Every hour, check the repo "temp" root, and delete old files and folders
     */
    @Schedule(persistent = false, second = "50", minute = "22", hour = "*", dayOfWeek = "*", year = "*")
    public void cleanUpTempRoot() {
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DATE, -1);
        File[] files = getTempRepoRoot().toFile().listFiles();
        if (files != null && files.length > 0) {
            Arrays.asList(files).forEach(f -> checkDeletePath(f, cal.getTime()));
        }
    }

    /**
     * Recursively delete one day old files and folders
     * @param file the current root file or folder
     * @param date the expiry date
     */
    private void checkDeletePath(File file, Date date) {
        if (FileUtils.isFileOlder(file, date)) {
            log.info("Deleting expired temp file or folder: " + file);
            FileUtils.deleteQuietly(file);
        } else if (file.isDirectory()) {
            File[] files = file.listFiles();
            if (files != null && files.length > 0) {
                Arrays.asList(files).forEach(f -> checkDeletePath(f, date));
            }
        }
    }

    /**
     * Moves the repository from the repoPath the the newRepoPath.
     * If the directory specified by the repoPath does not exists, false is returned.
     * @param repoPath the repository path
     * @param newRepoPath the new repository path
     */
    public boolean moveRepoFolder(String repoPath, String newRepoPath) throws IOException {
        Path from = getRepoRoot().resolve(repoPath);
        Path to = getRepoRoot().resolve(newRepoPath);
        if (Files.exists(from)) {
            FileUtils.copyDirectory(from.toFile(), to.toFile());
            return true;
        }
        return false;
    }
}