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