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. Can be null to get all user links
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        String query = getLinksForUserNodePath(siteName, language, user) + "/element(*, " + DefaultLinkFactory.LINK_NODE_TYPE + ")";
336        if (expression != null)
337        {
338            query += "[" + expression.build() + "]";
339        }
340        return query;
341    }
342    /**
343     * Get the query verifying the existence of an url
344     * @param siteName the site name.
345     * @param language the language.
346     * @param url the url to test. 
347     * @return the query verifying the existence of an url
348     */
349    public String getUrlExistsQuery(String siteName, String language, String url)
350    {
351        String lowerCaseUrl = StringUtils.replace(url, "'", "''").toLowerCase();
352        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 + "']";
353    }
354    
355    /**
356     * Get the query verifying the existence of an url for the given user
357     * @param siteName the site name.
358     * @param language the language.
359     * @param url the url to test. 
360     * @param user The user identity
361     * @return the query verifying the existence of an url for the given user
362     */
363    public String getUrlExistsForUserQuery(String siteName, String language, String url, UserIdentity user)
364    {
365        String lowerCaseUrl = StringUtils.replace(url, "'", "''").toLowerCase();
366        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 + "']";
367    }
368    
369    /**
370     * Normalizes an input string in order to capitalize it, remove accents, and replace whitespaces with underscores
371     * @param s the string to normalize
372     * @return the normalized string
373     */
374    public String normalizeString(String s)
375    {
376        // Strip accents
377        String normalizedLabel = Normalizer.normalize(s.toUpperCase(), Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "");
378        
379        // Upper case
380        String upperCaseLabel = normalizedLabel.replaceAll(" +", "_").replaceAll("[^\\w-]", "_").replaceAll("_+", "_").toUpperCase();
381        
382        return upperCaseLabel;
383    }
384    
385    /**
386     * Get links of a given site and language
387     * @param siteName the site name
388     * @param language the language
389     * @return the links
390     */
391    public AmetysObjectIterable<DefaultLink> getLinks(String siteName, String language)
392    {
393        Site site = _siteManager.getSite(siteName);
394        TraversableAmetysObject linksNode = getLinksNode(site, language);
395        return linksNode.getChildren();
396    }
397    
398    /**
399     * Get the list of links corresponding to the given theme ids
400     * @param themesIds the ids of the configured themes
401     * @param siteName the site's name
402     * @param language the site's language
403     * @return the list of default links corresponding to the given themes
404     */
405    public List<DefaultLink> getLinks(List<String> themesIds, String siteName, String language)
406    {
407        Site site = _siteManager.getSite(siteName);
408        TraversableAmetysObject linksNode = getLinksNode(site, language);
409        AmetysObjectIterable<DefaultLink> links = linksNode.getChildren();
410        
411        return links.stream()
412                .filter(l -> themesIds.isEmpty() || !Collections.disjoint(Arrays.asList(l.getThemes()), themesIds))
413                .collect(Collectors.toList());
414    }
415    
416    /**
417     * Get links of a given site and language, for the given user
418     * @param siteName the site name
419     * @param language the language
420     * @param user The user identity
421     * @return the links for the given user
422     */
423    public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user)
424    {
425        return getUserLinks(siteName, language, user, null);
426    }
427    
428    /**
429     * Get links of a given site and language, for the given user
430     * @param siteName the site name
431     * @param language the language
432     * @param user The user identity
433     * @param themeName the theme id to filter user links. If null, return all user links
434     * @return the links for the given user
435     */
436    public AmetysObjectIterable<DefaultLink> getUserLinks(String siteName, String language, UserIdentity user, String themeName)
437    {
438        ThemeExpression themeExpression = null;
439        if (StringUtils.isNotBlank(themeName) && themeExists(themeName, siteName, language))
440        {
441            themeExpression = new ThemeExpression(themeName);
442        }
443        
444        String linksQuery = getUserLinksQuery(siteName, language, user, themeExpression);
445        return _ametysObjectResolver.query(linksQuery);
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        
629        boolean hasIPRestriction = hasIPRestriction(site);
630        boolean isIPAuthorized = isInternalIP(site);
631        
632        // Sort the list according to the orderedLinksPrefLinksIdsArray
633        if (ArrayUtils.isNotEmpty(orderedLinksPrefLinksIdsArray))
634        {
635            DefaultLinkSorter defaultLinkSorter = new DefaultLinkSorter(allLinks, orderedLinksPrefLinksIdsArray);
636            allLinks.sort(defaultLinkSorter);
637        }
638        
639        for (Pair<Boolean, DefaultLink> linkPair : allLinks)
640        {
641            DefaultLink link = linkPair.getRight();
642            boolean userLink = linkPair.getLeft();
643            
644            // check the access granted if it is not a user link
645            if (userLink || _isCurrentUserGrantedAccess(link))
646            {
647                boolean selected = ArrayUtils.contains(orderedLinksPrefLinksIdsArray, link.getId());
648                boolean isHidden = ArrayUtils.contains(hiddenLinksPrefLinksIdsArray, link.getId()); // deprecated, only used for old views, isHidden should be used now
649                saxLink(siteName, contentHandler, link, selected, hasIPRestriction, isIPAuthorized, userLink, isHidden);
650            }
651        }
652    }
653    
654    /**
655     * Generate a directory link.
656     * @param siteName the site name
657     * @param contentHandler the content handler
658     * @param link the link to generate.
659     * @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)
660     * @param hasIPRestriction true if we have IP restriction
661     * @param isIPAuthorized true if the IP is authorized
662     * @param userLink true if it is a user link
663     * @param isHidden true if the link is hidden
664     * @throws SAXException If an error occurs while generating the SAX events
665     */
666    public void saxLink (String siteName, ContentHandler contentHandler, DefaultLink link, boolean selected, boolean hasIPRestriction, boolean isIPAuthorized, boolean userLink, boolean isHidden) throws SAXException
667    {
668        AttributesImpl attrs = new AttributesImpl();
669        attrs.addCDATAAttribute("id", link.getId());
670        attrs.addCDATAAttribute("lang", link.getLanguage());
671        
672        LinkType urlType = link.getUrlType();
673        
674        _addURLAttribute(link, hasIPRestriction, isIPAuthorized, attrs);
675        
676        attrs.addCDATAAttribute("urlType", StringUtils.defaultString(urlType.toString()));
677        
678        if (link.getStatus() != LinkStatus.BROKEN)
679        {
680            String dynInfoProviderId = StringUtils.defaultString(link.getDynamicInformationProvider());
681            // Check if provider exists
682            if (StringUtils.isNotEmpty(dynInfoProviderId) && _dynamicProviderEP.hasExtension(dynInfoProviderId))
683            {
684                attrs.addCDATAAttribute("dynamicInformationProvider", dynInfoProviderId);
685            }
686        }
687        attrs.addCDATAAttribute("title", StringUtils.defaultString(link.getTitle()));
688        attrs.addCDATAAttribute("content", StringUtils.defaultString(link.getContent()));
689        
690        if (urlType == LinkType.PAGE)
691        {
692            String pageId = link.getUrl();
693            try
694            {
695                Page page = _ametysObjectResolver.resolveById(pageId);
696                attrs.addCDATAAttribute("pageTitle", page.getTitle());
697            }
698            catch (UnknownAmetysObjectException e)
699            {
700                attrs.addCDATAAttribute("unknownPage", "true");
701            }
702        } 
703        
704        attrs.addCDATAAttribute("alternative", StringUtils.defaultString(link.getAlternative()));
705        attrs.addCDATAAttribute("pictureAlternative", StringUtils.defaultString(link.getPictureAlternative()));
706        
707        attrs.addCDATAAttribute("user-selected", selected ? "true" : "false");
708        
709        attrs.addCDATAAttribute("color", _linkDAO.getLinkColor(link));
710        
711        String pictureType = link.getPictureType();
712        attrs.addCDATAAttribute("pictureType", pictureType);
713        if (pictureType.equals("resource"))
714        {
715            String resourceId = link.getResourcePictureId();
716            try
717            {
718                Resource resource = _ametysObjectResolver.resolveById(resourceId);
719                attrs.addCDATAAttribute("pictureId", resourceId);
720                attrs.addCDATAAttribute("pictureName", resource.getName());
721                attrs.addCDATAAttribute("pictureSize", Long.toString(resource.getLength()));
722                attrs.addCDATAAttribute("imageType", "explorer");
723            }
724            catch (UnknownAmetysObjectException e)
725            {
726                getLogger().error("The resource of id'{}' does not exist anymore. The picture for link of id '{}' will be ignored.", resourceId, link.getId(), e);
727            }
728            
729        }
730        else if (pictureType.equals("external"))
731        {
732            Binary picMeta = link.getExternalPicture();
733            attrs.addCDATAAttribute("picturePath", DefaultLink.PROPERTY_PICTURE);
734            attrs.addCDATAAttribute("pictureName", picMeta.getFilename());
735            attrs.addCDATAAttribute("pictureSize", Long.toString(picMeta.getLength()));
736            attrs.addCDATAAttribute("imageType", "link-data");
737        }
738        else if (pictureType.equals("glyph"))
739        {
740            attrs.addCDATAAttribute("pictureGlyph", link.getPictureGlyph());
741        }
742        
743        attrs.addCDATAAttribute("limitedAccess", String.valueOf(!_rightManager.hasAnonymousReadAccess(link))); 
744        
745        attrs.addCDATAAttribute("userLink", String.valueOf(userLink));
746        attrs.addCDATAAttribute("isHidden", String.valueOf(isHidden));
747        
748        LinkStatus status = link.getStatus();
749        if (status != null)
750        {
751            attrs.addCDATAAttribute("status", status.name());
752        }
753        
754        if (StringUtils.isNotBlank(link.getPage()))
755        {
756            attrs.addCDATAAttribute("page", link.getPage());
757        }
758        
759        XMLUtils.startElement(contentHandler, "link", attrs);
760        
761        // Themes
762        _saxThemes(contentHandler, link);
763        
764        XMLUtils.endElement(contentHandler, "link");
765    }
766    
767    /**
768     * Add the URL attribute to sax
769     * @param link the link
770     * @param hasIPRestriction true if we have IP restriction
771     * @param isIPAuthorized true if the IP is authorized
772     * @param attrs the attribute
773     */
774    private void _addURLAttribute(DefaultLink link, boolean hasIPRestriction, boolean isIPAuthorized, AttributesImpl attrs)
775    {
776        String internalUrl = link.getInternalUrl();
777        String externalUrl = link.getUrl();
778        
779        // If we have no internal URL or no IP restriction, just sax external URL
780        if (StringUtils.isBlank(internalUrl) || !hasIPRestriction)
781        {
782            attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl));
783        }
784        else
785        {
786            // If the IP is authorized, sax internal URL
787            if (isIPAuthorized)
788            {
789                attrs.addCDATAAttribute("url", StringUtils.defaultString(internalUrl));
790            }
791            // else if we have external URL, we sax it
792            else if (StringUtils.isNotBlank(externalUrl))
793            {
794                attrs.addCDATAAttribute("url", StringUtils.defaultString(externalUrl));
795            }
796            // else link is disabled it because the IP is not authorized
797            else
798            {
799                attrs.addCDATAAttribute("disabled", "true");
800            }
801        }
802    }
803    
804    /**
805     * Get the actual ids of the themes configured properly, their names if they were not 
806     * @param configuredThemesNames the normalized ids of the configured themes
807     * @param siteName the site's name
808     * @param language the site's language
809     * @return the actual ids of the configured themes
810     */
811    public Map<String, List<String>> getThemesMap(List<String> configuredThemesNames, String siteName, String language)
812    {
813        Map<String, List<String>> themesMap = new HashMap<> ();
814        List<String> correctThemesList = new ArrayList<> ();
815        List<String> wrongThemesList = new ArrayList<> ();
816        
817        for (int i = 0; i < configuredThemesNames.size(); i++)
818        {
819            String configuredThemeName = configuredThemesNames.get(i);
820
821            Map<String, Object> contextualParameters = new HashMap<>();
822            contextualParameters.put("language", language);
823            contextualParameters.put("siteName", siteName);
824            Tag theme = _themesDAO.getTag(configuredThemeName, contextualParameters);
825            
826            if (theme == null)
827            {
828                getLogger().warn("The theme '{}' was not found. It will be ignored.", configuredThemeName);
829                wrongThemesList.add(configuredThemeName);
830            }
831            else
832            {
833                correctThemesList.add(configuredThemeName);
834            }
835        }
836        
837        themesMap.put("themes", correctThemesList);
838        themesMap.put("unknown-themes", wrongThemesList);
839        return themesMap;
840    }
841    
842    /**
843     * Verify the existence of a theme
844     * @param themeName the id of the theme to verify
845     * @param siteName the site's name
846     * @param language the site's language
847     * @return true if the theme exists, false otherwise
848     */
849    public boolean themeExists(String themeName, String siteName, String language)
850    {
851        if (StringUtils.isBlank(themeName))
852        {
853            return false;
854        }
855        Map<String, Object> contextualParameters = new HashMap<>();
856        contextualParameters.put("language", language);
857        contextualParameters.put("siteName", siteName);
858        List<String> checkTags = _themesDAO.checkTags(List.of(themeName), false, Collections.EMPTY_MAP, contextualParameters);
859        return !checkTags.isEmpty();
860    }
861    
862    /**
863     * Get theme's title from its name
864     * @param themeName the theme name
865     * @param siteName the site's name
866     * @param language the site's language
867     * @return the title of the theme. Null if the theme doesn't exist
868     */
869    public I18nizableText getThemeTitle(String themeName, String siteName, String language)
870    {
871        Map<String, Object> contextualParameters = new HashMap<>();
872        contextualParameters.put("language", language);
873        contextualParameters.put("siteName", siteName);
874        if (themeExists(themeName, siteName, language))
875        {
876            Tag tag = _themesDAO.getTag(themeName, contextualParameters);
877            return tag.getTitle();
878        }
879        else
880        {
881            getLogger().warn("Can't find theme with name {} for site {} and language {}", themeName, siteName, language);
882        }
883            
884        return null;
885    }
886
887    /**
888     * Get the site's name
889     * @param request the request
890     * @return the site's name
891     */
892    public String getSiteName(Request request)
893    {
894        return WebHelper.getSiteName(request, (Page) request.getAttribute(Page.class.getName()));
895    }
896
897    /**
898     * Get the site's language
899     * @param request the request
900     * @return the site's language
901     */
902    public String getLanguage(Request request)
903    {
904        Page page = (Page) request.getAttribute(Page.class.getName());
905        if (page != null)
906        {
907            return page.getSitemapName();
908        }
909        
910        String language = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME);
911        if (StringUtils.isEmpty(language))
912        {
913            language = request.getParameter("language");
914        }
915        
916        return language;
917    }
918    
919    /**
920     * Retrieve the context variables from the front
921     * @param request the request
922     * @return the map of context variables
923     */
924    public Map<String, String> getContextVars(Request request)
925    {
926        Map<String, String> contextVars = new HashMap<> ();
927        
928        contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_SITENAME, getSiteName(request));
929        contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_LANGUAGE, getLanguage(request));
930    
931        return contextVars;
932    }
933    
934    /**
935     * Get the appropriate storage context from request
936     * @param request the request
937     * @param zoneItemId the id of the zone item if we deal with a service, null for an input data
938     * @return the storage context in which the user preferences will be kept
939     */
940    public String getStorageContext(Request request, String zoneItemId)
941    {
942        String siteName = getSiteName(request);
943        String language = getLanguage(request);
944        
945        return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId;
946    }
947    
948    /**
949     * Get the appropriate storage context 
950     * @param siteName the name of the site
951     * @param language the language
952     * @param zoneItemId the id of the zone item if we deal with a service, null for an input data
953     * @return the storage context in which the user preferences will be kept
954     */
955    public String getStorageContext(String siteName, String language, String zoneItemId)
956    {
957        return StringUtils.isEmpty(zoneItemId) ? siteName + "/" + language : siteName + "/" + language + "/" + zoneItemId;
958    }
959    
960    /**
961     * Sax the themes
962     * @param contentHandler the content handler 
963     * @param link the link 
964     * @throws SAXException If an error occurs while generating the SAX events
965     */
966    private void _saxThemes (ContentHandler contentHandler, DefaultLink link) throws SAXException
967    {
968        XMLUtils.startElement(contentHandler, "themes");
969        
970        Map<String, Object> contextualParameters = new HashMap<>();
971        contextualParameters.put("language", link.getLanguage());
972        contextualParameters.put("siteName", link.getSiteName());
973        
974        for (String themeId : link.getThemes())
975        {
976            try
977            {
978                Tag tag = _themesDAO.getTag(themeId, contextualParameters);
979                if (tag != null)
980                {
981                    AttributesImpl attrs = new AttributesImpl();
982                    attrs.addCDATAAttribute("id", themeId);
983                    attrs.addCDATAAttribute("name", tag.getName());
984                    
985                    XMLUtils.startElement(contentHandler, "theme", attrs);
986                    tag.getTitle().toSAX(contentHandler, "label");
987                    XMLUtils.endElement(contentHandler, "theme");
988                }
989                else
990                {
991                    getLogger().error("Theme '{}' in link '{}' can not be found.", themeId, link.getId());
992                }
993            }
994            catch (UnknownAmetysObjectException e)
995            {
996                // Theme does not exist anymore
997            }
998        }
999            
1000        
1001        XMLUtils.endElement(contentHandler, "themes");
1002    }
1003    
1004    /**
1005     * Determines if the current user is allowed to see the link or not
1006     * @param link the link 
1007     * @return true if the current user is allowed to see the link, false otherwise
1008     */
1009    private boolean _isCurrentUserGrantedAccess(DefaultLink link)
1010    {
1011        UserIdentity user = _currentUserProvider.getUser();
1012        
1013        // There is no access restriction
1014        return _rightManager.hasReadAccess(user, link);
1015    }
1016    
1017    /**
1018     * Determines if the site has IP restriction for internal links
1019     * @param site the site
1020     * @return true if the site has IP restriction
1021     */
1022    public boolean hasIPRestriction(Site site)
1023    {
1024        return site.getValue("allowed-ip") != null;
1025    }
1026    
1027    /**
1028     * Determines if the user IP matches the configured internal IP range
1029     * @param site the site
1030     * @return true if the user IP is an authorized IP for internal links or if no IP restriction is configured
1031     */
1032    public boolean isInternalIP(Site site)
1033    {
1034        String ipRegexp = site.getValue("allowed-ip");
1035        if (StringUtils.isNotBlank(ipRegexp))
1036        {
1037            Pattern ipRestriction = Pattern.compile(ipRegexp);
1038            
1039            Request request = ContextHelper.getRequest(_context);
1040            
1041            // The real client IP may have been put in the non-standard "X-Forwarded-For" request header, in case of reverse proxy
1042            String xff = request.getHeader("X-Forwarded-For");
1043            String ip = null;
1044            
1045            if (xff != null)
1046            {
1047                ip = xff.split(",")[0];
1048            }
1049            else
1050            {
1051                ip = request.getRemoteAddr();
1052            }
1053            
1054            boolean internalIP = ipRestriction.matcher(ip).matches();
1055            
1056            if (getLogger().isDebugEnabled())
1057            {
1058                getLogger().debug("Ip '{}' is considered {} with pattern {}", ip, internalIP ? "internal" : "external", ipRestriction.pattern());
1059            }
1060            
1061            return internalIP;
1062        }
1063        
1064        // There is no IP restriction, considered user IP is authorized
1065        return true;
1066    }
1067    
1068    /**
1069     * Helper class to sort links (DefaultLinkSorter implementation)
1070     * If both links are in the ordered links list, this order is used
1071     * If one of them is in it and not the other, the one in it will be before the other
1072     * If none of them is in the list, the initial order will be used
1073     */
1074    private class DefaultLinkSorter implements Comparator<Pair<Boolean, DefaultLink>>
1075    {
1076        private String[] _orderedLinksPrefLinksIdsArray;
1077        private List<String> _initialList;
1078        /**
1079         * constructor for the helper
1080         * @param initialList initial list to keep track of the original order if no order is found
1081         * @param orderedLinksPrefLinksIdsArray ordered list of link ids
1082         */
1083        public DefaultLinkSorter(List<Pair<Boolean, DefaultLink>> initialList, String[] orderedLinksPrefLinksIdsArray)
1084        {
1085            _orderedLinksPrefLinksIdsArray = orderedLinksPrefLinksIdsArray;
1086            _initialList = initialList.stream()
1087                    .map(Pair::getRight)
1088                    .map(DefaultLink::getId)
1089                    .collect(Collectors.toList());
1090        }
1091        public int compare(Pair<Boolean, DefaultLink> pair1, Pair<Boolean, DefaultLink> pair2)
1092        {
1093            DefaultLink link1 = pair1.getRight();
1094            DefaultLink link2 = pair2.getRight();
1095            if (ArrayUtils.isNotEmpty(_orderedLinksPrefLinksIdsArray))
1096            {
1097                int pos1 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link1.getId());
1098                int pos2 = ArrayUtils.indexOf(_orderedLinksPrefLinksIdsArray, link2.getId());
1099
1100                if (pos1 == ArrayUtils.INDEX_NOT_FOUND && pos2 == ArrayUtils.INDEX_NOT_FOUND)
1101                {
1102                    // if both are not found, we keep the original order
1103                    pos1 = _initialList.indexOf(link1.getId());
1104                    pos2 = _initialList.indexOf(link2.getId());
1105                }
1106                else
1107                {
1108                    // if one of them is not found, we return the max value (to put them at the end)
1109                    if (pos1 == ArrayUtils.INDEX_NOT_FOUND)
1110                    {
1111                        pos1 = Integer.MAX_VALUE;
1112                    }
1113                    if (pos2 == ArrayUtils.INDEX_NOT_FOUND)
1114                    {
1115                        pos2 = Integer.MAX_VALUE;
1116                    }
1117                }
1118                return pos1 - pos2;
1119            }
1120            else
1121            {
1122                return 0; // No sorting if no sort array
1123            }
1124        }
1125    }
1126}