001/*
002 *  Copyright 2018 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.InputStream;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.activity.Initializable;
028import org.apache.avalon.framework.configuration.Configuration;
029import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.avalon.framework.logger.AbstractLogEnabled;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.ProcessingException;
038import org.apache.cocoon.components.ContextHelper;
039import org.apache.cocoon.environment.Request;
040import org.apache.cocoon.xml.AttributesImpl;
041import org.apache.cocoon.xml.XMLUtils;
042import org.apache.commons.collections.CollectionUtils;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.excalibur.source.Source;
045import org.apache.excalibur.source.SourceResolver;
046import org.xml.sax.ContentHandler;
047import org.xml.sax.SAXException;
048
049import org.ametys.core.user.CurrentUserProvider;
050import org.ametys.core.user.UserIdentity;
051import org.ametys.plugins.linkdirectory.repository.DefaultLink;
052import org.ametys.web.inputdata.InputData;
053import org.ametys.web.repository.page.Page;
054import org.ametys.web.repository.site.Site;
055
056/**
057 * Input data for the link directory user preferences in thumbnails mode 
058 */
059public class LinkDirectoryInputData extends AbstractLogEnabled implements Contextualizable, InputData, Initializable, Serviceable
060{
061    /** The path to the configuration file */
062    private static final String __CONF_FILE_PATH = "skin://conf/link-directory.xml";
063    
064    /** The wildcard */
065    private static final String __WILDCARD = "*";
066    
067    /** The current user provider */
068    protected CurrentUserProvider _currentUserProvider;
069    
070    /** The Avalon context */
071    private Context _context;
072    
073    /** Excalibur source resolver */
074    private SourceResolver _sourceResolver;
075    
076    private DirectoryHelper _directoryHelper;
077    
078    /** Cache for themes: <skinId, List<theme info>> */
079    private Map<String, List<ThemeInputData>> _themesCache;
080    
081    private Map<String, String> _configurationError;
082    
083    /** The last time the file was loaded */
084    private Map<String, Long> _lastConfUpdate;
085
086    @Override
087    public void contextualize(Context context) throws ContextException
088    {
089        _context = context; 
090    }
091    
092    @Override
093    public void initialize() throws Exception
094    {
095        _lastConfUpdate = new HashMap<>();
096        _themesCache = new HashMap<>();
097        _configurationError = new HashMap<>();
098    }
099    
100    @Override
101    public void service(ServiceManager manager) throws ServiceException
102    {
103        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
104        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
105        _directoryHelper = (DirectoryHelper) manager.lookup(DirectoryHelper.ROLE);
106    }
107    
108    @Override
109    public boolean isCacheable(Site site, Page currentPage)
110    {
111        Request request = ContextHelper.getRequest(_context);
112        
113        String template = _getTemplate(request);
114        if (template == null)
115        {
116            return true;
117        }
118        
119        try
120        {
121            String skinId = site.getSkinId();
122            _updateConfigurationValues(skinId);
123            if (CollectionUtils.isEmpty(_themesCache.get(skinId)) || _configurationError.containsKey(skinId))
124            {
125                // No configuration file or there are errors
126                return true;
127            }
128            
129            String language = _directoryHelper.getLanguage(request);
130            
131            List<ThemeInputData> themeInputDatas = _getThemesForSkinAndTemplate(skinId, template);
132            
133            if (themeInputDatas.isEmpty())
134            {
135                // The current template is not configured for a link directory input data
136                return true;
137            }
138            
139            for (ThemeInputData themeInputData : themeInputDatas)
140            {
141                if (themeInputData.isConfigurable() || themeInputData.displayUserLinks())
142                {
143                    // The applications are configurable
144                    return false;
145                }
146            }
147            
148            // Find the configured theme ids for this template
149            List<String> configuredThemesNames = themeInputDatas.stream()
150                .map(inputData -> _getConfiguredThemes(inputData, language))
151                .flatMap(Collection::stream)
152                .collect(Collectors.toList());
153            String siteName = _directoryHelper.getSiteName(request);
154            
155            return !_directoryHelper.hasRestrictions(siteName, language, configuredThemesNames) && !_directoryHelper.hasInternalUrl(siteName, language, configuredThemesNames);
156        }
157        catch (Exception e)
158        {
159            getLogger().error("An error occurred while retrieving information from the skin configuration", e);
160            // Configuration file is not readable => toSAX method will not generate any xml
161            return true;
162        }
163    }
164
165    @Override
166    public void toSAX(ContentHandler contentHandler) throws ProcessingException
167    {
168        Request request = ContextHelper.getRequest(_context);
169        
170        // Get the current user's login if he is in the front office
171        UserIdentity user = _currentUserProvider.getUser();
172
173        String template = _getTemplate(request);
174        if (template == null)
175        {
176            getLogger().info("There is no current template");
177            return; 
178        }
179        
180        String skinId = _getSkin(request);
181        if (skinId == null)
182        {
183            getLogger().info("There is no current skin");
184            return; 
185        }
186        
187        try
188        {
189            _updateConfigurationValues(skinId);
190            if (CollectionUtils.isEmpty(_themesCache.get(skinId)))
191            {
192                return;
193            }
194
195            contentHandler.startDocument();
196            
197            // Is there an error in the configuration file ?
198            if (_configurationError.containsKey(skinId))
199            {
200                AttributesImpl attrs = new AttributesImpl();
201                attrs.addCDATAAttribute("error", _configurationError.get(skinId));
202                XMLUtils.createElement(contentHandler, "linkDirectory", attrs);
203            }
204            else
205            {
206                String language = _directoryHelper.getLanguage(request);
207                String siteName = _directoryHelper.getSiteName(request);
208                List<ThemeInputData> themeInputDatas = _getThemesForSkinAndTemplate(skinId, template);
209                for (ThemeInputData themeInputData : themeInputDatas)
210                {
211                    AttributesImpl attrs = new AttributesImpl();
212                    attrs.addCDATAAttribute("applicable", Boolean.TRUE.toString());
213                    attrs.addCDATAAttribute("configurable", String.valueOf(themeInputData.isConfigurable()));
214                    attrs.addCDATAAttribute("displayUserLinks", String.valueOf(themeInputData.displayUserLinks()));
215                    attrs.addCDATAAttribute("id", themeInputData.getId());
216                    
217                    XMLUtils.startElement(contentHandler, "linkDirectory", attrs);
218                    
219                    List<String> configuredThemesNames = _getConfiguredThemes(themeInputData, language);
220                    if (configuredThemesNames != null)
221                    {
222                        Map<String, List<String>> themesMap = _directoryHelper.getThemesMap(configuredThemesNames, siteName, language);
223                        List<String> correctThemesIds = themesMap.get("themes");
224                        List<String> unknownThemesNames = themesMap.get("unknown-themes");
225
226                        _saxThemes(contentHandler, correctThemesIds, unknownThemesNames);
227                        _saxLinks(contentHandler, user, request, correctThemesIds, themeInputData.displayUserLinks(), themeInputData.getId());
228                    }
229                    
230                    XMLUtils.endElement(contentHandler, "linkDirectory");
231                }
232            }
233        }
234        catch (Exception e)
235        {
236            getLogger().error("An exception occurred during the processing of the link directory's input data" , e);
237        }
238    }
239    
240    private void _saxThemes(ContentHandler contentHandler, List<String> themeIds, List<String> unknownThemesNames) throws SAXException
241    {
242        if (!themeIds.isEmpty())
243        {
244            XMLUtils.startElement(contentHandler, "themes");
245            for (String themeId : themeIds)
246            {
247                XMLUtils.createElement(contentHandler, "theme", themeId);
248            }
249            XMLUtils.endElement(contentHandler, "themes");
250        }
251        
252        if (!unknownThemesNames.isEmpty())
253        {
254            AttributesImpl attr = new AttributesImpl();
255            attr.addCDATAAttribute("count", Integer.toString(unknownThemesNames.size()));
256            XMLUtils.createElement(contentHandler, "unknown-themes", attr, StringUtils.join(unknownThemesNames, ", "));
257        }
258    }
259
260    private void _saxLinks(ContentHandler contentHandler, UserIdentity user, Request request, List<String> themeIds, boolean displayUserLinks, String specificContext) throws ProcessingException
261    {
262        String language = _directoryHelper.getLanguage(request);
263        String siteName = _directoryHelper.getSiteName(request);
264        try
265        {
266            // SAX common links
267            List<DefaultLink> links = _directoryHelper.getLinks(themeIds, user, siteName, language);
268            
269            List<DefaultLink> userLinks = null;
270            if (user != null && displayUserLinks)
271            {
272                userLinks = _directoryHelper.getUserLinks(siteName, language, user).stream().collect(Collectors.toList());
273            }
274            
275            
276            // SAX the user own links
277            XMLUtils.startElement(contentHandler, "links");
278            
279            try
280            {
281                String storageContext = siteName + "/" + language;
282                if (StringUtils.isNotEmpty(specificContext))
283                {
284                    storageContext += "/" + specificContext;
285                }
286                _directoryHelper.saxLinks(siteName, contentHandler, links, userLinks, _directoryHelper.getContextVars(request), storageContext, user);
287            }
288            catch (Exception e)
289            {
290                getLogger().error("An exception occurred while saxing the links", e);
291            }
292            
293            XMLUtils.endElement(contentHandler, "links");
294        }
295        catch (Exception e)
296        {
297            throw new ProcessingException("An error occurred while retrieving or saxing the links", e);
298        }
299    }
300
301    /**
302     * Retrieve the configured themes names defined in the skin file link-directory.xml for the current input data and the current language
303     * @param themeInputData Can be an empty {@link String}
304     * @param lang language to filter by. Themes with lang=null will always be returned.
305     * @return the list of configured themes ids, can be empty, cannot be null
306     */
307    private List<String> _getConfiguredThemes(ThemeInputData themeInputData, String lang)
308    {
309        return themeInputData.getThemes()
310            .stream()
311            .filter(t -> t.get("lang") == null || t.get("lang").equals(lang))
312            .map(t -> t.get("id"))
313            .collect(Collectors.toList());
314    }
315    
316    private List<ThemeInputData> _getThemesForSkinAndTemplate(String skinId, String template) 
317    {
318        return _themesCache.getOrDefault(skinId, new ArrayList<>())
319            .stream()
320            .filter(t -> _filterByTemplate(t, template))
321            .collect(Collectors.toList());
322    }
323    
324    private boolean _filterByTemplate(ThemeInputData theme, String template)
325    {
326        List<String> templates = theme.getTemplates();
327        return templates.contains(template) || templates.contains(__WILDCARD); 
328    }
329    
330    /**
331     * Update the configuration values : read them if the map is empty, update them if the file was changed or simply return them
332     * @param skinId The skin
333     * @throws Exception if an exception occurs
334     */
335    private void _updateConfigurationValues(String skinId) throws Exception
336    {
337        Source source = null;
338        try
339        {
340            source = _sourceResolver.resolveURI(__CONF_FILE_PATH);
341            if (source.exists())
342            {
343                long lastModified = source.getLastModified();
344                if (_lastConfUpdate.containsKey(skinId) && _lastConfUpdate.get(skinId) != 0 && lastModified == _lastConfUpdate.get(skinId))
345                {
346                    return;
347                }
348                
349                _cacheConfigurationValues(source, skinId);
350            }
351            else
352            {
353                if (getLogger().isInfoEnabled())
354                {
355                    getLogger().info("There is no configuration file at path '" + __CONF_FILE_PATH + "' (no input data for link directory).");
356                }
357                
358                _lastConfUpdate.put(skinId, (long) 0);
359                _themesCache.put(skinId, null);
360            }
361        }
362        finally
363        {
364            if (_sourceResolver != null && source != null)
365            {
366                _sourceResolver.release(source);
367            }
368        }
369    }
370    
371    /**
372     * Read the configuration values and store them
373     * @param source the file's source
374     * @param skinId The skin
375     */
376    private synchronized void _cacheConfigurationValues (Source source, String skinId)
377    {
378        long lastModified = source.getLastModified();
379        if (_lastConfUpdate.containsKey(skinId) && _lastConfUpdate.get(skinId) != 0 && lastModified == _lastConfUpdate.get(skinId))
380        {
381            // While waiting for synchronized, someone else may have updated the cache
382            return;
383        }
384
385        List<ThemeInputData> themesCache = new ArrayList<>();
386
387        getLogger().info("Caching configuration");
388        
389        try (InputStream is = source.getInputStream())
390        {
391            Configuration configuration = new DefaultConfigurationBuilder().build(is);
392            
393            Configuration[] themesConfigurations = configuration.getChild("inputdata").getChildren("themes");
394            
395            for (Configuration themesConfiguration : themesConfigurations)
396            {
397                List<Map<String, String>> themes = new ArrayList<> ();
398                
399                Configuration[] themeConfigurations = themesConfiguration.getChildren();
400                for (Configuration themeConfiguration : themeConfigurations)
401                {
402                    Map<String, String> theme = new HashMap<> ();
403                    String id = themeConfiguration.getAttribute("id", null);
404                    theme.put("id", id);
405                    theme.put("lang", themeConfiguration.getAttribute("lang", null));
406                    themes.add(theme);
407                }
408                
409                String[] templates = StringUtils.split(themesConfiguration.getAttribute("templates", __WILDCARD), ',');
410                
411                ThemeInputData themeInputData = new ThemeInputData(themesConfiguration.getAttribute("inputDataId", StringUtils.EMPTY), Arrays.asList(templates), themes, themesConfiguration.getAttributeAsBoolean("configurable", false), themesConfiguration.getAttributeAsBoolean("displayUserLinks", false));
412                themesCache.add(themeInputData);
413            }
414            
415            _configurationError.remove(skinId);
416            _themesCache.put(skinId, themesCache);
417            _lastConfUpdate.put(skinId, (long) 0);
418        }
419        catch (Exception e)
420        {
421            getLogger().warn("An error occured while getting the configuration's file values", e);
422            _configurationError.put(skinId, e.getMessage());
423        }
424    }
425       
426    /**
427     * Get the current template 
428     * @param request the request
429     * @return the current template
430     */
431    private String _getTemplate(Request request)
432    {
433        return (String) request.getAttribute("template");
434    }
435    
436    
437     /**
438      * Get the current skin 
439      * @param request the request
440      * @return the current skin
441      */
442    private String _getSkin(Request request)
443    {
444        return (String) request.getAttribute("skin");
445    }
446     
447    private class ThemeInputData 
448    {
449        private String _id;
450        private List<String> _templates;
451        private List<Map<String, String>> _themes;
452        private boolean _configurable;
453        private boolean _displayUserLinks;
454        
455        ThemeInputData (String id, List<String> templates, List<Map<String, String>> themes, boolean configurable, boolean displayUserLinks)
456        {
457            _id = id;
458            _templates = templates;
459            _themes = themes;
460            _configurable = configurable;
461            _displayUserLinks = displayUserLinks;
462        } 
463        
464        boolean isConfigurable ()
465        {
466            return _configurable;
467        }
468        
469        boolean displayUserLinks()
470        {
471            return _displayUserLinks;
472        }
473        
474        List<String> getTemplates ()
475        {
476            return _templates;
477        }
478        
479        List<Map<String, String>> getThemes ()
480        {
481            return _themes;
482        }
483        
484        String getId()
485        {
486            return _id;
487        }
488    }
489}