001/**
002 * Copyright 2015 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.fcrepo.http.api;
017
018
019import static com.google.common.base.Predicates.alwaysTrue;
020import static com.google.common.base.Predicates.and;
021import static com.google.common.base.Predicates.not;
022import static com.google.common.collect.Iterators.concat;
023import static com.google.common.collect.Iterators.filter;
024import static com.google.common.collect.Iterators.transform;
025import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel;
026import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL;
027import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM_TYPE;
028import static javax.ws.rs.core.Response.ok;
029import static javax.ws.rs.core.Response.status;
030import static javax.ws.rs.core.Response.Status.PARTIAL_CONTENT;
031import static javax.ws.rs.core.Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE;
032import static javax.ws.rs.core.Response.temporaryRedirect;
033import static org.apache.commons.lang.StringUtils.isBlank;
034import static org.apache.jena.riot.RDFLanguages.contentTypeToLang;
035import static org.fcrepo.kernel.FedoraJcrTypes.LDP_BASIC_CONTAINER;
036import static org.fcrepo.kernel.FedoraJcrTypes.LDP_DIRECT_CONTAINER;
037import static org.fcrepo.kernel.FedoraJcrTypes.LDP_INDIRECT_CONTAINER;
038import static org.fcrepo.kernel.RdfLexicon.BASIC_CONTAINER;
039import static org.fcrepo.kernel.RdfLexicon.CONTAINER;
040import static org.fcrepo.kernel.RdfLexicon.DIRECT_CONTAINER;
041import static org.fcrepo.kernel.RdfLexicon.INDIRECT_CONTAINER;
042import static org.fcrepo.kernel.RdfLexicon.LDP_NAMESPACE;
043import static org.fcrepo.kernel.RdfLexicon.isManagedNamespace;
044import static org.slf4j.LoggerFactory.getLogger;
045
046import java.io.IOException;
047import java.io.InputStream;
048import java.net.URI;
049import java.net.URISyntaxException;
050import java.util.Date;
051import java.util.Iterator;
052
053import javax.inject.Inject;
054import javax.jcr.Binary;
055import javax.jcr.RepositoryException;
056import javax.jcr.Session;
057import javax.servlet.http.HttpServletResponse;
058import javax.ws.rs.BadRequestException;
059import javax.ws.rs.BeanParam;
060import javax.ws.rs.WebApplicationException;
061import javax.ws.rs.core.CacheControl;
062import javax.ws.rs.core.Context;
063import javax.ws.rs.core.EntityTag;
064import javax.ws.rs.core.MediaType;
065import javax.ws.rs.core.Request;
066import javax.ws.rs.core.Response;
067
068import org.apache.jena.riot.Lang;
069import org.fcrepo.http.commons.api.rdf.HttpTripleUtil;
070import org.fcrepo.http.commons.domain.MultiPrefer;
071import org.fcrepo.http.commons.domain.PreferTag;
072import org.fcrepo.http.commons.domain.Range;
073import org.fcrepo.http.commons.domain.ldp.LdpPreferTag;
074import org.fcrepo.http.commons.responses.RangeRequestInputStream;
075import org.fcrepo.kernel.exception.InvalidChecksumException;
076import org.fcrepo.kernel.exception.MalformedRdfException;
077import org.fcrepo.kernel.exception.RepositoryRuntimeException;
078import org.fcrepo.kernel.impl.rdf.ManagedRdf;
079import org.fcrepo.kernel.impl.rdf.impl.AclRdfContext;
080import org.fcrepo.kernel.impl.rdf.impl.BlankNodeRdfContext;
081import org.fcrepo.kernel.impl.rdf.impl.ChildrenRdfContext;
082import org.fcrepo.kernel.impl.rdf.impl.ContentRdfContext;
083import org.fcrepo.kernel.impl.rdf.impl.HashRdfContext;
084import org.fcrepo.kernel.impl.rdf.impl.LdpContainerRdfContext;
085import org.fcrepo.kernel.impl.rdf.impl.LdpIsMemberOfRdfContext;
086import org.fcrepo.kernel.impl.rdf.impl.LdpRdfContext;
087import org.fcrepo.kernel.impl.rdf.impl.ParentRdfContext;
088import org.fcrepo.kernel.impl.rdf.impl.PropertiesRdfContext;
089import org.fcrepo.kernel.impl.rdf.impl.ReferencesRdfContext;
090import org.fcrepo.kernel.impl.rdf.impl.RootRdfContext;
091import org.fcrepo.kernel.impl.rdf.impl.TypeRdfContext;
092import org.fcrepo.kernel.impl.services.TransactionServiceImpl;
093import org.fcrepo.kernel.models.Container;
094import org.fcrepo.kernel.models.FedoraBinary;
095import org.fcrepo.kernel.models.FedoraResource;
096import org.fcrepo.kernel.models.NonRdfSource;
097import org.fcrepo.kernel.models.NonRdfSourceDescription;
098import org.fcrepo.kernel.services.policy.StoragePolicyDecisionPoint;
099import org.fcrepo.kernel.utils.iterators.RdfStream;
100import org.glassfish.jersey.media.multipart.ContentDisposition;
101import org.jvnet.hk2.annotations.Optional;
102import org.slf4j.Logger;
103
104import com.google.common.base.Function;
105import com.google.common.base.Predicate;
106import com.google.common.collect.ImmutableList;
107import com.google.common.collect.Iterators;
108import com.hp.hpl.jena.graph.Triple;
109import com.hp.hpl.jena.rdf.model.Model;
110import com.hp.hpl.jena.rdf.model.Statement;
111import com.hp.hpl.jena.vocabulary.RDF;
112
113/**
114 * An abstract class that sits between AbstractResource and any resource that
115 * wishes to share the routines for building responses containing binary
116 * content.
117 *
118 * @author Mike Durbin
119 */
120public abstract class ContentExposingResource extends FedoraBaseResource {
121
122    private static final Logger LOGGER = getLogger(ContentExposingResource.class);
123    public static final MediaType MESSAGE_EXTERNAL_BODY = MediaType.valueOf("message/external-body");
124
125    @Context protected Request request;
126    @Context protected HttpServletResponse servletResponse;
127
128    @Inject
129    @Optional
130    private HttpTripleUtil httpTripleUtil;
131
132    @BeanParam
133    protected MultiPrefer prefer;
134
135    @Inject
136    @Optional
137    StoragePolicyDecisionPoint storagePolicyDecisionPoint;
138
139    protected FedoraResource resource;
140
141    private static long MAX_BUFFER_SIZE = 10240000;
142
143    protected abstract String externalPath();
144
145    protected Response getContent(final String rangeValue,
146                                  final RdfStream rdfStream) throws IOException {
147        if (resource() instanceof FedoraBinary) {
148
149            final String contentTypeString = ((FedoraBinary) resource()).getMimeType();
150
151            final Lang lang = contentTypeToLang(contentTypeString);
152
153            if (!contentTypeString.equals("text/plain") && lang != null) {
154
155                final String format = lang.getName().toUpperCase();
156
157                final InputStream content = ((FedoraBinary) resource()).getContent();
158
159                final Model inputModel = createDefaultModel()
160                        .read(content,  (resource()).toString(), format);
161
162                rdfStream.concat(Iterators.transform(inputModel.listStatements(),
163                        new Function<Statement, Triple>() {
164
165                            @Override
166                            public Triple apply(final Statement input) {
167                                return input.asTriple();
168                            }
169                        }));
170            } else {
171
172                final MediaType mediaType = MediaType.valueOf(contentTypeString);
173                if (MESSAGE_EXTERNAL_BODY.isCompatible(mediaType)
174                        && mediaType.getParameters().containsKey("access-type")
175                        && mediaType.getParameters().get("access-type").equals("URL")
176                        && mediaType.getParameters().containsKey("URL") ) {
177                    try {
178                        return temporaryRedirect(new URI(mediaType.getParameters().get("URL"))).build();
179                    } catch (final URISyntaxException e) {
180                        throw new RepositoryRuntimeException(e);
181                    }
182                }
183                return getBinaryContent(rangeValue);
184            }
185
186        } else {
187            rdfStream.concat(getResourceTriples());
188
189            if (prefer != null) {
190                prefer.getReturn().addResponseHeaders(servletResponse);
191            }
192
193        }
194        servletResponse.addHeader("Vary", "Accept, Range, Accept-Encoding, Accept-Language");
195
196        return Response.ok(rdfStream).build();
197    }
198
199    protected RdfStream getResourceTriples() {
200
201        final PreferTag returnPreference;
202
203        if (prefer != null && prefer.hasReturn()) {
204            returnPreference = prefer.getReturn();
205        } else if (prefer != null && prefer.hasHandling()) {
206            returnPreference = prefer.getHandling();
207        } else {
208            returnPreference = PreferTag.emptyTag();
209        }
210
211        final LdpPreferTag ldpPreferences = new LdpPreferTag(returnPreference);
212
213        final RdfStream rdfStream = new RdfStream();
214
215        final Predicate<Triple> tripleFilter;
216        if (ldpPreferences.prefersServerManaged()) {
217            tripleFilter = alwaysTrue();
218        } else {
219            tripleFilter = and(not(ManagedRdf.isManagedTriple), not(new Predicate<Triple>() {
220                @Override
221                public boolean apply(final Triple input) {
222                    return input.getPredicate().equals(RDF.type.asNode())
223                            && isManagedNamespace.apply(input.getObject().getNameSpace());
224                }
225            }));
226        }
227
228        if (ldpPreferences.prefersServerManaged()) {
229            rdfStream.concat(getTriples(LdpRdfContext.class));
230        }
231
232        rdfStream.concat(filter(getTriples(TypeRdfContext.class), tripleFilter));
233
234        rdfStream.concat(filter(getTriples(PropertiesRdfContext.class), tripleFilter));
235
236        if (!returnPreference.getValue().equals("minimal")) {
237
238            // Additional server-managed triples about this resource
239            if (ldpPreferences.prefersServerManaged()) {
240                rdfStream.concat(getTriples(AclRdfContext.class));
241                rdfStream.concat(getTriples(RootRdfContext.class));
242                rdfStream.concat(getTriples(ContentRdfContext.class));
243                rdfStream.concat(getTriples(ParentRdfContext.class));
244            }
245
246            // containment triples about this resource
247            if (ldpPreferences.prefersContainment()) {
248                rdfStream.concat(getTriples(ChildrenRdfContext.class));
249            }
250
251            // LDP container membership triples for this resource
252            if (ldpPreferences.prefersMembership()) {
253                rdfStream.concat(getTriples(LdpContainerRdfContext.class));
254                rdfStream.concat(getTriples(LdpIsMemberOfRdfContext.class));
255            }
256
257            // Include binary properties if this is a binary description
258            if (resource() instanceof NonRdfSourceDescription) {
259                final FedoraResource described = ((NonRdfSourceDescription) resource()).getDescribedResource();
260                rdfStream.concat(filter(described.getTriples(translator(), ImmutableList.of(TypeRdfContext.class,
261                        PropertiesRdfContext.class,
262                        ContentRdfContext.class)), tripleFilter));
263            }
264
265            // Embed all hash and blank nodes
266            rdfStream.concat(filter(getTriples(HashRdfContext.class), tripleFilter));
267            rdfStream.concat(filter(getTriples(BlankNodeRdfContext.class), tripleFilter));
268
269            // Include inbound references to this object
270            if (ldpPreferences.prefersReferences()) {
271                rdfStream.concat(getTriples(ReferencesRdfContext.class));
272            }
273
274            // Embed the children of this object
275            if (ldpPreferences.prefersEmbed()) {
276
277                final Iterator<FedoraResource> children = resource().getChildren();
278
279                rdfStream.concat(filter(concat(transform(children,
280                        new Function<FedoraResource, RdfStream>() {
281
282                            @Override
283                            public RdfStream apply(final FedoraResource child) {
284                                return child.getTriples(translator(), ImmutableList.of(
285                                        TypeRdfContext.class,
286                                        PropertiesRdfContext.class,
287                                        BlankNodeRdfContext.class));
288                            }
289                        })), tripleFilter));
290
291            }
292        }
293
294        if (httpTripleUtil != null && ldpPreferences.prefersServerManaged()) {
295            httpTripleUtil.addHttpComponentModelsForResourceToStream(rdfStream, resource(), uriInfo, translator());
296        }
297
298
299        return rdfStream;
300    }
301
302    /**
303     * Get the binary content of a datastream
304     *
305     * @return Binary blob
306     * @throws RepositoryException
307     */
308    protected Response getBinaryContent(final String rangeValue)
309            throws IOException {
310            final FedoraBinary binary = (FedoraBinary)resource();
311
312            // we include an explicit etag, because the default behavior is to use the JCR node's etag, not
313            // the jcr:content node digest. The etag is only included if we are not within a transaction.
314            final String txId = TransactionServiceImpl.getCurrentTransactionId(session());
315            if (txId == null) {
316                checkCacheControlHeaders(request, servletResponse, binary, session());
317            }
318            final CacheControl cc = new CacheControl();
319            cc.setMaxAge(0);
320            cc.setMustRevalidate(true);
321            Response.ResponseBuilder builder;
322
323            if (rangeValue != null && rangeValue.startsWith("bytes")) {
324
325                final Range range = Range.convert(rangeValue);
326
327                final long contentSize = binary.getContentSize();
328
329                final String endAsString;
330
331                if (range.end() == -1) {
332                    endAsString = Long.toString(contentSize - 1);
333                } else {
334                    endAsString = Long.toString(range.end());
335                }
336
337                final String contentRangeValue =
338                        String.format("bytes %s-%s/%s", range.start(),
339                                endAsString, contentSize);
340
341                if (range.end() > contentSize ||
342                        (range.end() == -1 && range.start() > contentSize)) {
343
344                    builder = status(REQUESTED_RANGE_NOT_SATISFIABLE)
345                            .header("Content-Range", contentRangeValue);
346                } else {
347                    final long maxBufferSize = MAX_BUFFER_SIZE; // 10MB max buffer size?
348                    final long rangeStart = range.start();
349                    final long rangeSize = range.size() == -1 ? contentSize - rangeStart : range.size();
350                    final long remainingBytes = contentSize - rangeStart;
351                    final long bufSize = rangeSize < remainingBytes ? rangeSize : remainingBytes;
352
353                    if (bufSize < maxBufferSize) {
354                        // Small size range content retrieval use javax.jcr.Binary to improve performance
355                        final byte[] buf = new byte[(int) bufSize];
356
357                        final Binary binaryContent = binary.getBinaryContent();
358                        try {
359                            binaryContent.read(buf, rangeStart);
360                        } catch (final RepositoryException e1) {
361                            throw new RepositoryRuntimeException(e1);
362                        }
363                        binaryContent.dispose();
364
365                        builder = status(PARTIAL_CONTENT).entity(buf)
366                                .header("Content-Range", contentRangeValue);
367                    } else {
368                        // For large range content retrieval, go with the InputStream class to balance
369                        // the memory usage, though this is a rare case in range content retrieval.
370                        final InputStream content = binary.getContent();
371                        final RangeRequestInputStream rangeInputStream =
372                                new RangeRequestInputStream(content, range.start(), range.size());
373
374                        builder = status(PARTIAL_CONTENT).entity(rangeInputStream)
375                                .header("Content-Range", contentRangeValue);
376                    }
377                }
378
379            } else {
380                final InputStream content = binary.getContent();
381                builder = ok(content);
382            }
383
384
385            // we set the content-type explicitly to avoid content-negotiation from getting in the way
386            return builder.type(binary.getMimeType())
387                    .cacheControl(cc)
388                    .build();
389
390        }
391
392    protected RdfStream getTriples(final Class<? extends RdfStream> x) {
393        return getTriples(resource(), x);
394    }
395
396    protected RdfStream getTriples(final FedoraResource resource, final Class<? extends RdfStream> x) {
397        return resource.getTriples(translator(), x);
398    }
399
400    protected URI getUri(final FedoraResource resource) {
401        try {
402            final String uri = translator().reverse().convert(resource).getURI();
403            return new URI(uri);
404        } catch (final URISyntaxException e) {
405            throw new BadRequestException(e);
406        }
407    }
408
409    protected FedoraResource resource() {
410        if (resource == null) {
411            resource = getResourceFromPath(externalPath());
412        }
413
414        return resource;
415    }
416
417
418    /**
419     * Add any resource-specific headers to the response
420     * @param resource
421     */
422    protected void addResourceHttpHeaders(final FedoraResource resource) {
423        if (resource instanceof FedoraBinary) {
424
425            final FedoraBinary binary = (FedoraBinary)resource;
426            final ContentDisposition contentDisposition = ContentDisposition.type("attachment")
427                    .fileName(binary.getFilename())
428                    .creationDate(binary.getCreatedDate())
429                    .modificationDate(binary.getLastModifiedDate())
430                    .size(binary.getContentSize())
431                    .build();
432
433            servletResponse.addHeader("Content-Type", binary.getMimeType());
434            servletResponse.addHeader("Content-Length", String.valueOf(binary.getContentSize()));
435            servletResponse.addHeader("Accept-Ranges", "bytes");
436            servletResponse.addHeader("Content-Disposition", contentDisposition.toString());
437        }
438
439        servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "Resource>;rel=\"type\"");
440
441        if (resource instanceof NonRdfSource) {
442            servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "NonRDFSource>;rel=\"type\"");
443        } else if (resource instanceof Container) {
444            servletResponse.addHeader("Link", "<" + CONTAINER.getURI() + ">;rel=\"type\"");
445            if (resource.hasType(LDP_BASIC_CONTAINER)) {
446                servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\"");
447            } else if (resource.hasType(LDP_DIRECT_CONTAINER)) {
448                servletResponse.addHeader("Link", "<" + DIRECT_CONTAINER.getURI() + ">;rel=\"type\"");
449            } else if (resource.hasType(LDP_INDIRECT_CONTAINER)) {
450                servletResponse.addHeader("Link", "<" + INDIRECT_CONTAINER.getURI() + ">;rel=\"type\"");
451            } else {
452                servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\"");
453            }
454        } else {
455            servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "RDFSource>;rel=\"type\"");
456        }
457
458    }
459
460    /**
461     * Evaluate the cache control headers for the request to see if it can be served from
462     * the cache.
463     *
464     * @param request
465     * @param servletResponse
466     * @param resource
467     * @param session
468     * @throws javax.jcr.RepositoryException
469     */
470    protected static void checkCacheControlHeaders(final Request request,
471                                                   final HttpServletResponse servletResponse,
472                                                   final FedoraResource resource,
473                                                   final Session session) {
474        evaluateRequestPreconditions(request, servletResponse, resource, session, true);
475        addCacheControlHeaders(servletResponse, resource, session);
476    }
477
478    /**
479     * Add ETag and Last-Modified cache control headers to the response
480     * @param servletResponse
481     * @param resource
482     */
483    protected static void addCacheControlHeaders(final HttpServletResponse servletResponse,
484                                                 final FedoraResource resource,
485                                                 final Session session) {
486
487        final String txId = TransactionServiceImpl.getCurrentTransactionId(session);
488        if (txId != null) {
489            // Do not add caching headers if in a transaction
490            return;
491        }
492
493        final EntityTag etag = new EntityTag(resource.getEtagValue());
494        final Date date = resource.getLastModifiedDate();
495
496        if (!etag.getValue().isEmpty()) {
497            servletResponse.addHeader("ETag", etag.toString());
498        }
499
500        if (date != null) {
501            servletResponse.addDateHeader("Last-Modified", date.getTime());
502        }
503    }
504
505    /**
506     * Evaluate request preconditions to ensure the resource is the expected state
507     * @param request
508     * @param resource
509     */
510    protected static void evaluateRequestPreconditions(final Request request,
511                                                       final HttpServletResponse servletResponse,
512                                                       final FedoraResource resource,
513                                                       final Session session) {
514        evaluateRequestPreconditions(request, servletResponse, resource, session, false);
515    }
516
517    private static void evaluateRequestPreconditions(final Request request,
518                                                     final HttpServletResponse servletResponse,
519                                                     final FedoraResource resource,
520                                                     final Session session,
521                                                     final boolean cacheControl) {
522
523        final String txId = TransactionServiceImpl.getCurrentTransactionId(session);
524        if (txId != null) {
525            // Force cache revalidation if in a transaction
526            servletResponse.addHeader(CACHE_CONTROL, "must-revalidate");
527            servletResponse.addHeader(CACHE_CONTROL, "max-age=0");
528            return;
529        }
530
531        final EntityTag etag = new EntityTag(resource.getEtagValue());
532        final Date date = resource.getLastModifiedDate();
533        final Date roundedDate = new Date();
534
535        if (date != null) {
536            roundedDate.setTime(date.getTime() - date.getTime() % 1000);
537        }
538
539        Response.ResponseBuilder builder = request.evaluatePreconditions(etag);
540        if ( builder != null ) {
541            builder = builder.entity("ETag mismatch");
542        } else {
543            builder = request.evaluatePreconditions(roundedDate);
544            if ( builder != null ) {
545                builder = builder.entity("Date mismatch");
546            }
547        }
548
549        if (builder != null && cacheControl ) {
550            final CacheControl cc = new CacheControl();
551            cc.setMaxAge(0);
552            cc.setMustRevalidate(true);
553            // here we are implicitly emitting a 304
554            // the exception is not an error, it's genuinely
555            // an exceptional condition
556            builder = builder.cacheControl(cc).lastModified(date).tag(etag);
557        }
558        if (builder != null) {
559            throw new WebApplicationException(builder.build());
560        }
561    }
562
563    protected static MediaType getSimpleContentType(final MediaType requestContentType) {
564        return requestContentType != null ? new MediaType(requestContentType.getType(), requestContentType.getSubtype())
565                : APPLICATION_OCTET_STREAM_TYPE;
566    }
567
568    protected static boolean isRdfContentType(final String contentTypeString) {
569        return contentTypeToLang(contentTypeString) != null;
570    }
571
572    protected void replaceResourceBinaryWithStream(final FedoraBinary result,
573                                                   final InputStream requestBodyStream,
574                                                   final ContentDisposition contentDisposition,
575                                                   final MediaType contentType,
576                                                   final String checksum) throws InvalidChecksumException {
577        final URI checksumURI = checksumURI(checksum);
578        final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : "";
579        final String originalContentType = contentType != null ? contentType.toString() : "";
580
581        result.setContent(requestBodyStream,
582                originalContentType,
583                checksumURI,
584                originalFileName,
585                storagePolicyDecisionPoint);
586    }
587
588    protected void replaceResourceWithStream(final FedoraResource resource,
589                                             final InputStream requestBodyStream,
590                                             final MediaType contentType,
591                                             final RdfStream resourceTriples) throws MalformedRdfException {
592        final Lang format = contentTypeToLang(contentType.toString());
593
594        final Model inputModel = createDefaultModel()
595                .read(requestBodyStream, getUri(resource).toString(), format.getName().toUpperCase());
596
597        resource.replaceProperties(translator(), inputModel, resourceTriples);
598    }
599
600    protected void patchResourcewithSparql(final FedoraResource resource,
601                                           final String requestBody,
602                                           final RdfStream resourceTriples) throws MalformedRdfException {
603        resource.updateProperties(translator(), requestBody, resourceTriples);
604    }
605
606    /**
607     * Create a checksum URI object.
608     **/
609    private static URI checksumURI( final String checksum ) {
610        if (!isBlank(checksum)) {
611            return URI.create(checksum);
612        }
613        return null;
614    }
615}