001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.http.api; 019 020 021import static com.google.common.base.Strings.isNullOrEmpty; 022import static com.google.common.base.Strings.nullToEmpty; 023import static java.nio.charset.StandardCharsets.UTF_8; 024import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION; 025import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; 026import static javax.ws.rs.core.HttpHeaders.LINK; 027import static javax.ws.rs.core.HttpHeaders.LOCATION; 028import static javax.ws.rs.core.MediaType.WILDCARD; 029import static javax.ws.rs.core.Response.noContent; 030import static javax.ws.rs.core.Response.notAcceptable; 031import static javax.ws.rs.core.Response.ok; 032import static javax.ws.rs.core.Response.status; 033import static javax.ws.rs.core.Response.temporaryRedirect; 034import static javax.ws.rs.core.Response.Status.BAD_REQUEST; 035import static javax.ws.rs.core.Response.Status.CONFLICT; 036import static javax.ws.rs.core.Response.Status.FORBIDDEN; 037import static javax.ws.rs.core.Response.Status.FOUND; 038import static javax.ws.rs.core.Response.Status.METHOD_NOT_ALLOWED; 039import static javax.ws.rs.core.Response.Status.UNSUPPORTED_MEDIA_TYPE; 040import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE; 041import static org.apache.commons.lang3.StringUtils.isBlank; 042import static org.apache.http.HttpStatus.SC_BAD_REQUEST; 043import static org.apache.jena.atlas.web.ContentType.create; 044import static org.apache.jena.rdf.model.ResourceFactory.createResource; 045import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate; 046import static org.apache.jena.riot.WebContent.ctSPARQLUpdate; 047import static org.apache.jena.riot.WebContent.ctTextCSV; 048import static org.apache.jena.riot.WebContent.ctTextPlain; 049import static org.apache.jena.riot.WebContent.matchContentType; 050import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD; 051import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET; 052import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET; 053import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES; 054import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML; 055import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET; 056import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET; 057import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET; 058import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X; 059import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_PAIRTREE; 060import static org.fcrepo.kernel.api.RdfLexicon.FEDORA_DESCRIPTION; 061import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODELS; 062import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODEL_RESOURCES; 063import static org.fcrepo.kernel.api.RdfLexicon.VERSIONED_RESOURCE; 064import static org.fcrepo.kernel.api.FedoraExternalContent.COPY; 065import static org.fcrepo.kernel.api.FedoraTypes.LDP_BASIC_CONTAINER; 066import static org.fcrepo.kernel.api.FedoraTypes.LDP_NON_RDF_SOURCE; 067import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER; 068import static org.slf4j.LoggerFactory.getLogger; 069 070import java.io.IOException; 071import java.io.InputStream; 072import java.io.UnsupportedEncodingException; 073import java.net.URI; 074import java.net.URLDecoder; 075import java.time.Instant; 076import java.time.format.DateTimeParseException; 077import java.util.Collection; 078import java.util.HashMap; 079import java.util.List; 080import java.util.Map; 081import java.util.Optional; 082import java.util.stream.Collectors; 083import javax.inject.Inject; 084import javax.ws.rs.BadRequestException; 085import javax.ws.rs.ClientErrorException; 086import javax.ws.rs.Consumes; 087import javax.ws.rs.DELETE; 088import javax.ws.rs.GET; 089import javax.ws.rs.HEAD; 090import javax.ws.rs.HeaderParam; 091import javax.ws.rs.NotSupportedException; 092import javax.ws.rs.OPTIONS; 093import javax.ws.rs.POST; 094import javax.ws.rs.PUT; 095import javax.ws.rs.Path; 096import javax.ws.rs.PathParam; 097import javax.ws.rs.Produces; 098import javax.ws.rs.core.HttpHeaders; 099import javax.ws.rs.core.Link; 100import javax.ws.rs.core.MediaType; 101import javax.ws.rs.core.Response; 102import javax.ws.rs.core.UriBuilderException; 103import javax.ws.rs.core.Variant.VariantListBuilder; 104 105import com.google.common.annotations.VisibleForTesting; 106import com.google.common.base.Splitter; 107import com.google.common.collect.ImmutableList; 108import org.apache.commons.io.IOUtils; 109import org.apache.commons.lang3.StringUtils; 110import org.apache.jena.atlas.web.ContentType; 111import org.apache.jena.rdf.model.Resource; 112import org.fcrepo.http.api.PathLockManager.AcquiredLock; 113import org.fcrepo.http.commons.domain.PATCH; 114import org.fcrepo.kernel.api.FedoraTypes; 115import org.fcrepo.kernel.api.RdfStream; 116import org.fcrepo.kernel.api.exception.AccessDeniedException; 117import org.fcrepo.kernel.api.exception.CannotCreateResourceException; 118import org.fcrepo.kernel.api.exception.InsufficientStorageException; 119import org.fcrepo.kernel.api.exception.InteractionModelViolationException; 120import org.fcrepo.kernel.api.exception.InvalidChecksumException; 121import org.fcrepo.kernel.api.exception.InvalidMementoPathException; 122import org.fcrepo.kernel.api.exception.MalformedRdfException; 123import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException; 124import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException; 125import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 126import org.fcrepo.kernel.api.exception.RequestWithAclLinkHeaderException; 127import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException; 128import org.fcrepo.kernel.api.models.Container; 129import org.fcrepo.kernel.api.models.FedoraBinary; 130import org.fcrepo.kernel.api.models.FedoraResource; 131import org.fcrepo.kernel.api.rdf.DefaultRdfStream; 132import org.fcrepo.kernel.api.utils.ContentDigest; 133import org.glassfish.jersey.media.multipart.ContentDisposition; 134import org.slf4j.Logger; 135import org.springframework.context.annotation.Scope; 136 137/** 138 * @author cabeer 139 * @author ajs6f 140 * @since 9/25/14 141 */ 142 143@Scope("request") 144@Path("/{path: .*}") 145public class FedoraLdp extends ContentExposingResource { 146 147 private static final Logger LOGGER = getLogger(FedoraLdp.class); 148 149 private static final String WANT_DIGEST = "Want-Digest"; 150 151 private static final String DIGEST = "Digest"; 152 153 @PathParam("path") protected String externalPath; 154 155 @Inject private FedoraHttpConfiguration httpConfiguration; 156 157 /** 158 * Default JAX-RS entry point 159 */ 160 public FedoraLdp() { 161 super(); 162 } 163 164 /** 165 * Create a new FedoraNodes instance for a given path 166 * @param externalPath the external path 167 */ 168 @VisibleForTesting 169 public FedoraLdp(final String externalPath) { 170 this.externalPath = externalPath; 171 } 172 173 /** 174 * Retrieve the node headers 175 * 176 * @return response 177 * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred 178 */ 179 @HEAD 180 @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 181 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 182 TURTLE_X, TEXT_HTML_WITH_CHARSET }) 183 public Response head() throws UnsupportedAlgorithmException { 184 LOGGER.info("HEAD for: {}", externalPath); 185 186 checkMementoPath(); 187 188 final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME); 189 if (!isBlank(datetimeHeader) && resource().isOriginalResource()) { 190 return getMemento(datetimeHeader, resource()); 191 } 192 193 checkCacheControlHeaders(request, servletResponse, resource(), session); 194 195 addResourceHttpHeaders(resource()); 196 197 Response.ResponseBuilder builder = ok(); 198 199 if (resource() instanceof FedoraBinary) { 200 final FedoraBinary binary = (FedoraBinary) resource(); 201 final MediaType mediaType = getBinaryResourceMediaType(binary); 202 203 if (binary.isRedirect()) { 204 builder = temporaryRedirect(binary.getRedirectURI()); 205 } 206 207 // we set the content-type explicitly to avoid content-negotiation from getting in the way 208 builder.type(mediaType.toString()); 209 210 // Respect the Want-Digest header with fixity check 211 final String wantDigest = headers.getHeaderString(WANT_DIGEST); 212 if (!isNullOrEmpty(wantDigest)) { 213 builder.header(DIGEST, handleWantDigestHeader(binary, wantDigest)); 214 } 215 } else { 216 final String accept = headers.getHeaderString(HttpHeaders.ACCEPT); 217 if (accept == null || "*/*".equals(accept)) { 218 builder.type(TURTLE_WITH_CHARSET); 219 } 220 setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource()); 221 } 222 223 224 return builder.build(); 225 } 226 227 /** 228 * Outputs information about the supported HTTP methods, etc. 229 * @return the outputs information about the supported HTTP methods, etc. 230 */ 231 @OPTIONS 232 public Response options() { 233 LOGGER.info("OPTIONS for '{}'", externalPath); 234 235 checkMementoPath(); 236 237 addLinkAndOptionsHttpHeaders(resource()); 238 return ok().build(); 239 } 240 241 242 /** 243 * Retrieve the node profile 244 * 245 * @param rangeValue the range value 246 * @return a binary or the triples for the specified node 247 * @throws IOException if IO exception occurred 248 * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred 249 */ 250 @GET 251 @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 252 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 253 TURTLE_X, TEXT_HTML_WITH_CHARSET}) 254 public Response getResource(@HeaderParam("Range") final String rangeValue) 255 throws IOException, UnsupportedAlgorithmException { 256 257 checkMementoPath(); 258 259 final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME); 260 if (!isBlank(datetimeHeader) && resource().isOriginalResource()) { 261 return getMemento(datetimeHeader, resource()); 262 } 263 264 checkCacheControlHeaders(request, servletResponse, resource(), session); 265 266 LOGGER.info("GET resource '{}'", externalPath); 267 final AcquiredLock readLock = lockManager.lockForRead(resource().getPath()); 268 try (final RdfStream rdfStream = new DefaultRdfStream(asNode(resource()))) { 269 270 // If requesting a binary, check the mime-type if "Accept:" header is present. 271 // (This needs to be done before setting up response headers, as getContent 272 // returns a response - so changing headers after that won't work so nicely.) 273 final ImmutableList<MediaType> acceptableMediaTypes = ImmutableList.copyOf(headers 274 .getAcceptableMediaTypes()); 275 276 if (resource() instanceof FedoraBinary && acceptableMediaTypes.size() > 0) { 277 278 final MediaType mediaType = getBinaryResourceMediaType(resource()); 279 280 // Respect the Want-Digest header for fixity check 281 final String wantDigest = headers.getHeaderString(WANT_DIGEST); 282 if (!isNullOrEmpty(wantDigest)) { 283 servletResponse.addHeader(DIGEST, handleWantDigestHeader((FedoraBinary)resource(), wantDigest)); 284 } 285 286 if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) { 287 return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build(); 288 } 289 } 290 291 addResourceHttpHeaders(resource()); 292 293 if (resource() instanceof FedoraBinary && ((FedoraBinary)resource()).isRedirect()) { 294 return temporaryRedirect(((FedoraBinary) resource()).getRedirectURI()).build(); 295 } else { 296 return getContent(rangeValue, getChildrenLimit(), rdfStream, resource()); 297 } 298 } finally { 299 readLock.release(); 300 } 301 } 302 303 /** 304 * Return the location of a requested Memento. 305 * 306 * @param datetimeHeader The RFC datetime for the Memento. 307 * @param resource The fedora resource 308 * @return A 302 Found response or 406 if no mementos. 309 */ 310 private Response getMemento(final String datetimeHeader, final FedoraResource resource) { 311 try { 312 final Instant mementoDatetime = Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader)); 313 final FedoraResource memento = resource.findMementoByDatetime(mementoDatetime); 314 final Response builder; 315 if (memento != null) { 316 builder = 317 status(FOUND).header(LOCATION, translator().reverse().convert(memento).toString()).build(); 318 } else { 319 builder = status(NOT_ACCEPTABLE).build(); 320 } 321 addResourceHttpHeaders(resource); 322 setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource); 323 return builder; 324 } catch (final DateTimeParseException e) { 325 throw new MementoDatetimeFormatException("Invalid Accept-Datetime value: " + e.getMessage() 326 + ". Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e); 327 } 328 } 329 330 /** 331 * Deletes an object. 332 * 333 * @return response 334 */ 335 @DELETE 336 public Response deleteObject() { 337 hasRestrictedPath(externalPath); 338 if (resource() instanceof Container) { 339 final String depth = headers.getHeaderString("Depth"); 340 LOGGER.debug("Depth header value is: {}", depth); 341 if (depth != null && !depth.equalsIgnoreCase("infinity")) { 342 throw new ClientErrorException("Depth header, if present, must be set to 'infinity' for containers", 343 SC_BAD_REQUEST); 344 } 345 } 346 347 evaluateRequestPreconditions(request, servletResponse, resource(), session); 348 349 LOGGER.info("Delete resource '{}'", externalPath); 350 351 final AcquiredLock lock = lockManager.lockForDelete(resource().getPath()); 352 353 try { 354 resource().delete(); 355 session.commit(); 356 return noContent().build(); 357 } finally { 358 lock.release(); 359 } 360 } 361 362 /** 363 * Create a resource at a specified path, or replace triples with provided RDF. 364 * 365 * @param requestContentType the request content type 366 * @param requestBodyStream the request body stream 367 * @param contentDisposition the content disposition value 368 * @param ifMatch the if-match value 369 * @param rawLinks the raw link values 370 * @param digest the digest header 371 * @return 204 372 * @throws InvalidChecksumException if invalid checksum exception occurred 373 * @throws MalformedRdfException if malformed rdf exception occurred 374 * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs 375 */ 376 @PUT 377 @Consumes 378 public Response createOrReplaceObjectRdf( 379 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType, 380 final InputStream requestBodyStream, 381 @HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition, 382 @HeaderParam("If-Match") final String ifMatch, 383 @HeaderParam(LINK) final List<String> rawLinks, 384 @HeaderParam("Digest") final String digest) 385 throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException { 386 387 hasRestrictedPath(externalPath); 388 389 final List<String> links = unpackLinks(rawLinks); 390 391 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 392 handleRequestDisallowedOnMemento(); 393 394 return status(METHOD_NOT_ALLOWED).build(); 395 } 396 397 final String interactionModel = checkInteractionModel(links); 398 399 checkAclLinkHeader(links); 400 401 final FedoraResource resource; 402 403 final String path = toPath(translator(), externalPath); 404 405 final AcquiredLock lock = lockManager.lockForWrite(path, session.getFedoraSession(), nodeService); 406 407 try { 408 409 final Collection<String> checksums = parseDigestHeader(digest); 410 final ExternalContentHandler extContent = extContentHandlerFactory.createFromLinks(links); 411 412 final MediaType contentType = getSimpleContentType( 413 extContent != null ? extContent.getContentType() : requestContentType); 414 415 if (nodeService.exists(session.getFedoraSession(), path)) { 416 resource = resource(); 417 418 final String resInteractionModel = getInteractionModel(resource); 419 if (StringUtils.isNoneBlank(interactionModel) && StringUtils.isNoneBlank(resInteractionModel) 420 && !resInteractionModel.equals(interactionModel)) { 421 throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel 422 + " to " + interactionModel + " is not allowed!"); 423 } 424 425 } else { 426 427 checkExistingAncestor(path); 428 429 final MediaType effectiveContentType 430 = requestBodyStream == null || requestContentType == null ? null : contentType; 431 resource = createFedoraResource(path, interactionModel, effectiveContentType, 432 !(requestBodyStream == null || requestContentType == null), extContent != null); 433 } 434 435 if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) { 436 throw new ClientErrorException("An If-Match header is required", 428); 437 } 438 439 evaluateRequestPreconditions(request, servletResponse, resource, session); 440 final boolean created = resource.isNew(); 441 442 try (final RdfStream resourceTriples = 443 created ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) { 444 if (resource instanceof FedoraBinary) { 445 InputStream stream = requestBodyStream; 446 MediaType type = requestContentType; 447 // override a few things, if it's external content 448 if (extContent != null) { 449 if (extContent.isCopy()) { 450 LOGGER.debug("External content COPY '{}', '{}'", externalPath, extContent.getURL()); 451 stream = extContent.fetchExternalContent(); 452 } 453 454 type = contentType; // if external, then this already holds the correct value 455 } 456 final String handling = extContent != null ? extContent.getHandling() : null; 457 replaceResourceBinaryWithStream((FedoraBinary) resource, 458 stream, contentDisposition, type, checksums, 459 (handling != null && !handling.equals(COPY)) ? handling : null, 460 (extContent != null && !handling.equals(COPY)) ? extContent.getURL() : null); 461 462 } else if (isRdfContentType(contentType.toString())) { 463 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 464 } else if (!created) { 465 boolean emptyRequest = true; 466 try { 467 emptyRequest = requestBodyStream.read() == -1; 468 } catch (final IOException ex) { 469 LOGGER.debug("Error checking for request body content", ex); 470 } 471 472 if (requestContentType == null && emptyRequest) { 473 throw new ClientErrorException("Resource Already Exists", CONFLICT); 474 } 475 throw new NotSupportedException("Invalid Content Type " + requestContentType); 476 } 477 } catch (final Exception e) { 478 checkForInsufficientStorageException(e, e); 479 } 480 481 ensureInteractionType(resource, interactionModel, 482 (requestBodyStream == null || requestContentType == null)); 483 484 session.commit(); 485 return createUpdateResponse(resource, created); 486 487 } finally { 488 lock.release(); 489 } 490 } 491 492 /** 493 * Make sure the resource has the specified interaction model 494 */ 495 private static void ensureInteractionType(final FedoraResource resource, final String interactionModel, 496 final boolean defaultContent) { 497 if (interactionModel != null) { 498 if (!resource.hasType(interactionModel)) { 499 resource.addType(interactionModel); 500 } 501 } else if (defaultContent) { 502 resource.addType(LDP_BASIC_CONTAINER); 503 } else if (resource instanceof FedoraBinary) { 504 resource.addType(LDP_NON_RDF_SOURCE); 505 } 506 } 507 508 /** 509 * Update an object using SPARQL-UPDATE 510 * 511 * @param requestBodyStream the request body stream 512 * @return 201 513 * @throws IOException if IO exception occurred 514 */ 515 @PATCH 516 @Consumes({contentTypeSPARQLUpdate}) 517 public Response updateSparql(final InputStream requestBodyStream) 518 throws IOException { 519 hasRestrictedPath(externalPath); 520 521 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 522 handleRequestDisallowedOnMemento(); 523 524 return status(METHOD_NOT_ALLOWED).build(); 525 } 526 527 if (null == requestBodyStream) { 528 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 529 } 530 531 if (resource() instanceof FedoraBinary) { 532 throw new BadRequestException(resource().getPath() + " is not a valid object to receive a PATCH"); 533 } 534 535 final AcquiredLock lock = lockManager.lockForWrite(resource().getPath(), session.getFedoraSession(), 536 nodeService); 537 538 try { 539 final String requestBody = IOUtils.toString(requestBodyStream, UTF_8); 540 if (isBlank(requestBody)) { 541 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 542 } 543 544 evaluateRequestPreconditions(request, servletResponse, resource(), session); 545 546 try (final RdfStream resourceTriples = 547 resource().isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) { 548 LOGGER.info("PATCH for '{}'", externalPath); 549 patchResourcewithSparql(resource(), requestBody, resourceTriples); 550 } 551 session.commit(); 552 553 addCacheControlHeaders(servletResponse, resource(), session); 554 555 return noContent().build(); 556 } catch (final IllegalArgumentException iae) { 557 throw new BadRequestException(iae.getMessage()); 558 } catch (final AccessDeniedException e) { 559 throw e; 560 } catch ( final RuntimeException ex ) { 561 final Throwable cause = ex.getCause(); 562 if (cause instanceof PathNotFoundRuntimeException) { 563 // the sparql update referred to a repository resource that doesn't exist 564 throw new BadRequestException(cause.getMessage()); 565 } 566 throw ex; 567 } finally { 568 lock.release(); 569 } 570 } 571 572 /** 573 * Creates a new object. 574 * 575 * This originally used application/octet-stream;qs=1001 as a workaround 576 * for JERSEY-2636, to ensure requests without a Content-Type get routed here. 577 * This qs value does not parse with newer versions of Jersey, as qs values 578 * must be between 0 and 1. We use qs=1.000 to mark where this historical 579 * anomaly had been. 580 * 581 * @param contentDisposition the content Disposition value 582 * @param requestContentType the request content type 583 * @param slug the slug value 584 * @param requestBodyStream the request body stream 585 * @param rawLinks the link values 586 * @param digest the digest header 587 * @return 201 588 * @throws InvalidChecksumException if invalid checksum exception occurred 589 * @throws MalformedRdfException if malformed rdf exception occurred 590 * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs 591 */ 592 @POST 593 @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1.000", WILDCARD}) 594 @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 595 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 596 TURTLE_X, TEXT_HTML_WITH_CHARSET, "*/*"}) 597 public Response createObject(@HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition, 598 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType, 599 @HeaderParam("Slug") final String slug, 600 final InputStream requestBodyStream, 601 @HeaderParam(LINK) final List<String> rawLinks, 602 @HeaderParam("Digest") final String digest) 603 throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException { 604 605 final List<String> links = unpackLinks(rawLinks); 606 607 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 608 handleRequestDisallowedOnMemento(); 609 610 return status(METHOD_NOT_ALLOWED).build(); 611 } 612 613 final String interactionModel = checkInteractionModel(links); 614 615 checkAclLinkHeader(links); 616 617 // If request is an external binary, verify link header before proceeding 618 final ExternalContentHandler extContent = extContentHandlerFactory.createFromLinks(links); 619 620 if (!(resource() instanceof Container)) { 621 throw new ClientErrorException("Object cannot have child nodes", CONFLICT); 622 } else if (resource().hasType(FEDORA_PAIRTREE)) { 623 throw new ClientErrorException("Objects cannot be created under pairtree nodes", FORBIDDEN); 624 } 625 626 final MediaType contentType = getSimpleContentType( 627 extContent != null ? extContent.getContentType() : requestContentType); 628 629 final String contentTypeString = contentType.toString(); 630 631 final String newObjectPath = mintNewPid(slug); 632 hasRestrictedPath(newObjectPath); 633 634 final AcquiredLock lock = lockManager.lockForWrite(newObjectPath, session.getFedoraSession(), nodeService); 635 636 try { 637 638 final Collection<String> checksum = parseDigestHeader(digest); 639 640 LOGGER.info("Ingest with path: {}", newObjectPath); 641 642 final FedoraResource resource = createFedoraResource(newObjectPath, interactionModel, contentType, 643 !(requestBodyStream == null || requestContentType == null), extContent != null); 644 645 try (final RdfStream resourceTriples = 646 resource.isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) { 647 648 if (requestBodyStream == null && extContent == null) { 649 LOGGER.trace("No request body detected"); 650 } else { 651 LOGGER.trace("Received createObject with a request body and content type \"{}\"", 652 contentTypeString); 653 654 if ((resource instanceof Container) && isRdfContentType(contentTypeString)) { 655 replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples); 656 } else if (resource instanceof FedoraBinary) { 657 LOGGER.trace("Created a datastream and have a binary payload."); 658 659 InputStream stream = requestBodyStream; 660 MediaType type = requestContentType; 661 662 if (extContent != null) { 663 if (extContent.isCopy()) { 664 LOGGER.debug("POST copying data {} ", externalPath); 665 stream = extContent.fetchExternalContent(); 666 } 667 668 type = contentType; // if external, then this already holds the correct value 669 } 670 671 final String handling = extContent != null ? extContent.getHandling() : null; 672 replaceResourceBinaryWithStream((FedoraBinary) resource, 673 stream, contentDisposition, type, checksum, 674 handling != null && !handling.equals(COPY) ? handling : null, 675 extContent != null ? extContent.getURL() : null); 676 677 } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) { 678 LOGGER.trace("Found SPARQL-Update content, applying.."); 679 patchResourcewithSparql(resource, IOUtils.toString(requestBodyStream, UTF_8), resourceTriples); 680 } else { 681 if (requestBodyStream.read() != -1) { 682 throw new ClientErrorException("Invalid Content Type " + contentTypeString, 683 UNSUPPORTED_MEDIA_TYPE); 684 } 685 } 686 } 687 688 ensureInteractionType(resource, interactionModel, 689 (requestBodyStream == null || requestContentType == null)); 690 691 session.commit(); 692 } catch (final Exception e) { 693 checkForInsufficientStorageException(e, e); 694 } 695 696 LOGGER.debug("Finished creating resource with path: {}", newObjectPath); 697 return createUpdateResponse(resource, true); 698 } finally { 699 lock.release(); 700 } 701 } 702 703 /** 704 * @param rootThrowable The original throwable 705 * @param throwable The throwable under direct scrutiny. 706 */ 707 @Override 708 protected void checkForInsufficientStorageException(final Throwable rootThrowable, final Throwable throwable) 709 throws InvalidChecksumException { 710 final String message = throwable.getMessage(); 711 if (throwable instanceof IOException && message != null && message.contains( 712 INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE)) { 713 throw new InsufficientStorageException(throwable.getMessage(), rootThrowable); 714 } 715 716 if (throwable.getCause() != null) { 717 checkForInsufficientStorageException(rootThrowable, throwable.getCause()); 718 } 719 720 if (rootThrowable instanceof InvalidChecksumException) { 721 throw (InvalidChecksumException) rootThrowable; 722 } else if (rootThrowable instanceof RuntimeException) { 723 throw (RuntimeException) rootThrowable; 724 } else { 725 throw new RepositoryRuntimeException(rootThrowable); 726 } 727 } 728 729 @Override 730 protected void addResourceHttpHeaders(final FedoraResource resource) { 731 super.addResourceHttpHeaders(resource); 732 733 if (session.isBatchSession()) { 734 final String canonical = translator().reverse() 735 .convert(resource) 736 .toString() 737 .replaceFirst("/tx:[^/]+", ""); 738 739 740 servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\""); 741 742 } 743 addExternalContentHeaders(resource); 744 } 745 746 @Override 747 protected String externalPath() { 748 return externalPath; 749 } 750 751 752 private static boolean isRDF(final MediaType requestContentType) { 753 if (requestContentType == null) { 754 return false; 755 } 756 757 final ContentType ctRequest = create(requestContentType.toString()); 758 759 // Text files and CSV files are not considered RDF to Fedora, though CSV is a valid 760 // RDF type to Jena (although deprecated). 761 if (matchContentType(ctRequest, ctTextPlain) || matchContentType(ctRequest, ctTextCSV)) { 762 return false; 763 } 764 765 // SPARQL updates are done on containers. 766 return isRdfContentType(requestContentType.toString()) || matchContentType(ctRequest, ctSPARQLUpdate); 767 } 768 769 private void checkExistingAncestor(final String path) { 770 // check the closest existing ancestor for containment violations. 771 String parentPath = path.substring(0, path.lastIndexOf("/")); 772 while (!(parentPath.isEmpty() || parentPath.equals("/"))) { 773 if (nodeService.exists(session.getFedoraSession(), parentPath)) { 774 if (!(getResourceFromPath(parentPath) instanceof Container)) { 775 throw new ClientErrorException("Unable to add child " + path.replace(parentPath, "") 776 + " to resource " + parentPath + ".", CONFLICT); 777 } 778 break; 779 } 780 parentPath = parentPath.substring(0, parentPath.lastIndexOf("/")); 781 } 782 } 783 784 private FedoraResource createFedoraResource(final String path, final String interactionModel, 785 final MediaType contentType, final boolean contentPresent, final boolean contentExternal) { 786 787 final MediaType simpleContentType = contentPresent ? getSimpleContentType(contentType) : null; 788 789 final FedoraResource result; 790 if ("ldp:NonRDFSource".equals(interactionModel) || contentExternal || 791 (contentPresent && interactionModel == null && !isRDF(simpleContentType))) { 792 result = binaryService.findOrCreate(session.getFedoraSession(), path); 793 timeMapService.findOrCreate(session.getFedoraSession(), path + "/" + FEDORA_DESCRIPTION); 794 } else { 795 result = containerService.findOrCreate(session.getFedoraSession(), path); 796 } 797 798 timeMapService.findOrCreate(session.getFedoraSession(), path); 799 800 final String resInteractionModel = getInteractionModel(result); 801 if (StringUtils.isNoneBlank(interactionModel) && StringUtils.isNoneBlank(resInteractionModel) 802 && !resInteractionModel.equals(interactionModel)) { 803 throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel 804 + " to " + interactionModel + " is not allowed!"); 805 } 806 807 return result; 808 } 809 810 /* 811 * Get the interaction model from the Fedora Resource 812 * @param resource Fedora Resource 813 * @return String the Interaction Model 814 */ 815 private String getInteractionModel(final FedoraResource resource) { 816 final Optional<String> result = INTERACTION_MODELS.stream().filter(x -> resource.hasType(x)).findFirst(); 817 return result.orElse(null); 818 } 819 820 private String mintNewPid(final String slug) { 821 String pid; 822 823 if (slug != null && !slug.isEmpty()) { 824 pid = slug; 825 } else if (pidMinter != null) { 826 pid = pidMinter.get(); 827 } else { 828 pid = defaultPidMinter.get(); 829 } 830 // reverse translate the proffered or created identifier 831 LOGGER.trace("Using external identifier {} to create new resource.", pid); 832 LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/" 833 + pid); 834 835 final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class) 836 .resolveTemplate("path", pid, false).build(); 837 838 pid = translator().asString(createResource(newResourceUri.toString())); 839 try { 840 pid = URLDecoder.decode(pid, "UTF-8"); 841 } catch (final UnsupportedEncodingException e) { 842 // noop 843 } 844 // remove leading slash left over from translation 845 LOGGER.trace("Using internal identifier {} to create new resource.", pid); 846 847 if (nodeService.exists(session.getFedoraSession(), pid)) { 848 LOGGER.trace("Resource with path {} already exists; minting new path instead", pid); 849 return mintNewPid(null); 850 } 851 852 return pid; 853 } 854 855 private String handleWantDigestHeader(final FedoraBinary binary, final String wantDigest) 856 throws UnsupportedAlgorithmException { 857 // handle the Want-Digest header with fixity check 858 final Collection<String> preferredDigests = parseWantDigestHeader(wantDigest); 859 if (preferredDigests.isEmpty()) { 860 throw new UnsupportedAlgorithmException( 861 "Unsupported digest algorithm provided in 'Want-Digest' header: " + wantDigest); 862 } 863 864 final Collection<URI> checksumResults = binary.checkFixity(idTranslator, preferredDigests); 865 return checksumResults.stream().map(uri -> uri.toString().replaceFirst("urn:", "") 866 .replaceFirst(":", "=").replaceFirst("sha1=", "sha=")).collect(Collectors.joining(",")); 867 } 868 869 private static String checkInteractionModel(final List<String> links) { 870 if (links == null) { 871 return null; 872 } 873 874 try { 875 for (final String link : links) { 876 final Link linq = Link.valueOf(link); 877 if ("type".equals(linq.getRel())) { 878 final Resource type = createResource(linq.getUri().toString()); 879 if (INTERACTION_MODEL_RESOURCES.contains(type)) { 880 return "ldp:" + type.getLocalName(); 881 } else if (type.equals(VERSIONED_RESOURCE)) { 882 // skip if versioned resource link header 883 // NB: the versioned resource header is used for enabling 884 // versioning on a resource and is thus orthogonal to 885 // issue of interaction models. Nevertheless, it is 886 // a possible link header and, therefore, must be ignored. 887 } else { 888 LOGGER.info("Invalid interaction model: {}", type); 889 throw new CannotCreateResourceException("Invalid interaction model: " + type); 890 } 891 } 892 } 893 } catch (final RuntimeException e) { 894 if (e instanceof IllegalArgumentException | e instanceof UriBuilderException) { 895 throw new ClientErrorException("Invalid link specified: " + String.join(", ", links), BAD_REQUEST); 896 } 897 throw e; 898 } 899 900 return null; 901 } 902 903 /** 904 * Parse the RFC-3230 Digest response header value. Look for a 905 * sha1 checksum and return it as a urn, if missing or malformed 906 * an empty string is returned. 907 * @param digest The Digest header value 908 * @return the sha1 checksum value 909 * @throws UnsupportedAlgorithmException if an unsupported digest is used 910 */ 911 protected static Collection<String> parseDigestHeader(final String digest) throws UnsupportedAlgorithmException { 912 try { 913 final Map<String,String> digestPairs = RFC3230_SPLITTER.split(nullToEmpty(digest)); 914 final boolean allSupportedAlgorithms = digestPairs.keySet().stream().allMatch( 915 ContentDigest.DIGEST_ALGORITHM::isSupportedAlgorithm); 916 917 // If you have one or more digests that are all valid or no digests. 918 if (digestPairs.isEmpty() || allSupportedAlgorithms) { 919 return digestPairs.entrySet().stream() 920 .filter(entry -> ContentDigest.DIGEST_ALGORITHM.isSupportedAlgorithm(entry.getKey())) 921 .map(entry -> ContentDigest.asURI(entry.getKey(), entry.getValue()).toString()) 922 .collect(Collectors.toSet()); 923 } else { 924 throw new UnsupportedAlgorithmException(String.format("Unsupported Digest Algorithm: %1$s", digest)); 925 } 926 } catch (final RuntimeException e) { 927 if (e instanceof IllegalArgumentException) { 928 throw new ClientErrorException("Invalid Digest header: " + digest + "\n", BAD_REQUEST); 929 } 930 throw e; 931 } 932 } 933 934 /** 935 * Parse the RFC-3230 Want-Digest header value. 936 * @param wantDigest The Want-Digest header value with optional q value in format: 937 * 'md5', 'md5, sha', 'MD5;q=0.3, sha;q=1' etc. 938 * @return Digest algorithms that are supported 939 */ 940 private static Collection<String> parseWantDigestHeader(final String wantDigest) { 941 final Map<String, Double> digestPairs = new HashMap<>(); 942 try { 943 final List<String> algs = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(wantDigest); 944 // Parse the optional q value with default 1.0, and 0 ignore. Format could be: SHA-1;qvalue=0.1 945 for (final String alg : algs) { 946 final String[] tokens = alg.split(";", 2); 947 final double qValue = tokens.length == 1 || !tokens[1].contains("=") ? 948 1.0 : Double.parseDouble(tokens[1].split("=", 2)[1]); 949 digestPairs.put(tokens[0], qValue); 950 } 951 952 return digestPairs.entrySet().stream().filter(entry -> entry.getValue() > 0) 953 .filter(entry -> ContentDigest.DIGEST_ALGORITHM.isSupportedAlgorithm(entry.getKey())) 954 .map(entry -> entry.getKey()).collect(Collectors.toSet()); 955 } catch (final NumberFormatException e) { 956 throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest, SC_BAD_REQUEST, e); 957 } catch (final RuntimeException e) { 958 if (e instanceof IllegalArgumentException) { 959 throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest + "\n", BAD_REQUEST); 960 } 961 throw e; 962 } 963 } 964 965 private void checkAclLinkHeader(final List<String> links) throws RequestWithAclLinkHeaderException { 966 if (links != null && links.stream().anyMatch(l -> Link.valueOf(l).getRel().equals("acl"))) { 967 throw new RequestWithAclLinkHeaderException( 968 "Unable to handle request with the specified LDP-RS as the ACL."); 969 } 970 } 971 972 private void handleRequestDisallowedOnMemento() { 973 try { 974 addLinkAndOptionsHttpHeaders(resource()); 975 } catch (final Exception ex) { 976 // Catch the exception to ensure status 405 for any requests on memento. 977 LOGGER.debug("Unable to add link and options headers for PATCH request to memento path {}: {}.", 978 externalPath, ex.getMessage()); 979 } 980 981 LOGGER.info("Unable to handle {} request on a path containing {}. Path was: {}", request.getMethod(), 982 FedoraTypes.FCR_VERSIONS, externalPath); 983 } 984 985 /* 986 * Ensure that an incoming versioning/memento path can be converted. 987 */ 988 private void checkMementoPath() { 989 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 990 final String path = toPath(translator(), externalPath); 991 if (path.contains(FedoraTypes.FCR_VERSIONS)) { 992 throw new InvalidMementoPathException("Invalid versioning request with path: " + path); 993 } 994 } 995 } 996}