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