Skip to content

Commit

Permalink
Update REST API for specifying checksums
Browse files Browse the repository at this point in the history
Initially clients could specify checksums for uploaded binary content
via a custom `checksum` query parameter, instead of this non-standard
approach, we want to refactor to follow RFC-3230 4.3.2.

* Adds 'Warning' header to announce deprecation of `checksum`
* Adds support for 'Digest' header as per RFC-3230
* Adds utility function for parsing Digest headers
* Adds integration tests for checksum match and mismatch

Resolves: https://jira.duraspace.org/browse/FCREPO-1468
  • Loading branch information
Rarian authored and Andrew Woods committed Dec 6, 2015
1 parent 43678c2 commit 4aefe3e
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 6 deletions.
129 changes: 123 additions & 6 deletions fcrepo-http-api/src/main/java/org/fcrepo/http/api/FedoraLdp.java
Expand Up @@ -50,6 +50,7 @@
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.util.Map;

import javax.annotation.PostConstruct;
import javax.inject.Inject;
Expand Down Expand Up @@ -96,6 +97,9 @@

import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;

import static com.google.common.base.Strings.nullToEmpty;

/**
* @author cabeer
Expand All @@ -113,6 +117,10 @@ public class FedoraLdp extends ContentExposingResource {

private static final Logger LOGGER = getLogger(FedoraLdp.class);

private static final Splitter.MapSplitter RFC3230_SPLITTER =
Splitter.on(',').omitEmptyStrings().trimResults().
withKeyValueSeparator(Splitter.on('=').limit(2));

@PathParam("path") protected String externalPath;

@Inject private FedoraHttpConfiguration httpConfiguration;
Expand Down Expand Up @@ -222,7 +230,6 @@ public Response deleteObject() {
return noContent().build();
}


/**
* Create a resource at a specified path, or replace triples with provided RDF.
* @param requestContentType the request content type
Expand All @@ -235,16 +242,52 @@ public Response deleteObject() {
* @throws InvalidChecksumException if invalid checksum exception occurred
* @throws MalformedRdfException if malformed rdf exception occurred
*/
public Response createOrReplaceObjectRdf(
@HeaderParam("Content-Type") final MediaType requestContentType,
@ContentLocation final InputStream requestBodyStream,
@QueryParam("checksum") final String checksum,
@HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
@HeaderParam("If-Match") final String ifMatch,
@HeaderParam("Link") final String link)
throws InvalidChecksumException, MalformedRdfException {
return createOrReplaceObjectRdf(requestContentType, requestBodyStream,
checksum, contentDisposition, ifMatch, link, null);
}

/**
* Create a resource at a specified path, or replace triples with provided RDF.
*
* Temporary 6 parameter version of this function to allow for backwards
* compatability during a period of transition from a digest hash being
* provided via non-standard 'checksum' query parameter to RFC-3230 compliant
* 'Digest' header.
*
* TODO: Remove this function in favour of the 5 parameter version that takes
* the Digest header in lieu of the checksum parameter
* https://jira.duraspace.org/browse/FCREPO-1851
*
* @param requestContentType the request content type
* @param requestBodyStream the request body stream
* @param checksumDeprecated the deprecated digest hash
* @param contentDisposition the content disposition value
* @param ifMatch the if-match value
* @param link the link value
* @param digest the digest header
* @return 204
* @throws InvalidChecksumException if invalid checksum exception occurred
* @throws MalformedRdfException if malformed rdf exception occurred
*/
@PUT
@Consumes
@Timed
public Response createOrReplaceObjectRdf(
@HeaderParam("Content-Type") final MediaType requestContentType,
@ContentLocation final InputStream requestBodyStream,
@QueryParam("checksum") final String checksum,
@QueryParam("checksum") final String checksumDeprecated,
@HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
@HeaderParam("If-Match") final String ifMatch,
@HeaderParam("Link") final String link)
@HeaderParam("Link") final String link,
@HeaderParam("Digest") final String digest)
throws InvalidChecksumException, MalformedRdfException {

checkLinkForLdpResourceCreation(link);
Expand All @@ -254,6 +297,10 @@ public Response createOrReplaceObjectRdf(

final String path = toPath(translator(), externalPath);

// TODO: Add final when deprecated checksum Query paramater is removed
// https://jira.duraspace.org/browse/FCREPO-1851
String checksum = parseDigestHeader(digest);

final MediaType contentType = getSimpleContentType(requestContentType);

if (nodeService.exists(session, path)) {
Expand Down Expand Up @@ -285,6 +332,10 @@ public Response createOrReplaceObjectRdf(

LOGGER.info("PUT resource '{}'", externalPath);
if (resource instanceof FedoraBinary) {
if (!StringUtils.isBlank(checksumDeprecated) && StringUtils.isBlank(digest)) {
addChecksumDeprecationHeader(resource);
checksum = checksumDeprecated;
}
replaceResourceBinaryWithStream((FedoraBinary) resource,
requestBodyStream, contentDisposition, requestContentType, checksum);
} else if (isRdfContentType(contentType.toString())) {
Expand Down Expand Up @@ -399,15 +450,44 @@ public Response updateSparql(@ContentLocation final InputStream requestBodyStrea
* @throws MalformedRdfException if malformed rdf exception occurred
* @throws AccessDeniedException if access denied in creating resource
*/
public Response createObject(@QueryParam("checksum") final String checksum,
@HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
@HeaderParam("Content-Type") final MediaType requestContentType,
@HeaderParam("Slug") final String slug,
@ContentLocation final InputStream requestBodyStream,
@HeaderParam("Link") final String link)
throws InvalidChecksumException, IOException, MalformedRdfException, AccessDeniedException {
return createObject(checksum, contentDisposition, requestContentType, slug, requestBodyStream, link, null);
}
/**
* Creates a new object.
*
* application/octet-stream;qs=1001 is a workaround for JERSEY-2636, to ensure
* requests without a Content-Type get routed here.
*
* @param checksumDeprecated the checksum value
* @param contentDisposition the content Disposition value
* @param requestContentType the request content type
* @param slug the slug value
* @param requestBodyStream the request body stream
* @param link the link value
* @param digest the digest header
* @return 201
* @throws InvalidChecksumException if invalid checksum exception occurred
* @throws IOException if IO exception occurred
* @throws MalformedRdfException if malformed rdf exception occurred
* @throws AccessDeniedException if access denied in creating resource
*/
@POST
@Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1001", MediaType.WILDCARD})
@Timed
public Response createObject(@QueryParam("checksum") final String checksum,
public Response createObject(@QueryParam("checksum") final String checksumDeprecated,
@HeaderParam("Content-Disposition") final ContentDisposition contentDisposition,
@HeaderParam("Content-Type") final MediaType requestContentType,
@HeaderParam("Slug") final String slug,
@ContentLocation final InputStream requestBodyStream,
@HeaderParam("Link") final String link)
@HeaderParam("Link") final String link,
@HeaderParam("Digest") final String digest)
throws InvalidChecksumException, IOException, MalformedRdfException, AccessDeniedException {

checkLinkForLdpResourceCreation(link);
Expand All @@ -424,6 +504,10 @@ public Response createObject(@QueryParam("checksum") final String checksum,

final String newObjectPath = mintNewPid(slug);

// TODO: Add final when deprecated checksum Query paramater is removed
// https://jira.duraspace.org/browse/FCREPO-1851
String checksum = parseDigestHeader(digest);

LOGGER.info("Ingest with path: {}", newObjectPath);

final MediaType effectiveContentType
Expand Down Expand Up @@ -451,7 +535,10 @@ && isRdfContentType(contentTypeString)) {
replaceResourceWithStream(result, requestBodyStream, contentType, resourceTriples);
} else if (result instanceof FedoraBinary) {
LOGGER.trace("Created a datastream and have a binary payload.");

if (!StringUtils.isBlank(checksumDeprecated) && StringUtils.isBlank(digest)) {
addChecksumDeprecationHeader(resource);
checksum = checksumDeprecated;
}
replaceResourceBinaryWithStream((FedoraBinary) result,
requestBodyStream, contentDisposition, requestContentType, checksum);

Expand Down Expand Up @@ -554,6 +641,13 @@ private void addResourceLinkHeaders(final FedoraResource resource, final boolean


}
/**
* Add a deprecation notice via the Warning header as per
* RFC-7234 https://tools.ietf.org/html/rfc7234#section-5.5
*/
private void addChecksumDeprecationHeader(final FedoraResource resource) {
servletResponse.addHeader("Warning", "Specifying a SHA-1 Checksum via query parameter is deprecated.");
}

private static String getRequestedObjectType(final MediaType requestContentType,
final ContentDisposition contentDisposition) {
Expand Down Expand Up @@ -645,4 +739,27 @@ private void checkLinkForLdpResourceCreation(final String link) {
}
}

/**
* Parse the RFC-3230 Digest response header value. Look for a
* sha1 checksum and return it as a urn, if missing or malformed
* an empty string is returned.
* @param digest The Digest header value
* @return the sha1 checksum value
*/
private String parseDigestHeader(final String digest) {
try {
final Map<String,String> digestPairs = RFC3230_SPLITTER.split(nullToEmpty(digest));
return digestPairs.entrySet().stream()
.filter(s -> s.getKey().toLowerCase().equals("sha1"))
.map(Map.Entry::getValue)
.findFirst()
.map("urn:sha1:"::concat)
.orElse("");
} catch (RuntimeException e) {
if (e instanceof IllegalArgumentException) {
throw new ClientErrorException("Invalid Digest header: " + digest + "\n", BAD_REQUEST);
}
throw e;
}
}
}
Expand Up @@ -128,6 +128,7 @@
import org.apache.http.entity.BasicHttpEntity;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.FileEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.cache.CachingHttpClientBuilder;
Expand Down Expand Up @@ -829,6 +830,57 @@ public void testIngestWithBinary() throws IOException {
}
}

/**
* Ensure that the objects can be created with a Digest header
* with a SHA1 sum of the binary content
*
* @throws IOException in case of IOException
*/
@Test
public void testIngestWithBinaryAndChecksum() throws IOException {
final HttpPost method = postObjMethod();
final File img = new File("src/test/resources/test-objects/img.png");
method.addHeader("Content-Type", "application/octet-stream");
method.addHeader("Digest", "SHA1=f0b632679fab4f22e031010bd81a3b0544294730");
method.setEntity(new FileEntity(img));

assertEquals("Didn't get a CREATED response!", CREATED.getStatusCode(), getStatus(method));
}

/**
* Ensure that the objects cannot be created when a Digest header
* contains a SHA1 sum that does not match the uploaded binary
* content
*
* @throws IOException in case of IOException
*/
@Test
public void testIngestWithBinaryAndChecksumMismatch() throws IOException {
final HttpPost method = postObjMethod();
final File img = new File("src/test/resources/test-objects/img.png");
method.addHeader("Content-Type", "application/octet-stream");
method.addHeader("Digest", "SHA1=fedoraicon");
method.setEntity(new FileEntity(img));

assertEquals("Should be a 409 Conflict!", CONFLICT.getStatusCode(), getStatus(method));
}

/**
* Ensure that the a malformed Digest header returns a 400 Bad Request
*
* @throws IOException in case of IOException
*/
@Test
public void testIngestWithBinaryAndMalformedDigestHeader() throws IOException {
final HttpPost method = postObjMethod();
final File img = new File("src/test/resources/test-objects/img.png");
method.addHeader("Content-Type", "application/octet-stream");
method.addHeader("Digest", "md5=not a valid hash,SHA1:thisisbadtoo");
method.setEntity(new FileEntity(img));

assertEquals("Should be a 400 BAD REQUEST!", BAD_REQUEST.getStatusCode(), getStatus(method));
}

@Test
public void testIngestOnSubtree() throws IOException {
final String id = getRandomUniqueId();
Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 4aefe3e

Please sign in to comment.