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.nullToEmpty; 021import static java.net.URI.create; 022import static java.text.MessageFormat.format; 023import static java.util.stream.Collectors.toSet; 024import static java.util.stream.Stream.empty; 025import static javax.ws.rs.core.HttpHeaders.ACCEPT; 026import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL; 027import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION; 028import static javax.ws.rs.core.HttpHeaders.CONTENT_LENGTH; 029import static javax.ws.rs.core.HttpHeaders.CONTENT_LOCATION; 030import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; 031import static javax.ws.rs.core.HttpHeaders.LINK; 032import static javax.ws.rs.core.MediaType.TEXT_HTML; 033import static javax.ws.rs.core.MediaType.TEXT_PLAIN_TYPE; 034import static javax.ws.rs.core.Response.Status.BAD_REQUEST; 035import static javax.ws.rs.core.Response.Status.PARTIAL_CONTENT; 036import static javax.ws.rs.core.Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE; 037import static javax.ws.rs.core.Response.created; 038import static javax.ws.rs.core.Response.noContent; 039import static javax.ws.rs.core.Response.notAcceptable; 040import static javax.ws.rs.core.Response.ok; 041import static javax.ws.rs.core.Response.status; 042import static javax.ws.rs.core.Variant.mediaTypes; 043import static org.apache.commons.lang3.StringUtils.isBlank; 044import static org.apache.http.HttpStatus.SC_BAD_REQUEST; 045import static org.apache.jena.graph.NodeFactory.createURI; 046import static org.apache.jena.riot.RDFLanguages.contentTypeToLang; 047import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate; 048import static org.apache.jena.riot.WebContent.ctSPARQLUpdate; 049import static org.apache.jena.riot.WebContent.ctTextCSV; 050import static org.apache.jena.riot.WebContent.ctTextPlain; 051import static org.apache.jena.riot.WebContent.matchContentType; 052import static org.fcrepo.http.api.FedoraVersioning.MEMENTO_DATETIME_HEADER; 053import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD; 054import static org.fcrepo.http.commons.domain.RDFMediaType.N3; 055import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2; 056import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES; 057import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML; 058import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE; 059import static org.fcrepo.http.commons.session.TransactionConstants.ATOMIC_ID_HEADER; 060import static org.fcrepo.http.commons.session.TransactionConstants.TX_ENDPOINT_REL; 061import static org.fcrepo.http.commons.session.TransactionConstants.TX_PREFIX; 062import static org.fcrepo.kernel.api.FedoraTypes.FCR_ACL; 063import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA; 064import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX; 065import static org.fcrepo.kernel.api.models.ExternalContent.COPY; 066import static org.fcrepo.kernel.api.models.ExternalContent.PROXY; 067import static org.fcrepo.kernel.api.models.ExternalContent.REDIRECT; 068import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER; 069import static org.slf4j.LoggerFactory.getLogger; 070 071import java.io.IOException; 072import java.io.InputStream; 073import java.net.URI; 074import java.net.URISyntaxException; 075import java.time.Instant; 076import java.time.ZoneOffset; 077import java.util.ArrayList; 078import java.util.Arrays; 079import java.util.Collection; 080import java.util.Date; 081import java.util.List; 082import java.util.Set; 083import java.util.function.Predicate; 084import java.util.stream.Collectors; 085import java.util.stream.Stream; 086 087import javax.inject.Inject; 088import javax.servlet.ServletContext; 089import javax.servlet.http.HttpServletResponse; 090import javax.ws.rs.BadRequestException; 091import javax.ws.rs.BeanParam; 092import javax.ws.rs.ClientErrorException; 093import javax.ws.rs.core.CacheControl; 094import javax.ws.rs.core.Context; 095import javax.ws.rs.core.EntityTag; 096import javax.ws.rs.core.Link; 097import javax.ws.rs.core.MediaType; 098import javax.ws.rs.core.Request; 099import javax.ws.rs.core.Response; 100 101import org.fcrepo.config.DigestAlgorithm; 102import org.fcrepo.http.api.services.EtagService; 103import org.fcrepo.http.api.services.HttpRdfService; 104import org.fcrepo.http.commons.api.rdf.HttpTripleUtil; 105import org.fcrepo.http.commons.domain.MultiPrefer; 106import org.fcrepo.http.commons.domain.PreferTag; 107import org.fcrepo.http.commons.domain.Range; 108import org.fcrepo.http.commons.domain.ldp.LdpPreferTag; 109import org.fcrepo.http.commons.responses.RangeRequestInputStream; 110import org.fcrepo.http.commons.responses.RdfNamespacedStream; 111import org.fcrepo.kernel.api.RdfStream; 112import org.fcrepo.kernel.api.Transaction; 113import org.fcrepo.kernel.api.exception.InsufficientStorageException; 114import org.fcrepo.kernel.api.exception.InvalidChecksumException; 115import org.fcrepo.kernel.api.exception.PathNotFoundException; 116import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException; 117import org.fcrepo.kernel.api.exception.PreconditionException; 118import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 119import org.fcrepo.kernel.api.exception.ServerManagedTypeException; 120import org.fcrepo.kernel.api.exception.TombstoneException; 121import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException; 122import org.fcrepo.kernel.api.identifiers.FedoraId; 123import org.fcrepo.kernel.api.models.Binary; 124import org.fcrepo.kernel.api.models.Container; 125import org.fcrepo.kernel.api.models.FedoraResource; 126import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 127import org.fcrepo.kernel.api.models.TimeMap; 128import org.fcrepo.kernel.api.models.Tombstone; 129import org.fcrepo.kernel.api.models.WebacAcl; 130import org.fcrepo.kernel.api.rdf.DefaultRdfStream; 131import org.fcrepo.kernel.api.rdf.RdfNamespaceRegistry; 132import org.fcrepo.kernel.api.services.CreateResourceService; 133import org.fcrepo.kernel.api.services.DeleteResourceService; 134import org.fcrepo.kernel.api.services.ReplacePropertiesService; 135import org.fcrepo.kernel.api.services.ResourceTripleService; 136import org.fcrepo.kernel.api.services.UpdatePropertiesService; 137import org.fcrepo.kernel.api.utils.ContentDigest; 138 139import org.apache.http.client.methods.HttpPatch; 140import org.apache.http.client.methods.HttpPut; 141import org.apache.jena.atlas.web.ContentType; 142import org.apache.jena.graph.Node; 143import org.apache.jena.graph.Triple; 144import org.glassfish.jersey.media.multipart.ContentDisposition; 145import org.jvnet.hk2.annotations.Optional; 146import org.slf4j.Logger; 147 148import com.google.common.annotations.VisibleForTesting; 149import com.google.common.base.Splitter; 150 151/** 152 * An abstract class that sits between AbstractResource and any resource that 153 * wishes to share the routines for building responses containing binary 154 * content. 155 * 156 * @author Mike Durbin 157 * @author ajs6f 158 */ 159public abstract class ContentExposingResource extends FedoraBaseResource { 160 161 private static final Logger LOGGER = getLogger(ContentExposingResource.class); 162 163 private static final List<String> VARY_HEADERS = Arrays.asList("Accept", "Range", "Accept-Encoding", 164 "Accept-Language"); 165 166 static final String INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE = "No space left on device"; 167 168 public static final String ACCEPT_DATETIME = "Accept-Datetime"; 169 170 static final String ACCEPT_EXTERNAL_CONTENT = "Accept-External-Content-Handling"; 171 172 static final String HTTP_HEADER_ACCEPT_PATCH = "Accept-Patch"; 173 174 private static final String FCR_PREFIX = "fcr:"; 175 private static final Set<String> ALLOWED_FCR_PARTS = Set.of(FCR_METADATA, FCR_ACL); 176 177 @Context protected Request request; 178 @Context protected HttpServletResponse servletResponse; 179 @Context protected ServletContext context; 180 181 @Inject 182 @Optional 183 private HttpTripleUtil httpTripleUtil; 184 185 @BeanParam 186 protected MultiPrefer prefer; 187 188 private FedoraResource fedoraResource; 189 190 @Inject 191 protected ExternalContentHandlerFactory extContentHandlerFactory; 192 193 @Inject 194 protected RdfNamespaceRegistry namespaceRegistry; 195 196 @Inject 197 protected CreateResourceService createResourceService; 198 199 @Inject 200 protected DeleteResourceService deleteResourceService; 201 202 @Inject 203 protected ReplacePropertiesService replacePropertiesService; 204 205 @Inject 206 protected UpdatePropertiesService updatePropertiesService; 207 208 @Inject 209 protected EtagService etagService; 210 211 @Inject 212 protected HttpRdfService httpRdfService; 213 214 @Inject 215 protected ResourceTripleService resourceTripleService; 216 217 protected abstract String externalPath(); 218 219 protected static final Splitter.MapSplitter RFC3230_SPLITTER = 220 Splitter.on(',').omitEmptyStrings().trimResults().withKeyValueSeparator(Splitter.on('=').limit(2)); 221 222 /** 223 * This method returns an HTTP response with content body appropriate to the following arguments. 224 * 225 * @param limit is the number of child resources returned in the response, -1 for all 226 * @param resource the fedora resource 227 * @return HTTP response 228 * @throws IOException in case of error extracting content 229 */ 230 protected Response getContent(final int limit, final FedoraResource resource) throws IOException { 231 final RdfStream rdfStream = httpRdfService.bodyToExternalStream(getUri(resource).toString(), 232 getResourceTriples(limit, resource), identifierConverter()); 233 final var outputStream = new RdfNamespacedStream( 234 rdfStream, namespaceRegistry.getNamespaces()); 235 setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource); 236 return ok(outputStream).build(); 237 } 238 239 protected void setVaryAndPreferenceAppliedHeaders(final HttpServletResponse servletResponse, 240 final MultiPrefer prefer, final FedoraResource resource) { 241 if (prefer != null) { 242 prefer.getReturn().addResponseHeaders(servletResponse); 243 } 244 245 // add vary headers 246 final List<String> varyValues = new ArrayList<>(VARY_HEADERS); 247 248 if (resource.isOriginalResource()) { 249 varyValues.add(ACCEPT_DATETIME); 250 } 251 252 varyValues.forEach(x -> servletResponse.addHeader("Vary", x)); 253 } 254 255 /** 256 * Utility to check if the Prefer header contains handling="lenient" 257 * @return True if handling="lenient" was sent. 258 */ 259 protected boolean hasLenientPreferHeader() { 260 return (prefer.hasHandling() && prefer.getHandling().getValue().equals("lenient")); 261 } 262 263 protected RdfStream getResourceTriples(final FedoraResource resource) { 264 return getResourceTriples(-1, resource); 265 } 266 267 /** 268 * This method returns a stream of RDF triples associated with this target resource 269 * 270 * @param limit is the number of child resources returned in the response, -1 for all 271 * @param resource the fedora resource 272 * @return {@link RdfStream} 273 */ 274 private RdfStream getResourceTriples(final int limit, final FedoraResource resource) { 275 276 final LdpPreferTag ldpPreferences = getLdpPreferTag(); 277 278 final List<Stream<Triple>> embedStreams = new ArrayList<>(); 279 280 embedStreams.add(resourceTripleService.getResourceTriples( 281 transaction(), resource, ldpPreferences, limit)); 282 283 // Embed the children of this object 284 if (ldpPreferences.displayEmbed()) { 285 final var containedResources = resourceFactory.getChildren( 286 transaction(), 287 resource.getFedoraId()); 288 embedStreams.add(containedResources.flatMap(child -> resourceTripleService.getResourceTriples( 289 transaction(), child, ldpPreferences, limit))); 290 } 291 292 final var rdfStream = new DefaultRdfStream( 293 asNode(resource), 294 embedStreams.stream().reduce(empty(), Stream::concat) 295 ); 296 297 if (httpTripleUtil != null && ldpPreferences.displayServerManaged()) { 298 // Adds fixity service triple to all resources and transaction triple to repo root. 299 return httpTripleUtil.addHttpComponentModelsForResourceToStream(rdfStream, resource, uriInfo); 300 } 301 302 return rdfStream; 303 } 304 305 private LdpPreferTag getLdpPreferTag() { 306 final PreferTag returnPreference; 307 308 if (prefer != null && prefer.hasReturn()) { 309 returnPreference = prefer.getReturn(); 310 } else if (prefer != null && prefer.hasHandling()) { 311 returnPreference = prefer.getHandling(); 312 } else { 313 returnPreference = PreferTag.emptyTag(); 314 } 315 316 return new LdpPreferTag(returnPreference); 317 } 318 319 /** 320 * Get the binary content of a datastream 321 * 322 * @param rangeValue the range value 323 * @param resource the fedora resource 324 * @return Binary blob 325 * @throws IOException if io exception occurred 326 */ 327 protected Response getBinaryContent(final String rangeValue, final FedoraResource resource) 328 throws IOException { 329 final Binary binary = (Binary)resource; 330 final CacheControl cc = new CacheControl(); 331 cc.setMaxAge(0); 332 cc.setMustRevalidate(true); 333 final Response.ResponseBuilder builder; 334 335 if (rangeValue != null && rangeValue.startsWith("bytes")) { 336 337 final Range range = Range.convert(rangeValue); 338 339 final long contentSize = binary.getContentSize(); 340 341 final String endAsString; 342 343 if (range.end() == -1) { 344 endAsString = Long.toString(contentSize - 1); 345 } else { 346 endAsString = Long.toString(range.end()); 347 } 348 349 final String contentRangeValue = 350 String.format("bytes %s-%s/%s", range.start(), 351 endAsString, contentSize); 352 353 if (range.end() > contentSize || 354 (range.end() == -1 && range.start() > contentSize)) { 355 356 builder = status(REQUESTED_RANGE_NOT_SATISFIABLE) 357 .header("Content-Range", contentRangeValue); 358 } else { 359 @SuppressWarnings("resource") 360 final RangeRequestInputStream rangeInputStream = 361 new RangeRequestInputStream(binary.getContent(), range.start(), range.size()); 362 363 builder = status(PARTIAL_CONTENT).entity(rangeInputStream) 364 .header("Content-Range", contentRangeValue) 365 .header(CONTENT_LENGTH, range.size()); 366 } 367 368 } else { 369 @SuppressWarnings("resource") 370 final InputStream content = binary.getContent(); 371 builder = ok(content); 372 } 373 374 375 // we set the content-type explicitly to avoid content-negotiation from getting in the way 376 // getBinaryResourceMediaType will try to use the mime type on the resource, falling back on 377 // 'application/octet-stream' if the mime type is syntactically invalid 378 return builder.type(getBinaryResourceMediaType(resource).toString()) 379 .cacheControl(cc) 380 .build(); 381 382 } 383 384 protected URI getUri(final FedoraResource resource) { 385 try { 386 final String uri = identifierConverter() 387 .toExternalId(resource.getFedoraId().getFullId()); 388 return new URI(uri); 389 } catch (final URISyntaxException e) { 390 throw new BadRequestException(e); 391 } 392 } 393 394 protected FedoraResource resource() { 395 if (fedoraResource == null) { 396 fedoraResource = getResourceFromPath(externalPath()); 397 } 398 return fedoraResource; 399 } 400 401 protected FedoraResource reloadResource() { 402 this.fedoraResource = null; 403 return resource(); 404 } 405 406 /** 407 * Add the standard Accept-Post header, for reuse. 408 */ 409 private void addAcceptPostHeader() { 410 final String rdfTypes = TURTLE + "," + N3 + "," + N3_ALT2 + "," + RDF_XML + "," + NTRIPLES + "," + JSON_LD; 411 servletResponse.addHeader("Accept-Post", rdfTypes); 412 } 413 414 /** 415 * Add the standard Accept-External-Content-Handling header, for reuse. 416 */ 417 private void addAcceptExternalHeader() { 418 servletResponse.addHeader(ACCEPT_EXTERNAL_CONTENT, COPY + "," + REDIRECT + "," + PROXY); 419 } 420 421 private void addMementoHeaders(final FedoraResource resource) { 422 if (resource.isMemento()) { 423 final Instant mementoInstant = resource.getMementoDatetime(); 424 if (mementoInstant != null) { 425 final String mementoDatetime = MEMENTO_RFC_1123_FORMATTER 426 .format(mementoInstant.atZone(ZoneOffset.UTC)); 427 servletResponse.addHeader(MEMENTO_DATETIME_HEADER, mementoDatetime); 428 } 429 } 430 } 431 432 protected void addExternalContentHeaders(final FedoraResource resource) { 433 if (resource instanceof Binary) { 434 final Binary binary = (Binary)resource; 435 436 if (binary.isProxy() || binary.isRedirect()) { 437 servletResponse.addHeader(CONTENT_LOCATION, binary.getExternalURL()); 438 } 439 } 440 } 441 442 private void addAclHeader(final FedoraResource resource) { 443 if (!(resource instanceof WebacAcl) && !resource.isMemento()) { 444 final String resourceUri = getUri(resource.getDescribedResource()).toString(); 445 final String aclLocation = resourceUri + (resourceUri.endsWith("/") ? "" : "/") + FCR_ACL; 446 servletResponse.addHeader(LINK, buildLink(aclLocation, "acl")); 447 } 448 } 449 450 private void addResourceLinkHeaders(final FedoraResource resource) { 451 addResourceLinkHeaders(resource, false); 452 } 453 454 private void addResourceLinkHeaders(final FedoraResource resource, final boolean includeAnchor) { 455 if (resource instanceof NonRdfSourceDescription) { 456 // Link to the original described resource 457 final FedoraResource described = resource.getOriginalResource().getDescribedResource(); 458 final URI uri = getUri(described); 459 final Link link = Link.fromUri(uri).rel("describes").build(); 460 servletResponse.addHeader(LINK, link.toString()); 461 } else if (resource instanceof Binary) { 462 // Link to the original description 463 final FedoraResource description = resource.getOriginalResource().getDescription(); 464 final URI uri = getUri(description); 465 final Link.Builder builder = Link.fromUri(uri).rel("describedby"); 466 467 if (includeAnchor) { 468 builder.param("anchor", getUri(resource).toString()); 469 } 470 servletResponse.addHeader(LINK, builder.build().toString()); 471 } 472 473 final boolean isOriginal = resource.isOriginalResource(); 474 // Add versioning headers for versioned originals and mementos 475 if (isOriginal || resource.isMemento() || resource instanceof TimeMap) { 476 final URI originalUri = getUri(resource.getOriginalResource()); 477 try { 478 final URI timemapUri = getUri(resource.getTimeMap()); 479 servletResponse.addHeader(LINK, buildLink(originalUri, "timegate")); 480 servletResponse.addHeader(LINK, buildLink(originalUri, "original")); 481 servletResponse.addHeader(LINK, buildLink(timemapUri, "timemap")); 482 } catch (final PathNotFoundRuntimeException e) { 483 LOGGER.debug("TimeMap not found for {}, resource not versioned", getUri(resource)); 484 } 485 } 486 // Add all system and user types as Link headers. 487 for (final var type : resource.getTypes()) { 488 servletResponse.addHeader(LINK, buildLink(type, "type")); 489 } 490 } 491 492 /** 493 * Add Link and Option headers 494 * 495 * @param resource the resource to generate headers for 496 */ 497 protected void addLinkAndOptionsHttpHeaders(final FedoraResource resource) { 498 // Add Link headers 499 addResourceLinkHeaders(resource); 500 addAcceptExternalHeader(); 501 502 // Add Options headers 503 final String options; 504 if (resource.isMemento()) { 505 options = "GET,HEAD,OPTIONS"; 506 } else if (resource instanceof TimeMap) { 507 options = "POST,HEAD,GET,OPTIONS"; 508 addAcceptPostHeader(); 509 } else if (resource instanceof Binary) { 510 options = "DELETE,HEAD,GET,PUT,OPTIONS"; 511 } else if (resource instanceof NonRdfSourceDescription) { 512 options = "HEAD,GET,DELETE,PUT,PATCH,OPTIONS"; 513 servletResponse.addHeader(HTTP_HEADER_ACCEPT_PATCH, contentTypeSPARQLUpdate); 514 } else if (resource instanceof Container) { 515 options = "DELETE,POST,HEAD,GET,PUT,PATCH,OPTIONS"; 516 servletResponse.addHeader(HTTP_HEADER_ACCEPT_PATCH, contentTypeSPARQLUpdate); 517 addAcceptPostHeader(); 518 } else { 519 options = ""; 520 } 521 522 servletResponse.addHeader("Allow", options); 523 } 524 525 protected void addTransactionHeaders(final FedoraResource resource) { 526 final var tx = transaction(); 527 if (!tx.isShortLived()) { 528 final var externalId = identifierConverter() 529 .toExternalId(FEDORA_ID_PREFIX + "/" + TX_PREFIX + tx.getId()); 530 servletResponse.addHeader(ATOMIC_ID_HEADER, externalId); 531 } 532 if (resource.getFedoraId().isRepositoryRoot()) { 533 final var txEndpointUri = identifierConverter() 534 .toExternalId(FEDORA_ID_PREFIX + "/" + TX_PREFIX); 535 final Link link = Link.fromUri(txEndpointUri).rel(TX_ENDPOINT_REL).build(); 536 servletResponse.addHeader(LINK, link.toString()); 537 } 538 } 539 540 /** 541 * Utility function for building a Link. 542 * 543 * @param linkUri String of URI for the link. 544 * @param relation the relation string. 545 * @return the string version of the link. 546 */ 547 protected static String buildLink(final String linkUri, final String relation) { 548 return buildLink(create(linkUri), relation); 549 } 550 551 /** 552 * Utility function for building a Link. 553 * 554 * @param linkUri The URI for the link. 555 * @param relation the relation string. 556 * @return the string version of the link. 557 */ 558 private static String buildLink(final URI linkUri, final String relation) { 559 return Link.fromUri(linkUri).rel(relation).build().toString(); 560 } 561 562 /** 563 * Multi-value Link header values parsed by the javax.ws.rs.core are not split out by the framework Therefore we 564 * must do this ourselves. 565 * 566 * @param rawLinks the list of unprocessed links 567 * @return List of strings containing one link value per string. 568 */ 569 protected List<String> unpackLinks(final List<String> rawLinks) { 570 if (rawLinks == null) { 571 return null; 572 } 573 574 return rawLinks.stream() 575 .flatMap(x -> Arrays.stream(x.split(","))) 576 .collect(Collectors.toList()); 577 } 578 579 /** 580 * Add any resource-specific headers to the response 581 * @param resource the resource 582 */ 583 protected void addResourceHttpHeaders(final FedoraResource resource) { 584 if (resource instanceof Binary) { 585 final Binary binary = (Binary)resource; 586 final Date createdDate = binary.getCreatedDate() != null ? Date.from(binary.getCreatedDate()) : null; 587 final Date modDate = binary.getLastModifiedDate() != null ? Date.from(binary.getLastModifiedDate()) : null; 588 589 final ContentDisposition contentDisposition = ContentDisposition.type("attachment") 590 .fileName(binary.getFilename()) 591 .creationDate(createdDate) 592 .modificationDate(modDate) 593 .size(binary.getContentSize()) 594 .build(); 595 596 servletResponse.addHeader(CONTENT_TYPE, binary.getMimeType()); 597 // Returning content-length > 0 causes the client to wait for additional data before following the redirect. 598 if (!binary.isRedirect()) { 599 servletResponse.addHeader(CONTENT_LENGTH, String.valueOf(binary.getContentSize())); 600 } 601 servletResponse.addHeader("Accept-Ranges", "bytes"); 602 servletResponse.addHeader(CONTENT_DISPOSITION, contentDisposition.toString()); 603 } 604 605 addLinkAndOptionsHttpHeaders(resource); 606 addAclHeader(resource); 607 addMementoHeaders(resource); 608 } 609 610 /** 611 * Evaluate the cache control headers for the request to see if it can be served from 612 * the cache. 613 * 614 * @param request the request 615 * @param servletResponse the servlet response 616 * @param resource the fedora resource 617 * @param transaction the transaction 618 */ 619 protected void checkCacheControlHeaders(final Request request, 620 final HttpServletResponse servletResponse, 621 final FedoraResource resource, 622 final Transaction transaction) { 623 evaluateRequestPreconditions(request, servletResponse, resource, transaction, true); 624 addCacheControlHeaders(servletResponse, resource, transaction); 625 } 626 627 /** 628 * Add ETag and Last-Modified cache control headers to the response 629 * <p> 630 * Note: In this implementation, the HTTP headers for ETags and Last-Modified dates are swapped 631 * for fedora:Binary resources and their descriptions. Here, we are drawing a distinction between 632 * the HTTP resource and the LDP resource. As an HTTP resource, the last-modified header should 633 * reflect when the resource at the given URL was last changed. With fedora:Binary resources and 634 * their descriptions, this is a little complicated, for the descriptions have, as their subjects, 635 * the binary itself. And the fedora:lastModified property produced by that NonRdfSourceDescription 636 * refers to the last-modified date of the binary -- not the last-modified date of the 637 * NonRdfSourceDescription. 638 * </p> 639 * @param servletResponse the servlet response 640 * @param resource the fedora resource 641 * @param transaction the transaction 642 */ 643 protected void addCacheControlHeaders(final HttpServletResponse servletResponse, 644 final FedoraResource resource, 645 final Transaction transaction) { 646 647 final EntityTag etag; 648 final Instant date; 649 650 // See note about this code in the javadoc above. 651 if (resource instanceof Binary) { 652 // Use a strong ETag for LDP-NR 653 etag = new EntityTag(resource.getEtagValue()); 654 } else { 655 // Use a weak ETag for the LDP-RS 656 etag = new EntityTag(etagService.getRdfResourceEtag(transaction, resource, getLdpPreferTag(), 657 headers.getAcceptableMediaTypes()), true); 658 } 659 660 date = resource.getLastModifiedDate(); 661 662 if (!etag.getValue().isEmpty()) { 663 servletResponse.addHeader("ETag", etag.toString()); 664 } 665 666 if (!resource.getStateToken().isEmpty()) { 667 //State Tokens, while not used for caching per se, nevertheless belong 668 //here since we can conveniently reuse the value of the etag for 669 //our state token 670 servletResponse.addHeader("X-State-Token", resource.getStateToken()); 671 } 672 673 if (date != null) { 674 servletResponse.addDateHeader("Last-Modified", date.toEpochMilli()); 675 } 676 } 677 678 /** 679 * Evaluate request preconditions to ensure the resource is the expected state 680 * @param request the request 681 * @param servletResponse the servlet response 682 * @param resource the resource 683 * @param transaction the transaction 684 */ 685 protected void evaluateRequestPreconditions(final Request request, 686 final HttpServletResponse servletResponse, 687 final FedoraResource resource, 688 final Transaction transaction) { 689 // The resource must be locked prior to applying pre-conditions for the optimistic locking to be effective 690 transaction.lockResource(resource.getFedoraId()); 691 evaluateRequestPreconditions(request, servletResponse, resource, transaction, false); 692 } 693 694 @VisibleForTesting 695 void evaluateRequestPreconditions(final Request request, 696 final HttpServletResponse servletResponse, 697 final FedoraResource resource, 698 final Transaction transaction, 699 final boolean cacheControl) { 700 701 if (!transaction.isShortLived()) { 702 // Force cache revalidation if in a transaction 703 servletResponse.addHeader(CACHE_CONTROL, "must-revalidate"); 704 servletResponse.addHeader(CACHE_CONTROL, "max-age=0"); 705 } 706 707 final EntityTag etag; 708 final Instant date; 709 Instant roundedDate = Instant.now(); 710 711 // See the related note about the next block of code in the 712 // ContentExposingResource::addCacheControlHeaders method 713 if (resource instanceof Binary) { 714 // Use a strong ETag for the LDP-NR 715 etag = new EntityTag(resource.getEtagValue()); 716 } else { 717 // Use a strong ETag for the LDP-RS when validating If-(None)-Match headers 718 etag = new EntityTag(etagService.getRdfResourceEtag(transaction, resource, getLdpPreferTag(), 719 headers.getAcceptableMediaTypes()), false); 720 } 721 722 date = resource.getLastModifiedDate(); 723 724 if (date != null) { 725 roundedDate = date.minusMillis(date.toEpochMilli() % 1000); 726 } 727 728 Response.ResponseBuilder builder = request.evaluatePreconditions(etag); 729 if ( builder == null ) { 730 builder = request.evaluatePreconditions(Date.from(roundedDate)); 731 } 732 733 if (builder != null && cacheControl ) { 734 final CacheControl cc = new CacheControl(); 735 cc.setMaxAge(0); 736 cc.setMustRevalidate(true); 737 // here we are implicitly emitting a 304 738 // the exception is not an error, it's genuinely 739 // an exceptional condition 740 builder = builder.cacheControl(cc).lastModified(Date.from(roundedDate)).tag(etag); 741 } 742 743 if (builder != null) { 744 final Response response = builder.build(); 745 final Object message = response.getEntity(); 746 throw new PreconditionException(message != null ? message.toString() 747 : "Request failed due to unspecified failed precondition.", response.getStatus()); 748 } 749 750 final String method = request.getMethod(); 751 if (method.equals(HttpPut.METHOD_NAME) || method.equals(HttpPatch.METHOD_NAME)) { 752 final String stateToken = resource.getStateToken(); 753 final String clientSuppliedStateToken = headers.getHeaderString("X-If-State-Token"); 754 if (clientSuppliedStateToken != null && !stateToken.equals(clientSuppliedStateToken)) { 755 throw new PreconditionException(format( 756 "The client-supplied value ({0}) does not match the current state token ({1}).", 757 clientSuppliedStateToken, stateToken), 412); 758 } 759 } 760 } 761 762 /** 763 * Returns an acceptable plain text media type if possible, or null if not. 764 * @return an acceptable plain-text media type, or null 765 */ 766 private MediaType acceptablePlainTextMediaType() { 767 final List<MediaType> acceptable = headers.getAcceptableMediaTypes(); 768 if (acceptable == null || acceptable.size() == 0) { 769 return TEXT_PLAIN_TYPE; 770 } 771 for (final MediaType type : acceptable) { 772 if (type.isWildcardType() || (type.isCompatible(TEXT_PLAIN_TYPE) && type.isWildcardSubtype())) { 773 return TEXT_PLAIN_TYPE; 774 } else if (type.isCompatible(TEXT_PLAIN_TYPE)) { 775 return type; 776 } 777 } 778 return null; 779 } 780 781 /** 782 * Create the appropriate response after a create or update request is processed. When a resource is created, 783 * examine the Prefer and Accept headers to determine whether to include a representation. By default, the URI for 784 * the created resource is return as plain text. If a minimal response is requested, then no body is returned. If a 785 * non-minimal return is requested, return the RDF for the created resource in the appropriate RDF serialization. 786 * 787 * @param resource The created or updated Fedora resource. 788 * @param created True for a newly-created resource, false for an updated resource. 789 * @return 204 No Content (for updated resources), 201 Created (for created resources) including the resource URI or 790 * content depending on Prefer headers. 791 */ 792 @SuppressWarnings("resource") 793 protected Response createUpdateResponse(final FedoraResource resource, final boolean created) { 794 addCacheControlHeaders(servletResponse, resource, transaction()); 795 addResourceLinkHeaders(resource, created); 796 addExternalContentHeaders(resource); 797 addAclHeader(resource); 798 addMementoHeaders(resource); 799 addTransactionHeaders(resource); 800 801 if (!created) { 802 return noContent().build(); 803 } 804 805 final URI location = getUri(resource); 806 final Response.ResponseBuilder builder = created(location); 807 808 if (prefer == null || !prefer.hasReturn()) { 809 final MediaType acceptablePlainText = acceptablePlainTextMediaType(); 810 if (acceptablePlainText != null) { 811 return builder.type(acceptablePlainText).entity(location.toString()).build(); 812 } 813 return notAcceptable(mediaTypes(TEXT_PLAIN_TYPE).build()).build(); 814 } else if (prefer.getReturn().getValue().equals("minimal")) { 815 return builder.build(); 816 } else { 817 prefer.getReturn().addResponseHeaders(servletResponse); 818 final RdfNamespacedStream rdfStream = new RdfNamespacedStream( 819 new DefaultRdfStream(asNode(resource), getResourceTriples(resource)), 820 namespaceRegistry.getNamespaces()); 821 return builder.entity(rdfStream).build(); 822 } 823 } 824 825 protected static String getSimpleContentType(final MediaType requestContentType) { 826 return requestContentType != null ? 827 requestContentType.getType() + "/" + requestContentType.getSubtype() 828 : null; 829 } 830 831 protected static boolean isRdfContentType(final String contentTypeString) { 832 final ContentType requestContentType = ContentType.create(contentTypeString); 833 if (requestContentType == null || matchContentType(requestContentType, ctTextPlain) || 834 matchContentType(requestContentType, ctTextCSV)) { 835 // Text files and CSV files are not considered RDF to Fedora, though CSV is a valid 836 // RDF type to Jena (although deprecated). 837 return false; 838 } 839 return (contentTypeToLang(contentTypeString) != null) || matchContentType(requestContentType, ctSPARQLUpdate); 840 } 841 842 843 protected void patchResourcewithSparql(final FedoraResource resource, 844 final String requestBody) { 845 updatePropertiesService.updateProperties(transaction(), 846 getUserPrincipal(), 847 resource.getFedoraId(), 848 requestBody); 849 } 850 851 /** 852 * This method returns a MediaType for a binary resource. 853 * If the resource's media type is syntactically incorrect, it will 854 * return 'application/octet-stream' as the media type. 855 * @param resource the fedora resource 856 * @return the media type of of a binary resource 857 */ 858 protected MediaType getBinaryResourceMediaType(final FedoraResource resource) { 859 try { 860 return MediaType.valueOf(((Binary) resource).getMimeType()); 861 } catch (final IllegalArgumentException e) { 862 LOGGER.warn("Syntactically incorrect MediaType encountered on resource {}: '{}'", 863 resource.getId(), ((Binary)resource).getMimeType()); 864 return MediaType.APPLICATION_OCTET_STREAM_TYPE; 865 } 866 } 867 868 /** 869 * Create a checksum URI object. 870 * @param checksum the checksum 871 * @return the new URI, or null 872 **/ 873 protected static URI checksumURI( final String checksum ) { 874 if (!isBlank(checksum)) { 875 return create(checksum); 876 } 877 return null; 878 } 879 880 /** 881 * Calculate the max number of children to display at once. 882 * 883 * @return the limit of children to display. 884 */ 885 protected int getChildrenLimit() { 886 final List<String> acceptHeaders = headers.getRequestHeader(ACCEPT); 887 if (acceptHeaders != null && acceptHeaders.size() > 0) { 888 final List<String> accept = Arrays.asList(acceptHeaders.get(0).split(",")); 889 if (accept.contains(TEXT_HTML)) { 890 // Magic number '100' is tied to common-metadata.vsl display of ellipses 891 return 100; 892 } 893 } 894 895 final List<String> limits = headers.getRequestHeader("Limit"); 896 if (null != limits && limits.size() > 0) { 897 try { 898 return Integer.parseInt(limits.get(0)); 899 900 } catch (final NumberFormatException e) { 901 LOGGER.warn("Invalid 'Limit' header value: {}", limits.get(0)); 902 throw new ClientErrorException("Invalid 'Limit' header value: " + limits.get(0), SC_BAD_REQUEST, e); 903 } 904 } 905 return -1; 906 } 907 908 /** 909 * Check if a path has a segment prefixed with fcr: that is not fcr:metadata or fcr:acl 910 * 911 * @param externalPath the path. 912 */ 913 protected static void hasRestrictedPath(final String externalPath) { 914 final String[] pathSegments = externalPath.split("/"); 915 for (final var part : pathSegments) { 916 if (part.startsWith(FCR_PREFIX) && !ALLOWED_FCR_PARTS.contains(part)) { 917 throw new ServerManagedTypeException("Path cannot contain a fcr: prefixed segment."); 918 } 919 } 920 } 921 922 /** 923 * Parse the RFC-3230 Digest response header value. Look for a sha1 checksum and return it as a urn, if missing or 924 * malformed an empty string is returned. 925 * 926 * @param digest The Digest header value 927 * @return the sha1 checksum value 928 * @throws UnsupportedAlgorithmException if an unsupported digest is used 929 */ 930 protected static Collection<URI> parseDigestHeader(final String digest) throws UnsupportedAlgorithmException { 931 try { 932 final var digestPairs = RFC3230_SPLITTER.split(nullToEmpty(digest)); 933 final var unsupportedAlgs = digestPairs.keySet().stream() 934 .filter(Predicate.not(DigestAlgorithm::isSupportedAlgorithm)) 935 .collect(Collectors.toSet()); 936 937 // If you have one or more digests that are all valid or no digests. 938 if (digestPairs.isEmpty() || unsupportedAlgs.isEmpty()) { 939 return digestPairs.entrySet().stream() 940 .map(entry -> ContentDigest.asURI(entry.getKey(), entry.getValue())) 941 .collect(toSet()); 942 } else { 943 throw new UnsupportedAlgorithmException(String.format("Unsupported Digest Algorithm%1$s: %2$s", 944 unsupportedAlgs.size() > 1 ? 's' : "", String.join(",", unsupportedAlgs))); 945 } 946 } catch (final IllegalArgumentException e) { 947 throw new ClientErrorException("Invalid Digest header: " + digest + "\n", BAD_REQUEST); 948 } 949 } 950 951 /** 952 * @param rootThrowable The original throwable 953 * @param throwable The throwable under direct scrutiny. 954 * @throws InvalidChecksumException in case there was a checksum mismatch 955 */ 956 protected void checkForInsufficientStorageException(final Throwable rootThrowable, final Throwable throwable) 957 throws InvalidChecksumException { 958 final String message = throwable.getMessage(); 959 if (throwable instanceof IOException && message != null && message.contains( 960 INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE)) { 961 throw new InsufficientStorageException(throwable.getMessage(), rootThrowable); 962 } 963 964 if (throwable.getCause() != null) { 965 checkForInsufficientStorageException(rootThrowable, throwable.getCause()); 966 } 967 968 if (rootThrowable instanceof InvalidChecksumException) { 969 throw (InvalidChecksumException) rootThrowable; 970 } else if (rootThrowable instanceof RuntimeException) { 971 throw (RuntimeException) rootThrowable; 972 } else { 973 throw new RepositoryRuntimeException(rootThrowable.getMessage(), rootThrowable); 974 } 975 } 976 977 /** 978 * This is a helper method for using the idTranslator to convert this resource into an associated Jena Node. 979 * 980 * @param resource to be converted into a Jena Node 981 * @return the Jena node 982 */ 983 protected Node asNode(final FedoraResource resource) { 984 return createURI(resource.getFedoraId().getFullId()); 985 } 986 987 /** 988 * Get the FedoraResource for the resource at the external path 989 * @param externalPath the external path 990 * @return the fedora resource at the external path 991 */ 992 private FedoraResource getResourceFromPath(final String externalPath) { 993 final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath); 994 995 try { 996 final FedoraResource fedoraResource = resourceFactory.getResource(transaction(), fedoraId); 997 998 final FedoraResource originalResource; 999 if (fedoraId.isMemento()) { 1000 originalResource = fedoraResource.getOriginalResource(); 1001 } else { 1002 originalResource = fedoraResource; 1003 } 1004 1005 if (originalResource instanceof Tombstone) { 1006 final String tombstoneUri = identifierConverter().toExternalId( 1007 originalResource.getFedoraId().asTombstone().getFullId()); 1008 throw new TombstoneException(fedoraResource, tombstoneUri); 1009 } 1010 1011 return fedoraResource; 1012 } catch (final PathNotFoundException exc) { 1013 throw new PathNotFoundRuntimeException(exc.getMessage(), exc); 1014 } 1015 } 1016}