001/*
002 *  Copyright 2019 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.web.inputdata;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.concurrent.ExecutorService;
023import java.util.concurrent.Executors;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.context.Context;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.context.Contextualizable;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.Constants;
035import org.apache.cocoon.ProcessingException;
036import org.apache.cocoon.environment.Environment;
037import org.apache.cocoon.environment.ObjectModelHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.util.log.SLF4JLoggerAdapter;
040import org.apache.cocoon.xml.AttributesImpl;
041import org.apache.cocoon.xml.XMLUtils;
042import org.apache.commons.lang.StringUtils;
043import org.slf4j.Logger;
044import org.xml.sax.ContentHandler;
045import org.xml.sax.SAXException;
046
047import org.ametys.core.cache.AbstractCacheManager;
048import org.ametys.core.cache.AbstractCacheManager.CacheType;
049import org.ametys.core.cache.Cache;
050import org.ametys.core.engine.BackgroundEngineHelper;
051import org.ametys.core.right.RightManager;
052import org.ametys.core.user.CurrentUserProvider;
053import org.ametys.core.user.UserIdentity;
054import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
055import org.ametys.plugins.repository.AmetysObjectIterable;
056import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
057import org.ametys.plugins.repository.provider.WorkspaceSelector;
058import org.ametys.runtime.i18n.I18nizableText;
059import org.ametys.runtime.model.type.ElementType;
060import org.ametys.runtime.model.type.ModelItemType;
061import org.ametys.runtime.plugin.component.AbstractLogEnabled;
062import org.ametys.web.pageaccess.RestrictedPagePolicy;
063import org.ametys.web.renderingcontext.RenderingContext;
064import org.ametys.web.renderingcontext.RenderingContextHandler;
065import org.ametys.web.repository.page.Page;
066import org.ametys.web.repository.page.Page.PageType;
067import org.ametys.web.repository.site.Site;
068import org.ametys.web.repository.site.SiteManager;
069import org.ametys.web.repository.sitemap.Sitemap;
070
071import com.google.common.util.concurrent.ThreadFactoryBuilder;
072
073/**
074 * Send a sitemap as SAX events.
075 * Handles both cacheable and non-cacheable cases.
076 */
077public class SitemapSaxer extends AbstractLogEnabled implements Serviceable, Component, Initializable, Contextualizable
078{
079    /** Avalon role. */
080    public static final String ROLE = SitemapSaxer.class.getName();
081    
082    /** Prefix for sitemap namespace. */
083    public static final String NAMESPACE_PREFIX = "sitemap";
084    /** URI for sitemap namespace. */
085    public static final String NAMESPACE_URI = "http://www.ametys.org/inputdata/sitemap/3.0";
086    
087    private static ExecutorService _executor = Executors.newCachedThreadPool(
088            new ThreadFactoryBuilder()
089                .setNameFormat("SitemapSaxer-pool-%d")
090                .build());
091    
092    // Constants for current path management
093    private static final int PATH_DESCENDANT = -2;
094    private static final int PATH_NOT_IN_PATH = -1;
095    private static final int PATH_IN_PATH = 0;
096    private static final int PATH_CURRENT = 1;
097
098    RightManager _rightManager;
099    private RenderingContextHandler _renderingContextHandler;
100    private CurrentUserProvider _currentUserProvider;
101    private WorkspaceSelector _workspaceSelector;
102    private SiteManager _siteManager;
103    private AbstractCacheManager _cacheManager;
104    private ServiceManager _serviceManager;
105
106    private org.apache.cocoon.environment.Context _environmentContext;
107    
108    static class PageElementKey extends AbstractCacheKey
109    {
110        PageElementKey(String siteName, String workspace, String sitemap, RenderingContext renderingContext)
111        {
112            super(siteName, workspace, sitemap, renderingContext);
113        }
114
115        static PageElementKey of(String siteName, String workspace, String sitemap, RenderingContext renderingContext)
116        {
117            return new PageElementKey(siteName, workspace, sitemap, renderingContext);
118        } 
119        
120        static PageElementKey of(String siteName, String workspace)
121        {
122            return of(siteName, workspace, null, null);
123        }
124    }
125
126    @Override
127    public void service(ServiceManager manager) throws ServiceException
128    {
129        _serviceManager = manager;
130        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
131        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
132        _renderingContextHandler = (RenderingContextHandler) manager.lookup(RenderingContextHandler.ROLE);
133        _workspaceSelector = (WorkspaceSelector) manager.lookup(WorkspaceSelector.ROLE);
134        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
135        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
136    }
137    
138    public void contextualize(Context context) throws ContextException
139    {
140        _environmentContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
141    }
142
143    @Override
144    public void initialize() throws Exception
145    {
146        _cacheManager.createCache(ROLE, 
147                new I18nizableText("plugin.web", "PLUGINS_WEB_SITE_MAP_SAXER_CACHE_LABEL"),
148                new I18nizableText("plugin.web", "PLUGINS_WEB_SITE_MAP_SAXER_CACHE_DESCRIPTION"),
149                CacheType.MEMORY,
150                true);
151    }
152    
153    /**
154     * Clear the page cache.
155     * @param siteName the site.
156     * @param workspace the JCR workspace.
157     */
158    public void clearCache(String siteName, String workspace)
159    {
160        _getCache().invalidate(PageElementKey.of(siteName, workspace));
161    }
162
163    /**
164     * Send SAX events representing pages from a sitemap.
165     * @param contentHandler the SAX handler.
166     * @param siteName the site name.
167     * @param sitemapName the sitemap name.
168     * @param currentPage the current {@link Page}.
169     * @param initialDepth max depth from the root.
170     * @param descendantDepth max depth under the current page.
171     * @throws SAXException if an error occurs with the content handler.
172     * @throws ProcessingException if an error occurs while processing data.
173     */
174    public void toSAX(ContentHandler contentHandler, String siteName, String sitemapName, Page currentPage, int initialDepth, int descendantDepth) throws SAXException, ProcessingException
175    {
176        RenderingContext renderingContext = _renderingContextHandler.getRenderingContext();
177        String workspace = _workspaceSelector.getWorkspace();
178        UserIdentity user = _currentUserProvider.getUser();
179        
180        Site site = _siteManager.getSite(siteName);
181        Sitemap sitemap = site.getSitemap(sitemapName);
182        RestrictedPagePolicy policy = site.getRestrictedPagePolicy();
183        
184        List<? extends PageWrapper> pages = null;
185        
186        if (policy == RestrictedPagePolicy.HIDDEN)
187        {
188            // input data not cacheable, so no need to cache anything
189            pages = sitemap.getChildrenPages().stream()
190                    .map(RepositoryPage::new)
191                    .collect(Collectors.toList());
192        }
193        else
194        {
195            PageElementKey key = PageElementKey.of(siteName, workspace, sitemapName, renderingContext);
196            if (_getCache().hasKey(key))
197            {
198                pages = _getCache().get(key);
199            }
200            else
201            {
202                // No found entry in cache: as it can be a long process, cache will be computed asynchronously
203                pages = sitemap.getChildrenPages().stream()
204                        .map(RepositoryPage::new)
205                        .collect(Collectors.toList());
206                _asyncFill(key, siteName, sitemapName, workspace);
207            }
208        }
209        
210        contentHandler.startPrefixMapping(NAMESPACE_PREFIX, NAMESPACE_URI);
211
212        AttributesImpl attrs = new AttributesImpl();
213        attrs.addCDATAAttribute(NAMESPACE_URI, "site", NAMESPACE_PREFIX + ":site", siteName);
214        attrs.addCDATAAttribute(NAMESPACE_URI, "lang", NAMESPACE_PREFIX + ":lang", sitemapName);
215        attrs.addCDATAAttribute(NAMESPACE_URI, "id", NAMESPACE_PREFIX + ":id", sitemap.getId());
216
217        XMLUtils.startElement(contentHandler, "sitemap", attrs);
218
219        // Process recursively pages
220        _saxPages(contentHandler, pages, currentPage, currentPage != null ? currentPage.getPathInSitemap() : null, user, renderingContext, site.getRestrictedPagePolicy(), initialDepth, descendantDepth, 1, -1);
221
222        XMLUtils.endElement(contentHandler, "sitemap");
223        contentHandler.endPrefixMapping(NAMESPACE_PREFIX);
224    }
225    
226    private void _asyncFill(PageElementKey key, String siteName, String sitemapName, String workspace)
227    {
228        Logger logger = getLogger();
229        
230        Runnable runnable = () ->
231        {
232            Map<String, Object> environmentInformation = null;
233            try
234            {
235                environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(_serviceManager, _environmentContext, new SLF4JLoggerAdapter(logger));
236                
237                // forces the workspace
238                Environment env = (Environment) environmentInformation.get("environment");
239                Request request = ObjectModelHelper.getRequest(env.getObjectModel());
240                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspace);
241                
242                _getCache().get(key, k -> _fill(siteName, sitemapName));
243            }
244            catch (Throwable t)
245            {
246                getLogger().error("An unexpected error occured while computing sitemap cache for key {}", key, t);
247            }
248            finally
249            {
250                if (environmentInformation != null)
251                {
252                    BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation);
253                }
254            }
255        };
256        
257        _executor.submit(runnable);
258    }
259    
260    private List<BufferedPage> _fill(String siteName, String sitemapName)
261    {
262        Site site = _siteManager.getSite(siteName);
263        Sitemap sitemap = site.getSitemap(sitemapName);
264        
265        return _fill(sitemap.getChildrenPages());
266    }
267    
268    private List<BufferedPage> _fill(AmetysObjectIterable<? extends Page> pages)
269    {
270        List<BufferedPage> result = new ArrayList<>();
271        
272        for (Page page : pages)
273        {
274            BufferedPage bufferedPage = new BufferedPage();
275            
276            bufferedPage._path = page.getPathInSitemap();
277            
278            Map<String, String> internalAttributes = _getInternalAttributes(page);
279            bufferedPage._internalAttributes = internalAttributes;
280            if ("true".equals(internalAttributes.get("invisible")))
281            {
282                bufferedPage._visible = false;
283            }
284            
285            bufferedPage._attributes = _getAttributes(page);
286            
287            bufferedPage._children = _fill(page.getChildrenPages());
288            result.add(bufferedPage);
289        }
290 
291        return result;
292    }
293    
294    Map<String, String> _getInternalAttributes(Page page)
295    {
296        Map<String, String> internalAttributes = new HashMap<>();
297        String path = page.getPathInSitemap();
298        PageType pageType = page.getType();
299        internalAttributes.put("id", page.getId());
300        internalAttributes.put("name", page.getName());
301        internalAttributes.put("title", page.getTitle());
302        internalAttributes.put("long-title", page.getLongTitle());
303        internalAttributes.put("path", path);
304        internalAttributes.put("type", page.getType().name());
305        
306        if (!page.isVisible())
307        {
308            internalAttributes.put("invisible", "true");
309        }
310        
311        if (!_rightManager.hasAnonymousReadAccess(page))
312        {
313            internalAttributes.put("restricted", "true");
314        }
315        
316        // Add a flag if there is data
317        if (pageType == PageType.CONTAINER)
318        {
319            internalAttributes.put("container", "true");
320        }
321        // Add URL if found
322        else if (pageType == PageType.LINK)
323        {
324            internalAttributes.put("link", page.getURL());
325            internalAttributes.put("link-type", page.getURLType().name());
326        }
327        
328        return internalAttributes;
329    }
330    
331    @SuppressWarnings("unchecked")
332    Map<String, String> _getAttributes(Page page)
333    {
334        Map<String, String> attributes = new HashMap<>();
335        
336        for (String dataName : page.getDataNames())
337        {
338            // SAX only non composite and non multivalued data
339            ModelItemType type = page.getType(dataName);
340            if (type instanceof ElementType && !page.isMultiple(dataName))
341            {
342                Object value = page.getValue(dataName);
343                attributes.put(dataName, ((ElementType) type).toString(value));
344            }
345        }
346        
347        // tags
348        for (String tag : page.getTags())
349        {
350            attributes.put("PLUGIN_TAGS_" + tag, "empty");
351        }
352        
353        return attributes;
354    }
355    
356    private void _saxPages(ContentHandler handler, List<? extends PageWrapper> pages, Page currentPage, String currentPagePath, UserIdentity userIdentity, RenderingContext renderingContext, RestrictedPagePolicy policy, int initialDepth, int descendantDepth, int currentDepth, int currentDescendantDepth) throws SAXException
357    {
358        boolean inBackOffice = renderingContext == RenderingContext.BACK || renderingContext == RenderingContext.PREVIEW;
359
360        for (PageWrapper page : pages)
361        {
362            String pagePath = page.getPath();
363
364            if (inBackOffice || policy == RestrictedPagePolicy.DISPLAYED || page.hasReadAccess(userIdentity))
365            {
366                if (page.isVisible() || (StringUtils.isNotBlank(currentPagePath) && currentPagePath.startsWith(pagePath)))
367                {
368                    _saxPage(handler, page, currentPage, currentPagePath, userIdentity, renderingContext, policy, initialDepth, descendantDepth, currentDepth, currentDescendantDepth);
369                }
370            }
371        }
372    }
373    
374    private void _saxPage(ContentHandler handler, PageWrapper page, Page currentPage, String currentPagePath, UserIdentity userIdentity, RenderingContext renderingContext, RestrictedPagePolicy policy, int initialDepth, int descendantDepth, int currentDepth, int currentDescendantDepth) throws SAXException
375    {
376        String pagePath = page.getPath();
377
378        AttributesImpl attrs = new AttributesImpl();
379        Map<String, String> internalAttributes = page.getInternalAttributes();
380        
381        for (String name : internalAttributes.keySet())
382        {
383            attrs.addCDATAAttribute(NAMESPACE_URI, name, NAMESPACE_PREFIX + ':' + name, internalAttributes.get(name));
384        }
385        
386        Map<String, String> attributes = page.getAttributes();
387        
388        for (String name : attributes.keySet())
389        {
390            attrs.addCDATAAttribute(name, attributes.get(name));
391        }
392        
393        int inPath = _saxCurrentStatus(currentPagePath, pagePath, attrs);
394        
395        XMLUtils.startElement(handler, "page", attrs);
396        
397        // Continue if current depth is less than initial depth
398        // or if we are inside current path or in current page descendants
399        if (currentDepth <= initialDepth || inPath >= PATH_IN_PATH || (inPath == PATH_DESCENDANT && currentDescendantDepth <= descendantDepth))
400        {
401            int descendant = (inPath == PATH_CURRENT) ? 0 : (inPath == PATH_DESCENDANT ? currentDescendantDepth + 1 : -1);
402            _saxPages(handler, page.getChildren(), currentPage, currentPagePath, userIdentity, renderingContext, policy, initialDepth, descendantDepth, currentDepth + 1, descendant);
403        }
404        
405        XMLUtils.endElement(handler, "page");
406    }
407    
408    /**
409     * SAX current status.
410     * @param currentPagePath the path to the current page.
411     * @param pagePath the path to the page to process.
412     * @param attrs the attributes to populate.
413     * @return the current status.
414     */
415    protected int _saxCurrentStatus(String currentPagePath, String pagePath, AttributesImpl attrs)
416    {
417        int result = PATH_NOT_IN_PATH;
418
419        if (currentPagePath == null)
420        {
421            return PATH_NOT_IN_PATH;
422        }
423
424        // If the page in an ancestor of the current page
425        boolean isPageCurrent = currentPagePath.equals(pagePath);
426
427        if (currentPagePath.startsWith(pagePath + "/") || isPageCurrent)
428        {
429            attrs.addCDATAAttribute(NAMESPACE_URI, "in-path", NAMESPACE_PREFIX + ":in-path", "true");
430            result = PATH_IN_PATH;
431        }
432        else if (pagePath.startsWith(currentPagePath))
433        {
434            result = PATH_DESCENDANT;
435        }
436
437        // If this is the current page
438        if (isPageCurrent)
439        {
440            attrs.addCDATAAttribute(NAMESPACE_URI, "current", NAMESPACE_PREFIX + ":current", "true");
441            result = PATH_CURRENT;
442        }
443
444        return result;
445    }
446    
447    interface PageWrapper
448    {
449        List<? extends PageWrapper> getChildren();
450        boolean isVisible();
451        String getPath();
452        Map<String, String> getAttributes();
453        Map<String, String> getInternalAttributes();
454        boolean hasReadAccess(UserIdentity user);
455    }
456
457    static class BufferedPage implements PageWrapper
458    {
459        String _path;
460        Map<String, String> _attributes;
461        Map<String, String> _internalAttributes;
462        List<BufferedPage> _children;
463        boolean _visible = true;
464        
465        public List<? extends PageWrapper> getChildren()
466        {
467            return _children;
468        }
469        
470        public boolean isVisible()
471        {
472            return _visible;
473        }
474        
475        public String getPath()
476        {
477            return _path;
478        }
479        
480        public Map<String, String> getAttributes()
481        {
482            return _attributes;
483        }
484        
485        public Map<String, String> getInternalAttributes()
486        {
487            return _internalAttributes;
488        }
489        
490        public boolean hasReadAccess(UserIdentity user)
491        {
492            throw new UnsupportedOperationException("A BufferedPage has no concept of read access");
493        }
494    }
495    
496    class RepositoryPage implements PageWrapper
497    {
498        Page _page;
499        
500        RepositoryPage(Page page)
501        {
502            _page = page;
503        }
504        
505        public List<? extends PageWrapper> getChildren()
506        {
507            return _page.getChildrenPages().stream().map(RepositoryPage::new).collect(Collectors.toList());
508        }
509        
510        public boolean isVisible()
511        {
512            return _page.isVisible();
513        }
514        
515        public String getPath()
516        {
517            return _page.getPathInSitemap();
518        }
519        
520        public Map<String, String> getAttributes()
521        {
522            return _getAttributes(_page);
523        }
524        
525        public Map<String, String> getInternalAttributes()
526        {
527            return _getInternalAttributes(_page);
528        }
529        
530        public boolean hasReadAccess(UserIdentity user)
531        {
532            return _rightManager.hasReadAccess(user, _page);
533        }
534    }
535
536    private Cache<PageElementKey, List<BufferedPage>> _getCache()
537    {
538        return _cacheManager.get(ROLE);
539    }
540
541}