001/* 002 * Copyright 2020 Anyware Services 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 */ 016package org.ametys.plugins.contentio.archive; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.nio.file.Path; 021import java.util.Map; 022import java.util.Map.Entry; 023import java.util.Properties; 024import java.util.zip.ZipEntry; 025import java.util.zip.ZipOutputStream; 026 027import javax.jcr.Node; 028import javax.jcr.RepositoryException; 029import javax.jcr.Session; 030import javax.jcr.nodetype.NodeType; 031import javax.xml.transform.OutputKeys; 032import javax.xml.transform.TransformerConfigurationException; 033import javax.xml.transform.TransformerException; 034import javax.xml.transform.TransformerFactory; 035import javax.xml.transform.sax.SAXTransformerFactory; 036import javax.xml.transform.sax.TransformerHandler; 037import javax.xml.transform.stream.StreamResult; 038 039import org.apache.cocoon.util.HashUtil; 040import org.apache.commons.io.IOUtils; 041import org.apache.commons.lang3.RegExUtils; 042import org.apache.commons.lang3.StringUtils; 043import org.apache.jackrabbit.core.NodeImpl; 044import org.apache.xml.serializer.OutputPropertiesFactory; 045import org.slf4j.Logger; 046import org.xml.sax.SAXException; 047 048import org.ametys.cms.data.Binary; 049import org.ametys.cms.data.NamedResource; 050import org.ametys.cms.data.RichText; 051import org.ametys.cms.data.type.ModelItemTypeConstants; 052import org.ametys.plugins.repository.AmetysObject; 053import org.ametys.plugins.repository.RepositoryConstants; 054import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 055import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper; 056 057/** 058 * Convenient methods for {@link Archiver} API implementations 059 */ 060public final class Archivers 061{ 062 /** The warning message for a root of {@link Archiver} which stil has pending change while we do a unitary save on every imported object. */ 063 public static final String WARN_MESSAGE_ROOT_HAS_PENDING_CHANGES = "{} still has pending changes while we should have saved unitarily every object. Another save will be done, but it is not normal."; 064 065 static final String __BINARY_ATTRIBUTES_FOLDER_NAME = "_binaryAttributes"; 066 static final String __FILE_ATTRIBUTES_FOLDER_NAME = "_fileAttributes"; 067 static final String __RICH_TEXT_ATTACHMENTS_FOLDER_NAME = "_richTextAttachments"; 068 069 private static final Properties __OUTPUT_FORMAT_PROPERTIES = new Properties(); 070 static 071 { 072 __OUTPUT_FORMAT_PROPERTIES.put(OutputKeys.ENCODING, "UTF-8"); 073 __OUTPUT_FORMAT_PROPERTIES.put(OutputKeys.METHOD, "xml"); 074 __OUTPUT_FORMAT_PROPERTIES.put(OutputKeys.INDENT, "yes"); 075 __OUTPUT_FORMAT_PROPERTIES.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4"); 076 } 077 078 private static SAXTransformerFactory __transformerFactory; 079 080 private Archivers() 081 { 082 // Nothing 083 } 084 085 /** 086 * Gets a {@link SAXTransformerFactory} 087 * @return a {@link SAXTransformerFactory} 088 */ 089 public static SAXTransformerFactory getSaxTransformerFactory() 090 { 091 if (__transformerFactory == null) 092 { 093 __transformerFactory = (SAXTransformerFactory) TransformerFactory.newInstance(); 094 } 095 return __transformerFactory; 096 } 097 098 /** 099 * Get a TransformerHandler object that can process SAXContentHandler events into a Result 100 * @return A non-null reference to a TransformerHandler, that maybe used as a ContentHandler for SAX parse events. 101 * @throws TransformerConfigurationException If for some reason theTransformerHandler cannot be created. 102 */ 103 public static TransformerHandler newTransformerHandler() throws TransformerConfigurationException 104 { 105 TransformerHandler transformerHandler = getSaxTransformerFactory().newTransformerHandler(); 106 setStandardOutputProperties(transformerHandler); 107 return transformerHandler; 108 } 109 110 /** 111 * Sets standard output properties to the transformer of the given handler, such as encoding and indentation. 112 * @param transformerHandler The transformer handler 113 */ 114 public static void setStandardOutputProperties(TransformerHandler transformerHandler) 115 { 116 transformerHandler.getTransformer().setOutputProperties(__OUTPUT_FORMAT_PROPERTIES); 117 } 118 119 /** 120 * Computes a hashed path from an object name. 121 * @param name the object name. 122 * @return a hashed path. 123 */ 124 public static String getHashedPath(String name) 125 { 126 long hash = Math.abs(HashUtil.hash(name)); 127 String hashStr = Long.toString(hash, 16); 128 hashStr = StringUtils.leftPad(hashStr, 4, '0'); 129 130 return hashStr.substring(0, 2) + "/" + hashStr.substring(2, 4); 131 } 132 133 /** 134 * Export ACL sub-node 135 * @param node The JCR node 136 * @param zos the ZIP OutputStream. 137 * @param path the zip entry path 138 * @throws RepositoryException if an error occurs 139 * @throws IOException if an I/O error occurs 140 */ 141 public static void exportAcl(Node node, ZipOutputStream zos, String path) throws RepositoryException, IOException 142 { 143 try 144 { 145 if (node.hasNode("ametys-internal:acl")) 146 { 147 ZipEntry aclEntry = new ZipEntry(path); 148 zos.putNextEntry(aclEntry); 149 150 TransformerHandler aclHandler = Archivers.newTransformerHandler(); 151 aclHandler.setResult(new StreamResult(zos)); 152 153 node.getSession().exportSystemView(node.getNode("ametys-internal:acl").getPath(), aclHandler, true, false); 154 } 155 } 156 catch (SAXException | TransformerConfigurationException e) 157 { 158 throw new RuntimeException("Unable to SAX ACL for node '" + node.getPath() + "' for archiving", e); 159 } 160 } 161 162 /** 163 * Import ACL sub-node 164 * @param node The JCR node 165 * @param zipPath the input zip path 166 * @param merger The {@link Merger} 167 * @param zipEntryPath the zip entry path 168 * @param logger The logger 169 * @throws RepositoryException if an error occurs 170 * @throws IOException if an I/O error occurs 171 */ 172 public static void importAcl(Node node, Path zipPath, Merger merger, String zipEntryPath, Logger logger) throws RepositoryException, IOException 173 { 174 if (ZipEntryHelper.zipEntryFileExists(zipPath, zipEntryPath)) 175 { 176 if (node.hasNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":acl")) 177 { 178 Node existingAclNode = node.getNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":acl"); 179 logger.info("Existing ACL node '{}' will be removed", existingAclNode); 180 existingAclNode.remove(); 181 } 182 Session session = node.getSession(); 183 String parentAbsPath = node.getPath(); 184 int uuidBehavior = merger.getImportUuidBehavior(); 185 try (InputStream in = ZipEntryHelper.zipEntryFileInputStream(zipPath, zipEntryPath)) 186 { 187 session.importXML(parentAbsPath, in, uuidBehavior); 188 logger.info("XML from '{}!{}' imported to '{}' with uuidBehavior '{}'", zipPath, zipEntryPath, parentAbsPath, uuidBehavior); 189 } 190 } 191 } 192 193 /** 194 * Evaluates a non-empty XPath query. If the result is empty, an {@link AmetysObjectNotImportedException} is thrown. 195 * @param xPath The XPath query 196 * @param domNode The DOM node 197 * @return The evaluation as String 198 * @throws AmetysObjectNotImportedException If the result is empty 199 * @throws TransformerException if an evaluation error occured 200 */ 201 public static String xpathEvalNonEmpty(String xPath, org.w3c.dom.Node domNode) throws AmetysObjectNotImportedException, TransformerException 202 { 203 String val = DomNodeHelper.nullableStringValue(domNode, xPath); 204 if (StringUtils.isEmpty(val)) 205 { 206 throw new AmetysObjectNotImportedException(String.format("'%s' xPath value is empty while it is required. The Ametys Object could not be imported.", xPath)); 207 } 208 return val; 209 } 210 211 /** 212 * Exception indicating an Ametys Object cannot be imported 213 */ 214 public static final class AmetysObjectNotImportedException extends Exception 215 { 216 /** 217 * Constructor with detail message 218 * @param message The detail message 219 */ 220 public AmetysObjectNotImportedException(String message) 221 { 222 super(message); 223 } 224 225 /** 226 * Constructor with cause 227 * @param cause The cause 228 */ 229 public AmetysObjectNotImportedException(Throwable cause) 230 { 231 super(cause); 232 } 233 } 234 235 /** 236 * Replace the given JCR Node by a copy of it with the given UUID. The source JCR Node is removed. 237 * @param srcNode The source JCR Node 238 * @param uuid The desired UUID 239 * @return The JCR Node with the desired UUID 240 * @throws RepositoryException if an error occurs 241 */ 242 public static Node replaceNodeWithDesiredUuid(Node srcNode, String uuid) throws RepositoryException 243 { 244 // The passed 'srcNode' was created just to create its node hierarchy and retrieve its mixin node types 245 // But immediatly after that, remove it because the uuid was not chosen 246 NodeImpl parentNode = (NodeImpl) srcNode.getParent(); 247 String name = srcNode.getName(); 248 String type = srcNode.getPrimaryNodeType().getName(); 249 NodeType[] mixinNodeTypes = srcNode.getMixinNodeTypes(); 250 srcNode.remove(); 251 252 // Add a node with the desired uuid at the same place than the first one (which was just removed) 253 // And set its mixin node types 254 Node nodeWithDesiredUuid = parentNode.addNodeWithUuid(name, type, uuid); 255 for (NodeType mixinNodeType : mixinNodeTypes) 256 { 257 nodeWithDesiredUuid.addMixin(mixinNodeType.getName()); 258 } 259 260 return nodeWithDesiredUuid; 261 } 262 263 /** 264 * Save the pending changes brought to this node associated to an {@link AmetysObject} 265 * <br>If the save failed, it is logged in ERROR level and the changes are discarded. 266 * @param ametysObjectNode The node 267 * @param logger The logger 268 * @throws AmetysObjectNotImportedException If the save failed 269 * @throws ImportGlobalFailException If a severe error occured and the global import process must be stopped 270 */ 271 public static void unitarySave(Node ametysObjectNode, Logger logger) throws AmetysObjectNotImportedException, ImportGlobalFailException 272 { 273 Session session; 274 try 275 { 276 session = ametysObjectNode.getSession(); 277 } 278 catch (RepositoryException e) 279 { 280 // Cannot even retrieve a session... 281 throw new ImportGlobalFailException(e); 282 } 283 284 try 285 { 286 logger.info("Saving '{}'...", ametysObjectNode); 287 session.save(); 288 } 289 catch (RepositoryException saveFailedException) 290 { 291 logger.error("Save did not succeed, changes on current object '{}' will be discarded...", ametysObjectNode, saveFailedException); 292 try 293 { 294 session.refresh(false); 295 } 296 catch (RepositoryException refreshFailedException) 297 { 298 // rollback did not succeed, global fail is inevitable... 299 throw new ImportGlobalFailException(refreshFailedException); 300 } 301 302 // rollback succeeded, throw AmetysObjectNotImportedException to indicate the save was a failure and thus, the object was not imported 303 throw new AmetysObjectNotImportedException(saveFailedException); 304 } 305 } 306 307 /** 308 * Export the attachments of the given data holder's rich texts 309 * @param dataHolder The data holder 310 * @param zos The {@link ZipOutputStream} where to export the attachments 311 * @param path The path of the folder used to export the attachments 312 * @throws IOException if an error occurs while exporting the attachments 313 */ 314 public static void exportRichTexts(ModelAwareDataHolder dataHolder, ZipOutputStream zos, String path) throws IOException 315 { 316 String prefix = path + __RICH_TEXT_ATTACHMENTS_FOLDER_NAME + "/"; 317 Map<String, Object> richTexts = DataHolderHelper.findItemsByType(dataHolder, ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID); 318 for (Entry<String, Object> entry : richTexts.entrySet()) 319 { 320 String richTextDataPath = entry.getKey(); 321 Object value = entry.getValue(); 322 if (value instanceof RichText) 323 { 324 _exportRichText(richTextDataPath, (RichText) value, zos, prefix); 325 } 326 else if (value instanceof RichText[]) 327 { 328 for (RichText richText : (RichText[]) value) 329 { 330 _exportRichText(richTextDataPath, richText, zos, prefix); 331 } 332 } 333 } 334 } 335 336 private static void _exportRichText(String richTextDataPath, RichText richText, ZipOutputStream zos, String prefix) throws IOException 337 { 338 for (NamedResource resource : richText.getAttachments()) 339 { 340 _exportResource(richTextDataPath, resource, zos, prefix); 341 } 342 } 343 344 /** 345 * Export the given data holder's binaries 346 * @param dataHolder The data holder 347 * @param zos The {@link ZipOutputStream} where to export the binaries 348 * @param path The path of the folder used to export the binaries 349 * @throws IOException if an error occurs while exporting the binaries 350 */ 351 public static void exportBinaries(ModelAwareDataHolder dataHolder, ZipOutputStream zos, String path) throws IOException 352 { 353 String prefix = path + __BINARY_ATTRIBUTES_FOLDER_NAME + "/"; 354 Map<String, Object> binaries = DataHolderHelper.findItemsByType(dataHolder, ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID); 355 for (Entry<String, Object> entry : binaries.entrySet()) 356 { 357 String binaryDataPath = entry.getKey(); 358 Object value = entry.getValue(); 359 if (value instanceof Binary) 360 { 361 _exportResource(binaryDataPath, (Binary) value, zos, prefix); 362 } 363 else if (value instanceof Binary[]) 364 { 365 for (Binary binary : (Binary[]) value) 366 { 367 _exportResource(binaryDataPath, binary, zos, prefix); 368 } 369 } 370 } 371 } 372 373 /** 374 * Export the given data holder's files 375 * @param dataHolder The data holder 376 * @param zos The {@link ZipOutputStream} where to export the files 377 * @param path The path of the folder used to export the files 378 * @throws IOException if an error occurs while exporting the files 379 */ 380 public static void exportFiles(ModelAwareDataHolder dataHolder, ZipOutputStream zos, String path) throws IOException 381 { 382 String prefix = path + __FILE_ATTRIBUTES_FOLDER_NAME + "/"; 383 Map<String, Object> files = DataHolderHelper.findItemsByType(dataHolder, ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID); 384 for (Entry<String, Object> entry : files.entrySet()) 385 { 386 String fileDataPath = entry.getKey(); 387 Object value = entry.getValue(); 388 if (value instanceof Binary) 389 { 390 _exportResource(fileDataPath, (Binary) value, zos, prefix); 391 } 392 else if (value instanceof Binary[]) 393 { 394 for (Binary binary : (Binary[]) value) 395 { 396 _exportResource(fileDataPath, binary, zos, prefix); 397 } 398 } 399 } 400 } 401 402 private static void _exportResource(String dataPath, NamedResource resource, ZipOutputStream zos, String prefix) throws IOException 403 { 404 String resourcePath = getFolderPathFromDataPath(dataPath); 405 ZipEntry newEntry = new ZipEntry(prefix + resourcePath + "/" + resource.getFilename()); 406 zos.putNextEntry(newEntry); 407 408 try (InputStream is = resource.getInputStream()) 409 { 410 IOUtils.copy(is, zos); 411 } 412 } 413 414 /** 415 * Retrieves a folder path from a data path. 416 * Replaces all repeater entries like '[x]', to a folder with the position ('/x') 417 * @param dataPath the data path 418 * @return the folder path 419 */ 420 public static String getFolderPathFromDataPath(String dataPath) 421 { 422 return RegExUtils.replaceAll(dataPath, "\\[([0-9]+)\\]", "/$1"); 423 } 424}