001/*
002 *  Copyright 2015 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.linkdirectory;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.text.Normalizer;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collections;
024import java.util.Comparator;
025import java.util.HashMap;
026import java.util.Iterator;
027import java.util.List;
028import java.util.Map;
029import java.util.regex.Pattern;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.configuration.Configuration;
034import org.apache.avalon.framework.configuration.ConfigurationException;
035import org.apache.avalon.framework.context.Context;
036import org.apache.avalon.framework.context.ContextException;
037import org.apache.avalon.framework.context.Contextualizable;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.cocoon.components.ContextHelper;
042import org.apache.cocoon.environment.Request;
043import org.apache.cocoon.xml.AttributesImpl;
044import org.apache.cocoon.xml.XMLUtils;
045import org.apache.commons.lang3.ArrayUtils;
046import org.apache.commons.lang3.StringUtils;
047import org.apache.commons.lang3.tuple.ImmutablePair;
048import org.apache.commons.lang3.tuple.Pair;
049import org.apache.jackrabbit.util.ISO9075;
050import org.xml.sax.ContentHandler;
051import org.xml.sax.SAXException;
052
053import org.ametys.cms.data.Binary;
054import org.ametys.cms.tag.Tag;
055import org.ametys.core.right.RightManager;
056import org.ametys.core.user.CurrentUserProvider;
057import org.ametys.core.user.UserIdentity;
058import org.ametys.core.userpref.UserPreferencesException;
059import org.ametys.core.userpref.UserPreferencesManager;
060import org.ametys.plugins.explorer.resources.Resource;
061import org.ametys.plugins.linkdirectory.Link.LinkStatus;
062import org.ametys.plugins.linkdirectory.Link.LinkType;
063import org.ametys.plugins.linkdirectory.dynamic.DynamicInformationProviderExtensionPoint;
064import org.ametys.plugins.linkdirectory.link.LinkDAO;
065import org.ametys.plugins.linkdirectory.repository.DefaultLink;
066import org.ametys.plugins.linkdirectory.repository.DefaultLinkFactory;
067import org.ametys.plugins.linkdirectory.theme.ThemeExpression;
068import org.ametys.plugins.linkdirectory.theme.ThemesDAO;
069import org.ametys.plugins.repository.AmetysObject;
070import org.ametys.plugins.repository.AmetysObjectIterable;
071import org.ametys.plugins.repository.AmetysObjectResolver;
072import org.ametys.plugins.repository.AmetysRepositoryException;
073import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
074import org.ametys.plugins.repository.TraversableAmetysObject;
075import org.ametys.plugins.repository.UnknownAmetysObjectException;
076import org.ametys.plugins.repository.query.expression.Expression;
077import org.ametys.runtime.i18n.I18nizableText;
078import org.ametys.runtime.plugin.component.AbstractLogEnabled;
079import org.ametys.web.WebConstants;
080import org.ametys.web.WebHelper;
081import org.ametys.web.repository.page.Page;
082import org.ametys.web.repository.site.Site;
083import org.ametys.web.repository.site.SiteManager;
084import org.ametys.web.skin.Skin;
085import org.ametys.web.skin.SkinConfigurationHelper;
086import org.ametys.web.skin.SkinsManager;
087import org.ametys.web.userpref.FOUserPreferencesConstants;
088
089/**
090 * Link directory helper.
091 */
092public final class DirectoryHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
093{
094    /** The component role */
095    public static final String ROLE = DirectoryHelper.class.getName();
096
097    /** The path to the configuration file */
098    private static final String __CONF_FILE_PATH = "conf/link-directory.xml";
099    
100    private static final String __PLUGIN_NODE_NAME = "linkdirectory";
101    
102    private static final String __LINKS_NODE_NAME = "ametys:directoryLinks";
103    
104    private static final String __USER_LINKS_NODE_NAME = "user-favorites";
105    
106    /** Themes DAO */
107    protected ThemesDAO _themesDAO;
108    
109    /** The Ametys object resolver */
110    private AmetysObjectResolver _ametysObjectResolver;
111    
112    /** The site manager */
113    private SiteManager _siteManager;
114    
115    /** The user preferences manager */
116    private UserPreferencesManager _userPreferencesManager;
117    
118    /** The current user provider */
119    private CurrentUserProvider _currentUserProvider;
120    
121    /** The right manager */
122    private RightManager _rightManager;
123    
124    /** The link DAO */
125    private LinkDAO _linkDAO;
126    
127    /** The context */
128    private Context _context;
129
130    private DynamicInformationProviderExtensionPoint _dynamicProviderEP;
131
132    private SkinsManager _skinsManager;
133
134    private SkinConfigurationHelper _skinConfigurationHelper;
135
136    private ServiceManager _smanager;
137    
138    @Override
139    public void service(ServiceManager manager) throws ServiceException
140    {
141        _smanager = manager;
142        _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
143        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
144        _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE + ".FO");
145        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
146        _dynamicProviderEP = (DynamicInformationProviderExtensionPoint) manager.lookup(DynamicInformationProviderExtensionPoint.ROLE);
147        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
148        _linkDAO = (LinkDAO) manager.lookup(LinkDAO.ROLE);
149        _themesDAO = (ThemesDAO) manager.lookup(ThemesDAO.ROLE);
150    }
151    
152    private SkinsManager _getSkinManager()
153    {
154        if (_skinsManager == null)
155        {
156            try
157            {
158                _skinsManager = (SkinsManager) _smanager.lookup(SkinsManager.ROLE);
159            }
160            catch (ServiceException e)
161            {
162                throw new IllegalArgumentException(e);
163            }
164        }
165        return _skinsManager;
166    }
167    
168    private SkinConfigurationHelper _getSkinConfigurationHelper()
169    {
170        if (_skinConfigurationHelper == null)
171        {
172            try
173            {
174                _skinConfigurationHelper = (SkinConfigurationHelper) _smanager.lookup(SkinConfigurationHelper.ROLE);
175            }
176            catch (ServiceException e)
177            {
178                throw new IllegalArgumentException(e);
179            }
180        }
181        return _skinConfigurationHelper;
182    }
183    
184    @Override
185    public void contextualize(Context context) throws ContextException
186    {
187        _context = context;
188    }
189    
190    /**
191     * Get the root plugin storage object.
192     * @param site the site.
193     * @return the root plugin storage object.
194     * @throws AmetysRepositoryException if a repository error occurs.
195     */
196    public ModifiableTraversableAmetysObject getPluginNode(Site site) throws AmetysRepositoryException
197    {
198        try
199        {
200            ModifiableTraversableAmetysObject pluginsNode = site.getRootPlugins();
201            
202            return getOrCreateNode(pluginsNode, __PLUGIN_NODE_NAME, "ametys:unstructured");
203        }
204        catch (AmetysRepositoryException e)
205        {
206            throw new AmetysRepositoryException("Error getting the link directory plugin node for site " + site.getName(), e);
207        }
208    }
209    
210    /**
211     * Get the links root node.
212     * @param site the site
213     * @param language the language.
214     * @return the links root node.
215     * @throws AmetysRepositoryException if a repository error occurs.
216     */
217    public ModifiableTraversableAmetysObject getLinksNode(Site site, String language) throws AmetysRepositoryException
218    {
219        try
220        {
221            // Get the root plugin node.
222            ModifiableTraversableAmetysObject pluginNode = getPluginNode(site);
223            
224            // Get or create the language node.
225            ModifiableTraversableAmetysObject langNode = getOrCreateNode(pluginNode, language, "ametys:unstructured");
226            
227            // Get or create the definitions container node in the language node and return it.
228            return getOrCreateNode(langNode, __LINKS_NODE_NAME, DefaultLinkFactory.LINK_ROOT_NODE_TYPE);
229        }
230        catch (AmetysRepositoryException e)
231        {
232            throw new AmetysRepositoryException("Error getting the link directory root node for site " + site.getName() + " and language " + language, e);
233        }
234    }
235    
236    /**
237     * Get the links root node for the given user.
238     * @param site the site
239     * @param language the language.
240     * @param user The user identity
241     * @return the links root node for the given user.
242     * @throws AmetysRepositoryException if a repository error occurs.
243     */
244    public ModifiableTraversableAmetysObject getLinksForUserNode(Site site, String language, UserIdentity user) throws AmetysRepositoryException
245    {
246        try
247        {
248            // Get the root plugin node.
249            ModifiableTraversableAmetysObject pluginNode = getPluginNode(site);
250            
251            // Get or create the user links node.
252            ModifiableTraversableAmetysObject userLinksNode = getOrCreateNode(pluginNode, __USER_LINKS_NODE_NAME, "ametys:unstructured");
253            // Get or create the population node.
254            ModifiableTraversableAmetysObject populationNode = getOrCreateNode(userLinksNode, user.getPopulationId(), "ametys:unstructured");
255            // Get or create the login node.
256            ModifiableTraversableAmetysObject loginNode = getOrCreateNode(populationNode, user.getLogin(), "ametys:unstructured");
257            // Get or create the language node.
258            ModifiableTraversableAmetysObject langNode = getOrCreateNode(loginNode, language, "ametys:unstructured");
259            
260            // Get or create the definitions container node in the language node and return it.
261            return getOrCreateNode(langNode, __LINKS_NODE_NAME, DefaultLinkFactory.LINK_ROOT_NODE_TYPE);
262        }
263        catch (AmetysRepositoryException e)
264        {
265            throw new AmetysRepositoryException("Error getting the link directory root node for user " + user + " and for site " + site.getName() + " and language " + language, e);
266        }
267    }
268
269    /**
270     * Get the plugin node path
271     * @param siteName the site name.
272     * @return the plugin node path.
273     */
274    public String getPluginNodePath(String siteName)
275    {
276        return String.format("//element(%s, ametys:site)/ametys-internal:plugins/%s", siteName, __PLUGIN_NODE_NAME);
277    }
278    
279    /**
280     * Get the links root node path
281     * @param siteName the site name.
282     * @param language the language
283     * @return the links root node path.
284     */
285    public String getLinksNodePath(String siteName, String language)
286    {
287        return getPluginNodePath(siteName) + "/"  + language + "/" + __LINKS_NODE_NAME;
288    }
289    
290    /**
291     * Get the links root node path for the given user 
292     * @param siteName the site name.
293     * @param language the language
294     * @param user The user identity
295     * @return the links root node path for the given user.
296     */
297    public String getLinksForUserNodePath(String siteName, String language, UserIdentity user)
298    {
299        return getPluginNodePath(siteName) + "/" + __USER_LINKS_NODE_NAME + "/" + ISO9075.encode(user.getPopulationId()) + "/" + ISO9075.encode(user.getLogin()) + "/" + language + "/" + __LINKS_NODE_NAME;
300    }
301    
302    /**
303     * Get all the links
304     * @param siteName the site name.
305     * @param language the language.
306     * @return all the links' nodes
307     */
308    public String getAllLinksQuery(String siteName, String language)
309    {
310        return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")";
311    }
312    
313    /**
314     * Get the link query corresponding to the expression passed as a parameter
315     * @param siteName the site name.
316     * @param language the language.
317     * @param expression the {@link Expression} of the links retrieval query
318     * @return the link corresponding to the expression passed as a parameter
319     */
320    public String getLinksQuery(String siteName, String language, Expression expression)
321    {
322        return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[" + expression.build() + "]";
323    }
324    
325    /**
326     * Get the user link query corresponding to the expression passed as a parameter
327     * @param siteName the site name.
328     * @param language the language.
329     * @param user the user
330     * @param expression the {@link Expression} of the links retrieval query
331     * @return the user link corresponding to the expression passed as a parameter
332     */
333    public String getUserLinksQuery(String siteName, String language, UserIdentity user, Expression expression)
334    {
335        return getLinksForUserNodePath(siteName, language, user) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[" + expression.build() + "]";
336    }
337    /**
338     * Get the query verifying the existence of an url
339     * @param siteName the site name.
340     * @param language the language.
341     * @param url the url to test. 
342     * @return the query verifying the existence of an url
343     */
344    public String getUrlExistsQuery(String siteName, String language, String url)
345    {
346        String lowerCaseUrl = StringUtils.replace(url, "'", "''").toLowerCase();
347        return getLinksNodePath(siteName, language) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[fn:lower-case(@ametys-internal:url) = '" + lowerCaseUrl + "' or fn:lower-case(@ametys-internal:internal-url) = '" + lowerCaseUrl + "']";
348    }
349    
350    /**
351     * Get the query verifying the existence of an url for the given user
352     * @param siteName the site name.
353     * @param language the language.
354     * @param url the url to test. 
355     * @param user The user identity
356     * @return the query verifying the existence of an url for the given user
357     */
358    public String getUrlExistsForUserQuery(String siteName, String language, String url, UserIdentity user)
359    {
360        String lowerCaseUrl = StringUtils.replace(url, "'", "''").toLowerCase();
361        return getLinksForUserNodePath(siteName, language, user) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")[fn:lower-case(@ametys-internal:url) = '" + lowerCaseUrl + "' or fn:lower-case(@ametys-internal:internal-url) = '" + lowerCaseUrl + "']";
362    }
363    
364    /**
365     * Normalizes an input string in order to capitalize it, remove accents, and replace whitespaces with underscores
366     * @param s the string to normalize
367     * @return the normalized string
368     */
369    public String normalizeString(String s)
370    {
371        // Strip accents
372        String normalizedLabel = Normalizer.normalize(s.toUpperCase(), Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "");
373        
374        // Upper case
375        String upperCaseLabel = normalizedLabel.replaceAll(" +", "_").replaceAll("[^\\w-]", "_").replaceAll("_+", "_").toUpperCase();
376        
377        return upperCaseLabel;
378    }
379    
380    /**
381     * Get links of a given site and language
382     * @param siteName the site name
383     * @param language the language
384     * @return the links
385     */
386    public AmetysObjectIterable<DefaultLink> getLinks(String siteName, String language)
387    {
388        Site site = _siteManager.getSite(siteName);
389        TraversableAmetysObject linksNode = getLinksNode(site, language);
390        return linksNode.getChildren();
391    }
392    
393    /**
394     * Get the list of links corresponding to the given theme ids
395     * @param themesIds the ids of the configured themes
396     * @param siteName the site's name
397     * @param language the site's language
398     * @return the list of default links corresponding to the given themes
399     */
400    public List<DefaultLink> getLinks(List<String> themesIds, String siteName, String language)
401    {
402        Site site = _siteManager.getSite(siteName);
403        TraversableAmetysObject linksNode = getLinksNode(site, language);
404        AmetysObjectIterable<DefaultLink> links = linksNode.getChildren();
405        
406        return links.stream()
407                .filter(l -> themesIds.isEmpty() || !Collections.disjoint(Arrays.asList(l.getThemes()), themesIds))
408                .collect(Collectors.toList());
409    }
410    
411    /**
412     * Get links of a given site and language, for the given user
413     * @param siteName the site name
414     * @param language the language
415     * @param user The user identity
416     * @return the links for the given user
417     */
418    public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user)
419    {
420        return getUserLinks(siteName, language, user, null);
421    }
422    
423    /**
424     * Get links of a given site and language, for the given user
425     * @param siteName the site name
426     * @param language the language
427     * @param user The user identity
428     * @param themeName the theme id to filter user links. If null, return all user links
429     * @return the links for the given user
430     */
431    public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user, String themeName)
432    {
433        if (StringUtils.isNotBlank(themeName) && themeExists(themeName, siteName, language))
434        {
435            ThemeExpression themeExpression = new ThemeExpression(themeName);
436            String linksQuery = getUserLinksQuery(siteName, language, user, themeExpression);
437            return _ametysObjectResolver.query(linksQuery);
438        }
439        else
440        {
441            Site site = _siteManager.getSite(siteName);
442            TraversableAmetysObject linksNode = getLinksForUserNode(site, language, user);
443            AmetysObjectIterable<DefaultLink> links = linksNode.getChildren();
444            return links;
445        }
446    }
447    
448    /**
449     * Checks if the links displayed in a link directory service has access restrictions
450     * @param siteName the name of the site
451     * @param language the language
452     * @param themesIds the list of selected theme ids
453     * @return true if the links of the service have access restrictions, false otherwise
454     */
455    public boolean hasRestrictions(String siteName, String language, List<String> themesIds)
456    {
457        // No themes => we check all the links' access restrictions
458        if (themesIds.isEmpty())   
459        {
460            String allLinksQuery = getAllLinksQuery(siteName, language);
461            try (AmetysObjectIterable<AmetysObject> links = _ametysObjectResolver.query(allLinksQuery))
462            {
463                if (isAccessRestricted(links))
464                {
465                    return true;
466                }
467            }
468            
469            
470        }
471        // The service has themes specified => we solely check the corresponding links' access restrictions
472        else
473        {
474            for (String themeId : themesIds)
475            {
476                String xPathQuery = getLinksQuery(siteName, language, new ThemeExpression(themeId));
477                try (AmetysObjectIterable<AmetysObject> links = _ametysObjectResolver.query(xPathQuery))
478                {
479                    if (isAccessRestricted(links))
480                    {
481                        return true;
482                    }
483                }
484            }
485        }
486        
487        // All the tested links have no restricted access
488        return false;
489    }
490    
491    /**
492     * Checks if the links displayed in a link directory service has internal link
493     * @param siteName the name of the site
494     * @param language the language
495     * @param themesIds the list of selected theme ids
496     * @return true if the links of the service has internal link, false otherwise
497     */
498    public boolean hasInternalUrl(String siteName, String language, List<String> themesIds)
499    {
500        Site site = _siteManager.getSite(siteName);
501        String allowedIdParameter = site.getValue("allowed-ip");
502        if (StringUtils.isBlank(allowedIdParameter))
503        {
504            return false;
505        }
506        
507        List<DefaultLink> links = getLinks(themesIds, siteName, language);
508        for (DefaultLink link : links)
509        {
510            if (StringUtils.isNotBlank(link.getInternalUrl()))
511            {
512                return true;
513            }
514        }
515        
516        return false;
517    }
518    
519    /**
520     * Check if the links' access is restricted or not
521     * @param links the links to be tested
522     * @return true if the link has a restricted access, false otherwise
523     */
524    public boolean isAccessRestricted(AmetysObjectIterable<AmetysObject> links)
525    {
526        Iterator<AmetysObject> it = links.iterator();
527        
528        while (it.hasNext())
529        {
530            DefaultLink link = (DefaultLink) it.next();
531            
532            // If any of the links has a limited access, the service declares itself non-cacheable
533            if (!_rightManager.hasAnonymousReadAccess(link))
534            {
535                return true;
536            }
537        }
538        
539        return false;
540    }
541    
542    private ModifiableTraversableAmetysObject getOrCreateNode(ModifiableTraversableAmetysObject parentNode, String nodeName, String nodeType) throws AmetysRepositoryException
543    {
544        ModifiableTraversableAmetysObject node;
545        if (parentNode.hasChild(nodeName))
546        {
547            node = parentNode.getChild(nodeName);
548        }
549        else
550        {
551            node = parentNode.createChild(nodeName, nodeType);
552            parentNode.saveChanges();
553        }
554        return node;
555    }
556    
557    /**
558     * Get the configuration of links brought by skin
559     * @param skinName the skin name
560     * @return the skin configuration
561     * @throws IOException if an error occured
562     * @throws ConfigurationException if an error occured
563     * @throws SAXException if an error occured
564     */
565    public Configuration getSkinLinksConfiguration(String skinName) throws IOException, ConfigurationException, SAXException
566    {
567        Skin skin = _getSkinManager().getSkin(skinName);
568        try (InputStream xslIs = getClass().getResourceAsStream("link-directory-merge.xsl"))
569        {
570            return _getSkinConfigurationHelper().getInheritanceMergedConfiguration(skin, __CONF_FILE_PATH, xslIs);
571        }
572    }
573    
574    /**
575     * Sax the directory links
576     * @param siteName the site name
577     * @param contentHandler the content handler
578     * @param links the list of links to sax (can be null
579     * @param userLinks the user links to sax (can be null)
580     * @param storageContext the storage context, null if there is no connected user
581     * @param contextVars the context variables
582     * @param user the user
583     * @throws SAXException If an error occurs while generating the SAX events
584     * @throws UserPreferencesException if an exception occurs while getting the user preferences
585     */
586    public void saxLinks(String siteName, ContentHandler contentHandler, List<DefaultLink> links, List<DefaultLink> userLinks, Map<String, String> contextVars, String storageContext, UserIdentity user) throws SAXException, UserPreferencesException
587    {
588        // left : true if user link
589        // right : the link itself
590        List<Pair<Boolean, DefaultLink>> allLinks = new ArrayList<>();
591        
592        if (links != null)
593        {
594            for (DefaultLink link : links)
595            {
596                allLinks.add(new ImmutablePair<>(false, link));
597            }
598        }
599        
600        if (userLinks != null)
601        {
602            for (DefaultLink link : userLinks)
603            {
604                allLinks.add(new ImmutablePair<>(true, link));
605            }
606        }
607        
608        
609        String[] orderedLinksPrefLinksIdsArray = null; 
610        String[] hiddenLinksPrefLinksIdsArray = null; 
611        if (user != null)
612        {
613            // TODO it would be nice to change the name of this user pref but the storage is still the same, so for the moment we avoid the SQL migration
614            // Cf issue LINKS-141
615            // Change in org.ametys.plugins.linkdirectory.LinkDirectorySetUserPreferencesAction#act too
616            
617            Map<String, String> unTypedUserPrefs = _userPreferencesManager.getUnTypedUserPrefs(user, storageContext, contextVars);
618            
619            String orderedLinksPrefValues = unTypedUserPrefs.get("checked-links");
620            orderedLinksPrefLinksIdsArray = StringUtils.split(orderedLinksPrefValues, ",");
621            
622            String hiddenLinksPrefValues =  unTypedUserPrefs.get("hidden-links");
623            hiddenLinksPrefLinksIdsArray = StringUtils.split(hiddenLinksPrefValues, ",");
624            
625        }
626        
627        Site site = _siteManager.getSite(siteName);
628        String ipRegexp = site.getValue("allowed-ip");
629        Pattern ipRestriction = null;
630        if (StringUtils.isNotBlank(ipRegexp))
631        {
632            ipRestriction = Pattern.compile(ipRegexp);
633        }
634        
635        boolean hasIPRestriction = ipRestriction != null;
636        boolean isIPAuthorized = _isIPAuthorized(ipRestriction);
637        
638        // Sort the list according to the orderedLinksPrefLinksIdsArray
639        if (ArrayUtils.isNotEmpty(orderedLinksPrefLinksIdsArray))
640        {
641            DefaultLinkSorter defaultLinkSorter = new DefaultLinkSorter(allLinks, orderedLinksPrefLinksIdsArray);
642            allLinks.sort(defaultLinkSorter);
643        }
644        
645        for (Pair<Boolean, DefaultLink> linkPair : allLinks)
646        {
647            DefaultLink link = linkPair.getRight();
648            boolean userLink = linkPair.getLeft();
649            
650            // check the access granted if it is not a user link
651            if (userLink || _isCurrentUserGrantedAccess(link))
652            {
653                boolean selected = ArrayUtils.contains(orderedLinksPrefLinksIdsArray, link.getId());
654                boolean isHidden = ArrayUtils.contains(hiddenLinksPrefLinksIdsArray, link.getId()); // deprecated, only used for old views, isHidden should be used now
655                saxLink(siteName, contentHandler, link, selected, hasIPRestriction, isIPAuthorized, userLink, isHidden);
656            }
657        }
658    }
659    
660    /**
661     * Generate a directory link.
662     * @param siteName the site name
663     * @param contentHandler the content handler
664     * @param link the link to generate.
665     * @param selected true if a front end user has checked this link as a user preference, false otherwise (deprecated, only used for old views, isHidden should be used now)
666     * @param hasIPRestriction true if we have IP restriction
667     * @param isIPAuthorized true if the IP is authorized
668     * @param userLink true if it is a user link
669     * @param isHidden true if the link is hidden
670     * @throws SAXException If an error occurs while generating the SAX events
671     */
672    public void saxLink (String siteName, ContentHandler contentHandler, DefaultLink link, boolean selected, boolean hasIPRestriction, boolean isIPAuthorized, boolean userLink, boolean isHidden) throws SAXException
673    {
674        AttributesImpl attrs = new AttributesImpl();
675        attrs.addCDATAAttribute("id", link.getId());
676        attrs.addCDATAAttribute("lang", link.getLanguage());
677        
678        LinkType urlType = link.getUrlType();
679        
680        _addURLAttribute(link, hasIPRestriction, isIPAuthorized, attrs);
681        
682        attrs.addCDATAAttribute("urlType", StringUtils.defaultString(urlType.toString()));
683        
684        if (link.getStatus() != LinkStatus.BROKEN)
685        {
686            String dynInfoProviderId = StringUtils.defaultString(link.getDynamicInformationProvider());
687            // Check if provider exists
688            if (StringUtils.isNotEmpty(dynInfoProviderId) && _dynamicProviderEP.hasExtension(dynInfoProviderId))
689            {
690                attrs.addCDATAAttribute("dynamicInformationProvider", dynInfoProviderId);
691            }
692        }
693        attrs.addCDATAAttribute("title", StringUtils.defaultString(link.getTitle()));
694        attrs.addCDATAAttribute("content", StringUtils.defaultString(link.getContent()));
695        
696        if (urlType == LinkType.PAGE)
697        {
698            String pageId = link.getUrl();
699            try
700            {
701                Page page = _ametysObjectResolver.resolveById(pageId);
702                attrs.addCDATAAttribute("pageTitle", page.getTitle());
703            }
704            catch (UnknownAmetysObjectException e)
705            {
706                attrs.addCDATAAttribute("unknownPage", "true");
707            }
708        } 
709        
710        attrs.addCDATAAttribute("alternative", StringUtils.defaultString(link.getAlternative()));
711        attrs.addCDATAAttribute("pictureAlternative", StringUtils.defaultString(link.getPictureAlternative()));
712        
713        attrs.addCDATAAttribute("user-selected", selected ? "true" : "false");
714        
715        attrs.addCDATAAttribute("color", _linkDAO.getLinkColor(link));
716        
717        String pictureType = link.getPictureType();
718        attrs.addCDATAAttribute("pictureType", pictureType);
719        if (pictureType.equals("resource"))
720        {
721            String resourceId = link.getResourcePictureId();
722            try
723            {
724                Resource resource = _ametysObjectResolver.resolveById(resourceId);
725                attrs.addCDATAAttribute("pictureId", resourceId);
726                attrs.addCDATAAttribute("pictureName", resource.getName());
727                attrs.addCDATAAttribute("pictureSize", Long.toString(resource.getLength()));
728                attrs.addCDATAAttribute("imageType", "explorer");
729            }
730            catch (UnknownAmetysObjectException e)
731            {
732                getLogger().error("The resource of id'{}' does not exist anymore. The picture for link of id '{}' will be ignored.", resourceId, link.getId(), e);
733            }
734            
735        }
736        else if (pictureType.equals("external"))
737        {
738            Binary picMeta = link.getExternalPicture();
739            attrs.addCDATAAttribute("picturePath", DefaultLink.PROPERTY_PICTURE);
740            attrs.addCDATAAttribute("pictureName", picMeta.getFilename());
741            attrs.addCDATAAttribute("pictureSize", Long.toString(picMeta.getLength()));
742            attrs.addCDATAAttribute("imageType", "link-data");
743        }
744        else if (pictureType.equals("glyph"))
745        {
746            attrs.addCDATAAttribute("pictureGlyph", link.getPictureGlyph());
747        }
748        
749        attrs.addCDATAAttribute("limitedAccess", String.valueOf(!_rightManager.hasAnonymousReadAccess(link))); 
750        
751        attrs.addCDATAAttribute("userLink", String.valueOf(userLink));
752        attrs.addCDATAAttribute("isHidden", String.valueOf(isHidden));
753        
754        LinkStatus status = link.getStatus();
755        if (status != null)
756        {
757            attrs.addCDATAAttribute("status", status.name());
758        }
759        
760        if (StringUtils.isNotBlank(link.getPage()))
761        {
762            attrs.addCDATAAttribute("page", link.getPage());
763        }
764        
765        XMLUtils.startElement(contentHandler, "link", attrs);
766        
767        // Themes
768        _saxThemes(contentHandler, link);
769        
770        XMLUtils.endElement(contentHandler, "link");
771    }
772    
773    /**
774     * Add the URL attribute to sax
775     * @param link the link
776     * @param hasIPRestriction true if we have IP restriction
777     * @param isIPAuthorized true if the IP is authorized
778     * @param attrs the attribute
779     */
780    private void _addURLAttribute(DefaultLink link, boolean hasIPRestriction, boolean isIPAuthorized, AttributesImpl attrs)
781    {
782        String internalUrl = link.getInternalUrl();
783        String externalUrl = link.getUrl();
784        
785        // If we have no internal URL or no IP restriction, just sax external URL
786        if (StringUtils.isBlank(internalUrl) || !hasIPRestriction)
787        {
788            attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl));
789        }
790        else
791        {
792            // If the IP is authorized, sax internal URL
793            if (isIPAuthorized)
794            {
795                attrs.addCDATAAttribute("url", StringUtils.defaultString(internalUrl));
796            }
797            // else if we have external URL, we sax it
798            else if (StringUtils.isNotBlank(externalUrl))
799            {
800                attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl));
801            }
802            // else we sax the internal URL and we disable it because the IP is not authorized
803            else
804            {
805                attrs.addCDATAAttribute("url", StringUtils.defaultString(internalUrl));
806                attrs.addCDATAAttribute("disabled", "true");
807            }
808        }
809    }
810    
811    /**
812     * Get the actual ids of the themes configured properly, their names if they were not 
813     * @param configuredThemesNames the normalized ids of the configured themes
814     * @param siteName the site's name
815     * @param language the site's language
816     * @return the actual ids of the configured themes
817     */
818    public Map<String, List<String>> getThemesMap(List<String> configuredThemesNames, String siteName, String language)
819    {
820        Map<String, List<String>> themesMap = new HashMap<> ();
821        List<String> correctThemesList = new ArrayList<> ();
822        List<String> wrongThemesList = new ArrayList<> ();
823        
824        for (int i = 0; i < configuredThemesNames.size(); i++)
825        {
826            String configuredThemeName = configuredThemesNames.get(i);
827
828            Map<String, Object> contextualParameters = new HashMap<>();
829            contextualParameters.put("language", language);
830            contextualParameters.put("siteName", siteName);
831            Tag theme = _themesDAO.getTag(configuredThemeName, contextualParameters);
832            
833            if (theme == null)
834            {
835                getLogger().warn("The theme '{}' was not found. It will be ignored.", configuredThemeName);
836                wrongThemesList.add(configuredThemeName);
837            }
838            else
839            {
840                correctThemesList.add(configuredThemeName);
841            }
842        }
843        
844        themesMap.put("themes", correctThemesList);
845        themesMap.put("unknown-themes", wrongThemesList);
846        return themesMap;
847    }
848    
849    /**
850     * Verify the existence of a theme
851     * @param themeName the id of the theme to verify
852     * @param siteName the site's name
853     * @param language the site's language
854     * @return true if the theme exists, false otherwise
855     */
856    public boolean themeExists(String themeName, String siteName, String language)
857    {
858        if (StringUtils.isBlank(themeName))
859        {
860            return false;
861        }
862        Map<String, Object> contextualParameters = new HashMap<>();
863        contextualParameters.put("language", language);
864        contextualParameters.put("siteName", siteName);
865        List<String> checkTags = _themesDAO.checkTags(List.of(themeName), false, Collections.EMPTY_MAP, contextualParameters);
866        return !checkTags.isEmpty();
867    }
868    
869    /**
870     * Get theme's title from its name
871     * @param themeName the theme name
872     * @param siteName the site's name
873     * @param language the site's language
874     * @return the title of the theme. Null if the theme doesn't exist
875     */
876    public I18nizableText getThemeTitle(String themeName, String siteName, String language)
877    {
878        Map<String, Object> contextualParameters = new HashMap<>();
879        contextualParameters.put("language", language);
880        contextualParameters.put("siteName", siteName);
881        if (themeExists(themeName, siteName, language))
882        {
883            Tag tag = _themesDAO.getTag(themeName, contextualParameters);
884            return tag.getTitle();
885        }
886        else
887        {
888            getLogger().warn("Can't find theme with name {} for site {} and language {}", themeName, siteName, language);
889        }
890            
891        return null;
892    }
893
894    /**
895     * Get the site's name
896     * @param request the request
897     * @return the site's name
898     */
899    public String getSiteName(Request request)
900    {
901        return WebHelper.getSiteName(request, (Page) request.getAttribute(Page.class.getName()));
902    }
903
904    /**
905     * Get the site's language
906     * @param request the request
907     * @return the site's language
908     */
909    public String getLanguage(Request request)
910    {
911        Page page = (Page) request.getAttribute(Page.class.getName());
912        if (page != null)
913        {
914            return page.getSitemapName();
915        }
916        
917        String language = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME);
918        if (StringUtils.isEmpty(language))
919        {
920            language = request.getParameter("language");
921        }
922        
923        return language;
924    }
925    
926    /**
927     * Retrieve the context variables from the front
928     * @param request the request
929     * @return the map of context variables
930     */
931    public Map<String, String> getContextVars(Request request)
932    {
933        Map<String, String> contextVars = new HashMap<> ();
934        
935        contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_SITENAME, getSiteName(request));
936        contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_LANGUAGE, getLanguage(request));
937    
938        return contextVars;
939    }
940    
941    /**
942     * Get the appropriate storage context from request
943     * @param request the request
944     * @param zoneItemId the id of the zone item if we deal with a service, null for an input data
945     * @return the storage context in which the user preferences will be kept
946     */
947    public String getStorageContext(Request request, String zoneItemId)
948    {
949        String siteName = getSiteName(request);
950        String language = getLanguage(request);
951        
952        return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId;
953    }
954    
955    /**
956     * Get the appropriate storage context 
957     * @param siteName the name of the site
958     * @param language the language
959     * @param zoneItemId the id of the zone item if we deal with a service, null for an input data
960     * @return the storage context in which the user preferences will be kept
961     */
962    public String getStorageContext(String siteName, String language, String zoneItemId)
963    {
964        return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId;
965    }
966    
967    /**
968     * Sax the themes
969     * @param contentHandler the content handler 
970     * @param link the link 
971     * @throws SAXException If an error occurs while generating the SAX events
972     */
973    private void _saxThemes (ContentHandler contentHandler, DefaultLink link) throws SAXException
974    {
975        XMLUtils.startElement(contentHandler, "themes");
976        
977        Map<String, Object> contextualParameters = new HashMap<>();
978        contextualParameters.put("language", link.getLanguage());
979        contextualParameters.put("siteName", link.getSiteName());
980        
981        for (String themeId : link.getThemes())
982        {
983            try
984            {
985                Tag tag = _themesDAO.getTag(themeId, contextualParameters);
986                if (tag != null)
987                {
988                    AttributesImpl attrs = new AttributesImpl();
989                    attrs.addCDATAAttribute("id", themeId);
990                    attrs.addCDATAAttribute("name", tag.getName());
991                    
992                    XMLUtils.startElement(contentHandler, "theme", attrs);
993                    tag.getTitle().toSAX(contentHandler, "label");
994                    XMLUtils.endElement(contentHandler, "theme");
995                }
996                else
997                {
998                    getLogger().error("Theme '{}' in link '{}' can not be found.", themeId, link.getId());
999                }
1000            }
1001            catch (UnknownAmetysObjectException e)
1002            {
1003                // Theme does not exist anymore
1004            }
1005        }
1006            
1007        
1008        XMLUtils.endElement(contentHandler, "themes");
1009    }
1010    
1011    /**
1012     * Determines if the current user is allowed to see the link or not
1013     * @param link the link 
1014     * @return true if the current user is allowed to see the link, false otherwise
1015     */
1016    private boolean _isCurrentUserGrantedAccess(DefaultLink link)
1017    {
1018        UserIdentity user = _currentUserProvider.getUser();
1019        
1020        // There is no access restriction
1021        return _rightManager.hasReadAccess(user, link);
1022    }
1023    
1024    /**
1025     * Checks if the IP is authorized for use link internal URL
1026     * @param ipRestriction The ip restriction pattern
1027     * @return true the IP is authorized for use link internal URL, false otherwise
1028     */
1029    private boolean _isIPAuthorized(Pattern ipRestriction)
1030    {
1031        if (ipRestriction == null)
1032        {
1033            return true;
1034        }
1035        
1036        Request request = ContextHelper.getRequest(_context);
1037        
1038        // The real client IP may have been put in the non-standard "X-Forwarded-For" request header, in case of reverse proxy
1039        String xff = request.getHeader("X-Forwarded-For");
1040        String ip = null;
1041        
1042        if (xff != null)
1043        {
1044            ip = xff.split(",")[0];
1045        }
1046        else
1047        {
1048            ip = request.getRemoteAddr();
1049        }
1050        
1051        boolean result = ipRestriction.matcher(ip).matches();
1052        
1053        if (getLogger().isDebugEnabled())
1054        {
1055            getLogger().debug("Ip '{}' is considered {} with pattern {}", ip, result ? "internal" : "external", ipRestriction.pattern());
1056        }
1057        
1058        return result;
1059    }
1060    
1061    /**
1062     * Helper class to sort links (DefaultLinkSorter implementation)
1063     * If both links are in the ordered links list, this order is used
1064     * If one of them is in it and not the other, the one in it will be before the other
1065     * If none of them is in the list, the initial order will be used
1066     */
1067    private class DefaultLinkSorter implements Comparator<Pair<Boolean, DefaultLink>>
1068    {
1069        private String[] _orderedLinksPrefLinksIdsArray;
1070        private List<String> _initialList;
1071        /**
1072         * constructor for the helper
1073         * @param initialList initial list to keep track of the original order if no order is found
1074         * @param orderedLinksPrefLinksIdsArray ordered list of link ids
1075         */
1076        public DefaultLinkSorter(List<Pair<Boolean, DefaultLink>> initialList, String[] orderedLinksPrefLinksIdsArray)
1077        {
1078            _orderedLinksPrefLinksIdsArray = orderedLinksPrefLinksIdsArray;
1079            _initialList = initialList.stream()
1080                    .map(Pair::getRight)
1081                    .map(DefaultLink::getId)
1082                    .collect(Collectors.toList());
1083        }
1084        public int compare(Pair<Boolean, DefaultLink> pair1, Pair<Boolean, DefaultLink> pair2)
1085        {
1086            DefaultLink link1 = pair1.getRight();
1087            DefaultLink link2 = pair2.getRight();
1088            if (ArrayUtils.isNotEmpty(_orderedLinksPrefLinksIdsArray))
1089            {
1090                int pos1 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link1.getId());
1091                int pos2 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link2.getId());
1092
1093                if (pos1 == ArrayUtils.INDEX_NOT_FOUND && pos2 == ArrayUtils.INDEX_NOT_FOUND)
1094                {
1095                    // if both are not found, we keep the original order
1096                    pos1 = _initialList.indexOf(link1.getId());
1097                    pos2 = _initialList.indexOf(link2.getId());
1098                }
1099                else
1100                {
1101                    // if one of them is not found, we return the max value (to put them at the end)
1102                    if (pos1 == ArrayUtils.INDEX_NOT_FOUND)
1103                    {
1104                        pos1 = Integer.MAX_VALUE;
1105                    }
1106                    if (pos2 == ArrayUtils.INDEX_NOT_FOUND)
1107                    {
1108                        pos2 = Integer.MAX_VALUE;
1109                    }
1110                }
1111                return pos1 - pos2;
1112            }
1113            else
1114            {
1115                return 0; // No sorting if no sort array
1116            }
1117        }
1118    }
1119}