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 synchronized void commit() {
157        enforceOpen();
158        closed = true;
159
160        if (Files.exists(objectStaging)) {
161            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
162                if (Files.exists(objectStaging)) {
163                    if (SystemUtils.IS_OS_WINDOWS) {
164                        addDecodedPaths(updater, ocflOptions);
165                    } else {
166                        updater.addPath(objectStaging, ocflOptions);
167                    }
168                    digests.forEach((logicalPath, digestInfo) -> {
169                        digestInfo.forEach((digestType, digestValue) -> {
170                            try {
171                                updater.addFileFixity(logicalPath, DigestAlgorithm.fromOcflName(digestType, digestType),
172                                        digestValue);
173                            } catch (OcflInputException e) {
174                                if (!e.getMessage().contains("not newly added in this update")) {
175                                    throw e;
176                                }
177                            }
178                        });
179                    });
180                    updater.clearFixityBlock();
181                }
182            });
183        }
184
185        // Because the of the way ArchiveGroupHandler works, a commit will only ever contains only adds or only deletes
186        if (!deletePaths.isEmpty()) {
187            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
188                deletePaths.forEach(updater::removeFile);
189            });
190        }
191
192        cleanup();
193    }
194
195    @Override
196    public void abort() {
197        if (!closed) {
198            closed = true;
199            cleanup();
200        }
201    }
202
203    @Override
204    public void close() {
205        abort();
206    }
207
208    @Override
209    public void rollback() {
210        throw new UnsupportedOperationException("Rollback is not supported");
211    }
212
213    @Override
214    public boolean isOpen() {
215        return !closed;
216    }
217
218    @Override
219    public void deleteContentFile(final ResourceHeaders headers) {
220        enforceOpen();
221
222        final var paths = resolvePersistencePaths(headers);
223        deletePaths.add(paths.getContentFilePath());
224    }
225
226    @Override
227    public void deleteResource(final String resourceId) {
228        throw new UnsupportedOperationException("Not implemented");
229    }
230
231    @Override
232    public ResourceHeaders readHeaders(final String resourceId) {
233        throw new UnsupportedOperationException("Not implemented");
234    }
235
236    @Override
237    public ResourceHeaders readHeaders(final String resourceId, final String versionNumber) {
238        throw new UnsupportedOperationException("Not implemented");
239    }
240
241    @Override
242    public ResourceContent readContent(final String resourceId) {
243        throw new UnsupportedOperationException("Not implemented");
244    }
245
246    @Override
247    public ResourceContent readContent(final String resourceId, final String versionNumber) {
248        throw new UnsupportedOperationException("Not implemented");
249    }
250
251    @Override
252    public List<OcflVersionInfo> listVersions(final String resourceId) {
253        throw new UnsupportedOperationException("Not implemented");
254    }
255
256    @Override
257    public Stream<ResourceHeaders> streamResourceHeaders() {
258        throw new UnsupportedOperationException("Not implemented");
259    }
260
261    @Override
262    public void commitType(final CommitType commitType) {
263        throw new UnsupportedOperationException("Not implemented");
264    }
265
266    @Override
267    public boolean containsResource(final String resourceId) {
268        return ocflRepo.containsObject(ocflObjectId);
269    }
270
271    private Path stagingPath(final String path) {
272        return objectStaging.resolve(path);
273    }
274
275    private Path createStagingPath(final String path) {
276        final var stagingPath = stagingPath(path);
277
278        try {
279            Files.createDirectories(stagingPath.getParent());
280        } catch (IOException e) {
281            throw new UncheckedIOException(e);
282        }
283
284        return stagingPath;
285    }
286
287    private void write(final InputStream content, final Path destination) {
288        if (content != null) {
289            try {
290                Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING);
291            } catch (IOException e) {
292                throw new UncheckedIOException(e);
293            }
294        }
295    }
296
297    private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) {
298        final var resourceId = headers.getId();
299        final PersistencePaths paths;
300
301        if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) {
302            throw new UnsupportedOperationException("ACLs are not supported");
303        } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) {
304            paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId);
305        } else if (headers.getInteractionModel() != null) {
306            paths = PersistencePaths.rdfResource(ocflObjectId, resourceId);
307        } else {
308            throw new IllegalArgumentException(
309                    String.format("Interaction model for resource %s must be populated.", resourceId));
310        }
311
312        return paths;
313    }
314
315    private String encode(final String value) {
316        if (SystemUtils.IS_OS_WINDOWS) {
317            final String encoded;
318            if (value.contains("/")) {
319                encoded = Arrays.stream(value.split("/"))
320                        .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8))
321                        .collect(Collectors.joining("/"));
322            } else {
323                encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
324            }
325            return  encoded;
326        }
327        return value;
328    }
329
330    private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) {
331        try (var paths = Files.walk(objectStaging)) {
332            paths.filter(Files::isRegularFile).forEach(file -> {
333                final var logicalPath = windowsStagingPathToLogicalPath(file);
334                updater.addPath(file, logicalPath, ocflOptions);
335            });
336        } catch (IOException e) {
337            throw new UncheckedIOException(e);
338        }
339    }
340
341    private String windowsStagingPathToLogicalPath(final Path path) {
342        final var normalized = objectStaging.relativize(path).toString()
343                .replace("\\", "/");
344        return URLDecoder.decode(normalized, StandardCharsets.UTF_8);
345    }
346
347    private void cleanup() {
348        if (Files.exists(objectStaging)) {
349            FileUtils.deleteQuietly(objectStaging.toFile());
350        }
351    }
352
353    private void enforceOpen() {
354        if (closed) {
355            throw new IllegalStateException(
356                    String.format("Session %s is already closed!", sessionId));
357        }
358    }
359
360}