/* * Licensed to DuraSpace under one or more contributor license agreements. * See the NOTICE file distributed with this work for additional information * regarding copyright ownership. * * DuraSpace licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except in * compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.fcrepo.http.api; import static java.util.EnumSet.of; import static java.util.stream.Stream.concat; import static java.util.stream.Stream.empty; import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL; import static javax.ws.rs.core.HttpHeaders.CONTENT_LENGTH; import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION; import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; import static javax.ws.rs.core.HttpHeaders.LINK; import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM_TYPE; import static javax.ws.rs.core.Response.ok; import static javax.ws.rs.core.Response.status; import static javax.ws.rs.core.Response.temporaryRedirect; import static javax.ws.rs.core.Response.Status.PARTIAL_CONTENT; import static javax.ws.rs.core.Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel; import static org.apache.jena.rdf.model.ResourceFactory.createProperty; import static org.apache.jena.riot.RDFLanguages.contentTypeToLang; import static org.apache.jena.vocabulary.RDF.type; import static org.fcrepo.kernel.api.FedoraTypes.LDP_BASIC_CONTAINER; import static org.fcrepo.kernel.api.FedoraTypes.LDP_DIRECT_CONTAINER; import static org.fcrepo.kernel.api.FedoraTypes.LDP_INDIRECT_CONTAINER; import static org.fcrepo.kernel.api.RdfLexicon.BASIC_CONTAINER; import static org.fcrepo.kernel.api.RdfLexicon.CONTAINER; import static org.fcrepo.kernel.api.RdfLexicon.DIRECT_CONTAINER; import static org.fcrepo.kernel.api.RdfLexicon.INDIRECT_CONTAINER; import static org.fcrepo.kernel.api.RdfLexicon.LDP_NAMESPACE; import static org.fcrepo.kernel.api.RdfLexicon.isManagedNamespace; import static org.fcrepo.kernel.api.RdfLexicon.isManagedPredicate; import static org.fcrepo.kernel.api.RequiredRdfContext.EMBED_RESOURCES; import static org.fcrepo.kernel.api.RequiredRdfContext.INBOUND_REFERENCES; import static org.fcrepo.kernel.api.RequiredRdfContext.LDP_CONTAINMENT; import static org.fcrepo.kernel.api.RequiredRdfContext.LDP_MEMBERSHIP; import static org.fcrepo.kernel.api.RequiredRdfContext.MINIMAL; import static org.fcrepo.kernel.api.RequiredRdfContext.PROPERTIES; import static org.fcrepo.kernel.api.RequiredRdfContext.SERVER_MANAGED; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.inject.Inject; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.BadRequestException; import javax.ws.rs.BeanParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.CacheControl; import javax.ws.rs.core.Context; import javax.ws.rs.core.EntityTag; import javax.ws.rs.core.Link; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Request; import javax.ws.rs.core.Response; import org.apache.jena.atlas.RuntimeIOException; import org.apache.jena.graph.Triple; import org.apache.jena.rdf.model.Model; import org.apache.jena.riot.Lang; import org.apache.jena.riot.RiotException; import org.fcrepo.http.commons.api.HttpHeaderInjector; import org.fcrepo.http.commons.api.rdf.HttpTripleUtil; import org.fcrepo.http.commons.domain.MultiPrefer; import org.fcrepo.http.commons.domain.PreferTag; import org.fcrepo.http.commons.domain.Range; import org.fcrepo.http.commons.domain.ldp.LdpPreferTag; import org.fcrepo.http.commons.responses.RangeRequestInputStream; import org.fcrepo.http.commons.responses.RdfNamespacedStream; import org.fcrepo.http.commons.session.HttpSession; import org.fcrepo.kernel.api.RdfStream; import org.fcrepo.kernel.api.TripleCategory; import org.fcrepo.kernel.api.exception.InvalidChecksumException; import org.fcrepo.kernel.api.exception.MalformedRdfException; import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; import org.fcrepo.kernel.api.models.Container; import org.fcrepo.kernel.api.models.FedoraBinary; import org.fcrepo.kernel.api.models.FedoraResource; import org.fcrepo.kernel.api.models.NonRdfSourceDescription; import org.fcrepo.kernel.api.rdf.DefaultRdfStream; import org.fcrepo.kernel.api.services.policy.StoragePolicyDecisionPoint; import org.glassfish.jersey.media.multipart.ContentDisposition; import org.jvnet.hk2.annotations.Optional; import com.fasterxml.jackson.core.JsonParseException; import com.google.common.annotations.VisibleForTesting; /** * An abstract class that sits between AbstractResource and any resource that * wishes to share the routines for building responses containing binary * content. * * @author Mike Durbin * @author ajs6f */ public abstract class ContentExposingResource extends FedoraBaseResource { public static final MediaType MESSAGE_EXTERNAL_BODY = MediaType.valueOf("message/external-body"); @Context protected Request request; @Context protected HttpServletResponse servletResponse; @Inject @Optional private HttpTripleUtil httpTripleUtil; @Inject @Optional private HttpHeaderInjector httpHeaderInject; @BeanParam protected MultiPrefer prefer; @Inject @Optional StoragePolicyDecisionPoint storagePolicyDecisionPoint; protected FedoraResource resource; @Inject protected PathLockManager lockManager; private static final Predicate<Triple> IS_MANAGED_TYPE = t -> t.getPredicate().equals(type.asNode()) && isManagedNamespace.test(t.getObject().getNameSpace()); private static final Predicate<Triple> IS_MANAGED_TRIPLE = IS_MANAGED_TYPE .or(t -> isManagedPredicate.test(createProperty(t.getPredicate().getURI()))); protected abstract String externalPath(); protected Response getContent(final String rangeValue, final RdfStream rdfStream) throws IOException { return getContent(rangeValue, -1, rdfStream); } /** * This method returns an HTTP response with content body appropriate to the following arguments. * * @param rangeValue starting and ending byte offsets, see {@link Range} * @param limit is the number of child resources returned in the response, -1 for all * @param rdfStream to which response RDF will be concatenated * @return HTTP response * @throws IOException in case of error extracting content */ protected Response getContent(final String rangeValue, final int limit, final RdfStream rdfStream) throws IOException { final RdfNamespacedStream outputStream; if (resource() instanceof FedoraBinary) { final MediaType mediaType = MediaType.valueOf(((FedoraBinary) resource()).getMimeType()); if (isExternalBody(mediaType)) { return temporaryRedirect(URI.create(mediaType.getParameters().get("URL"))).build(); } return getBinaryContent(rangeValue); } else { outputStream = new RdfNamespacedStream( new DefaultRdfStream(rdfStream.topic(), concat(rdfStream, getResourceTriples(limit))), session.getFedoraSession().getNamespaces()); if (prefer != null) { prefer.getReturn().addResponseHeaders(servletResponse); } } servletResponse.addHeader("Vary", "Accept, Range, Accept-Encoding, Accept-Language"); return ok(outputStream).build(); } protected boolean isExternalBody(final MediaType mediaType) { return MESSAGE_EXTERNAL_BODY.isCompatible(mediaType) && mediaType.getParameters().containsKey("access-type") && mediaType.getParameters().get("access-type").equals("URL") && mediaType.getParameters().containsKey("URL"); } protected RdfStream getResourceTriples() { return getResourceTriples(-1); } /** * This method returns a stream of RDF triples associated with this target resource * * @param limit is the number of child resources returned in the response, -1 for all * @return {@link RdfStream} */ protected RdfStream getResourceTriples(final int limit) { // use the thing described, not the description, for the subject of descriptive triples if (resource() instanceof NonRdfSourceDescription) { resource = resource().getDescribedResource(); } final PreferTag returnPreference; if (prefer != null && prefer.hasReturn()) { returnPreference = prefer.getReturn(); } else if (prefer != null && prefer.hasHandling()) { returnPreference = prefer.getHandling(); } else { returnPreference = PreferTag.emptyTag(); } final LdpPreferTag ldpPreferences = new LdpPreferTag(returnPreference); final Predicate<Triple> tripleFilter = ldpPreferences.prefersServerManaged() ? x -> true : IS_MANAGED_TRIPLE.negate(); final List<Stream<Triple>> streams = new ArrayList<>(); if (returnPreference.getValue().equals("minimal")) { streams.add(getTriples(of(PROPERTIES, MINIMAL)).filter(tripleFilter)); if (ldpPreferences.prefersServerManaged()) { streams.add(getTriples(of(SERVER_MANAGED, MINIMAL))); } } else { streams.add(getTriples(PROPERTIES).filter(tripleFilter)); // Additional server-managed triples about this resource if (ldpPreferences.prefersServerManaged()) { streams.add(getTriples(SERVER_MANAGED)); } // containment triples about this resource if (ldpPreferences.prefersContainment()) { if (limit == -1) { streams.add(getTriples(LDP_CONTAINMENT)); } else { streams.add(getTriples(LDP_CONTAINMENT).limit(limit)); } } // LDP container membership triples for this resource if (ldpPreferences.prefersMembership()) { streams.add(getTriples(LDP_MEMBERSHIP)); } // Include inbound references to this object if (ldpPreferences.prefersReferences()) { streams.add(getTriples(INBOUND_REFERENCES)); } // Embed the children of this object if (ldpPreferences.prefersEmbed()) { streams.add(getTriples(EMBED_RESOURCES)); } } final RdfStream rdfStream = new DefaultRdfStream( asNode(resource()), streams.stream().reduce(empty(), Stream::concat)); if (httpTripleUtil != null && ldpPreferences.prefersServerManaged()) { return httpTripleUtil.addHttpComponentModelsForResourceToStream(rdfStream, resource(), uriInfo, translator()); } return rdfStream; } /** * Get the binary content of a datastream * * @param rangeValue the range value * @return Binary blob * @throws IOException if io exception occurred */ protected Response getBinaryContent(final String rangeValue) throws IOException { final FedoraBinary binary = (FedoraBinary)resource(); // we include an explicit etag, because the default behavior is to use the JCR node's etag, not // the jcr:content node digest. The etag is only included if we are not within a transaction. if (!session().isBatchSession()) { checkCacheControlHeaders(request, servletResponse, binary, session()); } final CacheControl cc = new CacheControl(); cc.setMaxAge(0); cc.setMustRevalidate(true); Response.ResponseBuilder builder; if (rangeValue != null && rangeValue.startsWith("bytes")) { final Range range = Range.convert(rangeValue); final long contentSize = binary.getContentSize(); final String endAsString; if (range.end() == -1) { endAsString = Long.toString(contentSize - 1); } else { endAsString = Long.toString(range.end()); } final String contentRangeValue = String.format("bytes %s-%s/%s", range.start(), endAsString, contentSize); if (range.end() > contentSize || (range.end() == -1 && range.start() > contentSize)) { builder = status(REQUESTED_RANGE_NOT_SATISFIABLE) .header("Content-Range", contentRangeValue); } else { @SuppressWarnings("resource") final RangeRequestInputStream rangeInputStream = new RangeRequestInputStream(binary.getContent(), range.start(), range.size()); builder = status(PARTIAL_CONTENT).entity(rangeInputStream) .header("Content-Range", contentRangeValue); } } else { @SuppressWarnings("resource") final InputStream content = binary.getContent(); builder = ok(content); } // we set the content-type explicitly to avoid content-negotiation from getting in the way return builder.type(binary.getMimeType()) .cacheControl(cc) .build(); } protected RdfStream getTriples(final Set<? extends TripleCategory> x) { return getTriples(resource(), x); } protected RdfStream getTriples(final FedoraResource resource, final Set<? extends TripleCategory> x) { return resource.getTriples(translator(), x); } protected RdfStream getTriples(final TripleCategory x) { return getTriples(resource(), x); } protected RdfStream getTriples(final FedoraResource resource, final TripleCategory x) { return resource.getTriples(translator(), x); } protected URI getUri(final FedoraResource resource) { try { final String uri = translator().reverse().convert(resource).getURI(); return new URI(uri); } catch (final URISyntaxException e) { throw new BadRequestException(e); } } protected FedoraResource resource() { if (resource == null) { resource = getResourceFromPath(externalPath()); } return resource; } protected void addResourceLinkHeaders(final FedoraResource resource) { addResourceLinkHeaders(resource, false); } protected void addResourceLinkHeaders(final FedoraResource resource, final boolean includeAnchor) { if (resource instanceof NonRdfSourceDescription) { final URI uri = getUri(resource.getDescribedResource()); final Link link = Link.fromUri(uri).rel("describes").build(); servletResponse.addHeader(LINK, link.toString()); } else if (resource instanceof FedoraBinary) { final URI uri = getUri(resource.getDescription()); final Link.Builder builder = Link.fromUri(uri).rel("describedby"); if (includeAnchor) { builder.param("anchor", getUri(resource).toString()); } servletResponse.addHeader(LINK, builder.build().toString()); } } /** * Add any resource-specific headers to the response * @param resource the resource */ protected void addResourceHttpHeaders(final FedoraResource resource) { if (resource instanceof FedoraBinary) { final FedoraBinary binary = (FedoraBinary)resource; final Date createdDate = binary.getCreatedDate() != null ? Date.from(binary.getCreatedDate()) : null; final Date modDate = binary.getLastModifiedDate() != null ? Date.from(binary.getLastModifiedDate()) : null; final ContentDisposition contentDisposition = ContentDisposition.type("attachment") .fileName(binary.getFilename()) .creationDate(createdDate) .modificationDate(modDate) .size(binary.getContentSize()) .build(); servletResponse.addHeader(CONTENT_TYPE, binary.getMimeType()); servletResponse.addHeader(CONTENT_LENGTH, String.valueOf(binary.getContentSize())); servletResponse.addHeader("Accept-Ranges", "bytes"); servletResponse.addHeader(CONTENT_DISPOSITION, contentDisposition.toString()); } servletResponse.addHeader(LINK, "<" + LDP_NAMESPACE + "Resource>;rel=\"type\""); if (resource instanceof FedoraBinary) { servletResponse.addHeader(LINK, "<" + LDP_NAMESPACE + "NonRDFSource>;rel=\"type\""); } else if (resource instanceof Container) { servletResponse.addHeader(LINK, "<" + CONTAINER.getURI() + ">;rel=\"type\""); if (resource.hasType(LDP_BASIC_CONTAINER)) { servletResponse.addHeader(LINK, "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\""); } else if (resource.hasType(LDP_DIRECT_CONTAINER)) { servletResponse.addHeader(LINK, "<" + DIRECT_CONTAINER.getURI() + ">;rel=\"type\""); } else if (resource.hasType(LDP_INDIRECT_CONTAINER)) { servletResponse.addHeader(LINK, "<" + INDIRECT_CONTAINER.getURI() + ">;rel=\"type\""); } else { servletResponse.addHeader(LINK, "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\""); } } else { servletResponse.addHeader(LINK, "<" + LDP_NAMESPACE + "RDFSource>;rel=\"type\""); } if (httpHeaderInject != null) { httpHeaderInject.addHttpHeaderToResponseStream(servletResponse, uriInfo, resource()); } } /** * Evaluate the cache control headers for the request to see if it can be served from * the cache. * * @param request the request * @param servletResponse the servlet response * @param resource the fedora resource * @param session the session */ protected void checkCacheControlHeaders(final Request request, final HttpServletResponse servletResponse, final FedoraResource resource, final HttpSession session) { evaluateRequestPreconditions(request, servletResponse, resource, session, true); addCacheControlHeaders(servletResponse, resource, session); } /** * Add ETag and Last-Modified cache control headers to the response * <p> * Note: In this implementation, the HTTP headers for ETags and Last-Modified dates are swapped * for fedora:Binary resources and their descriptions. Here, we are drawing a distinction between * the HTTP resource and the LDP resource. As an HTTP resource, the last-modified header should * reflect when the resource at the given URL was last changed. With fedora:Binary resources and * their descriptions, this is a little complicated, for the descriptions have, as their subjects, * the binary itself. And the fedora:lastModified property produced by that NonRdfSourceDescription * refers to the last-modified date of the binary -- not the last-modified date of the * NonRdfSourceDescription. * </p> * @param servletResponse the servlet response * @param resource the fedora resource * @param session the session */ protected void addCacheControlHeaders(final HttpServletResponse servletResponse, final FedoraResource resource, final HttpSession session) { if (session.isBatchSession()) { // Do not add caching headers if in a transaction return; } final EntityTag etag; final Instant date; // See note about this code in the javadoc above. if (resource instanceof FedoraBinary) { // Use a strong ETag for LDP-NR etag = new EntityTag(resource.getDescription().getEtagValue()); date = resource.getDescription().getLastModifiedDate(); } else { // Use a weak ETag for the LDP-RS etag = new EntityTag(resource.getDescribedResource().getEtagValue(), true); date = resource.getDescribedResource().getLastModifiedDate(); } if (!etag.getValue().isEmpty()) { servletResponse.addHeader("ETag", etag.toString()); } if (date != null) { servletResponse.addDateHeader("Last-Modified", date.toEpochMilli()); } } /** * Evaluate request preconditions to ensure the resource is the expected state * @param request the request * @param servletResponse the servlet response * @param resource the resource * @param session the session */ protected void evaluateRequestPreconditions(final Request request, final HttpServletResponse servletResponse, final FedoraResource resource, final HttpSession session) { evaluateRequestPreconditions(request, servletResponse, resource, session, false); } @VisibleForTesting void evaluateRequestPreconditions(final Request request, final HttpServletResponse servletResponse, final FedoraResource resource, final HttpSession session, final boolean cacheControl) { if (session.isBatchSession()) { // Force cache revalidation if in a transaction servletResponse.addHeader(CACHE_CONTROL, "must-revalidate"); servletResponse.addHeader(CACHE_CONTROL, "max-age=0"); return; } final EntityTag etag; final Instant date; Instant roundedDate = Instant.now(); // See the related note about the next block of code in the // ContentExposingResource::addCacheControlHeaders method if (resource instanceof FedoraBinary) { // Use a strong ETag for the LDP-NR etag = new EntityTag(resource.getDescription().getEtagValue()); date = resource.getDescription().getLastModifiedDate(); } else { // Use a strong ETag for the LDP-RS when validating If-(None)-Match headers etag = new EntityTag(resource.getDescribedResource().getEtagValue()); date = resource.getDescribedResource().getLastModifiedDate(); } if (date != null) { roundedDate = date.minusMillis(date.toEpochMilli() % 1000); } Response.ResponseBuilder builder = request.evaluatePreconditions(etag); if ( builder == null ) { builder = request.evaluatePreconditions(Date.from(roundedDate)); } if (builder != null && cacheControl ) { final CacheControl cc = new CacheControl(); cc.setMaxAge(0); cc.setMustRevalidate(true); // here we are implicitly emitting a 304 // the exception is not an error, it's genuinely // an exceptional condition builder = builder.cacheControl(cc).lastModified(Date.from(roundedDate)).tag(etag); } if (builder != null) { throw new WebApplicationException(builder.build()); } } protected static MediaType getSimpleContentType(final MediaType requestContentType) { return requestContentType != null ? new MediaType(requestContentType.getType(), requestContentType.getSubtype()) : APPLICATION_OCTET_STREAM_TYPE; } protected static boolean isRdfContentType(final String contentTypeString) { return contentTypeToLang(contentTypeString) != null; } protected void replaceResourceBinaryWithStream(final FedoraBinary result, final InputStream requestBodyStream, final ContentDisposition contentDisposition, final MediaType contentType, final Collection<String> checksums) throws InvalidChecksumException { final Collection<URI> checksumURIs = checksums == null ? new HashSet<>() : checksums.stream().map(checksum -> checksumURI(checksum)).collect(Collectors.toSet()); final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : ""; final String originalContentType = contentType != null ? contentType.toString() : ""; result.setContent(requestBodyStream, originalContentType, checksumURIs, originalFileName, storagePolicyDecisionPoint); } protected void replaceResourceWithStream(final FedoraResource resource, final InputStream requestBodyStream, final MediaType contentType, final RdfStream resourceTriples) throws MalformedRdfException { final Lang format = contentTypeToLang(contentType.toString()); final Model inputModel = createDefaultModel(); try { inputModel.read(requestBodyStream, getUri(resource).toString(), format.getName().toUpperCase()); } catch (final RiotException e) { throw new BadRequestException("RDF was not parsable: " + e.getMessage(), e); } catch (final RuntimeIOException e) { if (e.getCause() instanceof JsonParseException) { throw new MalformedRdfException(e.getCause()); } throw new RepositoryRuntimeException(e); } resource.replaceProperties(translator(), inputModel, resourceTriples); } protected void patchResourcewithSparql(final FedoraResource resource, final String requestBody, final RdfStream resourceTriples) { resource.getDescribedResource().updateProperties(translator(), requestBody, resourceTriples); } /** * Create a checksum URI object. **/ private static URI checksumURI( final String checksum ) { if (!isBlank(checksum)) { return URI.create(checksum); } return null; } }