/**
* 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 java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
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;
import javax.persistence.*;
import controllers.AttachmentApp;
import models.resource.GlobalResource;
import models.resource.Resource;
import models.resource.ResourceConvertible;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.tika.Tika;
import models.enumeration.ResourceType;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import play.data.validation.*;
import play.db.ebean.Model;
import play.libs.Akka;
import scala.concurrent.duration.Duration;
import scalax.file.NotDirectoryException;
import utils.FileUtil;
import utils.JodaDateUtil;
@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) {
return find.where()
.eq("containerType", containerType)
.eq("containerId", containerId).findList();
}
/**
* Gets all attachments from a container.
*
* @param container
* @return attachments of the container
*/
public static List<Attachment> findByContainer(Resource container) {
return findByContainer(container.getType(), container.getId());
}
/**
* @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 String moveFileIntoUploadDirectory(File file)
throws NoSuchAlgorithmException, IOException {
// Compute sha1 checksum.
MessageDigest algorithm = MessageDigest.getInstance("SHA1");
byte buf[] = new byte[10240];
FileInputStream fis = new FileInputStream(file);
for (int size = 0; size >= 0; size = fis.read(buf)) {
algorithm.update(buf, 0, size);
}
Formatter formatter = new Formatter();
for (byte b : algorithm.digest()) {
formatter.format("%02x", b);
}
String hash = formatter.toString();
formatter.close();
fis.close();
// Store the file.
// Before do that, create upload directory if it doesn't exist.
File uploads = new File(uploadDirectory);
uploads.mkdirs();
if (!uploads.isDirectory()) {
throw new NotDirectoryException(
"'" + file.getAbsolutePath() + "' is not a directory.");
}
File attachedFile = new File(uploadDirectory, hash);
boolean isMoved = file.renameTo(attachedFile);
if(!isMoved){
FileUtils.copyFile(file, attachedFile);
file.delete();
}
// Close all resources.
return hash;
}
/**
* 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 {
// Store the file as its SHA1 hash in filesystem, and record its
// metadata - containerType, containerId, size and hash - in Database.
this.containerType = container.getType();
this.containerId = container.getId();
this.createdDate = JodaDateUtil.now();
if (name == null) {
this.name = file.getName();
} else {
this.name = name;
}
if (this.mimeType == null) {
this.mimeType = FileUtil.detectMediaType(file, name).toString();
}
// the size must be set before it is moved.
this.size = file.length();
this.hash = Attachment.moveFileIntoUploadDirectory(file);
// 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;
}
}
/**
* 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(uploadDirectory, this.hash);
}
/**
* 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(uploadDirectory, hash).isFile();
}
/**
* Deletes this file.
*
* 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);
}
}
}
/**
* 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 +
'}';
}
}