/** * Yobi, Project Hosting SW * * Copyright 2012 NAVER Corp. * http://yobi.io * * @author Yi EungJun * * 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 models; import controllers.AttachmentApp; import models.enumeration.ResourceType; import models.resource.GlobalResource; import models.resource.Resource; import models.resource.ResourceConvertible; import org.apache.commons.io.FileUtils; import org.apache.tika.config.TikaConfig; import org.apache.tika.mime.MimeTypeException; import play.data.validation.Constraints; import play.db.ebean.Model; import play.libs.Akka; import scala.concurrent.duration.Duration; import utils.AttachmentCache; import utils.Config; import utils.FileUtil; import utils.JodaDateUtil; import javax.annotation.Nullable; import javax.persistence.*; import java.io.*; import java.nio.file.Files; import java.nio.file.NotDirectoryException; import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Date; import java.util.Formatter; import java.util.List; import java.util.concurrent.TimeUnit; @Entity public class Attachment extends Model implements ResourceConvertible { private static final long serialVersionUID = 7856282252495067924L; public static final Finder<Long, Attachment> find = new Finder<>(Long.class, Attachment.class); public static final int NOTHING_TO_ATTACH = 0; private static String uploadDirectory = "uploads"; @Id public Long id; @Constraints.Required public String name; @Constraints.Required public String hash; @Enumerated(EnumType.STRING) public ResourceType containerType; public String mimeType; public Long size; public String containerId; private Date createdDate; /** * Finds an attachment which matches the given one. * * Finds an attachment that matches {@link Attachment#name}, * {@link Attachment#hash}, {@link Attachment#containerType} and * {@link Attachment#containerId} with the given one. * * @param attach * @return an attachment which matches up with the given one. */ private static Attachment findBy(Attachment attach) { return find.where() .eq("name", attach.name) .eq("hash", attach.hash) .eq("containerType", attach.containerType) .eq("containerId", attach.containerId).findUnique(); } /** * @param hash * @return true if an attachment which has the given hash exists */ public static boolean exists(String hash) { return find.where().eq("hash", hash).findRowCount() > 0; } /** * Gets all attachments from a container. * * @param containerType the resource type of the container * @param containerId the resource id of the container * @return attachments of the container */ public static List<Attachment> findByContainer( ResourceType containerType, String containerId) { List<Attachment> cachedData = AttachmentCache.get(containerType, containerId); if (cachedData != null) { return cachedData; } List<Attachment> list = find.where() .eq("containerType", containerType) .eq("containerId", containerId).findList(); AttachmentCache.set(containerType.name() + containerId, list); return list; } /** * Gets all attachments from a container. * * @param container * @return attachments of the container */ public static List<Attachment> findByContainer(Resource container) { List<Attachment> cachedData = AttachmentCache.get(container); if (cachedData != null) { return cachedData; } List<Attachment> list = findByContainer(container.getType(), container.getId()); AttachmentCache.set(container, list); return list; } /** * @param container * @return the number of attachments in the container */ public static int countByContainer(Resource container) { return find.where() .eq("containerType", container.getType()) .eq("containerId", container.getId()).findRowCount(); } /** * Moves all attachments from a container to another container. * * This method is used when move attachments which were attached to an user * temporary to a specific resource(issue, posting, ...). * * @param from a container in which the attachment is currently stored * @param to a container to which the attachment moved * @return the number of attachments which was moved to another * container */ public static int moveAll(Resource from, Resource to) { List<Attachment> attachments = Attachment.findByContainer(from); for (Attachment attachment : attachments) { attachment.moveTo(to); } return attachments.size(); } /** * Moves specified attachments from a container to another one. * * This method is used when move attachments which were attached to an user * temporary to a specific resource(issue, posting, ...). * * @param from a container to which it was attached * @param to a container to which it will be attached * @param selectedFileIds IDs of attachments to be moved * @return the number of attachments which was moved to another container */ public static int moveOnlySelected(Resource from, Resource to, String[] selectedFileIds) { if(selectedFileIds.length == 0){ return NOTHING_TO_ATTACH; } List<Attachment> attachments = Attachment.find.where().idIn(Arrays.asList(selectedFileIds)).findList(); for (Attachment attachment : attachments) { if(attachment.containerId.equals(from.getId()) && attachment.containerType == from.getType()){ attachment.moveTo(to); } } return attachments.size(); } /** * Moves this attachment to another resource. * * @param to the destination */ public void moveTo(Resource to) { containerType = to.getType(); containerId = to.getId(); update(); } /** * Moves a file to the Upload Directory. * * This method is used to move a file stored in temporary directory by * PlayFramework to the Upload Directory managed by Yobi. * * @param file * @return SHA1 hash of the file * @throws NoSuchAlgorithmException * @throws IOException */ private static File moveFileIntoUploadDirectory(File file) throws NoSuchAlgorithmException, IOException { // Compute sha1 checksum. InputStream is = new FileInputStream(file); byte buf[] = new byte[10240]; MessageDigest algorithm = MessageDigest.getInstance("SHA1"); for (int readSize = 0; readSize >= 0; readSize = is.read(buf)) { algorithm.update(buf, 0, readSize); } is.close(); String hash = toHex(algorithm.digest()); return moveFileIntoUploadDirectory(file, hash); } private static File moveFileIntoUploadDirectory(File file, String hash) throws NoSuchAlgorithmException, IOException { // Store the file. File attachedFile = new File(createUploadDirectory(), hash); boolean isMoved = file.renameTo(attachedFile); if(!isMoved){ FileUtils.copyFile(file, attachedFile); file.delete(); } return attachedFile; } /** * Attaches an uploaded file to the given container with the given name. * * Moves an uploaded file to the Upload Directory and rename the file to * its SHA1 hash. And it stores the metadata of the file in this entity. * * If there is an entity that has the same values with this entity already, * it means the container has the same attachment. If that is the case, * this method will return {@code false} and do nothing; otherwise, return * {@code true}. * * This method is used when an uploaded file is attached to a user or * another resource directly. * * @param file a file to be attached * @param name the name of the file * @param container the resource to which the file attached * @return {@code true} if the file is attached, {@code false} otherwise. * @throws IOException * @throws NoSuchAlgorithmException */ @Transient public boolean store(File file, String name, Resource container) throws IOException, NoSuchAlgorithmException { return save(moveFileIntoUploadDirectory(file), name, container); } /** * Gets a file which mathces the hash from the Upload Directory. * * This method is used when an user downloads a file * * @return the file */ public File getFile() { return new File(getUploadDirectory(), this.hash); } public static File getUploadDirectory() { return new File(utils.Config.getYobiHome(), uploadDirectory); } /** * Sets the Upload Directory to store files that users uploaded. * * This method is used for unit tests. * * @param path a path to the Upload Directory */ public static void setUploadDirectory(String path) { uploadDirectory = path; } /** * Checks if there is a file that has the same hash in the Upload Directory. * * This method is used to check if the file exists in the system. * * @param hash * @return true if the file exists */ public static boolean fileExists(String hash) { return new File(getUploadDirectory(), hash).isFile(); } /** * Deletes this file and remove cache that contains it. * However, the cache can not be removed if Ebean.delete() is directly used or called by cascading. * * This method is used when an user delete an attachment or its container. */ @Override public void delete() { super.delete(); // FIXME: Rarely this may delete a file which is still referred by // attachment, if new attachment is added after checking nonexistence // of an attachment refers the file and before deleting the file. // // But synchronization with Attachment class may be a bad idea to solve // the problem. If you do that, blocking of project deletion causes // that all requests to attachments (even a user avatars you can see in // most of pages) are blocked. if (!exists(this.hash)) { try { Files.delete(Paths.get(uploadDirectory, this.hash)); } catch (Exception e) { play.Logger.error("Failed to delete: " + this, e); } } AttachmentCache.remove(this); } /** * Update this file and remove cache that contains it. * However, the cache can not be removed if Ebean.update() is directly used or called by cascading. */ @Override public void update() { super.update(); AttachmentCache.remove(this); } /** * Deletes every attachment attached to the given container. * * This method is used when a container, a resource may has attachments, is * deleted. * * @param container the resource that has the attachments to be deleted */ public static void deleteAll(Resource container) { List<Attachment> attachments = findByContainer(container); for (Attachment attachment : attachments) { attachment.delete(); } } private String messageForLosingProject() { return "An attachment '" + this +"' lost the project it belongs to"; } /** * Returns this as a resource. * * This method is used for access control. * * @return resource */ @Override public Resource asResource() { boolean isContainerProject = containerType.equals(ResourceType.PROJECT); final Project project; final Resource container; if (isContainerProject) { project = Project.find.byId(Long.parseLong(containerId)); if (project == null) { throw new RuntimeException(messageForLosingProject()); } container = project.asResource(); } else { container = Resource.get(containerType, containerId); if (!(container instanceof GlobalResource)) { project = container.getProject(); if (project == null) { throw new RuntimeException(messageForLosingProject()); } } else { project = null; } } if (project != null) { return new Resource() { @Override public String getId() { return id.toString(); } @Override public Project getProject() { return project; } @Override public ResourceType getType() { return ResourceType.ATTACHMENT; } @Override public Resource getContainer() { return container; } }; } else { return new GlobalResource() { @Override public String getId() { return id.toString(); } @Override public ResourceType getType() { return ResourceType.ATTACHMENT; } @Override public Resource getContainer() { return container; } }; } } /** * Remove all of temporary files uploaded by users */ private static void cleanupTemporaryUploadFilesWithSchedule() { Akka.system().scheduler().schedule( Duration.create(0, TimeUnit.SECONDS), Duration.create(AttachmentApp.TEMPORARYFILES_KEEPUP_TIME_MILLIS, TimeUnit.MILLISECONDS), new Runnable() { @Override public void run() { try { String result = removeUserTemporaryFiles(); play.Logger.info("User uploaded temporary files are cleaned up..." + result); } catch (Exception e) { play.Logger.warn("Failed!! User uploaded temporary files clean-up action failed!", e); } } private String removeUserTemporaryFiles() { List<Attachment> attachmentList = Attachment.find.where() .eq("containerType", ResourceType.USER) .ge("createdDate", JodaDateUtil.beforeByMillis(AttachmentApp.TEMPORARYFILES_KEEPUP_TIME_MILLIS)) .findList(); int deletedFileCount = 0; for (Attachment attachment : attachmentList) { attachment.delete(); deletedFileCount++; } if (attachmentList.size() != deletedFileCount) { play.Logger.error( String.format("Failed to delete user temporary files.\nExpected: %d Actual: %d", attachmentList.size(), deletedFileCount) ); } return String.format("(%d of %d)", attachmentList.size(), deletedFileCount); } }, Akka.system().dispatcher() ); } public static void onStart() { cleanupTemporaryUploadFilesWithSchedule(); } @Override public String toString() { return "Attachment{" + "id=" + id + ", name='" + name + '\'' + ", hash='" + hash + '\'' + ", containerType=" + containerType + ", mimeType='" + mimeType + '\'' + ", size=" + size + ", containerId='" + containerId + '\'' + ", createdDate=" + createdDate + '}'; } public boolean store(InputStream inputStream, @Nullable String fileName, Resource container) throws IOException, NoSuchAlgorithmException { byte buf[] = new byte[10240]; // Compute hash and store the stream as a temp file MessageDigest algorithm = MessageDigest.getInstance("SHA1"); String tempFileHash; File tmpFile = File.createTempFile("yobi", null); FileOutputStream fos = new FileOutputStream(tmpFile); try { int readSize; while ((readSize = inputStream.read(buf)) != -1) { algorithm.update(buf, 0, readSize); fos.write(buf, 0, readSize); } tempFileHash = toHex(algorithm.digest()); fos.flush(); } finally { fos.close(); } // Save this attachment with metadata return save(moveFileIntoUploadDirectory(tmpFile, tempFileHash), fileName, container); } /** * Save this attachment with metadata from the given arguments. * * @param file the file to be attached whose name is hash of the contents * @param fileName the name of this attachment * @param container the container to which the file attached * @return * @throws IOException */ private boolean save(File file, String fileName, Resource container) throws IOException { // Store the file as its SHA1 hash in filesystem, and record its // metadata - containerType, containerId, createdDate, name, size, hash and // mimeType - in Database. this.containerType = container.getType(); this.containerId = container.getId(); this.createdDate = JodaDateUtil.now(); this.hash = file.getName(); this.size = file.length(); if (this.mimeType == null) { this.mimeType = FileUtil.detectMediaType(file, name).toString(); } if (fileName == null) { this.name = String.valueOf(new Date().getTime()); try { this.name += "." + TikaConfig.getDefaultConfig() .getMimeRepository().forName(this.mimeType).getExtension(); } catch (MimeTypeException e) { } } else { this.name = fileName; } AttachmentCache.remove(this); // Add the attachment into the Database only if there is no same record. Attachment sameAttach = Attachment.findBy(this); if (sameAttach == null) { super.save(); return true; } else { this.id = sameAttach.id; return false; } } private static String toHex(byte[] bytes) { Formatter formatter = new Formatter(); for (byte b : bytes) { formatter.format("%02x", b); } String hex = formatter.toString(); formatter.close(); return hex; } // Create the upload directory if it doesn't exist. private static File createUploadDirectory() throws NotDirectoryException { File uploads = getUploadDirectory(); uploads.mkdirs(); if (!uploads.isDirectory()) { throw new NotDirectoryException( "'" + uploads.getAbsolutePath() + "' is not a directory."); } return uploads; } }