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.model.ObjectVersionId;
023import edu.wisc.library.ocfl.api.model.VersionInfo;
024import org.apache.commons.io.FileUtils;
025import org.apache.commons.lang3.SystemUtils;
026import org.fcrepo.storage.ocfl.CommitType;
027import org.fcrepo.storage.ocfl.InteractionModel;
028import org.fcrepo.storage.ocfl.OcflObjectSession;
029import org.fcrepo.storage.ocfl.OcflVersionInfo;
030import org.fcrepo.storage.ocfl.PersistencePaths;
031import org.fcrepo.storage.ocfl.ResourceContent;
032import org.fcrepo.storage.ocfl.ResourceHeaders;
033
034import java.io.IOException;
035import java.io.InputStream;
036import java.io.UncheckedIOException;
037import java.net.URLDecoder;
038import java.net.URLEncoder;
039import java.nio.charset.StandardCharsets;
040import java.nio.file.Files;
041import java.nio.file.Path;
042import java.nio.file.StandardCopyOption;
043import java.time.OffsetDateTime;
044import java.util.Arrays;
045import java.util.HashSet;
046import java.util.List;
047import java.util.Set;
048import java.util.stream.Collectors;
049import java.util.stream.Stream;
050
051/**
052 * Barebones OcflObjectSession implementation that writes F3 resources to OCFL without F6 resource headers.
053 * Operations other than writing are not supported.
054 *
055 * @author pwinckles
056 */
057public class PlainOcflObjectSession implements OcflObjectSession {
058
059    private final MutableOcflRepository ocflRepo;
060    private final String sessionId;
061    private final String ocflObjectId;
062    private final VersionInfo versionInfo;
063    private final Path objectStaging;
064
065    private final OcflOption[] ocflOptions;
066
067    private final Set<String> deletePaths;
068
069    private boolean closed = false;
070
071    /**
072     * @param sessionId the session's id
073     * @param ocflRepo the OCFL client
074     * @param ocflObjectId the OCFL object id
075     * @param objectStaging the object's staging directory
076     */
077    public PlainOcflObjectSession(final String sessionId,
078                                  final MutableOcflRepository ocflRepo,
079                                  final String ocflObjectId,
080                                  final Path objectStaging) {
081        this.sessionId = sessionId;
082        this.ocflRepo = ocflRepo;
083        this.ocflObjectId = ocflObjectId;
084        this.objectStaging = objectStaging;
085
086        this.versionInfo = new VersionInfo();
087        this.ocflOptions = new OcflOption[] {OcflOption.MOVE_SOURCE, OcflOption.OVERWRITE};
088        this.deletePaths = new HashSet<>();
089    }
090
091    @Override
092    public String sessionId() {
093        return sessionId;
094    }
095
096    @Override
097    public String ocflObjectId() {
098        return ocflObjectId;
099    }
100
101    @Override
102    public synchronized ResourceHeaders writeResource(final ResourceHeaders headers, final InputStream content) {
103        enforceOpen();
104
105        final var paths = resolvePersistencePaths(headers);
106
107        final var contentPath = encode(paths.getContentFilePath());
108
109        final var contentDst = createStagingPath(contentPath);
110        write(content, contentDst);
111        return headers;
112    }
113
114    @Override
115    public synchronized void writeHeaders(final ResourceHeaders headers) {
116        throw new UnsupportedOperationException("Not implemented");
117    }
118
119    @Override
120    public void versionCreationTimestamp(final OffsetDateTime timestamp) {
121        versionInfo.setCreated(timestamp);
122    }
123
124    @Override
125    public void versionAuthor(final String name, final String address) {
126        versionInfo.setUser(name, address);
127    }
128
129    @Override
130    public void versionMessage(final String message) {
131        versionInfo.setMessage(message);
132    }
133
134    @Override
135    public synchronized void commit() {
136        enforceOpen();
137        closed = true;
138
139        if (Files.exists(objectStaging)) {
140            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
141                if (Files.exists(objectStaging)) {
142                    if (SystemUtils.IS_OS_WINDOWS) {
143                        addDecodedPaths(updater, ocflOptions);
144                    } else {
145                        updater.addPath(objectStaging, ocflOptions);
146                    }
147                }
148            });
149        }
150
151        // Because the of the way ArchiveGroupHandler works, a commit will only ever contains only adds or only deletes
152        if (!deletePaths.isEmpty()) {
153            ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> {
154                deletePaths.forEach(updater::removeFile);
155            });
156        }
157
158        cleanup();
159    }
160
161    @Override
162    public void abort() {
163        if (!closed) {
164            closed = true;
165            cleanup();
166        }
167    }
168
169    @Override
170    public void close() {
171        abort();
172    }
173
174    @Override
175    public boolean isOpen() {
176        return !closed;
177    }
178
179    @Override
180    public void deleteContentFile(final ResourceHeaders headers) {
181        enforceOpen();
182
183        final var paths = resolvePersistencePaths(headers);
184        deletePaths.add(paths.getContentFilePath());
185    }
186
187    @Override
188    public void deleteResource(final String resourceId) {
189        throw new UnsupportedOperationException("Not implemented");
190    }
191
192    @Override
193    public ResourceHeaders readHeaders(final String resourceId) {
194        throw new UnsupportedOperationException("Not implemented");
195    }
196
197    @Override
198    public ResourceHeaders readHeaders(final String resourceId, final String versionNumber) {
199        throw new UnsupportedOperationException("Not implemented");
200    }
201
202    @Override
203    public ResourceContent readContent(final String resourceId) {
204        throw new UnsupportedOperationException("Not implemented");
205    }
206
207    @Override
208    public ResourceContent readContent(final String resourceId, final String versionNumber) {
209        throw new UnsupportedOperationException("Not implemented");
210    }
211
212    @Override
213    public List<OcflVersionInfo> listVersions(final String resourceId) {
214        throw new UnsupportedOperationException("Not implemented");
215    }
216
217    @Override
218    public Stream<ResourceHeaders> streamResourceHeaders() {
219        throw new UnsupportedOperationException("Not implemented");
220    }
221
222    @Override
223    public void commitType(final CommitType commitType) {
224        throw new UnsupportedOperationException("Not implemented");
225    }
226
227    @Override
228    public boolean containsResource(final String resourceId) {
229        throw new UnsupportedOperationException("Not implemented");
230    }
231
232    private Path stagingPath(final String path) {
233        return objectStaging.resolve(path);
234    }
235
236    private Path createStagingPath(final String path) {
237        final var stagingPath = stagingPath(path);
238
239        try {
240            Files.createDirectories(stagingPath.getParent());
241        } catch (IOException e) {
242            throw new UncheckedIOException(e);
243        }
244
245        return stagingPath;
246    }
247
248    private void write(final InputStream content, final Path destination) {
249        if (content != null) {
250            try {
251                Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING);
252            } catch (IOException e) {
253                throw new UncheckedIOException(e);
254            }
255        }
256    }
257
258    private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) {
259        final var resourceId = headers.getId();
260        final PersistencePaths paths;
261
262        if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) {
263            throw new UnsupportedOperationException("ACLs are not supported");
264        } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) {
265            paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId);
266        } else if (headers.getInteractionModel() != null) {
267            paths = PersistencePaths.rdfResource(ocflObjectId, resourceId);
268        } else {
269            throw new IllegalArgumentException(
270                    String.format("Interaction model for resource %s must be populated.", resourceId));
271        }
272
273        return paths;
274    }
275
276    private String encode(final String value) {
277        if (SystemUtils.IS_OS_WINDOWS) {
278            final String encoded;
279            if (value.contains("/")) {
280                encoded = Arrays.stream(value.split("/"))
281                        .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8))
282                        .collect(Collectors.joining("/"));
283            } else {
284                encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
285            }
286            return  encoded;
287        }
288        return value;
289    }
290
291    private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) {
292        try (var paths = Files.walk(objectStaging)) {
293            paths.filter(Files::isRegularFile).forEach(file -> {
294                final var logicalPath = windowsStagingPathToLogicalPath(file);
295                updater.addPath(file, logicalPath, ocflOptions);
296            });
297        } catch (IOException e) {
298            throw new UncheckedIOException(e);
299        }
300    }
301
302    private String windowsStagingPathToLogicalPath(final Path path) {
303        final var normalized = objectStaging.relativize(path).toString()
304                .replace("\\", "/");
305        return URLDecoder.decode(normalized, StandardCharsets.UTF_8);
306    }
307
308    private void cleanup() {
309        if (Files.exists(objectStaging)) {
310            FileUtils.deleteQuietly(objectStaging.toFile());
311        }
312    }
313
314    private void enforceOpen() {
315        if (closed) {
316            throw new IllegalStateException(
317                    String.format("Session %s is already closed!", sessionId));
318        }
319    }
320
321}