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