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