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}