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