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}