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