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}