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}