001/*
002 * Copyright 2019 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.fcrepo.migration.handlers.ocfl;
018
019import edu.wisc.library.ocfl.api.MutableOcflRepository;
020import edu.wisc.library.ocfl.api.OcflObjectUpdater;
021import edu.wisc.library.ocfl.api.OcflOption;
022import edu.wisc.library.ocfl.api.exception.OcflInputException;
023import edu.wisc.library.ocfl.api.model.DigestAlgorithm;
024import edu.wisc.library.ocfl.api.model.ObjectVersionId;
025import edu.wisc.library.ocfl.api.model.VersionInfo;
026import org.apache.commons.io.FileUtils;
027import org.apache.commons.lang3.SystemUtils;
028import org.fcrepo.storage.ocfl.CommitType;
029import org.fcrepo.storage.ocfl.InteractionModel;
030import org.fcrepo.storage.ocfl.OcflObjectSession;
031import org.fcrepo.storage.ocfl.OcflVersionInfo;
032import org.fcrepo.storage.ocfl.PersistencePaths;
033import org.fcrepo.storage.ocfl.ResourceContent;
034import org.fcrepo.storage.ocfl.ResourceHeaders;
035
036import java.io.IOException;
037import java.io.InputStream;
038import java.io.UncheckedIOException;
039import java.net.URLDecoder;
040import java.net.URLEncoder;
041import java.nio.charset.StandardCharsets;
042import java.nio.file.Files;
043import java.nio.file.Path;
044import java.nio.file.StandardCopyOption;
045import java.time.OffsetDateTime;
046import java.util.Arrays;
047import java.util.HashSet;
048import java.util.HashMap;
049import java.util.List;
050import java.util.Set;
051import java.util.stream.Collectors;
052import java.util.stream.Stream;
053
054/**
055 * Barebones OcflObjectSession implementation that writes F3 resources to OCFL without F6 resource headers.
056 * Operations other than writing are not supported.
057 *
058 * @author pwinckles
059 */
060public class PlainOcflObjectSession implements OcflObjectSession {
061
062    private final MutableOcflRepository ocflRepo;
063    private final String sessionId;
064    private final String ocflObjectId;
065    private final VersionInfo versionInfo;
066    private final Path objectStaging;
067    private final boolean disableChecksumValidation;
068
069    private final OcflOption[] ocflOptions;
070    private final HashMap<String, HashMap<String, String>> digests;
071    private final Set<String> deletePaths;
072
073    private boolean closed = false;
074
075    /**
076     * @param sessionId the session's id
077     * @param ocflRepo the OCFL client
078     * @param ocflObjectId the OCFL object id
079     * @param objectStaging the object's staging directory
080     * @param disableChecksumValidation whether to verify fedora3 checksums or not
081     */
082    public PlainOcflObjectSession(final String sessionId,
083                                  final MutableOcflRepository ocflRepo,
084                                  final String ocflObjectId,
085                                  final Path objectStaging,
086                                  final boolean disableChecksumValidation) {
087        this.sessionId = sessionId;
088        this.ocflRepo = ocflRepo;
089        this.ocflObjectId = ocflObjectId;
090        this.objectStaging = objectStaging;
091        this.disableChecksumValidation = disableChecksumValidation;
092
093        this.versionInfo = new VersionInfo();
094        this.ocflOptions = new OcflOption[] {OcflOption.MOVE_SOURCE, OcflOption.OVERWRITE};
095        this.digests = new HashMap<>();
096        this.deletePaths = new HashSet<>();
097    }
098
099    @Override
100    public String sessionId() {
101        return sessionId;
102    }
103
104    @Override
105    public String ocflObjectId() {
106        return ocflObjectId;
107    }
108
109    @Override
110    public synchronized ResourceHeaders writeResource(final ResourceHeaders headers, final InputStream content) {
111        enforceOpen();
112
113        final var paths = resolvePersistencePaths(headers);
114        final var logicalPath = paths.getContentFilePath();
115        final var contentPath = encode(logicalPath);
116
117        if (!disableChecksumValidation) {
118            final var externalUrl = headers.getExternalUrl();
119            if (externalUrl == null || externalUrl.isBlank()) {
120                final var headerDigests = headers.getDigests();
121                for (final var uri : headerDigests) {
122                    final var parts = uri.getSchemeSpecificPart().split(":");
123                    final var digestInfo = new HashMap<String, String>();
124                    digestInfo.put(parts[0], parts[1]);
125                    digests.put(logicalPath, digestInfo);
126                }
127            }
128        }
129
130        final var contentDst = createStagingPath(contentPath);
131        write(content, contentDst);
132        return headers;
133    }
134
135    @Override
136    public synchronized void writeHeaders(final ResourceHeaders headers) {
137        throw new UnsupportedOperationException("Not implemented");
138    }
139
140    @Override
141    public void versionCreationTimestamp(final OffsetDateTime timestamp) {
142        versionInfo.setCreated(timestamp);
143    }
144
145    @Override
146    public void versionAuthor(final String name, final String address) {
147        versionInfo.setUser(name, address);
148    }
149
150    @Override
151    public void versionMessage(final String message) {
152        versionInfo.setMessage(message);
153    }
154
155    @Override
156    public void invalidateCache(final String objectId) {
157        ocflRepo.invalidateCache(objectId);
158    }
159
160    @Override
161    public synchronized void commit() {
162        enforceOpen();
163        closed = true;
164
165        if (Files.exists(objectStaging)) {
166            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
167                if (Files.exists(objectStaging)) {
168                    if (SystemUtils.IS_OS_WINDOWS) {
169                        addDecodedPaths(updater, ocflOptions);
170                    } else {
171                        updater.addPath(objectStaging, ocflOptions);
172                    }
173                    digests.forEach((logicalPath, digestInfo) -> {
174                        digestInfo.forEach((digestType, digestValue) -> {
175                            try {
176                                updater.addFileFixity(logicalPath, DigestAlgorithm.fromOcflName(digestType, digestType),
177                                        digestValue);
178                            } catch (OcflInputException e) {
179                                if (!e.getMessage().contains("not newly added in this update")) {
180                                    throw e;
181                                }
182                            }
183                        });
184                    });
185                    updater.clearFixityBlock();
186                }
187            });
188        }
189
190        // Because the of the way ArchiveGroupHandler works, a commit will only ever contains only adds or only deletes
191        if (!deletePaths.isEmpty()) {
192            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
193                deletePaths.forEach(updater::removeFile);
194            });
195        }
196
197        cleanup();
198    }
199
200    @Override
201    public void abort() {
202        if (!closed) {
203            closed = true;
204            cleanup();
205        }
206    }
207
208    @Override
209    public void close() {
210        abort();
211    }
212
213    @Override
214    public void rollback() {
215        throw new UnsupportedOperationException("Rollback is not supported");
216    }
217
218    @Override
219    public boolean isOpen() {
220        return !closed;
221    }
222
223    @Override
224    public void deleteContentFile(final ResourceHeaders headers) {
225        enforceOpen();
226
227        final var paths = resolvePersistencePaths(headers);
228        deletePaths.add(paths.getContentFilePath());
229    }
230
231    @Override
232    public void deleteResource(final String resourceId) {
233        throw new UnsupportedOperationException("Not implemented");
234    }
235
236    @Override
237    public ResourceHeaders readHeaders(final String resourceId) {
238        throw new UnsupportedOperationException("Not implemented");
239    }
240
241    @Override
242    public ResourceHeaders readHeaders(final String resourceId, final String versionNumber) {
243        throw new UnsupportedOperationException("Not implemented");
244    }
245
246    @Override
247    public ResourceContent readContent(final String resourceId) {
248        throw new UnsupportedOperationException("Not implemented");
249    }
250
251    @Override
252    public ResourceContent readContent(final String resourceId, final String versionNumber) {
253        throw new UnsupportedOperationException("Not implemented");
254    }
255
256    @Override
257    public List<OcflVersionInfo> listVersions(final String resourceId) {
258        throw new UnsupportedOperationException("Not implemented");
259    }
260
261    @Override
262    public Stream<ResourceHeaders> streamResourceHeaders() {
263        throw new UnsupportedOperationException("Not implemented");
264    }
265
266    @Override
267    public void commitType(final CommitType commitType) {
268        throw new UnsupportedOperationException("Not implemented");
269    }
270
271    @Override
272    public boolean containsResource(final String resourceId) {
273        return ocflRepo.containsObject(ocflObjectId);
274    }
275
276    private Path stagingPath(final String path) {
277        return objectStaging.resolve(path);
278    }
279
280    private Path createStagingPath(final String path) {
281        final var stagingPath = stagingPath(path);
282
283        try {
284            Files.createDirectories(stagingPath.getParent());
285        } catch (IOException e) {
286            throw new UncheckedIOException(e);
287        }
288
289        return stagingPath;
290    }
291
292    private void write(final InputStream content, final Path destination) {
293        if (content != null) {
294            try {
295                Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING);
296            } catch (IOException e) {
297                throw new UncheckedIOException(e);
298            }
299        }
300    }
301
302    private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) {
303        final var resourceId = headers.getId();
304        final PersistencePaths paths;
305
306        if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) {
307            throw new UnsupportedOperationException("ACLs are not supported");
308        } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) {
309            paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId);
310        } else if (headers.getInteractionModel() != null) {
311            paths = PersistencePaths.rdfResource(ocflObjectId, resourceId);
312        } else {
313            throw new IllegalArgumentException(
314                    String.format("Interaction model for resource %s must be populated.", resourceId));
315        }
316
317        return paths;
318    }
319
320    private String encode(final String value) {
321        if (SystemUtils.IS_OS_WINDOWS) {
322            final String encoded;
323            if (value.contains("/")) {
324                encoded = Arrays.stream(value.split("/"))
325                        .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8))
326                        .collect(Collectors.joining("/"));
327            } else {
328                encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
329            }
330            return  encoded;
331        }
332        return value;
333    }
334
335    private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) {
336        try (var paths = Files.walk(objectStaging)) {
337            paths.filter(Files::isRegularFile).forEach(file -> {
338                final var logicalPath = windowsStagingPathToLogicalPath(file);
339                updater.addPath(file, logicalPath, ocflOptions);
340            });
341        } catch (IOException e) {
342            throw new UncheckedIOException(e);
343        }
344    }
345
346    private String windowsStagingPathToLogicalPath(final Path path) {
347        final var normalized = objectStaging.relativize(path).toString()
348                .replace("\\", "/");
349        return URLDecoder.decode(normalized, StandardCharsets.UTF_8);
350    }
351
352    private void cleanup() {
353        if (Files.exists(objectStaging)) {
354            FileUtils.deleteQuietly(objectStaging.toFile());
355        }
356    }
357
358    private void enforceOpen() {
359        if (closed) {
360            throw new IllegalStateException(
361                    String.format("Session %s is already closed!", sessionId));
362        }
363    }
364
365}