001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.http.api;
019
020import static com.google.common.base.Strings.isNullOrEmpty;
021import static java.nio.charset.StandardCharsets.UTF_8;
022import static javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION;
023import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
024import static javax.ws.rs.core.HttpHeaders.LINK;
025import static javax.ws.rs.core.HttpHeaders.LOCATION;
026import static javax.ws.rs.core.MediaType.WILDCARD;
027import static javax.ws.rs.core.Response.noContent;
028import static javax.ws.rs.core.Response.notAcceptable;
029import static javax.ws.rs.core.Response.ok;
030import static javax.ws.rs.core.Response.status;
031import static javax.ws.rs.core.Response.temporaryRedirect;
032import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
033import static javax.ws.rs.core.Response.Status.FOUND;
034import static javax.ws.rs.core.Response.Status.METHOD_NOT_ALLOWED;
035import static javax.ws.rs.core.Response.Status.NOT_ACCEPTABLE;
036import static org.apache.commons.lang3.StringUtils.isBlank;
037import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
038import static org.apache.jena.rdf.model.ResourceFactory.createResource;
039import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
040import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD;
041import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET;
042import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET;
043import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES;
044import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML;
045import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET;
046import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET;
047import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET;
048import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X;
049import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_TYPE;
050import static org.fcrepo.http.commons.domain.RDFMediaType.APPLICATION_OCTET_STREAM_TYPE;
051
052import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
053import static org.fcrepo.kernel.api.RdfLexicon.ARCHIVAL_GROUP;
054import static org.fcrepo.kernel.api.RdfLexicon.INTERACTION_MODEL_RESOURCES;
055import static org.fcrepo.kernel.api.RdfLexicon.NON_RDF_SOURCE;
056import static org.fcrepo.kernel.api.RdfLexicon.VERSIONED_RESOURCE;
057import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER;
058import static org.slf4j.LoggerFactory.getLogger;
059
060import java.io.IOException;
061import java.io.InputStream;
062import java.net.URI;
063import java.net.URLDecoder;
064import java.time.Instant;
065import java.time.format.DateTimeParseException;
066import java.util.Collection;
067import java.util.HashMap;
068import java.util.List;
069import java.util.Map;
070import java.util.Objects;
071import java.util.concurrent.atomic.AtomicBoolean;
072import java.util.stream.Collectors;
073
074import javax.inject.Inject;
075import javax.ws.rs.BadRequestException;
076import javax.ws.rs.ClientErrorException;
077import javax.ws.rs.Consumes;
078import javax.ws.rs.DELETE;
079import javax.ws.rs.GET;
080import javax.ws.rs.HEAD;
081import javax.ws.rs.HeaderParam;
082import javax.ws.rs.OPTIONS;
083import javax.ws.rs.POST;
084import javax.ws.rs.PUT;
085import javax.ws.rs.Path;
086import javax.ws.rs.PathParam;
087import javax.ws.rs.Produces;
088import javax.ws.rs.core.HttpHeaders;
089import javax.ws.rs.core.Link;
090import javax.ws.rs.core.MediaType;
091import javax.ws.rs.core.Response;
092import javax.ws.rs.core.UriBuilderException;
093import javax.ws.rs.core.Variant.VariantListBuilder;
094
095import io.micrometer.core.annotation.Timed;
096import org.apache.commons.io.IOUtils;
097import org.apache.commons.lang3.StringUtils;
098import org.apache.jena.rdf.model.Model;
099import org.apache.jena.rdf.model.Resource;
100
101import org.fcrepo.http.commons.domain.PATCH;
102import org.fcrepo.kernel.api.FedoraTypes;
103import org.fcrepo.kernel.api.exception.AccessDeniedException;
104import org.fcrepo.kernel.api.exception.CannotCreateResourceException;
105import org.fcrepo.kernel.api.exception.GhostNodeException;
106import org.fcrepo.kernel.api.exception.InteractionModelViolationException;
107import org.fcrepo.kernel.api.exception.InvalidChecksumException;
108import org.fcrepo.kernel.api.exception.MalformedRdfException;
109import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException;
110import org.fcrepo.kernel.api.exception.PathNotFoundException;
111import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException;
112import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException;
113import org.fcrepo.kernel.api.identifiers.FedoraId;
114import org.fcrepo.kernel.api.models.Binary;
115import org.fcrepo.kernel.api.models.Container;
116import org.fcrepo.kernel.api.models.ExternalContent;
117import org.fcrepo.kernel.api.models.FedoraResource;
118import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
119import org.fcrepo.kernel.api.services.FixityService;
120import org.fcrepo.kernel.api.services.ReplaceBinariesService;
121import org.fcrepo.config.DigestAlgorithm;
122
123import org.glassfish.jersey.media.multipart.ContentDisposition;
124import org.slf4j.Logger;
125import org.springframework.context.annotation.Scope;
126
127import com.google.common.annotations.VisibleForTesting;
128import com.google.common.base.Splitter;
129import com.google.common.collect.ImmutableList;
130
131/**
132 * @author cabeer
133 * @author ajs6f
134 * @since 9/25/14
135 */
136
137@Timed
138@Scope("request")
139@Path("/{path: .*}")
140public class FedoraLdp extends ContentExposingResource {
141
142    private static final Logger LOGGER = getLogger(FedoraLdp.class);
143
144    private static final String WANT_DIGEST = "Want-Digest";
145
146    private static final String DIGEST = "Digest";
147
148    private static final MediaType DEFAULT_RDF_CONTENT_TYPE = TURTLE_TYPE;
149    private static final MediaType DEFAULT_NON_RDF_CONTENT_TYPE = APPLICATION_OCTET_STREAM_TYPE;
150
151    @PathParam("path") protected String externalPath;
152
153    @Inject
154    private FixityService fixityService;
155
156    @Inject
157    private FedoraHttpConfiguration httpConfiguration;
158
159    @Inject
160    protected ReplaceBinariesService replaceBinariesService;
161
162    /**
163     * Default JAX-RS entry point
164     */
165    public FedoraLdp() {
166        super();
167    }
168
169    /**
170     * Create a new FedoraNodes instance for a given path
171     * @param externalPath the external path
172     */
173    @VisibleForTesting
174    public FedoraLdp(final String externalPath) {
175        this.externalPath = externalPath;
176    }
177
178    /**
179     * Retrieve the node headers
180     *
181     * @return response
182     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
183     */
184    @HEAD
185    @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
186        N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
187        TURTLE_X, TEXT_HTML_WITH_CHARSET })
188    public Response head() throws UnsupportedAlgorithmException {
189        LOGGER.info("HEAD for: {}", externalPath);
190
191        final String datetimeHeader = headers.getHeaderString(ACCEPT_DATETIME);
192        if (!isBlank(datetimeHeader) && resource().isOriginalResource()) {
193            return getMemento(datetimeHeader, resource());
194        }
195
196        checkCacheControlHeaders(request, servletResponse, resource(), transaction());
197
198        addResourceHttpHeaders(resource());
199
200        Response.ResponseBuilder builder = ok();
201
202        if (resource() instanceof Binary) {
203            final Binary binary = (Binary) resource();
204            final MediaType mediaType = getBinaryResourceMediaType(binary);
205
206            if (binary.isRedirect()) {
207                builder = temporaryRedirect(binary.getExternalURI());
208            }
209
210            // we set the content-type explicitly to avoid content-negotiation from getting in the way
211            builder.type(mediaType.toString());
212
213            // Respect the Want-Digest header with fixity check
214            final String wantDigest = headers.getHeaderString(WANT_DIGEST);
215            if (!isNullOrEmpty(wantDigest)) {
216                builder.header(DIGEST, handleWantDigestHeader(binary, wantDigest));
217            }
218        } else {
219            final String accept = headers.getHeaderString(HttpHeaders.ACCEPT);
220            if (accept == null || "*/*".equals(accept)) {
221                builder.type(TURTLE_WITH_CHARSET);
222            }
223            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource());
224        }
225
226
227        return builder.build();
228    }
229
230    /**
231     * Outputs information about the supported HTTP methods, etc.
232     * @return the outputs information about the supported HTTP methods, etc.
233     */
234    @OPTIONS
235    public Response options() {
236        LOGGER.info("OPTIONS for '{}'", externalPath);
237
238        addLinkAndOptionsHttpHeaders(resource());
239        return ok().build();
240    }
241
242
243    /**
244     * Retrieve the node profile
245     *
246     * @param rangeValue the range value
247     * @return a binary or the triples for the specified node
248     * @throws IOException if IO exception occurred
249     * @throws UnsupportedAlgorithmException if unsupported digest algorithm occurred
250     */
251    @GET
252    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
253            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
254            TURTLE_X, TEXT_HTML_WITH_CHARSET})
255    public Response getResource(@HeaderParam("Range") final String rangeValue)
256            throws IOException, UnsupportedAlgorithmException {
257
258        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(), transaction());
264
265        final ImmutableList<MediaType> acceptableMediaTypes = ImmutableList.copyOf(headers
266                .getAcceptableMediaTypes());
267
268        LOGGER.info("GET resource '{}'", externalPath);
269        addResourceHttpHeaders(resource());
270
271        if (resource() instanceof Binary) {
272            final Binary binary = (Binary) resource();
273            if (!acceptableMediaTypes.isEmpty()) {
274                final MediaType mediaType = getBinaryResourceMediaType(resource());
275
276                if (acceptableMediaTypes.stream().noneMatch(t -> t.isCompatible(mediaType))) {
277                    return notAcceptable(VariantListBuilder.newInstance().mediaTypes(mediaType).build()).build();
278                }
279            }
280
281            // Respect the Want-Digest header for fixity check
282            final String wantDigest = headers.getHeaderString(WANT_DIGEST);
283            if (!isNullOrEmpty(wantDigest)) {
284                servletResponse.addHeader(DIGEST, handleWantDigestHeader(binary, wantDigest));
285            }
286
287            if (binary.isRedirect()) {
288                return temporaryRedirect(binary.getExternalURI()).build();
289            } else {
290                return getBinaryContent(rangeValue, binary);
291            }
292        } else {
293            return getContent(getChildrenLimit(), resource());
294        }
295    }
296
297    /**
298     * Return the location of a requested Memento.
299     *
300     * @param datetimeHeader The RFC datetime for the Memento.
301     * @param resource The fedora resource
302     * @return A 302 Found response or 406 if no mementos.
303     */
304    private Response getMemento(final String datetimeHeader, final FedoraResource resource) {
305        try {
306            final Instant mementoDatetime = Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader));
307            final FedoraResource memento = resource.findMementoByDatetime(mementoDatetime);
308            final Response builder;
309            if (memento != null) {
310                builder =
311                    status(FOUND).header(LOCATION, getUri(memento)).build();
312            } else {
313                builder = status(NOT_ACCEPTABLE).build();
314            }
315            addResourceHttpHeaders(resource);
316            setVaryAndPreferenceAppliedHeaders(servletResponse, prefer, resource);
317            return builder;
318        } catch (final DateTimeParseException e) {
319            throw new MementoDatetimeFormatException("Invalid Accept-Datetime value: " + e.getMessage()
320                + ". Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e);
321        }
322    }
323
324    /**
325     * Deletes an object.
326     *
327     * @return response
328     */
329    @DELETE
330    public Response deleteObject() {
331        hasRestrictedPath(externalPath);
332        if (resource() instanceof Container) {
333            final String depth = headers.getHeaderString("Depth");
334            LOGGER.debug("Depth header value is: {}", depth);
335            if (depth != null && !depth.equalsIgnoreCase("infinity")) {
336                throw new ClientErrorException("Depth header, if present, must be set to 'infinity' for containers",
337                        SC_BAD_REQUEST);
338            }
339        }
340        if (resource() instanceof NonRdfSourceDescription && resource().isOriginalResource()) {
341            LOGGER.debug("Trying to delete binary description directly.");
342            throw new ClientErrorException(
343                "NonRDFSource descriptions are removed when their associated NonRDFSource object is removed.",
344                METHOD_NOT_ALLOWED);
345        }
346
347        LOGGER.info("Delete resource '{}'", externalPath);
348
349        try {
350            evaluateRequestPreconditions(request, servletResponse, resource(), transaction());
351
352            doInDbTxWithRetry(() -> {
353                deleteResourceService.perform(transaction(), resource(), getUserPrincipal());
354                transaction().commitIfShortLived();
355            });
356            return noContent().build();
357        } finally {
358            transaction().releaseResourceLocksIfShortLived();
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                   PathNotFoundException {
387        LOGGER.info("PUT to create resource with ID: {}", externalPath());
388
389        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
390            handleRequestDisallowedOnMemento();
391
392            return status(METHOD_NOT_ALLOWED).build();
393        }
394
395        hasRestrictedPath(externalPath);
396
397        final var transaction = transaction();
398
399        try {
400            final List<String> links = unpackLinks(rawLinks);
401
402            // If request is an external binary, verify link header before proceeding
403            final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links);
404
405            final String interactionModel = checkInteractionModel(links);
406
407            final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath());
408            final boolean resourceExists = doesResourceExist(transaction, fedoraId, true);
409
410            if (resourceExists) {
411
412                if (httpConfiguration.putRequiresIfMatch() && StringUtils.isBlank(ifMatch)) {
413                    throw new ClientErrorException("An If-Match header is required", 428);
414                }
415
416                final String resInteractionModel = resource().getInteractionModel();
417                if (StringUtils.isNoneBlank(resInteractionModel, interactionModel) &&
418                        !Objects.equals(resInteractionModel, interactionModel)) {
419                    throw new InteractionModelViolationException("Changing the interaction model " + resInteractionModel
420                            + " to " + interactionModel + " is not allowed!");
421                }
422                evaluateRequestPreconditions(request, servletResponse, resource(), transaction);
423            }
424
425            if (isGhostNode(transaction(), fedoraId)) {
426                throw new GhostNodeException("Resource path " + externalPath() + " is an immutable resource.");
427            }
428
429            if (!resourceExists && fedoraId.isDescription()) {
430                // Can't PUT a description to a non-existant binary.
431                final String message;
432                if (fedoraId.asBaseId().isRepositoryRoot()) {
433                    message = "The root of the repository is not a binary, so /" + FCR_METADATA + " does not exist.";
434                } else {
435                    message = "Binary at path " + fedoraId.asBaseId().getFullIdPath() + " not found";
436                }
437                throw new PathNotFoundException(message);
438            }
439
440            final var providedContentType = getSimpleContentType(requestContentType);
441
442            final var created = new AtomicBoolean(false);
443
444                if ((resourceExists && resource() instanceof Binary) ||
445                        (!resourceExists && isBinary(interactionModel,
446                                providedContentType,
447                                requestBodyStream != null && providedContentType != null,
448                                extContent != null))) {
449                    ensureArchivalGroupHeaderNotPresentForBinaries(links);
450
451                    final Collection<URI> checksums = parseDigestHeader(digest);
452                    final var binaryType = requestContentType != null ?
453                            requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE;
454                    final var contentType = extContent == null ?
455                            binaryType.toString() : extContent.getContentType();
456                    final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : "";
457                    final long contentSize = contentDisposition == null ? -1L : contentDisposition.getSize();
458
459                    doInDbTx(() -> {
460                        if (resourceExists) {
461                            replaceBinariesService.perform(transaction,
462                                    getUserPrincipal(),
463                                    fedoraId,
464                                    originalFileName,
465                                    contentType,
466                                    checksums,
467                                    requestBodyStream,
468                                    contentSize,
469                                    extContent);
470                        } else {
471                            createResourceService.perform(transaction,
472                                    getUserPrincipal(),
473                                    fedoraId,
474                                    contentType,
475                                    originalFileName,
476                                    contentSize,
477                                    links,
478                                    checksums,
479                                    requestBodyStream,
480                                    extContent);
481                            created.set(true);
482                        }
483                        transaction.commitIfShortLived();
484                    });
485                } else {
486                    final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE;
487                    final Model model = httpRdfService.bodyToInternalModel(fedoraId, requestBodyStream,
488                            contentType, identifierConverter(), hasLenientPreferHeader());
489
490                    doInDbTxWithRetry(() -> {
491                        if (resourceExists) {
492                            replacePropertiesService.perform(transaction,
493                                    getUserPrincipal(),
494                                    fedoraId,
495                                    model);
496                        } else {
497                            createResourceService.perform(transaction, getUserPrincipal(), fedoraId, links, model);
498                            created.set(true);
499                        }
500                        transaction.commitIfShortLived();
501                    });
502                }
503
504            LOGGER.debug("Finished creating resource with path: {}", externalPath());
505
506            return createUpdateResponse(getFedoraResource(transaction, fedoraId), created.get());
507        } finally {
508            transaction.releaseResourceLocksIfShortLived();
509        }
510    }
511
512    /**
513     * Update an object using SPARQL-UPDATE
514     *
515     * @param requestBodyStream the request body stream
516     * @return 201
517     * @throws IOException if IO exception occurred
518     */
519    @PATCH
520    @Consumes({contentTypeSPARQLUpdate})
521    public Response updateSparql(final InputStream requestBodyStream)
522            throws IOException {
523        if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
524            handleRequestDisallowedOnMemento();
525
526            return status(METHOD_NOT_ALLOWED).build();
527        }
528
529        hasRestrictedPath(externalPath);
530
531        if (null == requestBodyStream) {
532            throw new BadRequestException("SPARQL-UPDATE requests must have content!");
533        }
534
535        if (resource() instanceof Binary) {
536            throw new BadRequestException(resource().getFedoraId().getFullIdPath() +
537                    " is not a valid object to receive a PATCH");
538        }
539
540        final var transaction = transaction();
541
542        try {
543            final String requestBody = IOUtils.toString(requestBodyStream, UTF_8);
544            if (isBlank(requestBody)) {
545                throw new BadRequestException("SPARQL-UPDATE requests must have content!");
546            }
547
548            evaluateRequestPreconditions(request, servletResponse, resource(), transaction);
549
550            LOGGER.info("PATCH for '{}'", externalPath);
551            final String newRequest = httpRdfService.patchRequestToInternalString(resource().getFedoraId(),
552                    requestBody, identifierConverter());
553            LOGGER.debug("PATCH request translated to '{}'", newRequest);
554
555            doInDbTxWithRetry(() -> {
556                patchResourcewithSparql(resource(), newRequest);
557                transaction.commitIfShortLived();
558            });
559
560            addCacheControlHeaders(servletResponse, reloadResource(), transaction);
561
562            return noContent().build();
563        } catch (final IllegalArgumentException iae) {
564            throw new BadRequestException(iae.getMessage());
565        } catch (final AccessDeniedException e) {
566            throw e;
567        } catch ( final RuntimeException ex ) {
568            final Throwable cause = ex.getCause();
569            if (cause instanceof PathNotFoundRuntimeException) {
570                // the sparql update referred to a repository resource that doesn't exist
571                throw new BadRequestException(cause.getMessage());
572            }
573            throw ex;
574        } finally {
575            transaction.releaseResourceLocksIfShortLived();
576        }
577    }
578
579    /**
580     * Creates a new object.
581     *
582     * This originally used application/octet-stream;qs=1001 as a workaround
583     * for JERSEY-2636, to ensure requests without a Content-Type get routed here.
584     * This qs value does not parse with newer versions of Jersey, as qs values
585     * must be between 0 and 1. We use qs=1.000 to mark where this historical
586     * anomaly had been.
587     *
588     * @param contentDisposition the content Disposition value
589     * @param requestContentType the request content type
590     * @param slug the slug value
591     * @param requestBodyStream the request body stream
592     * @param rawLinks the link values
593     * @param digest the digest header
594     * @return 201
595     * @throws InvalidChecksumException if invalid checksum exception occurred
596     * @throws MalformedRdfException if malformed rdf exception occurred
597     * @throws UnsupportedAlgorithmException if an unsupported algorithm exception occurs
598     */
599    @POST
600    @Consumes({MediaType.APPLICATION_OCTET_STREAM + ";qs=1.000", WILDCARD})
601    @Produces({TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
602            N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
603            TURTLE_X, TEXT_HTML_WITH_CHARSET, "*/*"})
604    public Response createObject(@HeaderParam(CONTENT_DISPOSITION) final ContentDisposition contentDisposition,
605                                 @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
606                                 @HeaderParam("Slug") final String slug,
607                                 final InputStream requestBodyStream,
608                                 @HeaderParam(LINK) final List<String> rawLinks,
609                                 @HeaderParam("Digest") final String digest)
610            throws InvalidChecksumException, MalformedRdfException, UnsupportedAlgorithmException {
611
612        final var decodedSlug = slug != null ? URLDecoder.decode(slug, UTF_8) : null;
613        final var transaction = transaction();
614
615        try {
616            final List<String> links = unpackLinks(rawLinks);
617
618            if (externalPath.contains("/" + FedoraTypes.FCR_VERSIONS)) {
619                handleRequestDisallowedOnMemento();
620
621                return status(METHOD_NOT_ALLOWED).build();
622            }
623
624            // If request is an external binary, verify link header before proceeding
625            final ExternalContent extContent = extContentHandlerFactory.createFromLinks(links);
626
627            final String interactionModel = checkInteractionModel(links);
628
629            final FedoraId fedoraId = identifierConverter().pathToInternalId(externalPath());
630            // If the resource doesn't exist and it's not a ghost node, throw an exception.
631            // Ghost node checking is done further down in the code and returns a 400 Bad Request error.
632            if (!doesResourceExist(transaction, fedoraId, false) && !isGhostNode(transaction, fedoraId)) {
633                throw new PathNotFoundRuntimeException(String.format("Path %s not found", fedoraId.getFullIdPath()));
634            }
635            final FedoraId newFedoraId = mintNewPid(fedoraId, decodedSlug);
636            final var providedContentType = getSimpleContentType(requestContentType);
637
638            LOGGER.info("POST to create resource with ID: {}, slug: {}", newFedoraId.getFullIdPath(), decodedSlug);
639
640            if (isBinary(interactionModel,
641                    providedContentType,
642                    requestBodyStream != null && providedContentType != null,
643                    extContent != null)) {
644                ensureArchivalGroupHeaderNotPresentForBinaries(links);
645
646                final Collection<URI> checksums = parseDigestHeader(digest);
647                final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : "";
648                final var binaryType = requestContentType != null ?
649                        requestContentType : DEFAULT_NON_RDF_CONTENT_TYPE;
650                final var contentType = extContent == null ? binaryType.toString() : extContent.getContentType();
651                final long contentSize = contentDisposition == null ? -1L : contentDisposition.getSize();
652
653                doInDbTx(() -> {
654                    createResourceService.perform(transaction,
655                            getUserPrincipal(),
656                            newFedoraId,
657                            contentType,
658                            originalFileName,
659                            contentSize,
660                            links,
661                            checksums,
662                            requestBodyStream,
663                            extContent);
664
665                    transaction.commitIfShortLived();
666                });
667            } else {
668                final var contentType = requestContentType != null ? requestContentType : DEFAULT_RDF_CONTENT_TYPE;
669                final Model model = httpRdfService.bodyToInternalModel(newFedoraId, requestBodyStream,
670                        contentType, identifierConverter(), hasLenientPreferHeader());
671
672                doInDbTxWithRetry(() -> {
673                    createResourceService.perform(transaction,
674                            getUserPrincipal(),
675                            newFedoraId,
676                            links,
677                            model);
678
679                    transaction.commitIfShortLived();
680                });
681            }
682
683            LOGGER.debug("Finished creating resource with path: {}", externalPath());
684
685            try {
686                final var resource = getFedoraResource(transaction, newFedoraId);
687                return createUpdateResponse(resource, true);
688            } catch (final PathNotFoundException e) {
689                throw new PathNotFoundRuntimeException(e.getMessage(), e);
690            }
691        } finally {
692            transaction.releaseResourceLocksIfShortLived();
693        }
694    }
695
696    @Override
697    protected void addResourceHttpHeaders(final FedoraResource resource) {
698        super.addResourceHttpHeaders(resource);
699
700        if (!transaction().isShortLived()) {
701            final String canonical = identifierConverter().toExternalId(resource.getFedoraId().getFullId())
702                    .replaceFirst("/tx:[^/]+", "");
703
704            servletResponse.addHeader(LINK, "<" + canonical + ">;rel=\"canonical\"");
705
706        }
707        addExternalContentHeaders(resource);
708        addTransactionHeaders(resource);
709    }
710
711    @Override
712    protected String externalPath() {
713        return externalPath;
714    }
715
716    /**
717     * Determine based on several factors whether the interaction model should be ldp:NonRdfSource
718     * @param interactionModel the interaction model from the links.
719     * @param contentType the content type.
720     * @param contentPresent is there a request body.
721     * @param contentExternal is there an external content header.
722     * @return Use ldp:NonRdfSource as the interaction model.
723     */
724    private boolean isBinary(final String interactionModel, final String contentType,
725                             final boolean contentPresent, final boolean contentExternal) {
726        final String simpleContentType = contentPresent ? contentType : null;
727        final boolean isRdfContent = isRdfContentType(simpleContentType);
728        return NON_RDF_SOURCE.getURI().equals(interactionModel) || contentExternal ||
729                (contentPresent && interactionModel == null && !isRdfContent);
730    }
731
732    private String handleWantDigestHeader(final Binary binary, final String wantDigest)
733            throws UnsupportedAlgorithmException {
734        // handle the Want-Digest header with fixity check
735        final Collection<String> preferredDigests = parseWantDigestHeader(wantDigest);
736        if (preferredDigests.isEmpty()) {
737            throw new UnsupportedAlgorithmException(
738                    "Unsupported digest algorithm provided in 'Want-Digest' header: " + wantDigest);
739        }
740
741        final Collection<URI> checksumResults = fixityService.getFixity(binary, preferredDigests);
742        return checksumResults.stream().map(uri -> uri.toString().replaceFirst("urn:", "")
743                .replaceFirst(":", "=").replaceFirst("sha1=", "sha=")).collect(Collectors.joining(","));
744    }
745
746    private static void ensureArchivalGroupHeaderNotPresentForBinaries(final List<String> links) {
747        if (links == null) {
748            return;
749        }
750
751        if (links.stream().map(Link::valueOf)
752                      .filter(l -> l.getUri().toString().equals(ARCHIVAL_GROUP.getURI()))
753                      .anyMatch(l -> l.getRel().equals("type"))) {
754            throw new ClientErrorException("Binary resources cannot be created as an" +
755                    " ArchiveGroup. Please remove the ArchiveGroup link header and try again", BAD_REQUEST);
756        }
757    }
758
759    private static String checkInteractionModel(final List<String> links) {
760        if (links == null) {
761            return null;
762        }
763
764        try {
765            for (final String link : links) {
766                final Link linq = Link.valueOf(link);
767                if ("type".equals(linq.getRel())) {
768                    //skip ArchivalGroup types
769                    if (linq.getUri().toString().equals(ARCHIVAL_GROUP.getURI())) {
770                        continue;
771                    }
772                    final Resource type = createResource(linq.getUri().toString());
773                    if (INTERACTION_MODEL_RESOURCES.contains(type)) {
774                        return type.getURI();
775                    } else if (type.equals(VERSIONED_RESOURCE)) {
776                        // skip if versioned resource link header
777                        // NB: the versioned resource header is used for enabling
778                        // versioning on a resource and is thus orthogonal to
779                        // issue of interaction models. Nevertheless, it is
780                        // a possible link header and, therefore, must be ignored.
781                    } else {
782                        LOGGER.info("Invalid interaction model: {}", type);
783                        throw new CannotCreateResourceException("Invalid interaction model: " + type);
784                    }
785                }
786            }
787        } catch (final RuntimeException e) {
788            if (e instanceof IllegalArgumentException || e instanceof UriBuilderException) {
789                throw new ClientErrorException("Invalid link specified: " + String.join(", ", links), BAD_REQUEST);
790            }
791            throw e;
792        }
793
794        return null;
795    }
796
797    /**
798     * Parse the RFC-3230 Want-Digest header value.
799     * @param wantDigest The Want-Digest header value with optional q value in format:
800     *    'md5', 'md5, sha', 'MD5;q=0.3, sha;q=1' etc.
801     * @return Digest algorithms that are supported
802     */
803    private static Collection<String> parseWantDigestHeader(final String wantDigest) {
804        final Map<String, Double> digestPairs = new HashMap<>();
805        try {
806            final List<String> algs = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(wantDigest);
807            // Parse the optional q value with default 1.0, and 0 ignore. Format could be: SHA-1;qvalue=0.1
808            for (final String alg : algs) {
809                final String[] tokens = alg.split(";", 2);
810                final double qValue = tokens.length == 1 || !tokens[1].contains("=") ?
811                        1.0 : Double.parseDouble(tokens[1].split("=", 2)[1]);
812                digestPairs.put(tokens[0], qValue);
813            }
814
815            return digestPairs.entrySet().stream().filter(entry -> entry.getValue() > 0)
816                    .map(Map.Entry::getKey)
817                    .filter(DigestAlgorithm::isSupportedAlgorithm)
818                    .collect(Collectors.toSet());
819        } catch (final NumberFormatException e) {
820            throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest, SC_BAD_REQUEST, e);
821        } catch (final RuntimeException e) {
822            if (e instanceof IllegalArgumentException) {
823                throw new ClientErrorException("Invalid 'Want-Digest' header value: " + wantDigest + "\n", BAD_REQUEST);
824            }
825            throw e;
826        }
827    }
828
829    private void handleRequestDisallowedOnMemento() {
830        try {
831            addLinkAndOptionsHttpHeaders(resource());
832        } catch (final Exception ex) {
833            // Catch the exception to ensure status 405 for any requests on memento.
834            LOGGER.debug("Unable to add link and options headers for PATCH request to memento path {}: {}.",
835                externalPath, ex.getMessage());
836        }
837
838        LOGGER.info("Unable to handle {} request on a path containing {}. Path was: {}", request.getMethod(),
839            FedoraTypes.FCR_VERSIONS, externalPath);
840    }
841
842    private FedoraId mintNewPid(final FedoraId fedoraId, final String slug) {
843        final String pid;
844
845        if (isGhostNode(transaction(), fedoraId)) {
846            LOGGER.debug("Resource with path {} is an immutable resource; it cannot be POSTed to.", fedoraId);
847            throw new CannotCreateResourceException("Cannot create resource as child of the immutable resource at " +
848                    fedoraId.getFullIdPath());
849        }
850        if (!isBlank(slug)) {
851            pid = slug;
852        } else if (pidMinter != null) {
853            pid = pidMinter.get();
854        } else {
855            pid = defaultPidMinter.get();
856        }
857
858        final FedoraId fullTestPath = fedoraId.resolve(pid);
859        hasRestrictedPath(fullTestPath.getFullIdPath());
860
861        if (doesResourceExist(transaction(), fullTestPath, true) || isGhostNode(transaction(), fullTestPath)) {
862            LOGGER.debug("Resource with path {} already exists or is an immutable resource; minting new path instead",
863                    fullTestPath);
864            return mintNewPid(fedoraId, null);
865        }
866
867        return fullTestPath;
868    }
869
870}