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