/**
* Copyright (c) 2013 Mark S. Kolich
* http://mark.koli.ch
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
package com.kolich.havalo.controllers.api;
import com.kolich.bolt.ReentrantReadWriteEntityLock;
import com.kolich.common.util.secure.KolichChecksum;
import com.kolich.curacao.annotations.Controller;
import com.kolich.curacao.annotations.Injectable;
import com.kolich.curacao.annotations.methods.RequestMapping;
import com.kolich.curacao.annotations.parameters.convenience.ContentLength;
import com.kolich.curacao.annotations.parameters.convenience.ContentType;
import com.kolich.curacao.annotations.parameters.convenience.IfMatch;
import com.kolich.curacao.entities.CuracaoEntity;
import com.kolich.curacao.entities.empty.StatusCodeOnlyCuracaoEntity;
import com.kolich.curacao.handlers.requests.matchers.AntPathMatcher;
import com.kolich.havalo.components.RepositoryManagerComponent;
import com.kolich.havalo.controllers.HavaloApiController;
import com.kolich.havalo.entities.types.DiskObject;
import com.kolich.havalo.entities.types.HashedFileObject;
import com.kolich.havalo.entities.types.KeyPair;
import com.kolich.havalo.entities.types.Repository;
import com.kolich.havalo.exceptions.objects.ObjectConflictException;
import com.kolich.havalo.exceptions.objects.ObjectLengthNotSpecifiedException;
import com.kolich.havalo.exceptions.objects.ObjectNotFoundException;
import com.kolich.havalo.exceptions.objects.ObjectTooLargeException;
import com.kolich.havalo.filters.HavaloAuthenticationFilter;
import com.kolich.havalo.mappers.ObjectKeyArgumentMapper.ObjectKey;
import org.slf4j.Logger;
import javax.servlet.AsyncContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.io.Files.move;
import static com.google.common.net.HttpHeaders.*;
import static com.google.common.net.MediaType.OCTET_STREAM;
import static com.kolich.common.util.secure.KolichChecksum.getSHA1HashAndCopy;
import static com.kolich.curacao.annotations.methods.RequestMapping.RequestMethod.*;
import static com.kolich.havalo.HavaloConfigurationFactory.getMaxUploadSize;
import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
import static org.apache.commons.io.FileUtils.deleteQuietly;
import static org.apache.commons.io.IOUtils.copyLarge;
import static org.slf4j.LoggerFactory.getLogger;
@Controller
public class ObjectApi extends HavaloApiController {
private static final Logger logger__ = getLogger(ObjectApi.class);
private static final String OCTET_STREAM_TYPE = OCTET_STREAM.toString();
private final long uploadMaxSize_;
@Injectable
public ObjectApi(final RepositoryManagerComponent component) {
super(component.getRepositoryManager());
uploadMaxSize_ = getMaxUploadSize();
}
@RequestMapping(methods=HEAD,
value="/api/object/{key}",
matcher=AntPathMatcher.class,
filters=HavaloAuthenticationFilter.class)
public final void head(final ObjectKey key,
final KeyPair userKp,
final HttpServletResponse response,
final AsyncContext context) throws Exception {
final Repository repo = getRepository(userKp.getKey());
new ReentrantReadWriteEntityLock<Void>(repo) {
@Override
public Void transaction() throws Exception {
final HashedFileObject hfo = getHashedFileObject(repo,
// The URL-decoded key of the object to delete.
key,
// Fail if not found.
true);
new ReentrantReadWriteEntityLock<HashedFileObject>(hfo) {
@Override
public HashedFileObject transaction() throws Exception {
final DiskObject object = getCanonicalObject(repo, hfo);
streamHeaders(object, hfo, response);
return hfo;
}
}.read(); // Shared read lock on file object
return null;
}
@Override
public void success(final Void v) throws Exception {
context.complete();
}
}.read(); // Shared read lock on repo
}
@RequestMapping(methods=GET,
value="/api/object/{key}",
matcher=AntPathMatcher.class,
filters=HavaloAuthenticationFilter.class)
public final void get(final ObjectKey key,
final KeyPair userKp,
final HttpServletResponse response,
final AsyncContext context) throws Exception {
final Repository repo = getRepository(userKp.getKey());
new ReentrantReadWriteEntityLock<Void>(repo) {
@Override
public Void transaction() throws Exception {
final HashedFileObject hfo = getHashedFileObject(repo,
// The URL-decoded key of the object to delete.
key,
// Fail if not found.
true);
new ReentrantReadWriteEntityLock<HashedFileObject>(hfo) {
@Override
public HashedFileObject transaction() throws Exception {
final DiskObject object = getCanonicalObject(repo, hfo);
// Validate that the object file exists on disk
// before we attempt to load it.
if(!object.getFile().exists()) {
throw new ObjectNotFoundException("Failed " +
"to find canonical object on disk " +
"(key=" + key + ", file=" +
object.getFile().getAbsolutePath() +
")");
}
streamHeaders(object, hfo, response);
streamObject(object, response);
return hfo;
}
}.read(); // Shared read lock on file object, wait
return null;
}
@Override
public void success(final Void v) throws Exception {
context.complete();
}
}.read(false); // Shared read lock on repo, no wait
}
@RequestMapping(methods=PUT,
value="/api/object/{key}",
matcher=AntPathMatcher.class,
filters=HavaloAuthenticationFilter.class)
public final HashedFileObject put(final ObjectKey key,
final KeyPair userKp,
@IfMatch final String ifMatch,
@ContentType final String contentType,
@ContentLength final Long contentLength,
final HttpServletRequest request,
final HttpServletResponse response) throws Exception {
final Repository repo = getRepository(userKp.getKey());
return new ReentrantReadWriteEntityLock<HashedFileObject>(repo) {
@Override
public HashedFileObject transaction() throws Exception {
// Havalo requires the consumer to send a Content-Length
// request header with the request when uploading an
// object.
if(contentLength < 0L) {
// A value of -1 indicates that no Content-Length
// header was set. Bail gracefully.
throw new ObjectLengthNotSpecifiedException("Client " +
"sent request where '" + CONTENT_LENGTH +
"' header was not present or less than zero.");
}
// Only accept the object if the Content-Length of the
// incoming request is less than or equal to the max
// upload size.
if(contentLength > uploadMaxSize_) {
throw new ObjectTooLargeException("The '" +
CONTENT_LENGTH + "' of the incoming request " +
"is too large. Max upload size allowed is " +
uploadMaxSize_ + "-bytes.");
}
final HashedFileObject hfo = getHashedFileObject(repo, key);
return new ReentrantReadWriteEntityLock<HashedFileObject>(hfo) {
@Override
public HashedFileObject transaction() throws Exception {
final String eTag = hfo.getFirstHeader(ETAG);
// If we have an incoming If-Match, we need to compare
// that against the current HFO before we attempt to
// update. If the If-Match ETag does not match, fail.
if(ifMatch != null && eTag != null) {
// OK, we have an incoming If-Match ETag, use it.
// NOTE: HFO's will _always_ have an ETag attached
// to their meta-data. ETag's are always computed
// for HFO's upload. But new HFO's (one's the repo
// have never seen before) may not yet have an ETag.
if(!ifMatch.equals(eTag)) {
throw new ObjectConflictException("Failed " +
"to update HFO; incoming If-Match ETag " +
"does not match (hfo=" + hfo.getName() +
", etag=" + eTag + ", if-match=" +
ifMatch + ")");
}
}
final DiskObject object = getCanonicalObject(
repo, hfo,
// Create the File on disk if it does not
// already exist. Yay!
true);
// The file itself (should exist now).
final File objFile = object.getFile();
final File tempObjFile = object.getTempFile();
try(final InputStream is = request.getInputStream();
final OutputStream os = new FileOutputStream(tempObjFile);) {
// Compute the ETag (an MD5 hash of the file) while
// copying the file into place. The bytes of the
// input stream and piped into an MD5 digest _and_
// to the output stream -- ideally computing the
// hash and copying the file at the same time.
// Set the resulting ETag header (meta data).
hfo.setETag(getSHA1HashAndCopy(is, os,
// Only copy as much as the incoming
// Content-Length header sez is going
// to be sent. Anything more than this
// is caught gracefully and dropped.
contentLength));
// Move the uploaded file into place (moves
// the file from the temp location to the
// real destination inside of the repository
// on disk).
move(tempObjFile, objFile);
// Set the Last-Modified header (meta data).
hfo.setLastModified(objFile.lastModified());
// Set the Content-Length header (meta data).
hfo.setContentLength(objFile.length());
// Set the Content-Type header (meta data).
if(contentType != null) {
hfo.setContentType(contentType);
}
} catch (KolichChecksum.KolichChecksumException e) {
// Quietly delete the object on disk when
// it has exceeded the max upload size allowed
// by this Havalo instance.
throw new ObjectTooLargeException("The " +
"size of the incoming object is too " +
"large. Max upload size is " +
uploadMaxSize_ + "-bytes.", e);
} finally {
// Delete the file from the temp upload
// location. Note, this file may not exist
// if the upload was successful and the object
// was moved into place.. which is OK here.
deleteQuietly(tempObjFile);
}
// Append an ETag header to the response for the
// PUT'ed object.
response.setHeader(ETAG, hfo.getFirstHeader(ETAG));
return hfo;
}
@Override
public void success(final HashedFileObject e) throws Exception {
// On success only, ask the repo manager to
// asynchronously flush this repository's meta
// data to disk.
flushRepository(repo);
}
}.write(); // Exclusive lock on this HFO, no wait
}
}.read(false); // Shared read lock on repo, no wait
}
@RequestMapping(methods=DELETE,
value="/api/object/{key}",
matcher=AntPathMatcher.class,
filters=HavaloAuthenticationFilter.class)
public final CuracaoEntity delete(final ObjectKey key,
@IfMatch final String ifMatch,
final KeyPair userKp) throws Exception {
// The delete operation does return a pointer to the "deleted"
// HFO, but we're not using it, we're just dropping it on the
// floor (intentionally not returning it to the caller).
deleteHashedFileObject(userKp.getKey(),
// The URL-decoded key of the object to delete.
key,
// Only delete the object if the provided ETag via the
// If-Match header matches the object on disk.
ifMatch);
return new StatusCodeOnlyCuracaoEntity(SC_NO_CONTENT);
}
private static final void streamHeaders(final DiskObject object,
final HashedFileObject hfo,
final HttpServletResponse response) {
checkNotNull(hfo, "Hashed file object cannot be null.");
// Validate that the File object still exists.
if(!object.getFile().exists()) {
throw new ObjectNotFoundException("Object not " +
"found (file=" + object.getFile().getAbsolutePath() +
", key=" + hfo.getName() + ")");
}
// Extract any response headers from this objects' meta data.
final Map<String,List<String>> headers = hfo.getHeaders();
// Always set the Content-Length header to the actual length of the
// file on disk -- effectively overriding any "Content-Length" meta
// data header set by the user in the PUT request.
headers.put(CONTENT_LENGTH, Arrays.asList(Long.toString(object.getFile().length())));
// Set the Content-Type header to a default if one was not set by
// the consumer in the meta data.
if(headers.get(CONTENT_TYPE) == null) {
headers.put(CONTENT_TYPE, Arrays.asList(OCTET_STREAM_TYPE));
}
// Now, send all headers to the response stream.
for(final Map.Entry<String,List<String>> entry : headers.entrySet()) {
final String key = entry.getKey();
for(final String value : entry.getValue()) {
response.addHeader(key, value);
}
}
}
private static final void streamObject(final DiskObject object,
final HttpServletResponse response) {
try(final InputStream is = new FileInputStream(object.getFile());
final OutputStream os = response.getOutputStream()) {
copyLarge(is, os);
} catch (Exception e) {
// On any Exception case, just log the failure and move on.
// We're closing the output stream in the finally{} block below
// so it's not like we can fail here then somehow return an error
// message to the API consumer. We're handling this as gracefully
// as best we can.
logger__.error("Failed to stream object to client.", e);
}
}
}