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 020import static com.google.common.base.Strings.isNullOrEmpty; 021import static java.nio.charset.StandardCharsets.UTF_8; 022import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION; 023import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; 024import static javax.ws.rs.core.HttpHeaders.LINK; 025import static javax.ws.rs.core.HttpHeaders.LOCATION; 026import static javax.ws.rs.core.MediaType.WILDCARD; 027import static javax.ws.rs.core.Response.noContent; 028import static javax.ws.rs.core.Response.notAcceptable; 029import static javax.ws.rs.core.Response.ok; 030import static javax.ws.rs.core.Response.status; 031import static javax.ws.rs.core.Response.temporaryRedirect; 032import static javax.ws.rs.core.Response.Status.BAD_REQUEST; 033import static javax.ws.rs.core.Response.Status.FOUND; 034import static javax.ws.rs.core.Response.Status.METHOD_NOT_ALLOWED; 035import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE; 036import static org.apache.commons.lang3.StringUtils.isBlank; 037import static org.apache.http.HttpStatus.SC_BAD_REQUEST; 038import static org.apache.jena.rdf.model.ResourceFactory.createResource; 039import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate; 040import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD; 041import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET; 042import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET; 043import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES; 044import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML; 045import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET; 046import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET; 047import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET; 048import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X; 049import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_TYPE; 050import static org.fcrepo.http.commons.domain.RDFMediaType.APPLICATION_OCTET_STREAM_TYPE; 051 052import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA; 053import static org.fcrepo.kernel.api.RdfLexicon.ARCHIVAL_GROUP; 054import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODEL_RESOURCES; 055import static org.fcrepo.kernel.api.RdfLexicon.NON_RDF_SOURCE; 056import static org.fcrepo.kernel.api.RdfLexicon.VERSIONED_RESOURCE; 057import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER; 058import static org.slf4j.LoggerFactory.getLogger; 059 060import java.io.IOException; 061import java.io.InputStream; 062import java.net.URI; 063import java.net.URLDecoder; 064import java.time.Instant; 065import java.time.format.DateTimeParseException; 066import java.util.Collection; 067import java.util.HashMap; 068import java.util.List; 069import java.util.Map; 070import java.util.Objects; 071import java.util.concurrent.atomic.AtomicBoolean; 072import java.util.stream.Collectors; 073 074import javax.inject.Inject; 075import javax.ws.rs.BadRequestException; 076import javax.ws.rs.ClientErrorException; 077import javax.ws.rs.Consumes; 078import javax.ws.rs.DELETE; 079import javax.ws.rs.GET; 080import javax.ws.rs.HEAD; 081import javax.ws.rs.HeaderParam; 082import javax.ws.rs.OPTIONS; 083import javax.ws.rs.POST; 084import javax.ws.rs.PUT; 085import javax.ws.rs.Path; 086import javax.ws.rs.PathParam; 087import javax.ws.rs.Produces; 088import javax.ws.rs.core.HttpHeaders; 089import javax.ws.rs.core.Link; 090import javax.ws.rs.core.MediaType; 091import javax.ws.rs.core.Response; 092import javax.ws.rs.core.UriBuilderException; 093import javax.ws.rs.core.Variant.VariantListBuilder; 094 095import io.micrometer.core.annotation.Timed; 096import org.apache.commons.io.IOUtils; 097import org.apache.commons.lang3.StringUtils; 098import org.apache.jena.rdf.model.Model; 099import org.apache.jena.rdf.model.Resource; 100 101import org.fcrepo.http.commons.domain.PATCH; 102import org.fcrepo.kernel.api.FedoraTypes; 103import org.fcrepo.kernel.api.exception.AccessDeniedException; 104import org.fcrepo.kernel.api.exception.CannotCreateResourceException; 105import org.fcrepo.kernel.api.exception.GhostNodeException; 106import org.fcrepo.kernel.api.exception.InteractionModelViolationException; 107import org.fcrepo.kernel.api.exception.InvalidChecksumException; 108import org.fcrepo.kernel.api.exception.MalformedRdfException; 109import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException; 110import org.fcrepo.kernel.api.exception.PathNotFoundException; 111import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException; 112import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException; 113import org.fcrepo.kernel.api.identifiers.FedoraId; 114import org.fcrepo.kernel.api.models.Binary; 115import org.fcrepo.kernel.api.models.Container; 116import org.fcrepo.kernel.api.models.ExternalContent; 117import org.fcrepo.kernel.api.models.FedoraResource; 118import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 119import org.fcrepo.kernel.api.services.FixityService; 120import org.fcrepo.kernel.api.services.ReplaceBinariesService; 121import org.fcrepo.config.DigestAlgorithm; 122 123import org.glassfish.jersey.media.multipart.ContentDisposition; 124import org.slf4j.Logger; 125import org.springframework.context.annotation.Scope; 126 127import com.google.common.annotations.VisibleForTesting; 128import com.google.common.base.Splitter; 129import com.google.common.collect.ImmutableList; 130 131/** 132 * @author cabeer 133 * @author ajs6f 134 * @since 9/25/14 135 */ 136 137@Timed 138@Scope("request") 139@Path("/{path: .*}") 140public class FedoraLdp extends ContentExposingResource { 141 142 private static final Logger LOGGER = getLogger(FedoraLdp.class); 143 144 private static final String WANT_DIGEST = "Want-Digest"; 145 146 private static final String DIGEST = "Digest"; 147 148 private static final MediaType DEFAULT_RDF_CONTENT_TYPE = TURTLE_TYPE; 149 private static final MediaType DEFAULT_NON_RDF_CONTENT_TYPE = APPLICATION_OCTET_STREAM_TYPE; 150 151 @PathParam("path") protected String externalPath; 152 153 @Inject 154 private FixityService fixityService; 155 156 @Inject 157 private FedoraHttpConfiguration httpConfiguration; 158 159 @Inject 160 protected ReplaceBinariesService replaceBinariesService; 161 162 /** 163 * Default JAX-RS entry point 164 */ 165 public FedoraLdp() { 166 super(); 167 } 168 169 /** 170 * Create a new FedoraNodes instance for a given path 171 * @param externalPath the external path 172 */ 173 @VisibleForTesting 174 public FedoraLdp(final String externalPath) { 175 this.externalPath = externalPath; 176 } 177 178 /** 179 * Retrieve the node headers 180 * 181 * @return response 182 * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred 183 */ 184 @HEAD 185 @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8", 186 N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET, 187 TURTLE_X, TEXT_HTML_WITH_CHARSET }) 188 public Response head() throws UnsupportedAlgorithmException { 189 LOGGER.info("HEAD for: {}", externalPath); 190 191 final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME); 192 if (!isBlank(datetimeHeader) && resource().isOriginalResource()) { 193 return getMemento(datetimeHeader, resource()); 194 } 195 196 checkCacheControlHeaders(request, servletResponse, resource(), transaction()); 197 198 addResourceHttpHeaders(resource()); 199 200 Response.ResponseBuilder builder = ok(); 201 202 if (resource() instanceof Binary) { 203 final Binary binary = (Binary) resource(); 204 final MediaType mediaType = getBinaryResourceMediaType(binary); 205 206 if (binary.isRedirect()) { 207 builder = temporaryRedirect(binary.getExternalURI()); 208 } 209 210 // we set the content-type explicitly to avoid content-negotiation from getting in the way 211 builder.type(mediaType.toString()); 212 213 // Respect the Want-Digest header with fixity check 214 final String wantDigest = headers.getHeaderString(WANT_DIGEST); 215 if (!isNullOrEmpty(wantDigest)) { 216 builder.header(DIGEST, handleWantDigestHeader(binary, wantDigest)); 217 } 218 } else { 219 final String accept = headers.getHeaderString(HttpHeaders.ACCEPT); 220 if (accept == null || "*/*".equals(accept)) { 221 builder.type(TURTLE_WITH_CHARSET); 222 } 223 setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource()); 224 } 225 226 227 return builder.build(); 228 } 229 230 /** 231 * Outputs information about the supported HTTP methods, etc. 232 * @return the outputs information about the supported HTTP methods, etc. 233 */ 234 @OPTIONS 235 public Response options() { 236 LOGGER.info("OPTIONS for '{}'", externalPath); 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 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(), transaction()); 264 265 final ImmutableList<MediaType> acceptableMediaTypes = ImmutableList.copyOf(headers 266 .getAcceptableMediaTypes()); 267 268 LOGGER.info("GET resource '{}'", externalPath); 269 addResourceHttpHeaders(resource()); 270 271 if (resource() instanceof Binary) { 272 final Binary binary = (Binary) resource(); 273 if (!acceptableMediaTypes.isEmpty()) { 274 final MediaType mediaType = getBinaryResourceMediaType(resource()); 275 276 if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) { 277 return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build(); 278 } 279 } 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(binary, wantDigest)); 285 } 286 287 if (binary.isRedirect()) { 288 return temporaryRedirect(binary.getExternalURI()).build(); 289 } else { 290 return getBinaryContent(rangeValue, binary); 291 } 292 } else { 293 return getContent(getChildrenLimit(), resource()); 294 } 295 } 296 297 /** 298 * Return the location of a requested Memento. 299 * 300 * @param datetimeHeader The RFC datetime for the Memento. 301 * @param resource The fedora resource 302 * @return A 302 Found response or 406 if no mementos. 303 */ 304 private Response getMemento(final String datetimeHeader, final FedoraResource resource) { 305 try { 306 final Instant mementoDatetime = Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader)); 307 final FedoraResource memento = resource.findMementoByDatetime(mementoDatetime); 308 final Response builder; 309 if (memento != null) { 310 builder = 311 status(FOUND).header(LOCATION, getUri(memento)).build(); 312 } else { 313 builder = status(NOT_ACCEPTABLE).build(); 314 } 315 addResourceHttpHeaders(resource); 316 setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource); 317 return builder; 318 } catch (final DateTimeParseException e) { 319 throw new MementoDatetimeFormatException("Invalid Accept-Datetime value: " + e.getMessage() 320 + ". Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e); 321 } 322 } 323 324 /** 325 * Deletes an object. 326 * 327 * @return response 328 */ 329 @DELETE 330 public Response deleteObject() { 331 hasRestrictedPath(externalPath); 332 if (resource() instanceof Container) { 333 final String depth = headers.getHeaderString("Depth"); 334 LOGGER.debug("Depth header value is: {}", depth); 335 if (depth != null && !depth.equalsIgnoreCase("infinity")) { 336 throw new ClientErrorException("Depth header, if present, must be set to 'infinity' for containers", 337 SC_BAD_REQUEST); 338 } 339 } 340 if (resource() instanceof NonRdfSourceDescription && resource().isOriginalResource()) { 341 LOGGER.debug("Trying to delete binary description directly."); 342 throw new ClientErrorException( 343 "NonRDFSource descriptions are removed when their associated NonRDFSource object is removed.", 344 METHOD_NOT_ALLOWED); 345 } 346 347 LOGGER.info("Delete resource '{}'", externalPath); 348 349 try { 350 evaluateRequestPreconditions(request, servletResponse, resource(), transaction()); 351 352 doInDbTxWithRetry(() -> { 353 deleteResourceService.perform(transaction(), resource(), getUserPrincipal()); 354 transaction().commitIfShortLived(); 355 }); 356 return noContent().build(); 357 } finally { 358 transaction().releaseResourceLocksIfShortLived(); 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 PathNotFoundException { 387 LOGGER.info("PUT to create resource with ID: {}", externalPath()); 388 389 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 390 handleRequestDisallowedOnMemento(); 391 392 return status(METHOD_NOT_ALLOWED).build(); 393 } 394 395 hasRestrictedPath(externalPath); 396 397 final var transaction = transaction(); 398 399 try { 400 final List<String> links = unpackLinks(rawLinks); 401 402 // If request is an external binary, verify link header before proceeding 403 final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links); 404 405 final String interactionModel = checkInteractionModel(links); 406 407 final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath()); 408 final boolean resourceExists = doesResourceExist(transaction, fedoraId, true); 409 410 if (resourceExists) { 411 412 if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch)) { 413 throw new ClientErrorException("An If-Match header is required", 428); 414 } 415 416 final String resInteractionModel = resource().getInteractionModel(); 417 if (StringUtils.isNoneBlank(resInteractionModel, interactionModel) && 418 !Objects.equals(resInteractionModel, interactionModel)) { 419 throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel 420 + " to " + interactionModel + " is not allowed!"); 421 } 422 evaluateRequestPreconditions(request, servletResponse, resource(), transaction); 423 } 424 425 if (isGhostNode(transaction(), fedoraId)) { 426 throw new GhostNodeException("Resource path " + externalPath() + " is an immutable resource."); 427 } 428 429 if (!resourceExists && fedoraId.isDescription()) { 430 // Can't PUT a description to a non-existant binary. 431 final String message; 432 if (fedoraId.asBaseId().isRepositoryRoot()) { 433 message = "The root of the repository is not a binary, so /" + FCR_METADATA + " does not exist."; 434 } else { 435 message = "Binary at path " + fedoraId.asBaseId().getFullIdPath() + " not found"; 436 } 437 throw new PathNotFoundException(message); 438 } 439 440 final var providedContentType = getSimpleContentType(requestContentType); 441 442 final var created = new AtomicBoolean(false); 443 444 if ((resourceExists && resource() instanceof Binary) || 445 (!resourceExists && isBinary(interactionModel, 446 providedContentType, 447 requestBodyStream != null && providedContentType != null, 448 extContent != null))) { 449 ensureArchivalGroupHeaderNotPresentForBinaries(links); 450 451 final Collection<URI> checksums = parseDigestHeader(digest); 452 final var binaryType = requestContentType != null ? 453 requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE; 454 final var contentType = extContent == null ? 455 binaryType.toString() : extContent.getContentType(); 456 final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : ""; 457 final long contentSize = contentDisposition == null ? -1L : contentDisposition.getSize(); 458 459 doInDbTx(() -> { 460 if (resourceExists) { 461 replaceBinariesService.perform(transaction, 462 getUserPrincipal(), 463 fedoraId, 464 originalFileName, 465 contentType, 466 checksums, 467 requestBodyStream, 468 contentSize, 469 extContent); 470 } else { 471 createResourceService.perform(transaction, 472 getUserPrincipal(), 473 fedoraId, 474 contentType, 475 originalFileName, 476 contentSize, 477 links, 478 checksums, 479 requestBodyStream, 480 extContent); 481 created.set(true); 482 } 483 transaction.commitIfShortLived(); 484 }); 485 } else { 486 final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE; 487 final Model model = httpRdfService.bodyToInternalModel(fedoraId, requestBodyStream, 488 contentType, identifierConverter(), hasLenientPreferHeader()); 489 490 doInDbTxWithRetry(() -> { 491 if (resourceExists) { 492 replacePropertiesService.perform(transaction, 493 getUserPrincipal(), 494 fedoraId, 495 model); 496 } else { 497 createResourceService.perform(transaction, getUserPrincipal(), fedoraId, links, model); 498 created.set(true); 499 } 500 transaction.commitIfShortLived(); 501 }); 502 } 503 504 LOGGER.debug("Finished creating resource with path: {}", externalPath()); 505 506 return createUpdateResponse(getFedoraResource(transaction, fedoraId), created.get()); 507 } finally { 508 transaction.releaseResourceLocksIfShortLived(); 509 } 510 } 511 512 /** 513 * Update an object using SPARQL-UPDATE 514 * 515 * @param requestBodyStream the request body stream 516 * @return 201 517 * @throws IOException if IO exception occurred 518 */ 519 @PATCH 520 @Consumes({contentTypeSPARQLUpdate}) 521 public Response updateSparql(final InputStream requestBodyStream) 522 throws IOException { 523 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 524 handleRequestDisallowedOnMemento(); 525 526 return status(METHOD_NOT_ALLOWED).build(); 527 } 528 529 hasRestrictedPath(externalPath); 530 531 if (null == requestBodyStream) { 532 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 533 } 534 535 if (resource() instanceof Binary) { 536 throw new BadRequestException(resource().getFedoraId().getFullIdPath() + 537 " is not a valid object to receive a PATCH"); 538 } 539 540 final var transaction = transaction(); 541 542 try { 543 final String requestBody = IOUtils.toString(requestBodyStream, UTF_8); 544 if (isBlank(requestBody)) { 545 throw new BadRequestException("SPARQL-UPDATE requests must have content!"); 546 } 547 548 evaluateRequestPreconditions(request, servletResponse, resource(), transaction); 549 550 LOGGER.info("PATCH for '{}'", externalPath); 551 final String newRequest = httpRdfService.patchRequestToInternalString(resource().getFedoraId(), 552 requestBody, identifierConverter()); 553 LOGGER.debug("PATCH request translated to '{}'", newRequest); 554 555 doInDbTxWithRetry(() -> { 556 patchResourcewithSparql(resource(), newRequest); 557 transaction.commitIfShortLived(); 558 }); 559 560 addCacheControlHeaders(servletResponse, reloadResource(), transaction); 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 transaction.releaseResourceLocksIfShortLived(); 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 var decodedSlug = slug != null ? URLDecoder.decode(slug, UTF_8) : null; 613 final var transaction = transaction(); 614 615 try { 616 final List<String> links = unpackLinks(rawLinks); 617 618 if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) { 619 handleRequestDisallowedOnMemento(); 620 621 return status(METHOD_NOT_ALLOWED).build(); 622 } 623 624 // If request is an external binary, verify link header before proceeding 625 final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links); 626 627 final String interactionModel = checkInteractionModel(links); 628 629 final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath()); 630 // If the resource doesn't exist and it's not a ghost node, throw an exception. 631 // Ghost node checking is done further down in the code and returns a 400 Bad Request error. 632 if (!doesResourceExist(transaction, fedoraId, false) && !isGhostNode(transaction, fedoraId)) { 633 throw new PathNotFoundRuntimeException(String.format("Path %s not found", fedoraId.getFullIdPath())); 634 } 635 final FedoraId newFedoraId = mintNewPid(fedoraId, decodedSlug); 636 final var providedContentType = getSimpleContentType(requestContentType); 637 638 LOGGER.info("POST to create resource with ID: {}, slug: {}", newFedoraId.getFullIdPath(), decodedSlug); 639 640 if (isBinary(interactionModel, 641 providedContentType, 642 requestBodyStream != null && providedContentType != null, 643 extContent != null)) { 644 ensureArchivalGroupHeaderNotPresentForBinaries(links); 645 646 final Collection<URI> checksums = parseDigestHeader(digest); 647 final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : ""; 648 final var binaryType = requestContentType != null ? 649 requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE; 650 final var contentType = extContent == null ? binaryType.toString() : extContent.getContentType(); 651 final long contentSize = contentDisposition == null ? -1L : contentDisposition.getSize(); 652 653 doInDbTx(() -> { 654 createResourceService.perform(transaction, 655 getUserPrincipal(), 656 newFedoraId, 657 contentType, 658 originalFileName, 659 contentSize, 660 links, 661 checksums, 662 requestBodyStream, 663 extContent); 664 665 transaction.commitIfShortLived(); 666 }); 667 } else { 668 final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE; 669 final Model model = httpRdfService.bodyToInternalModel(newFedoraId, requestBodyStream, 670 contentType, identifierConverter(), hasLenientPreferHeader()); 671 672 doInDbTxWithRetry(() -> { 673 createResourceService.perform(transaction, 674 getUserPrincipal(), 675 newFedoraId, 676 links, 677 model); 678 679 transaction.commitIfShortLived(); 680 }); 681 } 682 683 LOGGER.debug("Finished creating resource with path: {}", externalPath()); 684 685 try { 686 final var resource = getFedoraResource(transaction, newFedoraId); 687 return createUpdateResponse(resource, true); 688 } catch (final PathNotFoundException e) { 689 throw new PathNotFoundRuntimeException(e.getMessage(), e); 690 } 691 } finally { 692 transaction.releaseResourceLocksIfShortLived(); 693 } 694 } 695 696 @Override 697 protected void addResourceHttpHeaders(final FedoraResource resource) { 698 super.addResourceHttpHeaders(resource); 699 700 if (!transaction().isShortLived()) { 701 final String canonical = identifierConverter().toExternalId(resource.getFedoraId().getFullId()) 702 .replaceFirst("/tx:[^/]+", ""); 703 704 servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\""); 705 706 } 707 addExternalContentHeaders(resource); 708 addTransactionHeaders(resource); 709 } 710 711 @Override 712 protected String externalPath() { 713 return externalPath; 714 } 715 716 /** 717 * Determine based on several factors whether the interaction model should be ldp:NonRdfSource 718 * @param interactionModel the interaction model from the links. 719 * @param contentType the content type. 720 * @param contentPresent is there a request body. 721 * @param contentExternal is there an external content header. 722 * @return Use ldp:NonRdfSource as the interaction model. 723 */ 724 private boolean isBinary(final String interactionModel, final String contentType, 725 final boolean contentPresent, final boolean contentExternal) { 726 final String simpleContentType = contentPresent ? contentType : null; 727 final boolean isRdfContent = isRdfContentType(simpleContentType); 728 return NON_RDF_SOURCE.getURI().equals(interactionModel) || contentExternal || 729 (contentPresent && interactionModel == null && !isRdfContent); 730 } 731 732 private String handleWantDigestHeader(final Binary binary, final String wantDigest) 733 throws UnsupportedAlgorithmException { 734 // handle the Want-Digest header with fixity check 735 final Collection<String> preferredDigests = parseWantDigestHeader(wantDigest); 736 if (preferredDigests.isEmpty()) { 737 throw new UnsupportedAlgorithmException( 738 "Unsupported digest algorithm provided in 'Want-Digest' header: " + wantDigest); 739 } 740 741 final Collection<URI> checksumResults = fixityService.getFixity(binary, preferredDigests); 742 return checksumResults.stream().map(uri -> uri.toString().replaceFirst("urn:", "") 743 .replaceFirst(":", "=").replaceFirst("sha1=", "sha=")).collect(Collectors.joining(",")); 744 } 745 746 private static void ensureArchivalGroupHeaderNotPresentForBinaries(final List<String> links) { 747 if (links == null) { 748 return; 749 } 750 751 if (links.stream().map(Link::valueOf) 752 .filter(l -> l.getUri().toString().equals(ARCHIVAL_GROUP.getURI())) 753 .anyMatch(l -> l.getRel().equals("type"))) { 754 throw new ClientErrorException("Binary resources cannot be created as an" + 755 " ArchiveGroup. Please remove the ArchiveGroup link header and try again", BAD_REQUEST); 756 } 757 } 758 759 private static String checkInteractionModel(final List<String> links) { 760 if (links == null) { 761 return null; 762 } 763 764 try { 765 for (final String link : links) { 766 final Link linq = Link.valueOf(link); 767 if ("type".equals(linq.getRel())) { 768 //skip ArchivalGroup types 769 if (linq.getUri().toString().equals(ARCHIVAL_GROUP.getURI())) { 770 continue; 771 } 772 final Resource type = createResource(linq.getUri().toString()); 773 if (INTERACTION_MODEL_RESOURCES.contains(type)) { 774 return type.getURI(); 775 } else if (type.equals(VERSIONED_RESOURCE)) { 776 // skip if versioned resource link header 777 // NB: the versioned resource header is used for enabling 778 // versioning on a resource and is thus orthogonal to 779 // issue of interaction models. Nevertheless, it is 780 // a possible link header and, therefore, must be ignored. 781 } else { 782 LOGGER.info("Invalid interaction model: {}", type); 783 throw new CannotCreateResourceException("Invalid interaction model: " + type); 784 } 785 } 786 } 787 } catch (final RuntimeException e) { 788 if (e instanceof IllegalArgumentException || e instanceof UriBuilderException) { 789 throw new ClientErrorException("Invalid link specified: " + String.join(", ", links), BAD_REQUEST); 790 } 791 throw e; 792 } 793 794 return null; 795 } 796 797 /** 798 * Parse the RFC-3230 Want-Digest header value. 799 * @param wantDigest The Want-Digest header value with optional q value in format: 800 * 'md5', 'md5, sha', 'MD5;q=0.3, sha;q=1' etc. 801 * @return Digest algorithms that are supported 802 */ 803 private static Collection<String> parseWantDigestHeader(final String wantDigest) { 804 final Map<String, Double> digestPairs = new HashMap<>(); 805 try { 806 final List<String> algs = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(wantDigest); 807 // Parse the optional q value with default 1.0, and 0 ignore. Format could be: SHA-1;qvalue=0.1 808 for (final String alg : algs) { 809 final String[] tokens = alg.split(";", 2); 810 final double qValue = tokens.length == 1 || !tokens[1].contains("=") ? 811 1.0 : Double.parseDouble(tokens[1].split("=", 2)[1]); 812 digestPairs.put(tokens[0], qValue); 813 } 814 815 return digestPairs.entrySet().stream().filter(entry -> entry.getValue() > 0) 816 .map(Map.Entry::getKey) 817 .filter(DigestAlgorithm::isSupportedAlgorithm) 818 .collect(Collectors.toSet()); 819 } catch (final NumberFormatException e) { 820 throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest, SC_BAD_REQUEST, e); 821 } catch (final RuntimeException e) { 822 if (e instanceof IllegalArgumentException) { 823 throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest + "\n", BAD_REQUEST); 824 } 825 throw e; 826 } 827 } 828 829 private void handleRequestDisallowedOnMemento() { 830 try { 831 addLinkAndOptionsHttpHeaders(resource()); 832 } catch (final Exception ex) { 833 // Catch the exception to ensure status 405 for any requests on memento. 834 LOGGER.debug("Unable to add link and options headers for PATCH request to memento path {}: {}.", 835 externalPath, ex.getMessage()); 836 } 837 838 LOGGER.info("Unable to handle {} request on a path containing {}. Path was: {}", request.getMethod(), 839 FedoraTypes.FCR_VERSIONS, externalPath); 840 } 841 842 private FedoraId mintNewPid(final FedoraId fedoraId, final String slug) { 843 final String pid; 844 845 if (isGhostNode(transaction(), fedoraId)) { 846 LOGGER.debug("Resource with path {} is an immutable resource; it cannot be POSTed to.", fedoraId); 847 throw new CannotCreateResourceException("Cannot create resource as child of the immutable resource at " + 848 fedoraId.getFullIdPath()); 849 } 850 if (!isBlank(slug)) { 851 pid = slug; 852 } else if (pidMinter != null) { 853 pid = pidMinter.get(); 854 } else { 855 pid = defaultPidMinter.get(); 856 } 857 858 final FedoraId fullTestPath = fedoraId.resolve(pid); 859 hasRestrictedPath(fullTestPath.getFullIdPath()); 860 861 if (doesResourceExist(transaction(), fullTestPath, true) || isGhostNode(transaction(), fullTestPath)) { 862 LOGGER.debug("Resource with path {} already exists or is an immutable resource; minting new path instead", 863 fullTestPath); 864 return mintNewPid(fedoraId, null); 865 } 866 867 return fullTestPath; 868 } 869 870}