001/*
002 *  Copyright 2010 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.web.synchronization;
017
018import java.io.IOException;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Iterator;
023import java.util.List;
024import java.util.Map;
025
026import javax.jcr.ItemNotFoundException;
027import javax.jcr.Node;
028import javax.jcr.NodeIterator;
029import javax.jcr.Property;
030import javax.jcr.PropertyIterator;
031import javax.jcr.RepositoryException;
032import javax.jcr.Session;
033import javax.jcr.version.VersionHistory;
034
035import org.apache.avalon.framework.activity.Initializable;
036import org.apache.avalon.framework.component.Component;
037import org.apache.avalon.framework.logger.AbstractLogEnabled;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.commons.collections.Predicate;
042import org.apache.commons.collections.PredicateUtils;
043import org.apache.commons.lang.StringUtils;
044
045import org.ametys.cms.CmsConstants;
046import org.ametys.cms.repository.CloneComponent;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.support.AmetysPredicateUtils;
049import org.ametys.core.util.I18nUtils;
050import org.ametys.core.util.mail.SendMailHelper;
051import org.ametys.plugins.repository.AmetysObject;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.AmetysRepositoryException;
055import org.ametys.plugins.repository.UnknownAmetysObjectException;
056import org.ametys.plugins.repository.jcr.JCRAmetysObject;
057import org.ametys.plugins.repository.version.VersionableAmetysObject;
058import org.ametys.runtime.config.Config;
059import org.ametys.runtime.i18n.I18nizableText;
060import org.ametys.web.repository.page.Page;
061import org.ametys.web.repository.page.Page.LinkType;
062import org.ametys.web.repository.page.Page.PageType;
063import org.ametys.web.repository.page.Zone;
064import org.ametys.web.repository.page.ZoneItem;
065import org.ametys.web.repository.page.jcr.DefaultPage;
066import org.ametys.web.repository.sitemap.Sitemap;
067import org.ametys.web.skin.Skin;
068import org.ametys.web.skin.SkinTemplate;
069import org.ametys.web.skin.SkinTemplateZone;
070import org.ametys.web.skin.SkinsManager;
071
072import jakarta.mail.MessagingException;
073
074/**
075 * Helper for common processing used while synchronizing.
076 */
077public class SynchronizeComponent extends AbstractLogEnabled implements Component, Serviceable, Initializable
078{
079    /** Avalon Role */
080    public static final String ROLE = SynchronizeComponent.class.getName();
081    
082    private static SynchronizeComponent _instance;
083    
084    private CloneComponent _cloneComponent;
085    private AmetysObjectResolver _resolver;
086    private SkinsManager _skinsManager;
087    private I18nUtils _i18nUtils;
088    
089    @Override
090    public void service(ServiceManager smanager) throws ServiceException
091    {
092        _cloneComponent = (CloneComponent) smanager.lookup(CloneComponent.ROLE);
093        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
094        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
095        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
096    }
097    
098    @Override
099    public void initialize() throws Exception
100    {
101        _instance = this;
102    }
103
104    /**
105     * Get the unique instance
106     * @return the unique instance
107     */
108    @Deprecated
109    public static SynchronizeComponent getInstance()
110    {
111        return _instance;
112    }
113    
114    /**
115     * Returns true if the hierarchy of the given Page is valid, ie if all its ancestors are valid and synchronized in the live workspace.
116     * @param page the source page.
117     * @param liveSession the live Session.
118     * @return the hierarchy validity status.
119     * @throws RepositoryException if failed to check page hierarchy
120     */
121    public boolean isHierarchyValid(Page page, Session liveSession) throws RepositoryException
122    {
123        AmetysObject parent = page.getParent();
124        if (parent instanceof Sitemap)
125        {
126            return true;
127        }
128        
129        Page parentPage = (Page) parent;
130        if (!(parentPage instanceof JCRAmetysObject))
131        {
132            return isPageValid(parentPage, _skinsManager.getSkin(page.getSite().getSkinId())) && isHierarchyValid(parentPage, liveSession);
133        }
134        else if (parentPage.getType() == PageType.NODE)
135        {
136            return isDateValid(parentPage) && isHierarchyValid(parentPage, liveSession);
137        }
138        else
139        {
140            return liveSession.itemExists(((JCRAmetysObject) parentPage).getNode().getPath());
141        }
142    }
143    
144    /**
145     * Returns true if the given page should be synchronized in the live workspace.
146     * @param page the page to test.
147     * @param skin the skin of the page's site.
148     * @return true if the page is valid
149     */
150    public boolean isPageValid(Page page, Skin skin)
151    {
152        if (!isDateValid(page))
153        {
154            return false;
155        }
156        
157        switch (page.getType())
158        {
159            case LINK:
160                return _isLinkPageValid(page);
161            case NODE:
162                return _isNodePageValid(page, skin);
163            case CONTAINER:
164                return _isContainerPageValid(page, skin);
165            default:
166                return false;
167        }   
168    }
169    
170    /**
171     * Returns true if the publication date of the given page are valid.
172     * @param page the page to test.
173     * @return true if the publication dates are valid
174     */
175    public boolean isDateValid (Page page)
176    {
177        ZonedDateTime startDate = page.getValue(DefaultPage.METADATA_PUBLICATION_START_DATE);
178        ZonedDateTime endDate = page.getValue(DefaultPage.METADATA_PUBLICATION_END_DATE);
179        
180        if (startDate != null && startDate.isAfter(ZonedDateTime.now()))
181        {
182            return false;
183        }
184        
185        if (endDate != null && endDate.isBefore(ZonedDateTime.now()))
186        {
187            return false;
188        }
189        
190        return true;
191    }
192    
193    private boolean _isInfiniteRedirection (Page page, List<String> pagesSequence)
194    {
195        Page redirectPage = _getPageRedirection (page);
196        if (redirectPage == null)
197        {
198            return false;
199        }
200        
201        if (pagesSequence.contains(redirectPage.getId()))
202        {
203            return true;
204        }
205        
206        pagesSequence.add(redirectPage.getId());
207        return _isInfiniteRedirection (redirectPage, pagesSequence);
208    }
209    
210    private Page _getPageRedirection (Page page)
211    {
212        if (PageType.LINK.equals(page.getType()) && LinkType.PAGE.equals(page.getURLType()))
213        {
214            try
215            {
216                String pageId = page.getURL();
217                return _resolver.resolveById(pageId);
218            }
219            catch (AmetysRepositoryException e)
220            {
221                return null;
222            }
223        }
224        else if (PageType.NODE.equals(page.getType()))
225        {
226            AmetysObjectIterable<? extends Page> childPages = page.getChildrenPages();
227            Iterator<? extends Page> it = childPages.iterator();
228            if (it.hasNext())
229            {
230                return it.next();
231            }
232        }
233        
234        return null;
235    }
236    
237    private boolean _isLinkPageValid(Page page)
238    {
239        if (LinkType.WEB.equals(page.getURLType()))
240        {
241            return true;
242        }
243        
244        // Check for infinitive loop redirection
245        ArrayList<String> pagesSequence = new ArrayList<>();
246        pagesSequence.add(page.getId());
247        if (_isInfiniteRedirection (page, pagesSequence))
248        {
249            getLogger().error("An infinite loop redirection was detected for page '" + page.getPathInSitemap() + "'");
250            _sendErrorMailForInfiniteRedirection (page);
251            return false;
252        }
253        
254        try
255        {
256            String pageId = page.getURL();
257            Page linkedPage = _resolver.resolveById(pageId);
258            
259            Skin linkedPageSkin = _skinsManager.getSkin(linkedPage.getSite().getSkinId());
260            return isPageValid(linkedPage, linkedPageSkin);
261        }
262        catch (UnknownAmetysObjectException e)
263        {
264            getLogger().error("Page '" + page.getPathInSitemap() + "' redirects to an unexisting page '" + page.getURL() + "'", e);
265            return false;
266        }
267        catch (AmetysRepositoryException e)
268        {
269            getLogger().error("Unable to check page validity for page link '" + page.getId() + "'", e);
270            return false;
271        }
272    }
273    
274    private boolean _isNodePageValid(Page page, Skin skin)
275    {
276        // Check for infinitive loop redirection
277        ArrayList<String> pagesSequence = new ArrayList<>();
278        pagesSequence.add(page.getId());
279        if (_isInfiniteRedirection (page, pagesSequence))
280        {
281            getLogger().error("An infinite loop redirection was detected for page '" + page.getPathInSitemap() + "'");
282            _sendErrorMailForInfiniteRedirection (page);
283            return false;
284        }
285        
286        // a node page is valid if at least one of its child pages is valid
287        AmetysObjectIterable<? extends Page> childPages = page.getChildrenPages();
288        boolean hasOneConcreteChildPage = false;
289        Iterator<? extends Page> it = childPages.iterator();
290        
291        while (!hasOneConcreteChildPage && it.hasNext())
292        {
293            Page childPage = it.next();
294            if (isPageValid(childPage, skin))
295            {
296                hasOneConcreteChildPage = true;
297            }
298        }
299        
300        return hasOneConcreteChildPage;
301    }
302    
303    private boolean _isContainerPageValid(Page page, Skin skin)
304    {
305        // a container page is valid if it has no zones or at least one zone is valid
306        if (skin == null)
307        {
308            return false;
309        }
310        else
311        {
312            SkinTemplate template = skin.getTemplate(page.getTemplate());
313            if (template == null)
314            {
315                return false;
316            }
317            
318            Map<String, SkinTemplateZone> modelZones = template.getZones();
319            Iterator<String> zoneNames = modelZones.keySet().iterator();
320            
321            boolean pageIsValid = modelZones.size() == 0;
322            
323            while (!pageIsValid && zoneNames.hasNext())
324            {
325                // a zone is valid if it is not empty and if at least one ZoneItem is valid
326                String zoneName = zoneNames.next();
327                
328                if (page.hasZone(zoneName))
329                {
330                    Zone zone = page.getZone(zoneName);
331                    AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
332                    Iterator<? extends ZoneItem> it = zoneItems.iterator();
333                    boolean zoneIsValid = false;
334                    
335                    while (!zoneIsValid && it.hasNext())
336                    {
337                        ZoneItem zoneItem = it.next();
338                        zoneIsValid = _isZoneItemValid(zoneItem);
339                    }
340                    
341                    pageIsValid = zoneIsValid;
342                }
343            }
344            
345            return pageIsValid;
346        }
347    }
348    
349    private boolean _isZoneItemValid(ZoneItem zoneItem)
350    {
351        switch (zoneItem.getType())
352        {
353            case SERVICE:
354                // a service is always valid
355                return true;
356            case CONTENT:
357                try
358                {
359                    // a content is valid if it has been validated at least once
360                    Content content = zoneItem.getContent();
361                    if (content instanceof VersionableAmetysObject)
362                    {
363                        return Arrays.asList(((VersionableAmetysObject) content).getAllLabels()).contains(CmsConstants.LIVE_LABEL);
364                    }
365                    else
366                    {
367                        return false;
368                    }
369                }
370                catch (AmetysRepositoryException e)
371                {
372                    getLogger().error("Unable to get content property", e);
373                    return false;
374                }
375            default:
376                throw new IllegalArgumentException("A zoneItem must be either a service or a content.");
377        }
378    }
379
380    /**
381     * Adds a node to a parent node using a source node for name, type
382     * and potential UUID.
383     * @param srcNode the source node.
384     * @param parentNode the parent node for the newly created node.
385     * @param nodeName the node name to use.
386     * @return the created node.
387     * @throws RepositoryException if an error occurs.
388     */
389    public Node addNodeWithUUID(Node srcNode, Node parentNode, String nodeName) throws RepositoryException
390    {
391        Node node = null;
392        
393        if (AmetysPredicateUtils.isAllowedForLiveContent().evaluate(srcNode))
394        {
395            node = _cloneComponent.addNodeWithUUID(srcNode, parentNode, nodeName);
396        }
397        
398        return node;
399    }
400    
401    /**
402     * Clones properties of a node
403     * @param srcNode the source node.
404     * @param clonedNode the cloned node.
405     * @param propertyPredicate the property selector.
406     * @throws RepositoryException if an error occurs.
407     */
408    public void cloneProperties(Node srcNode, Node clonedNode, Predicate propertyPredicate) throws RepositoryException
409    {
410        if (srcNode == null || clonedNode == null)
411        {
412            return;
413        }
414        
415        // Ignore protected properties + filter node/property for live
416        final Predicate predicate = AmetysPredicateUtils.ignoreProtectedProperties(propertyPredicate);
417        
418        // First remove existing matching properties from cloned Node
419        PropertyIterator clonedProperties = clonedNode.getProperties();
420        while (clonedProperties.hasNext())
421        {
422            Property property = clonedProperties.nextProperty();
423            if (predicate.evaluate(property))
424            {
425                property.remove();
426            }
427        }
428        
429        // Then copy properties
430        PropertyIterator itProperties = srcNode.getProperties();
431        
432        while (itProperties.hasNext())
433        {
434            Property property =  itProperties.nextProperty();
435            
436            
437            if (predicate.evaluate(property))
438            {
439                boolean propertyHandled = false;
440                /*
441                if (property.getType() == PropertyType.REFERENCE)
442                {
443                    try
444                    {
445                        Node referencedNode = property.getNode();
446                        if (property.getName().equals("ametys-internal:initial-content"))
447                        {
448                            // Do not clone the initial-content reference.
449                            propertyHandled = true;
450                        }
451                        
452                    }
453                    catch (ItemNotFoundException e)
454                    {
455                        // the target node does not exist anymore, this could be due to workflow having been deleted
456                        propertyHandled = true;
457                    }
458                }*/
459                
460                if (!propertyHandled)
461                {
462                    if (property.getDefinition().isMultiple())
463                    {
464                        clonedNode.setProperty(property.getName(), property.getValues());
465                    }
466                    else
467                    {
468                        clonedNode.setProperty(property.getName(), property.getValue());
469                    }
470                }
471            }
472        }
473    }
474
475    /**
476     * Clones a node by preserving the source node UUID for a content.
477     * @param srcNode the source node.
478     * @param clonedNode the cloned node.
479     * @param propertyPredicate the property selector.
480     * @param nodePredicate the node selector.
481     * @throws RepositoryException if an error occurs.
482     */
483    public void cloneContentNodeAndPreserveUUID(Node srcNode, Node clonedNode, Predicate propertyPredicate, Predicate nodePredicate) throws RepositoryException
484    {
485        Predicate finalNodePredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), nodePredicate);
486        Predicate finalPropertiesPredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), propertyPredicate);
487
488        cloneNodeAndPreserveUUID(srcNode, clonedNode, finalPropertiesPredicate, finalNodePredicate);
489    }
490    
491    /**
492     * Clones properties of a content node
493     * @param srcNode the source node.
494     * @param clonedNode the cloned node.
495     * @param propertyPredicate the property selector.
496     * @throws RepositoryException if an error occurs.
497     */
498    public void cloneContentProperties(Node srcNode, Node clonedNode, Predicate propertyPredicate) throws RepositoryException
499    {
500        Predicate finalPropertiesPredicate = PredicateUtils.andPredicate(AmetysPredicateUtils.isAllowedForLiveContent(), propertyPredicate);
501        
502        cloneProperties(srcNode, clonedNode, finalPropertiesPredicate);
503    }
504    
505    /**
506     * Clones a node by preserving the source node UUID.
507     * @param srcNode the source node.
508     * @param clonedNode the cloned node.
509     * @param propertyPredicate the property selector.
510     * @param nodePredicate the node selector.
511     * @throws RepositoryException if an error occurs.
512     */
513    public void cloneNodeAndPreserveUUID(Node srcNode, Node clonedNode, Predicate propertyPredicate, Predicate nodePredicate) throws RepositoryException
514    {
515        if (srcNode == null || clonedNode == null)
516        {
517            return;
518        }
519        
520        // Clone properties
521        cloneProperties (srcNode, clonedNode, propertyPredicate);
522        
523        // Remove all matching subNodes before cloning, for better handling of same name siblings
524        NodeIterator subNodes = clonedNode.getNodes();
525        
526        while (subNodes.hasNext())
527        {
528            Node subNode = subNodes.nextNode();
529            
530            if (nodePredicate.evaluate(subNode))
531            {
532                subNode.remove();
533            }
534        }
535        
536        // Then copy sub nodes
537        NodeIterator itNodes = srcNode.getNodes();
538        
539        while (itNodes.hasNext())
540        {
541            Node subNode = itNodes.nextNode();
542            
543            if (nodePredicate.evaluate(subNode))
544            {
545                Node clonedSubNode = addNodeWithUUID(subNode, clonedNode, subNode.getName());
546                cloneNodeAndPreserveUUID(subNode, clonedSubNode, propertyPredicate, nodePredicate);
547            }
548        }
549    }
550    
551    /**
552     * Will copy the LIVE version of the content into the LIVE workspace. Works only with JCRAmetysObject.
553     * This method DO NOT save the liveSession.
554     * @param content The content to copy
555     * @param liveSession The session for live 
556     * @throws RepositoryException If an error occurred
557     */
558    public void synchronizeContent(Content content, Session liveSession) throws RepositoryException
559    {
560        if (getLogger().isDebugEnabled())
561        {
562            getLogger().debug("Synchronizing content " + content.getId());
563        }
564        
565        if (!(content instanceof JCRAmetysObject))
566        {
567            return;
568        }
569        
570        Node node = ((JCRAmetysObject) content).getNode();
571        VersionHistory versionHistory = node.getSession().getWorkspace().getVersionManager().getVersionHistory(node.getPath());
572        if (Arrays.asList(versionHistory.getVersionLabels()).contains(CmsConstants.LIVE_LABEL))
573        {
574            Node validatedContentNode = versionHistory.getVersionByLabel(CmsConstants.LIVE_LABEL).getFrozenNode();
575            
576            Node clonedNode;
577            try
578            {
579                // content already exists in the live workspace 
580                clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
581            }
582            catch (ItemNotFoundException e)
583            {
584                // content does not exist in the live workspace
585                
586                // clone content itself
587                Node clonedContentParentNode = cloneAncestorsAndPreserveUUID(node, liveSession);
588                
589                clonedNode = addNodeWithUUID(validatedContentNode, clonedContentParentNode, node.getName());
590             
591            }
592            
593            // First, let's remove all child node and all properties (versionned or not)
594            // Second, clone all versionned node and properties 
595            cloneContentNodeAndPreserveUUID(validatedContentNode, clonedNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
596            
597            // Third, clone all unversionned node and properties (such as acl, tags or all unversionned metadata but except initial-content or workflow)
598            cloneContentNodeAndPreserveUUID(node, clonedNode, AmetysPredicateUtils.isNonVersionned(node), AmetysPredicateUtils.isNonVersionned(node));
599            
600            // Clone validation metadata from the current version.
601            cloneContentProperties(node, clonedNode, AmetysPredicateUtils.propertyNamesPredicate("ametys:lastValidationDate", "ametys:lastMajorValidationDate", "ametys:privacy")); // FIXME CMS-7630 privacy should be not versionned
602        }
603        else
604        {
605            try
606            {
607                // content already exists in the live workspace 
608                Node clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
609                clonedNode.remove();
610            }
611            catch (ItemNotFoundException e)
612            {
613                // Ok, the content was not in live
614            }
615        }
616    }
617    
618    /**
619     * Clones ancestors of a node by preserving the source node UUID.
620     * @param srcNode the source node.
621     * @param liveSession the session to the live workspace.
622     * @return the parent node in the live workspace.
623     * @throws RepositoryException if an error occurs.
624     */
625    public Node cloneAncestorsAndPreserveUUID(Node srcNode, Session liveSession) throws RepositoryException
626    {
627        if (srcNode.getName().length() == 0)
628        {
629            // We are on the root node which already exists
630            return liveSession.getRootNode();
631        }
632        else
633        {
634            Node liveRootNode = liveSession.getRootNode();
635            Node parentNode = srcNode.getParent();
636            String parentNodePath = parentNode.getPath().substring(1);
637            
638            if (liveRootNode.hasNode(parentNodePath))
639            {
640                // Found existing parent
641                return liveRootNode.getNode(parentNodePath);
642            }
643            else
644            {
645                Node clonedAncestorNode = cloneAncestorsAndPreserveUUID(parentNode, liveSession);
646                Node clonedParentNode = null;
647                
648                if (clonedAncestorNode.hasNode(parentNode.getName()))
649                {
650                    // Possible with autocreated children
651                    clonedParentNode = clonedAncestorNode.getNode(parentNode.getName());
652                }
653                else
654                {
655                    clonedParentNode = addNodeWithUUID(parentNode, clonedAncestorNode, parentNode.getName());
656                    
657                }
658                
659                // reorder node when possible
660                if (parentNode.getParent().getPrimaryNodeType().hasOrderableChildNodes())
661                {
662                    orderNode(parentNode.getParent(), parentNode.getName(), clonedParentNode);
663                }
664                
665                // update existing acl
666                synchronizeACL(parentNode, liveSession);
667                
668                // Copy only properties
669                cloneNodeAndPreserveUUID(parentNode, clonedParentNode, PredicateUtils.truePredicate(), PredicateUtils.falsePredicate());
670                
671                return clonedParentNode;
672            }
673        }
674    }
675    
676    /**
677     * Clones a page and its eligible child pages, recursively.
678     * It is assumed that parent page already exist in the live workspace
679     * @param page the page to clone.
680     * @param skin the skin of the page's site.
681     * @param liveSession the session to the live workspace.
682     * @throws RepositoryException if an error occurs.
683     */
684    public void cloneEligiblePage(Page page, Skin skin, Session liveSession) throws RepositoryException
685    {
686        if (!(page instanceof JCRAmetysObject))
687        {
688            return;
689        }
690        
691        Node pageNode = ((JCRAmetysObject) page).getNode();
692        String pagePathInJcr = pageNode.getPath().substring(1);
693        Node rootNode = liveSession.getRootNode();
694        
695        boolean isNew = false;
696        Node liveNode = null;
697        
698        if (!rootNode.hasNode(pagePathInJcr))
699        {
700            isNew = true;
701            Node parentNode = pageNode.getParent();
702            String parentPath = parentNode.getPath().substring(1);
703            String pageName = pageNode.getName();
704            
705            // We assume that the parent Node exists.
706            Node liveParentNode = rootNode.getNode(parentPath);
707            liveNode = addNodeWithUUID(pageNode, liveParentNode, pageNode.getName());
708            
709            // reorder correctly
710            orderNode(parentNode, pageName, liveNode);
711        }
712        else
713        {
714            liveNode = rootNode.getNode(pagePathInJcr);
715        }
716        
717        // Clone all but child pages and zones
718        Predicate childPredicate = PredicateUtils.andPredicate(PredicateUtils.notPredicate(AmetysPredicateUtils.nodeTypePredicate("ametys:page")), 
719                PredicateUtils.notPredicate(AmetysPredicateUtils.nodeTypePredicate("ametys:zones")));
720        
721        cloneNodeAndPreserveUUID(pageNode, liveNode, PredicateUtils.truePredicate(), childPredicate);
722        
723        // Clone zones, if relevant
724        cloneZones(pageNode, page, liveNode);
725        
726        // In case of a new page , there may be valid children pages
727        if (isNew)
728        {
729            // Clone each child page
730            for (Page childPage : page.getChildrenPages())
731            {
732                if (childPage instanceof JCRAmetysObject && isPageValid(childPage, skin))
733                {
734                    cloneEligiblePage(childPage, skin, liveSession);
735                }
736            }
737        }
738    }
739    
740    /**
741     * Reorder a node, mirroring the order in the default workspace.
742     * @param parentNode the parent of the source Node in the default workspace.
743     * @param nodeName the node name.
744     * @param liveNode the node in the live workspace to be reordered.
745     * @throws RepositoryException if an error occurs.
746     */
747    public void orderNode(Node parentNode, String nodeName, Node liveNode) throws RepositoryException
748    {
749        _cloneComponent.orderNode(parentNode, nodeName, liveNode);
750    }
751    
752    /**
753     * Clones the zones of a page.
754     * @param pageNode the JCR Node of the page.
755     * @param page the page.
756     * @param liveNode the node in the live workspace.
757     * @throws RepositoryException if an error occurs.
758     */
759    public void cloneZones(Node pageNode, Page page, Node liveNode) throws RepositoryException
760    {
761        if (pageNode.hasNode("ametys-internal:zones"))
762        {
763            if (liveNode.hasNode("ametys-internal:zones"))
764            {
765                liveNode.getNode("ametys-internal:zones").remove();
766            }
767            
768            Node zonesNode = addNodeWithUUID(pageNode.getNode("ametys-internal:zones"), liveNode, "ametys-internal:zones");
769            
770            for (Zone zone : page.getZones())
771            {
772                Node zoneNode = addNodeWithUUID(((JCRAmetysObject) zone).getNode(), zonesNode, zone.getName());
773                Node zoneItemsNode = zoneNode.getNode("ametys-internal:zoneItems");
774                
775                for (ZoneItem zoneItem : zone.getZoneItems())
776                {
777                    if (_isZoneItemValid(zoneItem))
778                    {
779                        Node srcNode = ((JCRAmetysObject) zoneItem).getNode();
780                        Node zoneItemNode = addNodeWithUUID(srcNode, zoneItemsNode, "ametys:zoneItem");
781                        cloneNodeAndPreserveUUID(srcNode, zoneItemNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
782                    }
783                }
784            }
785        }
786    }
787    
788    /**
789     * Invalidates the hierarchy of a page if needed
790     * @param page the page.
791     * @param skin the skin of the page's site.
792     * @param liveSession the session to the live workspace.
793     * @throws RepositoryException if an error occurs.
794     */
795    public void invalidateHierarchy(Page page, Skin skin, Session liveSession) throws RepositoryException
796    {
797        String jcrPath = ((JCRAmetysObject) page).getNode().getPath();
798        if (liveSession.itemExists(jcrPath))
799        {
800            liveSession.getItem(jcrPath).remove();
801        }
802
803        AmetysObject parent = page.getParent();
804        
805        if (parent instanceof Page)
806        {
807            if (!isPageValid((Page) parent, skin))
808            {
809                invalidateHierarchy((Page) parent, skin, liveSession);
810            }
811        }
812    }
813    
814    /**
815     * Synchronizes a page with the live workspace. Also synchronizes hierarchy.
816     * @param page the page.
817     * @param skin the skin of the page's site.
818     * @param liveSession the session to the live workspace.
819     * @throws RepositoryException if an error occurs.
820     */
821    public void synchronizePage(Page page, Skin skin, Session liveSession) throws RepositoryException
822    {
823        if (isHierarchyValid(page, liveSession))
824        {
825            if (isPageValid(page, skin))
826            {
827                //FIXME clone ancestor pages, not only ancestor nodes
828                cloneAncestorsAndPreserveUUID(((JCRAmetysObject) page).getNode(), liveSession);
829                
830                // clone page and valid children
831                cloneEligiblePage(page, skin, liveSession);
832            }
833            else
834            {
835                // page is invalid, remove it from live if it was previously valid, then potentially invalidate hierarchy
836                invalidateHierarchy(page, skin, liveSession);
837            }
838        }
839    }
840    
841    private void _sendErrorMailForInfiniteRedirection (Page page)
842    {
843        String recipient = Config.getInstance().getValue("smtp.mail.sysadminto");
844        try
845        {
846            List<String> i18nParams = new ArrayList<>();
847            i18nParams.add(page.getSite().getTitle());
848            
849            I18nizableText i18nSubject = new I18nizableText("plugin.web", "PLUGINS_WEB_SYNCHRONIZE_INFINITE_REDIRECTION_MAIL_SUBJECT", i18nParams);
850            String subject = _i18nUtils.translate(i18nSubject);
851            
852            i18nParams.add(page.getSitemapName() + "/" + page.getPathInSitemap());
853            i18nParams.add(StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "index.html"), "/"));
854            String body = _i18nUtils.translate(new I18nizableText("plugin.web", "PLUGINS_WEB_SYNCHRONIZE_INFINITE_REDIRECTION_MAIL_BODY", i18nParams));
855            
856            SendMailHelper.newMail()
857                          .withSubject(subject)
858                          .withTextBody(body)
859                          .withRecipient(recipient)
860                          .sendMail();
861        }
862        catch (MessagingException | IOException e)
863        {
864            if (getLogger().isWarnEnabled())
865            {
866                getLogger().warn("Could not send an alert e-mail to " + recipient, e);
867            }
868        }
869    }
870    
871    /**
872     * Synchronize a node ACL information with node in live session if available
873     * This method does NOT save live session
874     * @param node The trunk node in default workspace
875     * @param liveSession The live session
876     */
877    public void synchronizeACL(Node node, Session liveSession)
878    {
879        try
880        {
881            if (getLogger().isDebugEnabled())
882            {
883                getLogger().debug("Synchronizing ACL for node " + node.getIdentifier());
884            }
885
886            try
887            {
888                // content already exists in the live workspace 
889                Node clonedNode = liveSession.getNodeByIdentifier(node.getIdentifier());
890                
891                // Clone acl
892                String aclNodeNode = "ametys-internal:acl";
893                if (node.hasNode(aclNodeNode))
894                {
895                    Node aclNode = node.getNode(aclNodeNode);
896                    
897                    Node aclClonedNode;
898                    if (clonedNode.hasNode(aclNodeNode))
899                    {
900                        aclClonedNode = clonedNode.getNode(aclNodeNode);
901                    }
902                    else
903                    {
904                        aclClonedNode = clonedNode.addNode(aclNodeNode, aclNode.getPrimaryNodeType().getName());
905                    }
906
907                    cloneNodeAndPreserveUUID(aclNode, aclClonedNode, PredicateUtils.truePredicate(), PredicateUtils.truePredicate());
908                }
909            }
910            catch (ItemNotFoundException e)
911            {
912                // Nothing to synchronize: the content is not in live
913            }
914        }
915        catch (RepositoryException e)
916        {
917            throw new RuntimeException("Can not copy ACL for node", e);
918        }
919    }
920}