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