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 javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
021import static javax.ws.rs.core.HttpHeaders.LINK;
022import static javax.ws.rs.core.Response.Status.CONFLICT;
023import static javax.ws.rs.core.Response.Status.UNSUPPORTED_MEDIA_TYPE;
024import static javax.ws.rs.core.Response.ok;
025import static org.apache.commons.lang3.StringUtils.isBlank;
026import static org.apache.jena.riot.RDFLanguages.contentTypeToLang;
027import static org.fcrepo.http.commons.domain.RDFMediaType.APPLICATION_LINK_FORMAT;
028import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD;
029import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET;
030import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET;
031import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES;
032import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML;
033import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET;
034import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET;
035import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET;
036import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X;
037import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS;
038import static org.fcrepo.kernel.api.services.VersionService.MEMENTO_RFC_1123_FORMATTER;
039import static org.slf4j.LoggerFactory.getLogger;
040
041import java.io.IOException;
042import java.io.InputStream;
043import java.net.URI;
044import java.time.Instant;
045import java.time.ZoneId;
046import java.time.format.DateTimeParseException;
047import java.util.ArrayList;
048import java.util.Arrays;
049import java.util.Collection;
050import java.util.Comparator;
051import java.util.HashSet;
052import java.util.List;
053import java.util.stream.Collectors;
054
055import javax.jcr.ItemExistsException;
056import javax.servlet.http.HttpServletResponse;
057import javax.ws.rs.BadRequestException;
058import javax.ws.rs.ClientErrorException;
059import javax.ws.rs.GET;
060import javax.ws.rs.HeaderParam;
061import javax.ws.rs.OPTIONS;
062import javax.ws.rs.POST;
063import javax.ws.rs.Path;
064import javax.ws.rs.PathParam;
065import javax.ws.rs.Produces;
066import javax.ws.rs.core.Context;
067import javax.ws.rs.core.Link;
068import javax.ws.rs.core.Link.Builder;
069import javax.ws.rs.core.MediaType;
070import javax.ws.rs.core.Request;
071import javax.ws.rs.core.Response;
072import javax.ws.rs.core.UriInfo;
073import com.google.common.annotations.VisibleForTesting;
074import org.apache.jena.riot.Lang;
075import org.fcrepo.http.api.PathLockManager.AcquiredLock;
076import org.fcrepo.http.commons.responses.HtmlTemplate;
077import org.fcrepo.http.commons.responses.LinkFormatStream;
078import org.fcrepo.kernel.api.RdfStream;
079import org.fcrepo.kernel.api.exception.InvalidChecksumException;
080import org.fcrepo.kernel.api.exception.MementoDatetimeFormatException;
081import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
082import org.fcrepo.kernel.api.exception.UnsupportedAlgorithmException;
083import org.fcrepo.kernel.api.models.FedoraBinary;
084import org.fcrepo.kernel.api.models.FedoraResource;
085import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
086import org.slf4j.Logger;
087import org.springframework.context.annotation.Scope;
088
089/**
090 * @author cabeer
091 * @since 9/25/14
092 */
093@Scope("request")
094@Path("/{path: .*}/fcr:versions")
095public class FedoraVersioning extends ContentExposingResource {
096
097    private static final Logger LOGGER = getLogger(FedoraVersioning.class);
098
099    @VisibleForTesting
100    public static final String MEMENTO_DATETIME_HEADER = "Memento-Datetime";
101
102    @Context protected Request request;
103    @Context protected HttpServletResponse servletResponse;
104    @Context protected UriInfo uriInfo;
105
106    @PathParam("path") protected String externalPath;
107
108
109    /**
110     * Default JAX-RS entry point
111     */
112    public FedoraVersioning() {
113        super();
114    }
115
116    /**
117     * Create a new FedoraNodes instance for a given path
118     * @param externalPath the external path
119     */
120    @VisibleForTesting
121    public FedoraVersioning(final String externalPath) {
122        this.externalPath = externalPath;
123    }
124
125    /**
126     * Create a new version of a resource. If a memento-datetime header is provided, then the new version will be
127     * based off the provided body using that datetime. If one was not provided, then a version is created based off
128     * the current version of the resource.
129     *
130     * @param datetimeHeader memento-datetime header
131     * @param requestContentType Content-Type of the request body
132     * @param digest digests of the request body
133     * @param requestBodyStream request body stream
134     * @return response
135     * @throws InvalidChecksumException thrown if one of the provided digests does not match the content
136     * @throws MementoDatetimeFormatException if the header value of memento-datetime is not RFC-1123 format
137     */
138    @POST
139    public Response addVersion(@HeaderParam(MEMENTO_DATETIME_HEADER) final String datetimeHeader,
140            @HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
141            @HeaderParam("Digest") final String digest,
142            final InputStream requestBodyStream,
143            @HeaderParam(LINK) final List<String> rawLinks)
144            throws InvalidChecksumException, MementoDatetimeFormatException {
145
146        final FedoraResource resource = resource();
147        final FedoraResource timeMap = resource.getTimeMap();
148
149        final AcquiredLock lock = lockManager.lockForWrite(timeMap.getPath(),
150            session.getFedoraSession(), nodeService);
151
152        try {
153            final MediaType contentType = getSimpleContentType(requestContentType);
154
155            final String slug = headers.getHeaderString("Slug");
156            if (slug != null) {
157                throw new BadRequestException("Slug header is no longer supported for versioning label. "
158                        + "Please use " + MEMENTO_DATETIME_HEADER + " header with RFC-1123 date-time.");
159            }
160
161            final Instant mementoInstant;
162            try {
163                mementoInstant = (isBlank(datetimeHeader) ? Instant.now()
164                    : Instant.from(MEMENTO_RFC_1123_FORMATTER.parse(datetimeHeader)));
165            } catch (final DateTimeParseException e) {
166                throw new MementoDatetimeFormatException("Invalid memento date-time value. "
167                        + "Please use RFC-1123 date-time format, such as 'Tue, 3 Jun 2008 11:05:30 GMT'", e);
168            }
169
170            final boolean createFromExisting = isBlank(datetimeHeader);
171
172            try {
173                LOGGER.debug("Request to add version for date '{}' for '{}'", datetimeHeader, externalPath);
174
175                // Create memento
176                FedoraResource memento = null;
177                final boolean isBinary = resource instanceof FedoraBinary;
178                if (isBinary) {
179                    final FedoraBinary binaryResource = (FedoraBinary) resource;
180                    if (createFromExisting) {
181                        memento = versionService.createBinaryVersion(session.getFedoraSession(),
182                                binaryResource, mementoInstant, storagePolicyDecisionPoint);
183                    } else {
184                        final List<String> links = unpackLinks(rawLinks);
185                        final ExternalContentHandler extContent = extContentHandlerFactory.createFromLinks(links);
186
187                        memento = createBinaryMementoFromRequest(binaryResource, mementoInstant,
188                                requestBodyStream, extContent, digest);
189                    }
190                }
191                // Create rdf memento if the request resource was an rdf resource or a binary from the
192                // current version of the original resource.
193                if (!isBinary || createFromExisting) {
194                    // Version the description in case the original is a binary
195                    final FedoraResource originalResource = resource().getDescription();
196                    final InputStream bodyStream = createFromExisting ? null : requestBodyStream;
197                    final Lang format = createFromExisting ? null : contentTypeToLang(contentType.toString());
198                    if (!createFromExisting && format == null) {
199                        throw new ClientErrorException("Invalid Content Type " + contentType.toString(),
200                                UNSUPPORTED_MEDIA_TYPE);
201                    }
202
203                    final FedoraResource rdfMemento = versionService.createVersion(session.getFedoraSession(),
204                            originalResource, idTranslator, mementoInstant, bodyStream, format);
205                    // If a binary memento was also generated, use the binary in the response
206                    if (!isBinary) {
207                        memento = rdfMemento;
208                    }
209                }
210
211                session.commit();
212                return createUpdateResponse(memento, true);
213            } catch (final Exception e) {
214                checkForInsufficientStorageException(e, e);
215                return null; // not reachable
216            }
217        } catch (final RepositoryRuntimeException e) {
218            if (e.getCause() instanceof ItemExistsException) {
219                throw new ClientErrorException("Memento with provided datetime already exists",
220                        CONFLICT);
221            } else {
222                throw e;
223            }
224        } finally {
225            lock.release();
226        }
227    }
228
229    private FedoraBinary createBinaryMementoFromRequest(final FedoraBinary binaryResource,
230            final Instant mementoInstant,
231            final InputStream requestBodyStream,
232            final ExternalContentHandler extContent,
233            final String digest) throws InvalidChecksumException, UnsupportedAlgorithmException {
234
235        final Collection<String> checksums = parseDigestHeader(digest);
236        final Collection<URI> checksumURIs = checksums == null ? new HashSet<>() : checksums.stream().map(
237                checksum -> checksumURI(checksum)).collect(Collectors.toSet());
238
239        // Create internal binary either from supplied body or copy external uri
240        if (extContent == null || extContent.isCopy()) {
241            InputStream contentStream = requestBodyStream;
242            if (extContent != null) {
243                contentStream = extContent.fetchExternalContent();
244            }
245
246            return versionService.createBinaryVersion(session.getFedoraSession(), binaryResource,
247                    mementoInstant, contentStream, checksumURIs, storagePolicyDecisionPoint);
248        } else {
249            return versionService.createExternalBinaryVersion(session.getFedoraSession(), binaryResource,
250                    mementoInstant, checksumURIs, extContent.getHandling(), extContent.getURL());
251        }
252    }
253
254    /**
255     * Get the list of versions for the object
256     *
257     * @param rangeValue starting and ending byte offsets
258     * @param acceptValue the rdf media-type
259     * @return List of versions for the object as RDF
260     * @throws IOException in case of error extracting content
261     */
262    @GET
263    @HtmlTemplate(value = "fcr:versions")
264    @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
265        N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
266        TURTLE_X, TEXT_HTML_WITH_CHARSET, APPLICATION_LINK_FORMAT })
267    public Response getVersionList(@HeaderParam("Range") final String rangeValue,
268        @HeaderParam("Accept") final String acceptValue) throws IOException {
269
270        final FedoraResource theTimeMap = resource().getTimeMap();
271        checkCacheControlHeaders(request, servletResponse, theTimeMap, session);
272
273        LOGGER.debug("GET resource '{}'", externalPath);
274
275        addResourceHttpHeaders(theTimeMap);
276
277        if (acceptValue != null && acceptValue.equalsIgnoreCase(APPLICATION_LINK_FORMAT)) {
278            final URI parentUri = getUri(resource());
279            final List<Link> versionLinks = new ArrayList<>();
280            versionLinks.add(Link.fromUri(parentUri).rel("original").build());
281            versionLinks.add(Link.fromUri(parentUri).rel("timegate").build());
282            // So we don't collect the children twice, store them in an array.
283            final FedoraResource[] children = theTimeMap.getChildren().toArray(FedoraResource[]::new);
284
285            Arrays.stream(children).forEach(t -> {
286                final URI childUri = getUri(t);
287                versionLinks.add(Link.fromUri(childUri).rel("memento")
288                                     .param("datetime",
289                        MEMENTO_RFC_1123_FORMATTER.format(t.getMementoDatetime()))
290                                     .build());
291            });
292            // Based on the dates of the above mementos, add the range to the below link.
293            final Instant[] Mementos = Arrays.stream(children).map(FedoraResource::getMementoDatetime)
294                .sorted(Comparator.naturalOrder())
295                .toArray(Instant[]::new);
296            final Builder linkBuilder =
297                Link.fromUri(parentUri + "/" + FCR_VERSIONS).rel("self").type(APPLICATION_LINK_FORMAT);
298            if (Mementos.length >= 2) {
299                // There are 2 or more Mementos so make a range.
300                linkBuilder.param("from", MEMENTO_RFC_1123_FORMATTER.format(Mementos[0].atZone(ZoneId.of("UTC"))));
301                linkBuilder.param("until",
302                    MEMENTO_RFC_1123_FORMATTER.format(Mementos[Mementos.length - 1].atZone(ZoneId.of("UTC"))));
303            }
304            versionLinks.add(linkBuilder.build());
305            return ok(new LinkFormatStream(versionLinks.stream())).build();
306        } else {
307            final AcquiredLock readLock = lockManager.lockForRead(theTimeMap.getPath());
308            try (final RdfStream rdfStream = new DefaultRdfStream(asNode(theTimeMap))) {
309                return getContent(rangeValue, getChildrenLimit(), rdfStream, theTimeMap);
310            } finally {
311                readLock.release();
312            }
313        }
314    }
315
316    /**
317     * Outputs information about the supported HTTP methods, etc.
318     *
319     * @return the information about the supported HTTP methods, etc.
320     */
321    @OPTIONS
322    public Response options() {
323        final FedoraResource theTimeMap = resource().getTimeMap();
324        LOGGER.info("OPTIONS for '{}'", externalPath);
325        addResourceHttpHeaders(theTimeMap);
326        return ok().build();
327    }
328
329    @Override
330    protected String externalPath() {
331        return externalPath;
332    }
333}