/******************************************************************************* * Australian National University Data Commons * Copyright (C) 2013 The Australian National University * * This file is part of Australian National University Data Commons. * * Australian National University Data Commons is free software: you * can redistribute it and/or modify it under the terms of the GNU * General Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later * version. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package au.edu.anu.datacommons.storage.temp; import static java.text.MessageFormat.format; import java.io.IOException; import java.io.InputStream; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.channels.ReadableByteChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.Callable; import org.apache.commons.codec.binary.Hex; import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import au.edu.anu.datacommons.util.StopWatch; import au.edu.anu.datacommons.util.Util; /** * Saves an inputstream to disk. * * @author Rahul Khanna * */ public class SaveInputStreamTask implements Callable<UploadedFileInfo> { private static final Logger LOGGER = LoggerFactory.getLogger(SaveInputStreamTask.class); private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS"); protected Path uploadDir; protected DigestInputStream dis; protected long expectedLength; protected String expectedMd5; protected long actualLength; protected String actualMd5; /** * Creates an instance of this task * * @param uploadDir * Directory to which a stream will be saved * @param is * InputStream to save * @param expectedLength * Expected length of the stream. No size verification is performed if <= 0 (not recommended) * @param expectedMd5 * Expected MD5 of the stream. No MD5 check is performed if null (not recommended) * @throws IOException * if uploadDir doesn't exist or cannot be written to */ public SaveInputStreamTask(Path uploadDir, InputStream is, long expectedLength, String expectedMd5) throws IOException { validateUploadDir(uploadDir); this.uploadDir = uploadDir; this.dis = new DigestInputStream(is, createMd5Digest()); this.expectedLength = expectedLength; this.expectedMd5 = expectedMd5; if (expectedLength <= 0 && (expectedMd5 == null || expectedMd5.length() == 0)) { LOGGER.warn("Content Length and Expected MD5 not provided. Data integrity cannot be guaranteed."); } } @Override public UploadedFileInfo call() throws Exception { UploadedFileInfo ufi; Path targetFile = createTempFile(); StopWatch sw = new StopWatch(); sw.start(); try { LOGGER.debug("Saving {} ({}) Expected MD5:{}...", targetFile.toString(), Util.byteCountToDisplaySize(expectedLength), expectedMd5); this.actualLength = saveStreamToFile(this.dis, targetFile); this.actualMd5 = Hex.encodeHexString(dis.getMessageDigest().digest()); verifyExpecteds(); ufi = new UploadedFileInfo(targetFile, this.actualLength, this.actualMd5); sw.stop(); LOGGER.debug("Saved {} ({}) Computed MD5:{}, Time: {}, Speed: {}", ufi.getFilepath().toString(), Util.byteCountToDisplaySize(ufi.getSize()), ufi.getMd5(), sw.getTimeElapsedFormatted(), sw.getRate(ufi.getSize())); } catch (Exception e) { LOGGER.error("Error saving {} ({} bytes) Expected MD5:{} - {}", targetFile.toString(), this.expectedLength, this.expectedMd5, e.getMessage()); throw e; } finally { IOUtils.closeQuietly(dis); } return ufi; } /** * Writes out an InputStream to a file on disk. * * @param srcStream * Stream to read data from * @param targetFile * File to which the stream will be written. * @return size of stream, measured in bytes * @throws IOException * when unable to write to target file */ protected long saveStreamToFile(InputStream srcStream, Path targetFile) throws IOException { long position = 0; try (ReadableByteChannel srcChannel = Channels.newChannel(srcStream); FileChannel targetFileChannel = FileChannel.open(targetFile, StandardOpenOption.WRITE)) { long count = 0; while ((count = targetFileChannel.transferFrom(srcChannel, position, Long.MAX_VALUE)) > 0) { position += count; } } return position; } /** * Creates a temporary file with a unique name to save the InputStream to. * * @return The created temporary file * @throws IOException * when unable to create a temporary file */ protected Path createTempFile() throws IOException { Path tempFile; synchronized (uploadDir) { do { String filename = dateFormat.format(new Date()); tempFile = uploadDir.resolve(filename); } while (Files.exists(tempFile)); Files.createFile(tempFile); } return tempFile; } protected void verifyExpecteds() throws IOException { verifyLength(); verifyMd5(); } /** * Verifies that the saved file is the size expected in bytes. If expected bytes is specified as <= 0 then size * verification is not performed. * * @throws IOException */ protected void verifyLength() throws IOException { if (this.expectedLength > 0) { if (this.actualLength != this.expectedLength) { throw new IOException(format("Saved stream''s length invalid - Expected: {0} ({1}), Actual: {2} ({3})", this.expectedLength, Util.byteCountToDisplaySize(this.expectedLength), this.actualLength, Util.byteCountToDisplaySize(this.actualLength))); } } } /** * Verifies that the saved file's MD5 matches the expected MD5. * @throws IOException */ protected void verifyMd5() throws IOException { if (this.expectedMd5 != null) { if (!expectedMd5.equals(this.actualMd5)) { throw new IOException(format("Saved stream''s MD5 invalid - Expected: {0}, Actual: {1}", expectedMd5, this.actualMd5)); } } } /** * Creates an instance of the MessageDigest instance for MD5 algorithm. * * @return An instance of MessageDigest for MD5 algorithm. */ protected MessageDigest createMd5Digest() { try { return MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException e) { // This shouldn't ever happen. throw new RuntimeException(e); } } /** * Verifies that the specified directory to which the stream will be saved exists and is writable. * * @param uploadDir * Directory where stream will be saved * @throws IOException */ private void validateUploadDir(Path uploadDir) throws IOException { if (!Files.isDirectory(uploadDir)) { throw new IOException(format("Upload directory {0} doesn''t exist.", uploadDir.toString())); } if (!Files.isWritable(uploadDir)) { throw new IOException(format("Upload directory {0} isn''t writable. Check permissions.", uploadDir.toString())); } } }