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}