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 io.ocfl.api.MutableOcflRepository; 020import io.ocfl.api.OcflObjectUpdater; 021import io.ocfl.api.OcflOption; 022import io.ocfl.api.exception.OcflInputException; 023import io.ocfl.api.model.DigestAlgorithm; 024import io.ocfl.api.model.ObjectVersionId; 025import io.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 ResourceContent readRange(final String resourceId, final String versionNumber, 258 final long startPosition, final long endPosition) { 259 throw new UnsupportedOperationException("Not implemented"); 260 } 261 262 @Override 263 public ResourceContent readRange(final String resourceId, final long startPosition, final long endPosition) { 264 throw new UnsupportedOperationException("Not implemented"); 265 } 266 267 @Override 268 public List<OcflVersionInfo> listVersions(final String resourceId) { 269 throw new UnsupportedOperationException("Not implemented"); 270 } 271 272 @Override 273 public Stream<ResourceHeaders> streamResourceHeaders() { 274 throw new UnsupportedOperationException("Not implemented"); 275 } 276 277 @Override 278 public void commitType(final CommitType commitType) { 279 throw new UnsupportedOperationException("Not implemented"); 280 } 281 282 @Override 283 public boolean containsResource(final String resourceId) { 284 return ocflRepo.containsObject(ocflObjectId); 285 } 286 287 private Path stagingPath(final String path) { 288 return objectStaging.resolve(path); 289 } 290 291 private Path createStagingPath(final String path) { 292 final var stagingPath = stagingPath(path); 293 294 try { 295 Files.createDirectories(stagingPath.getParent()); 296 } catch (IOException e) { 297 throw new UncheckedIOException(e); 298 } 299 300 return stagingPath; 301 } 302 303 private void write(final InputStream content, final Path destination) { 304 if (content != null) { 305 try { 306 Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING); 307 } catch (IOException e) { 308 throw new UncheckedIOException(e); 309 } 310 } 311 } 312 313 private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) { 314 final var resourceId = headers.getId(); 315 final PersistencePaths paths; 316 317 if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) { 318 throw new UnsupportedOperationException("ACLs are not supported"); 319 } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) { 320 paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId); 321 } else if (headers.getInteractionModel() != null) { 322 paths = PersistencePaths.rdfResource(ocflObjectId, resourceId); 323 } else { 324 throw new IllegalArgumentException( 325 String.format("Interaction model for resource %s must be populated.", resourceId)); 326 } 327 328 return paths; 329 } 330 331 private String encode(final String value) { 332 if (SystemUtils.IS_OS_WINDOWS) { 333 final String encoded; 334 if (value.contains("/")) { 335 encoded = Arrays.stream(value.split("/")) 336 .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8)) 337 .collect(Collectors.joining("/")); 338 } else { 339 encoded = URLEncoder.encode(value, StandardCharsets.UTF_8); 340 } 341 return encoded; 342 } 343 return value; 344 } 345 346 private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) { 347 try (var paths = Files.walk(objectStaging)) { 348 paths.filter(Files::isRegularFile).forEach(file -> { 349 final var logicalPath = windowsStagingPathToLogicalPath(file); 350 updater.addPath(file, logicalPath, ocflOptions); 351 }); 352 } catch (IOException e) { 353 throw new UncheckedIOException(e); 354 } 355 } 356 357 private String windowsStagingPathToLogicalPath(final Path path) { 358 final var normalized = objectStaging.relativize(path).toString() 359 .replace("\\", "/"); 360 return URLDecoder.decode(normalized, StandardCharsets.UTF_8); 361 } 362 363 private void cleanup() { 364 if (Files.exists(objectStaging)) { 365 FileUtils.deleteQuietly(objectStaging.toFile()); 366 } 367 } 368 369 private void enforceOpen() { 370 if (closed) { 371 throw new IllegalStateException( 372 String.format("Session %s is already closed!", sessionId)); 373 } 374 } 375 376}