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
020
021import static com.google.common.base.Strings.isNullOrEmpty;
022import static com.google.common.base.Strings.nullToEmpty;
023import static java.nio.charset.StandardCharsets.UTF_8;
024import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION;
025import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
026import static javax.ws.rs.core.HttpHeaders.LINK;
027import static javax.ws.rs.core.HttpHeaders.LOCATION;
028import static javax.ws.rs.core.MediaType.WILDCARD;
029import static javax.ws.rs.core.Response.noContent;
030import static javax.ws.rs.core.Response.notAcceptable;
031import static javax.ws.rs.core.Response.ok;
032import static javax.ws.rs.core.Response.status;
033import static javax.ws.rs.core.Response.temporaryRedirect;
034import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
035import static javax.ws.rs.core.Response.Status.CONFLICT;
036import static javax.ws.rs.core.Response.Status.FORBIDDEN;
037import static javax.ws.rs.core.Response.Status.FOUND;
038import static javax.ws.rs.core.Response.Status.METHOD_NOT_ALLOWED;
039import static javax.ws.rs.core.Response.Status.UNSUPPORTED_MEDIA_TYPE;
040import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE;
041import static org.apache.commons.lang3.StringUtils.isBlank;
042import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
043import static org.apache.jena.atlas.web.ContentType.create;
044import static org.apache.jena.rdf.model.ResourceFactory.createResource;
045import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
046import static org.apache.jena.riot.WebContent.ctSPARQLUpdate;
047import static org.apache.jena.riot.WebContent.ctTextCSV;
048import static org.apache.jena.riot.WebContent.ctTextPlain;
049import static org.apache.jena.riot.WebContent.matchContentType;
050import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD;
051import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET;
052import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET;
053import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES;
054import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML;
055import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET;
056import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET;
057import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET;
058import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X;
059import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_PAIRTREE;
060import static org.fcrepo.kernel.api.RdfLexicon.FEDORA_DESCRIPTION;
061import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODELS;
062import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODEL_RESOURCES;
063import static org.fcrepo.kernel.api.RdfLexicon.VERSIONED_RESOURCE;
064import static org.fcrepo.kernel.api.FedoraExternalContent.COPY;
065import static org.fcrepo.kernel.api.FedoraTypes.LDP_BASIC_CONTAINER;
066import static org.fcrepo.kernel.api.FedoraTypes.LDP_NON_RDF_SOURCE;
067import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER;
068import static org.slf4j.LoggerFactory.getLogger;
069
070import java.io.IOException;
071import java.io.InputStream;
072import java.io.UnsupportedEncodingException;
073import java.net.URI;
074import java.net.URLDecoder;
075import java.time.Instant;
076import java.time.format.DateTimeParseException;
077import java.util.Collection;
078import java.util.HashMap;
079import java.util.List;
080import java.util.Map;
081import java.util.Optional;
082import java.util.stream.Collectors;
083import javax.inject.Inject;
084import javax.ws.rs.BadRequestException;
085import javax.ws.rs.ClientErrorException;
086import javax.ws.rs.Consumes;
087import javax.ws.rs.DELETE;
088import javax.ws.rs.GET;
089import javax.ws.rs.HEAD;
090import javax.ws.rs.HeaderParam;
091import javax.ws.rs.NotSupportedException;
092import javax.ws.rs.OPTIONS;
093import javax.ws.rs.POST;
094import javax.ws.rs.PUT;
095import javax.ws.rs.Path;
096import javax.ws.rs.PathParam;
097import javax.ws.rs.Produces;
098import javax.ws.rs.core.HttpHeaders;
099import javax.ws.rs.core.Link;
100import javax.ws.rs.core.MediaType;
101import javax.ws.rs.core.Response;
102import javax.ws.rs.core.UriBuilderException;
103import javax.ws.rs.core.Variant.VariantListBuilder;
104
105import com.google.common.annotations.VisibleForTesting;
106import com.google.common.base.Splitter;
107import com.google.common.collect.ImmutableList;
108import org.apache.commons.io.IOUtils;
109import org.apache.commons.lang3.StringUtils;
110import org.apache.jena.atlas.web.ContentType;
111import org.apache.jena.rdf.model.Resource;
112import org.fcrepo.http.api.PathLockManager.AcquiredLock;
113import org.fcrepo.http.commons.domain.PATCH;
114import org.fcrepo.kernel.api.FedoraTypes;
115import org.fcrepo.kernel.api.RdfStream;
116import org.fcrepo.kernel.api.exception.AccessDeniedException;
117import org.fcrepo.kernel.api.exception.CannotCreateResourceException;
118import org.fcrepo.kernel.api.exception.InsufficientStorageException;
119import org.fcrepo.kernel.api.exception.InteractionModelViolationException;
120import org.fcrepo.kernel.api.exception.InvalidChecksumException;
121import org.fcrepo.kernel.api.exception.InvalidMementoPathException;
122import org.fcrepo.kernel.api.exception.MalformedRdfException;
123import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException;
124import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException;
125import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
126import org.fcrepo.kernel.api.exception.RequestWithAclLinkHeaderException;
127import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException;
128import org.fcrepo.kernel.api.models.Container;
129import org.fcrepo.kernel.api.models.FedoraBinary;
130import org.fcrepo.kernel.api.models.FedoraResource;
131import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
132import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
133import org.fcrepo.kernel.api.utils.ContentDigest;
134import org.glassfish.jersey.media.multipart.ContentDisposition;
135import org.slf4j.Logger;
136import org.springframework.context.annotation.Scope;
137
138/**
139 * @author cabeer
140 * @author ajs6f
141 * @since 9/25/14
142 */
143
144@Scope("request")
145@Path("/{path: .*}")
146public class FedoraLdp extends ContentExposingResource {
147
148    private static final Logger LOGGER = getLogger(FedoraLdp.class);
149
150    private static final String WANT_DIGEST = "Want-Digest";
151
152    private static final String DIGEST = "Digest";
153
154    @PathParam("path") protected String externalPath;
155
156    @Inject private FedoraHttpConfiguration httpConfiguration;
157
158    /**
159     * Default JAX-RS entry point
160     */
161    public FedoraLdp() {
162        super();
163    }
164
165    /**
166     * Create a new FedoraNodes instance for a given path
167     * @param externalPath the external path
168     */
169    @VisibleForTesting
170    public FedoraLdp(final String externalPath) {
171        this.externalPath = externalPath;
172    }
173
174    /**
175     * Retrieve the node headers
176     *
177     * @return response
178     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
179     */
180    @HEAD
181    @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
182        N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
183        TURTLE_X, TEXT_HTML_WITH_CHARSET })
184    public Response head() throws UnsupportedAlgorithmException {
185        LOGGER.info("HEAD for: {}", externalPath);
186
187        checkMementoPath();
188
189        final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME);
190        if (!isBlank(datetimeHeader) && resource().isOriginalResource()) {
191            return getMemento(datetimeHeader, resource());
192        }
193
194        checkCacheControlHeaders(request, servletResponse, resource(), session);
195
196        addResourceHttpHeaders(resource());
197
198        Response.ResponseBuilder builder = ok();
199
200        if (resource() instanceof FedoraBinary) {
201            final FedoraBinary binary = (FedoraBinary) resource();
202            final MediaType mediaType = getBinaryResourceMediaType(binary);
203
204            if (binary.isRedirect()) {
205                    builder = temporaryRedirect(binary.getRedirectURI());
206            }
207
208            // we set the content-type explicitly to avoid content-negotiation from getting in the way
209            builder.type(mediaType.toString());
210
211            // Respect the Want-Digest header with fixity check
212            final String wantDigest = headers.getHeaderString(WANT_DIGEST);
213            if (!isNullOrEmpty(wantDigest)) {
214                builder.header(DIGEST, handleWantDigestHeader(binary, wantDigest));
215            }
216        } else {
217            final String accept = headers.getHeaderString(HttpHeaders.ACCEPT);
218            if (accept == null || "*/*".equals(accept)) {
219                builder.type(TURTLE_WITH_CHARSET);
220            }
221            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource());
222        }
223
224
225        return builder.build();
226    }
227
228    /**
229     * Outputs information about the supported HTTP methods, etc.
230     * @return the outputs information about the supported HTTP methods, etc.
231     */
232    @OPTIONS
233    public Response options() {
234        LOGGER.info("OPTIONS for '{}'", externalPath);
235
236        checkMementoPath();
237
238        addLinkAndOptionsHttpHeaders(resource());
239        return ok().build();
240    }
241
242
243    /**
244     * Retrieve the node profile
245     *
246     * @param rangeValue the range value
247     * @return a binary or the triples for the specified node
248     * @throws IOException if IO exception occurred
249     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
250     */
251    @GET
252    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
253            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
254            TURTLE_X, TEXT_HTML_WITH_CHARSET})
255    public Response getResource(@HeaderParam("Range") final String rangeValue)
256            throws IOException, UnsupportedAlgorithmException {
257
258        checkMementoPath();
259
260        final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME);
261        if (!isBlank(datetimeHeader) && resource().isOriginalResource()) {
262            return getMemento(datetimeHeader, resource());
263        }
264
265        checkCacheControlHeaders(request, servletResponse, resource(), session);
266
267        LOGGER.info("GET resource '{}'", externalPath);
268        final AcquiredLock readLock = lockManager.lockForRead(resource().getPath());
269        try (final RdfStream rdfStream = new DefaultRdfStream(asNode(resource()))) {
270
271            // If requesting a binary, check the mime-type if "Accept:" header is present.
272            // (This needs to be done before setting up response headers, as getContent
273            // returns a response - so changing headers after that won't work so nicely.)
274            final ImmutableList<MediaType> acceptableMediaTypes = ImmutableList.copyOf(headers
275                    .getAcceptableMediaTypes());
276
277            if (resource() instanceof FedoraBinary && acceptableMediaTypes.size() > 0) {
278
279                final MediaType mediaType = getBinaryResourceMediaType(resource());
280
281                // Respect the Want-Digest header for fixity check
282                final String wantDigest = headers.getHeaderString(WANT_DIGEST);
283                if (!isNullOrEmpty(wantDigest)) {
284                    servletResponse.addHeader(DIGEST, handleWantDigestHeader((FedoraBinary)resource(), wantDigest));
285                }
286
287                if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) {
288                    return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build();
289                }
290            }
291
292            addResourceHttpHeaders(resource());
293
294            if (resource() instanceof FedoraBinary && ((FedoraBinary)resource()).isRedirect()) {
295                return temporaryRedirect(((FedoraBinary) resource()).getRedirectURI()).build();
296            } else {
297                return getContent(rangeValue, getChildrenLimit(), rdfStream, resource());
298            }
299        } finally {
300            readLock.release();
301        }
302    }
303
304    /**
305     * Return the location of a requested Memento.
306     *
307     * @param datetimeHeader The RFC datetime for the Memento.
308     * @param resource The fedora resource
309     * @return A 302 Found response or 406 if no mementos.
310     */
311    private Response getMemento(final String datetimeHeader, final FedoraResource resource) {
312        try {
313            final Instant mementoDatetime = Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader));
314            final FedoraResource memento = resource.findMementoByDatetime(mementoDatetime);
315            final Response builder;
316            if (memento != null) {
317                builder =
318                    status(FOUND).header(LOCATION, translator().reverse().convert(memento).toString()).build();
319            } else {
320                builder = status(NOT_ACCEPTABLE).build();
321            }
322            addResourceHttpHeaders(resource);
323            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource);
324            return builder;
325        } catch (final DateTimeParseException e) {
326            throw new MementoDatetimeFormatException("Invalid Accept-Datetime value: " + e.getMessage()
327                + ". Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e);
328        }
329    }
330
331    /**
332     * Deletes an object.
333     *
334     * @return response
335     */
336    @DELETE
337    public Response deleteObject() {
338        hasRestrictedPath(externalPath);
339        if (resource() instanceof Container) {
340            final String depth = headers.getHeaderString("Depth");
341            LOGGER.debug("Depth header value is: {}", depth);
342            if (depth != null && !depth.equalsIgnoreCase("infinity")) {
343                throw new ClientErrorException("Depth header, if present, must be set to 'infinity' for containers",
344                        SC_BAD_REQUEST);
345            }
346        }
347        if (resource() instanceof NonRdfSourceDescription && resource().isOriginalResource()) {
348            LOGGER.debug("Trying to delete binary description directly.");
349            throw new ClientErrorException(
350                "NonRDFSource descriptions are removed when their associated NonRDFSource object is removed.",
351                METHOD_NOT_ALLOWED);
352        }
353
354        evaluateRequestPreconditions(request, servletResponse, resource(), session);
355
356        LOGGER.info("Delete resource '{}'", externalPath);
357
358        final AcquiredLock lock = lockManager.lockForDelete(resource().getPath());
359
360        try {
361            resource().delete();
362            session.commit();
363            return noContent().build();
364        } finally {
365            lock.release();
366        }
367    }
368
369    /**
370     * Create a resource at a specified path, or replace triples with provided RDF.
371     *
372     * @param requestContentType the request content type
373     * @param requestBodyStream the request body stream
374     * @param contentDisposition the content disposition value
375     * @param ifMatch the if-match value
376     * @param rawLinks the raw link values
377     * @param digest the digest header
378     * @return 204
379     * @throws InvalidChecksumException if invalid checksum exception occurred
380     * @throws MalformedRdfException if malformed rdf exception occurred
381     * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs
382     */
383    @PUT
384    @Consumes
385    public Response createOrReplaceObjectRdf(
386            @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
387            final InputStream requestBodyStream,
388            @HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition,
389            @HeaderParam("If-Match") final String ifMatch,
390            @HeaderParam(LINK) final List<String> rawLinks,
391            @HeaderParam("Digest") final String digest)
392            throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException {
393
394        hasRestrictedPath(externalPath);
395
396        final List<String> links = unpackLinks(rawLinks);
397
398        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
399            handleRequestDisallowedOnMemento();
400
401            return status(METHOD_NOT_ALLOWED).build();
402        }
403
404        final String interactionModel = checkInteractionModel(links);
405
406        checkAclLinkHeader(links);
407
408        final FedoraResource resource;
409
410        final String path = toPath(translator(), externalPath);
411
412        final AcquiredLock lock = lockManager.lockForWrite(path, session.getFedoraSession(), nodeService);
413
414        try {
415
416            final Collection<String> checksums = parseDigestHeader(digest);
417            final ExternalContentHandler extContent = extContentHandlerFactory.createFromLinks(links);
418
419            final MediaType contentType =  getSimpleContentType(
420                    extContent != null ? extContent.getContentType() : requestContentType);
421
422            if (nodeService.exists(session.getFedoraSession(), path)) {
423                resource = resource();
424
425                final String resInteractionModel = getInteractionModel(resource);
426                if (StringUtils.isNoneBlank(interactionModel) && StringUtils.isNoneBlank(resInteractionModel)
427                        && !resInteractionModel.equals(interactionModel)) {
428                    throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel
429                                + " to " + interactionModel + " is not allowed!");
430                }
431
432            } else {
433
434                checkExistingAncestor(path);
435
436                final MediaType effectiveContentType
437                        = requestBodyStream == null || requestContentType == null ? null : contentType;
438                resource = createFedoraResource(path, interactionModel, effectiveContentType,
439                        !(requestBodyStream == null || requestContentType == null), extContent != null);
440            }
441
442            if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch) && !resource.isNew()) {
443                throw new ClientErrorException("An If-Match header is required", 428);
444            }
445
446            evaluateRequestPreconditions(request, servletResponse, resource, session);
447            final boolean created = resource.isNew();
448
449            try (final RdfStream resourceTriples =
450                    created ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) {
451                if (resource instanceof FedoraBinary) {
452                    InputStream stream = requestBodyStream;
453                    MediaType type = requestContentType;
454                    // override a few things, if it's external content
455                    if (extContent != null) {
456                        if (extContent.isCopy()) {
457                            LOGGER.debug("External content COPY '{}', '{}'", externalPath, extContent.getURL());
458                            stream = extContent.fetchExternalContent();
459                        }
460
461                        type = contentType;  // if external, then this already holds the correct value
462                    }
463                    final String handling = extContent != null ? extContent.getHandling() : null;
464                    replaceResourceBinaryWithStream((FedoraBinary) resource,
465                            stream, contentDisposition, type, checksums,
466                            (handling != null && !handling.equals(COPY)) ? handling : null,
467                            (extContent != null && !handling.equals(COPY)) ? extContent.getURL() : null);
468
469                } else if (isRdfContentType(contentType.toString())) {
470                    replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples);
471                } else if (!created) {
472                    boolean emptyRequest = true;
473                    try {
474                        emptyRequest = requestBodyStream.read() == -1;
475                    } catch (final IOException ex) {
476                        LOGGER.debug("Error checking for request body content", ex);
477                    }
478
479                    if (requestContentType == null && emptyRequest) {
480                        throw new ClientErrorException("Resource Already Exists", CONFLICT);
481                    }
482                    throw new NotSupportedException("Invalid Content Type " + requestContentType);
483                }
484            } catch (final Exception e) {
485                checkForInsufficientStorageException(e, e);
486            }
487
488            ensureInteractionType(resource, interactionModel,
489                    (requestBodyStream == null || requestContentType == null));
490
491            session.commit();
492            return createUpdateResponse(resource, created);
493
494        } finally {
495            lock.release();
496        }
497    }
498
499    /**
500     * Make sure the resource has the specified interaction model
501     */
502    private static void ensureInteractionType(final FedoraResource resource, final String interactionModel,
503            final boolean defaultContent) {
504        if (interactionModel != null) {
505            if (!resource.hasType(interactionModel)) {
506                resource.addType(interactionModel);
507            }
508        } else if (defaultContent) {
509            resource.addType(LDP_BASIC_CONTAINER);
510        } else if (resource instanceof FedoraBinary) {
511            resource.addType(LDP_NON_RDF_SOURCE);
512        }
513    }
514
515    /**
516     * Update an object using SPARQL-UPDATE
517     *
518     * @param requestBodyStream the request body stream
519     * @return 201
520     * @throws IOException if IO exception occurred
521     */
522    @PATCH
523    @Consumes({contentTypeSPARQLUpdate})
524    public Response updateSparql(final InputStream requestBodyStream)
525            throws IOException {
526        hasRestrictedPath(externalPath);
527
528        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
529            handleRequestDisallowedOnMemento();
530
531            return status(METHOD_NOT_ALLOWED).build();
532        }
533
534        if (null == requestBodyStream) {
535            throw new BadRequestException("SPARQL-UPDATE requests must have content!");
536        }
537
538        if (resource() instanceof FedoraBinary) {
539            throw new BadRequestException(resource().getPath() + " is not a valid object to receive a PATCH");
540        }
541
542        final AcquiredLock lock = lockManager.lockForWrite(resource().getPath(), session.getFedoraSession(),
543                nodeService);
544
545        try {
546            final String requestBody = IOUtils.toString(requestBodyStream, UTF_8);
547            if (isBlank(requestBody)) {
548                throw new BadRequestException("SPARQL-UPDATE requests must have content!");
549            }
550
551            evaluateRequestPreconditions(request, servletResponse, resource(), session);
552
553            try (final RdfStream resourceTriples =
554                    resource().isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) {
555                LOGGER.info("PATCH for '{}'", externalPath);
556                patchResourcewithSparql(resource(), requestBody, resourceTriples);
557            }
558            session.commit();
559
560            addCacheControlHeaders(servletResponse, resource(), session);
561
562            return noContent().build();
563        } catch (final IllegalArgumentException iae) {
564            throw new BadRequestException(iae.getMessage());
565        } catch (final AccessDeniedException e) {
566            throw e;
567        } catch ( final RuntimeException ex ) {
568            final Throwable cause = ex.getCause();
569            if (cause instanceof PathNotFoundRuntimeException) {
570                // the sparql update referred to a repository resource that doesn't exist
571                throw new BadRequestException(cause.getMessage());
572            }
573            throw ex;
574        } finally {
575            lock.release();
576        }
577    }
578
579    /**
580     * Creates a new object.
581     *
582     * This originally used application/octet-stream;qs=1001 as a workaround
583     * for JERSEY-2636, to ensure requests without a Content-Type get routed here.
584     * This qs value does not parse with newer versions of Jersey, as qs values
585     * must be between 0 and 1. We use qs=1.000 to mark where this historical
586     * anomaly had been.
587     *
588     * @param contentDisposition the content Disposition value
589     * @param requestContentType the request content type
590     * @param slug the slug value
591     * @param requestBodyStream the request body stream
592     * @param rawLinks the link values
593     * @param digest the digest header
594     * @return 201
595     * @throws InvalidChecksumException if invalid checksum exception occurred
596     * @throws MalformedRdfException if malformed rdf exception occurred
597     * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs
598     */
599    @POST
600    @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1.000", WILDCARD})
601    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
602            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
603            TURTLE_X, TEXT_HTML_WITH_CHARSET, "*/*"})
604    public Response createObject(@HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition,
605                                 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
606                                 @HeaderParam("Slug") final String slug,
607            final InputStream requestBodyStream,
608                                 @HeaderParam(LINK) final List<String> rawLinks,
609                                 @HeaderParam("Digest") final String digest)
610            throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException {
611
612        final List<String> links = unpackLinks(rawLinks);
613
614        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
615            handleRequestDisallowedOnMemento();
616
617            return status(METHOD_NOT_ALLOWED).build();
618        }
619
620        final String interactionModel = checkInteractionModel(links);
621
622        checkAclLinkHeader(links);
623
624        // If request is an external binary, verify link header before proceeding
625        final ExternalContentHandler extContent = extContentHandlerFactory.createFromLinks(links);
626
627        if (!(resource() instanceof Container)) {
628            throw new ClientErrorException("Object cannot have child nodes", CONFLICT);
629        } else if (resource().hasType(FEDORA_PAIRTREE)) {
630            throw new ClientErrorException("Objects cannot be created under pairtree nodes", FORBIDDEN);
631        }
632
633        final MediaType contentType = getSimpleContentType(
634                extContent != null ? extContent.getContentType() : requestContentType);
635
636        final String contentTypeString = contentType.toString();
637
638        final String newObjectPath = mintNewPid(slug);
639        hasRestrictedPath(newObjectPath);
640
641        final AcquiredLock lock = lockManager.lockForWrite(newObjectPath, session.getFedoraSession(), nodeService);
642
643        try {
644
645            final Collection<String> checksum = parseDigestHeader(digest);
646
647            LOGGER.info("Ingest with path: {}", newObjectPath);
648
649            final FedoraResource resource = createFedoraResource(newObjectPath, interactionModel, contentType,
650                    !(requestBodyStream == null || requestContentType == null), extContent != null);
651
652            try (final RdfStream resourceTriples =
653                     resource.isNew() ? new DefaultRdfStream(asNode(resource())) : getResourceTriples(resource())) {
654
655                if (requestBodyStream == null && extContent == null) {
656                    LOGGER.trace("No request body detected");
657                } else {
658                    LOGGER.trace("Received createObject with a request body and content type \"{}\"",
659                            contentTypeString);
660
661                    if ((resource instanceof Container) && isRdfContentType(contentTypeString)) {
662                        replaceResourceWithStream(resource, requestBodyStream, contentType, resourceTriples);
663                    } else if (resource instanceof FedoraBinary) {
664                        LOGGER.trace("Created a datastream and have a binary payload.");
665
666                        InputStream stream = requestBodyStream;
667                        MediaType type = requestContentType;
668
669                        if (extContent != null) {
670                            if (extContent.isCopy()) {
671                                LOGGER.debug("POST copying data {} ", externalPath);
672                                stream = extContent.fetchExternalContent();
673                            }
674
675                            type = contentType; // if external, then this already holds the correct value
676                        }
677
678                        final String handling = extContent != null ? extContent.getHandling() : null;
679                        replaceResourceBinaryWithStream((FedoraBinary) resource,
680                                stream, contentDisposition, type, checksum,
681                            handling != null && !handling.equals(COPY) ? handling : null,
682                            extContent != null ? extContent.getURL() : null);
683
684                    } else if (contentTypeString.equals(contentTypeSPARQLUpdate)) {
685                        LOGGER.trace("Found SPARQL-Update content, applying..");
686                        patchResourcewithSparql(resource, IOUtils.toString(requestBodyStream, UTF_8), resourceTriples);
687                    } else {
688                        if (requestBodyStream.read() != -1) {
689                            throw new ClientErrorException("Invalid Content Type " + contentTypeString,
690                                    UNSUPPORTED_MEDIA_TYPE);
691                        }
692                    }
693                }
694
695                ensureInteractionType(resource, interactionModel,
696                        (requestBodyStream == null || requestContentType == null));
697
698                session.commit();
699            } catch (final Exception e) {
700                checkForInsufficientStorageException(e, e);
701            }
702
703            LOGGER.debug("Finished creating resource with path: {}", newObjectPath);
704            return createUpdateResponse(resource, true);
705        } finally {
706            lock.release();
707        }
708    }
709
710    /**
711     * @param rootThrowable The original throwable
712     * @param throwable The throwable under direct scrutiny.
713     */
714    @Override
715    protected void checkForInsufficientStorageException(final Throwable rootThrowable, final Throwable throwable)
716            throws InvalidChecksumException {
717        final String message = throwable.getMessage();
718        if (throwable instanceof IOException && message != null && message.contains(
719                INSUFFICIENT_SPACE_IDENTIFYING_MESSAGE)) {
720            throw new InsufficientStorageException(throwable.getMessage(), rootThrowable);
721        }
722
723        if (throwable.getCause() != null) {
724            checkForInsufficientStorageException(rootThrowable, throwable.getCause());
725        }
726
727        if (rootThrowable instanceof InvalidChecksumException) {
728            throw (InvalidChecksumException) rootThrowable;
729        } else if (rootThrowable instanceof RuntimeException) {
730            throw (RuntimeException) rootThrowable;
731        } else {
732            throw new RepositoryRuntimeException(rootThrowable);
733        }
734    }
735
736    @Override
737    protected void addResourceHttpHeaders(final FedoraResource resource) {
738        super.addResourceHttpHeaders(resource);
739
740        if (session.isBatchSession()) {
741            final String canonical = translator().reverse()
742                    .convert(resource)
743                    .toString()
744                    .replaceFirst("/tx:[^/]+", "");
745
746
747            servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\"");
748
749        }
750        addExternalContentHeaders(resource);
751    }
752
753    @Override
754    protected String externalPath() {
755        return externalPath;
756    }
757
758
759    private static boolean isRDF(final MediaType requestContentType) {
760        if (requestContentType == null) {
761            return false;
762        }
763
764        final ContentType ctRequest = create(requestContentType.toString());
765
766        // Text files and CSV files are not considered RDF to Fedora, though CSV is a valid
767        // RDF type to Jena (although deprecated).
768        if (matchContentType(ctRequest, ctTextPlain) || matchContentType(ctRequest, ctTextCSV)) {
769            return false;
770        }
771
772        // SPARQL updates are done on containers.
773        return isRdfContentType(requestContentType.toString()) || matchContentType(ctRequest, ctSPARQLUpdate);
774    }
775
776    private void checkExistingAncestor(final String path) {
777        // check the closest existing ancestor for containment violations.
778        String parentPath = path.substring(0, path.lastIndexOf("/"));
779        while (!(parentPath.isEmpty() || parentPath.equals("/"))) {
780            if (nodeService.exists(session.getFedoraSession(), parentPath)) {
781                if (!(getResourceFromPath(parentPath) instanceof Container)) {
782                    throw new ClientErrorException("Unable to add child " + path.replace(parentPath, "")
783                            + " to resource " + parentPath + ".", CONFLICT);
784                }
785                break;
786            }
787            parentPath = parentPath.substring(0, parentPath.lastIndexOf("/"));
788        }
789    }
790
791    private FedoraResource createFedoraResource(final String path, final String interactionModel,
792            final MediaType contentType, final boolean contentPresent, final boolean contentExternal) {
793
794        final MediaType simpleContentType = contentPresent ? getSimpleContentType(contentType) : null;
795
796        final FedoraResource result;
797        if ("ldp:NonRDFSource".equals(interactionModel) || contentExternal ||
798                (contentPresent && interactionModel == null && !isRDF(simpleContentType))) {
799            result = binaryService.findOrCreate(session.getFedoraSession(), path);
800            timeMapService.findOrCreate(session.getFedoraSession(), path + "/" + FEDORA_DESCRIPTION);
801        } else {
802            result = containerService.findOrCreate(session.getFedoraSession(), path, interactionModel);
803        }
804
805        timeMapService.findOrCreate(session.getFedoraSession(), path);
806
807        final String resInteractionModel = getInteractionModel(result);
808        if (StringUtils.isNoneBlank(interactionModel) && StringUtils.isNoneBlank(resInteractionModel)
809                && !resInteractionModel.equals(interactionModel)) {
810            throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel
811                        + " to " + interactionModel + " is not allowed!");
812        }
813
814        return result;
815    }
816
817    /*
818     * Get the interaction model from the Fedora Resource
819     * @param resource Fedora Resource
820     * @return String the Interaction Model
821     */
822    private String getInteractionModel(final FedoraResource resource) {
823        final Optional<String> result = INTERACTION_MODELS.stream().filter(x -> resource.hasType(x)).findFirst();
824        return result.orElse(null);
825    }
826
827    private String mintNewPid(final String slug) {
828        String pid;
829
830        if (slug != null && !slug.isEmpty()) {
831            pid = slug;
832        } else if (pidMinter != null) {
833            pid = pidMinter.get();
834        } else {
835            pid = defaultPidMinter.get();
836        }
837        // reverse translate the proffered or created identifier
838        LOGGER.trace("Using external identifier {} to create new resource.", pid);
839        LOGGER.trace("Using prefixed external identifier {} to create new resource.", uriInfo.getBaseUri() + "/"
840                + pid);
841
842        final URI newResourceUri = uriInfo.getAbsolutePathBuilder().clone().path(FedoraLdp.class)
843                .resolveTemplate("path", pid, false).build();
844
845        pid = translator().asString(createResource(newResourceUri.toString()));
846        try {
847            pid = URLDecoder.decode(pid, "UTF-8");
848        } catch (final UnsupportedEncodingException e) {
849            // noop
850        }
851        // remove leading slash left over from translation
852        LOGGER.trace("Using internal identifier {} to create new resource.", pid);
853
854        if (nodeService.exists(session.getFedoraSession(), pid)) {
855            LOGGER.trace("Resource with path {} already exists; minting new path instead", pid);
856            return mintNewPid(null);
857        }
858
859        return pid;
860    }
861
862    private String handleWantDigestHeader(final FedoraBinary binary, final String wantDigest)
863            throws UnsupportedAlgorithmException {
864        // handle the Want-Digest header with fixity check
865        final Collection<String> preferredDigests = parseWantDigestHeader(wantDigest);
866        if (preferredDigests.isEmpty()) {
867            throw new UnsupportedAlgorithmException(
868                    "Unsupported digest algorithm provided in 'Want-Digest' header: " + wantDigest);
869        }
870
871        final Collection<URI> checksumResults = binary.checkFixity(idTranslator, preferredDigests);
872        return checksumResults.stream().map(uri -> uri.toString().replaceFirst("urn:", "")
873                .replaceFirst(":", "=").replaceFirst("sha1=", "sha=")).collect(Collectors.joining(","));
874    }
875
876    private static String checkInteractionModel(final List<String> links) {
877        if (links == null) {
878            return null;
879        }
880
881        try {
882            for (final String link : links) {
883                final Link linq = Link.valueOf(link);
884                if ("type".equals(linq.getRel())) {
885                    final Resource type = createResource(linq.getUri().toString());
886                    if (INTERACTION_MODEL_RESOURCES.contains(type)) {
887                        return "ldp:" + type.getLocalName();
888                    } else if (type.equals(VERSIONED_RESOURCE)) {
889                        // skip if versioned resource link header
890                        // NB: the versioned resource header is used for enabling
891                        // versioning on a resource and is thus orthogonal to
892                        // issue of interaction models. Nevertheless, it is
893                        // a possible link header and, therefore, must be ignored.
894                    } else {
895                        LOGGER.info("Invalid interaction model: {}", type);
896                        throw new CannotCreateResourceException("Invalid interaction model: " + type);
897                    }
898                }
899            }
900        } catch (final RuntimeException e) {
901            if (e instanceof IllegalArgumentException | e instanceof UriBuilderException) {
902                throw new ClientErrorException("Invalid link specified: " + String.join(", ", links), BAD_REQUEST);
903            }
904            throw e;
905        }
906
907        return null;
908    }
909
910    /**
911     * Parse the RFC-3230 Digest response header value.  Look for a
912     * sha1 checksum and return it as a urn, if missing or malformed
913     * an empty string is returned.
914     * @param digest The Digest header value
915     * @return the sha1 checksum value
916     * @throws UnsupportedAlgorithmException if an unsupported digest is used
917     */
918    protected static Collection<String> parseDigestHeader(final String digest) throws UnsupportedAlgorithmException {
919        try {
920            final Map<String,String> digestPairs = RFC3230_SPLITTER.split(nullToEmpty(digest));
921            final boolean allSupportedAlgorithms = digestPairs.keySet().stream().allMatch(
922                    ContentDigest.DIGEST_ALGORITHM::isSupportedAlgorithm);
923
924            // If you have one or more digests that are all valid or no digests.
925            if (digestPairs.isEmpty() || allSupportedAlgorithms) {
926                return digestPairs.entrySet().stream()
927                    .filter(entry -> ContentDigest.DIGEST_ALGORITHM.isSupportedAlgorithm(entry.getKey()))
928                    .map(entry -> ContentDigest.asURI(entry.getKey(), entry.getValue()).toString())
929                    .collect(Collectors.toSet());
930            } else {
931                throw new UnsupportedAlgorithmException(String.format("Unsupported Digest Algorithm: %1$s", digest));
932            }
933        } catch (final RuntimeException e) {
934            if (e instanceof IllegalArgumentException) {
935                throw new ClientErrorException("Invalid Digest header: " + digest + "\n", BAD_REQUEST);
936            }
937            throw e;
938        }
939    }
940
941    /**
942     * Parse the RFC-3230 Want-Digest header value.
943     * @param wantDigest The Want-Digest header value with optional q value in format:
944     *    'md5', 'md5, sha', 'MD5;q=0.3, sha;q=1' etc.
945     * @return Digest algorithms that are supported
946     */
947    private static Collection<String> parseWantDigestHeader(final String wantDigest) {
948        final Map<String, Double> digestPairs = new HashMap<>();
949        try {
950            final List<String> algs = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(wantDigest);
951            // Parse the optional q value with default 1.0, and 0 ignore. Format could be: SHA-1;qvalue=0.1
952            for (final String alg : algs) {
953                final String[] tokens = alg.split(";", 2);
954                final double qValue = tokens.length == 1 || !tokens[1].contains("=") ?
955                        1.0 : Double.parseDouble(tokens[1].split("=", 2)[1]);
956                digestPairs.put(tokens[0], qValue);
957            }
958
959            return digestPairs.entrySet().stream().filter(entry -> entry.getValue() > 0)
960                .filter(entry -> ContentDigest.DIGEST_ALGORITHM.isSupportedAlgorithm(entry.getKey()))
961                .map(entry -> entry.getKey()).collect(Collectors.toSet());
962        } catch (final NumberFormatException e) {
963            throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest, SC_BAD_REQUEST, e);
964        } catch (final RuntimeException e) {
965            if (e instanceof IllegalArgumentException) {
966                throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest + "\n", BAD_REQUEST);
967            }
968            throw e;
969        }
970    }
971
972    private void checkAclLinkHeader(final List<String> links) throws RequestWithAclLinkHeaderException {
973        if (links != null && links.stream().anyMatch(l -> Link.valueOf(l).getRel().equals("acl"))) {
974            throw new RequestWithAclLinkHeaderException(
975                    "Unable to handle request with the specified LDP-RS as the ACL.");
976        }
977    }
978
979    private void handleRequestDisallowedOnMemento() {
980        try {
981            addLinkAndOptionsHttpHeaders(resource());
982        } catch (final Exception ex) {
983            // Catch the exception to ensure status 405 for any requests on memento.
984            LOGGER.debug("Unable to add link and options headers for PATCH request to memento path {}: {}.",
985                externalPath, ex.getMessage());
986        }
987
988        LOGGER.info("Unable to handle {} request on a path containing {}. Path was: {}", request.getMethod(),
989            FedoraTypes.FCR_VERSIONS, externalPath);
990    }
991
992    /*
993     * Ensure that an incoming versioning/memento path can be converted.
994     */
995    private void checkMementoPath() {
996        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
997            final String path = toPath(translator(), externalPath);
998            if (path.contains(FedoraTypes.FCR_VERSIONS)) {
999                throw new InvalidMementoPathException("Invalid versioning request with path: " + path);
1000            }
1001        }
1002    }
1003}