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