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