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