001/*
002 *  Copyright 2023 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.statistics;
017
018import java.util.ArrayList;
019import java.util.Comparator;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.function.Function;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030import org.apache.commons.lang3.StringUtils;
031
032import org.ametys.cms.repository.Content;
033import org.ametys.core.util.I18nUtils;
034import org.ametys.plugins.explorer.resources.Resource;
035import org.ametys.plugins.explorer.resources.ResourceCollection;
036import org.ametys.plugins.repository.AmetysObject;
037import org.ametys.plugins.repository.AmetysObjectIterable;
038import org.ametys.plugins.repository.AmetysObjectResolver;
039import org.ametys.plugins.repository.TraversableAmetysObject;
040import org.ametys.runtime.config.Config;
041import org.ametys.runtime.i18n.I18nizableText;
042import org.ametys.runtime.plugin.component.PluginAware;
043import org.ametys.runtime.plugins.admin.statistics.Statistics;
044import org.ametys.runtime.plugins.admin.statistics.StatisticsNode;
045import org.ametys.runtime.plugins.admin.statistics.StatisticsProvider;
046import org.ametys.runtime.plugins.admin.statistics.StatisticsValue;
047import org.ametys.web.cache.PageHelper;
048import org.ametys.web.gdpr.GDPRComponentEnumerator;
049import org.ametys.web.repository.content.SharedContent;
050import org.ametys.web.repository.content.WebContent;
051import org.ametys.web.repository.page.Page;
052import org.ametys.web.repository.page.SitemapElement;
053import org.ametys.web.repository.page.Zone;
054import org.ametys.web.repository.page.ZoneItem;
055import org.ametys.web.repository.site.Site;
056import org.ametys.web.repository.site.SiteManager;
057import org.ametys.web.repository.sitemap.Sitemap;
058import org.ametys.web.service.Service;
059import org.ametys.web.service.ServiceExtensionPoint;
060import org.ametys.web.skin.SkinsManager;
061
062/**
063 * Web statistics
064 */
065public class WebStatisticsProvider implements StatisticsProvider, Serviceable, PluginAware
066{
067    private String _id;
068    private SiteManager _siteManager;
069    private PageHelper _pageHelper;
070    private ServiceExtensionPoint _serviceEP;
071    private I18nUtils _i18nUtils;
072    private AmetysObjectResolver _ametysResolver;
073    private SkinsManager _skinsManager;
074    private GDPRComponentEnumerator _gDPRComponentEnumerator;
075
076    public void service(ServiceManager manager) throws ServiceException
077    {
078        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
079        _pageHelper = (PageHelper) manager.lookup(PageHelper.ROLE);
080        _serviceEP = (ServiceExtensionPoint) manager.lookup(ServiceExtensionPoint.ROLE);
081        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
082        _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
083        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
084        _gDPRComponentEnumerator = (GDPRComponentEnumerator) manager.lookup(GDPRComponentEnumerator.ROLE);
085    }
086    
087    public void setPluginInfo(String pluginName, String featureName, String id)
088    {
089        _id = id;
090    }
091    
092    public Statistics getStatistics()
093    {
094        List<List<Map<String, Long>>> countPages = _countPages();
095        int siteCount = countPages.size();
096        int sitemapCount = countPages.stream().map(List<Map<String, Long>>::size).reduce(0, Integer::sum);
097        List<Long> nbPagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBPAGES")).sorted().toList();
098        long pagesCount = nbPagesPerSitemap.stream().reduce(0L, Long::sum);
099        List<Long> nbContentsRefPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("CONTENTS")).sorted().toList();
100        long contentsRefsCount = nbContentsRefPerSitemap.stream().reduce(0L, Long::sum);
101        List<Long> nbServicesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("SERVICES")).sorted().toList();
102        long servicesCount = nbServicesPerSitemap.stream().reduce(0L, Long::sum);
103        List<Long> nbCacheablePagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBCACHEABLE")).sorted().toList();
104        long cacheablePagesCount = nbCacheablePagesPerSitemap.stream().reduce(0L, Long::sum);
105        
106        Map<String, Long> contentsForSites = _processContentsForSites();
107        
108        List<Statistics> skins = _skinsManager.getResourceSkins()
109                                              .stream()
110                                              .sorted(Comparator.naturalOrder())
111                                              .map(skinName -> new StatisticsValue(skinName,
112                                                                                   new I18nizableText(skinName),
113                                                                                   "ametysicon-puzzle-piece1", 
114                                                                                   _skinsManager.getVersion(skinName)))
115                                              .map(Statistics.class::cast)
116                                              .toList();
117
118        
119        return new StatisticsNode(
120            _id,
121            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_LABEL"),
122            "ametysicon-code-html-link",
123            null,
124            List.of(
125                new StatisticsNode(
126                    "sites",
127                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_LABEL"),
128                    "ametysicon-world-earth-black",
129                    siteCount,
130                    List.of(
131                        new StatisticsNode(
132                            "sitemaps",
133                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_LABEL"),
134                            "ametysicon-world-flag",
135                            sitemapCount,
136                            List.of(
137                                new StatisticsNode(
138                                    "pages",
139                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_LABEL"),
140                                    "ametysicon-website38",
141                                    pagesCount,
142                                    List.of(
143                                        new StatisticsValue(
144                                            "max",
145                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MAX_LABEL"),
146                                            "ametysicon-sort51",
147                                            nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() - 1) : 0
148                                        ),
149                                        new StatisticsValue(
150                                            "median",
151                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MEDIAN_LABEL"),
152                                            "ametysicon-maths-window-symbol-x",
153                                            nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() / 2) : 0
154                                        ),
155                                        new StatisticsValue(
156                                            "cacheable",
157                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_CACHEABLE_LABEL"),
158                                            "ametysicon-desktop-archive",
159                                            cacheablePagesCount
160                                        )
161                                    ),
162                                    false
163                                ),
164                                new StatisticsNode(
165                                    "content-references",
166                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_LABEL"),
167                                    "ametysicon-text70",
168                                    contentsRefsCount,
169                                    List.of(
170                                        new StatisticsValue(
171                                            "max",
172                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MAX_LABEL"),
173                                            "ametysicon-sort51",
174                                            nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() - 1) : 0
175                                        ),
176                                        new StatisticsValue(
177                                            "median",
178                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MEDIAN_LABEL"),
179                                            "ametysicon-maths-window-symbol-x",
180                                            nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() / 2) : 0
181                                        )
182                                    ),
183                                    false
184                                ),
185                                new StatisticsNode(
186                                    "content-references",
187                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_LABEL"),
188                                    "ametysicon-text70",
189                                    servicesCount,
190                                    List.of(
191                                        new StatisticsValue(
192                                            "max",
193                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MAX_LABEL"),
194                                            "ametysicon-sort51",
195                                            nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() - 1) : 0
196                                        ),
197                                        new StatisticsValue(
198                                            "median",
199                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MEDIAN_LABEL"),
200                                            "ametysicon-maths-window-symbol-x",
201                                            nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() / 2) : 0
202                                        )
203                                    ),
204                                    false
205                                )
206                            ),
207                            false
208                        ),
209                        new StatisticsNode(
210                            "contents",
211                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_LABEL"),
212                            "ametysicon-text70",
213                            contentsForSites.get("COUNT"),
214                            List.of(
215                                new StatisticsValue(
216                                    "orphans",
217                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_ORPHANS_LABEL"),
218                                    "ametysicon-sort51",
219                                    contentsForSites.get("ORPHANS")
220                                ),
221                                new StatisticsValue(
222                                    "shared",
223                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_EXTERNAL_LABEL"),
224                                    "ametysicon-maths-window-symbol-x",
225                                    contentsForSites.get("EXTERNAL")
226                                )
227                            ),
228                            false
229                        ),
230                        new StatisticsNode(
231                            "services",
232                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_LABEL"),
233                            "ametysicon-system-monitoring",
234                            null,
235                            _getServices(),
236                            false
237                        ),
238                        _processResources(),
239                        new StatisticsNode(
240                            "skins",
241                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SKINS_LABEL"),
242                            "ametysicon-puzzle-piece1",
243                            null,
244                            skins,
245                            false
246                        ),
247                        new StatisticsNode(
248                                "gdpr",
249                                new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_GDPR_LABEL"),
250                                "ametysicon-desktop-clipboard-list",
251                                _getGDPRGlobal(),
252                                _getGDPRBySite(),
253                                false
254                        )
255                    ),
256                    true
257                )
258            ),
259            true
260        );
261    }
262
263    private List<List<Map<String, Long>>> _countPages()
264    {
265        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
266        {
267            List<List<Map<String, Long>>> sitesValues = new ArrayList<>();
268            
269            for (Site site : sites)
270            {
271                sitesValues.add(_countPages(site));
272            }
273            
274            return sitesValues;
275        }
276    }
277    
278    private List<Map<String, Long>> _countPages(Site site)
279    {
280        List<Map<String, Long>> siteValues = new ArrayList<>();
281
282        for (Sitemap sitemap : site.getSitemaps())
283        {
284            siteValues.add(_countPages(sitemap));
285        }
286        
287        return siteValues;
288    }
289    
290    private Map<String, Long> _countPages(SitemapElement pages)
291    {
292        Map<String, Long> sitemapElementValues = _newCountMap();
293        
294        AmetysObjectIterable<? extends Page> childPages = pages.getChildrenPages();
295        
296        sitemapElementValues.put("NBPAGES", sitemapElementValues.get("NBPAGES") + childPages.getSize());
297        
298        int cacheableCount = 0;
299        
300        for (Page page : childPages)
301        {
302            boolean isCacheable = _pageHelper.isCacheable(page);
303            for (Zone zone : page.getZones())
304            {
305                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
306                for (ZoneItem item : zoneItems)
307                {
308                    switch (item.getType())
309                    {
310                        case CONTENT:
311                            sitemapElementValues.put("CONTENTS", sitemapElementValues.get("CONTENTS") + 1);
312                            break;
313                        case SERVICE:
314                            sitemapElementValues.put("SERVICES", sitemapElementValues.get("SERVICES") + 1);
315                            break;
316                        default:
317                            break;
318                    }
319                }
320            }
321            
322            if (isCacheable)
323            {
324                cacheableCount++;
325            }
326            
327            _sumMaps(sitemapElementValues, _countPages(page));
328        }
329        
330        sitemapElementValues.put("NBCACHEABLE", sitemapElementValues.get("NBCACHEABLE") + cacheableCount);
331        
332        return sitemapElementValues;
333    }
334    
335    
336    private Map<String, Long> _newCountMap()
337    {
338        Map<String, Long> countMap = new HashMap<>();
339        countMap.put("NBPAGES", 0L); // Number of pages
340        countMap.put("NBCACHEABLE", 0L); // Number of cacheable pages
341        countMap.put("SERVICES", 0L); // Number of services
342        countMap.put("CONTENTS", 0L); // Number of contents
343        return countMap;
344    }
345    
346    private void _sumMaps(Map<String, Long> mainMap, Map<String, Long> mapToAdd)
347    {
348        for (String k : mainMap.keySet())
349        {
350            mainMap.put(k, mainMap.get(k) + mapToAdd.get(k));
351        }
352    }
353    
354    private List<Statistics> _getServices()
355    {
356        List<Statistics> stats = new ArrayList<>();
357        
358        for (String serviceId : _serviceEP.getExtensionsIds())
359        {
360            Service service = _serviceEP.getExtension(serviceId);
361            
362            stats.add(new StatisticsValue(
363                serviceId,
364                new I18nizableText(_i18nUtils.translate(service.getLabel())), // Translate now for sorting
365                StringUtils.defaultIfBlank(service.getIconGlyph(), "ametysicon-system-monitoring"),
366                _countAllServices(serviceId)
367            ));
368        }
369        
370        stats.sort(new Comparator<>() {
371            public int compare(Statistics o1, Statistics o2)
372            {
373                return o1.getLabel().getLabel().compareTo(o2.getLabel().getLabel());
374            }
375        });
376        
377        String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'"
378                + _serviceEP.getExtensionsIds().stream().map(id -> " and not(@ametys-internal:service='" + id + "')").collect(Collectors.joining())
379                + "]";
380        try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query))
381        {
382            stats.add(0, new StatisticsValue(
383                "unknown",
384                new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_UNKNOWN_LABEL"),
385                "ametysicon-file-extension-generic-unknown",
386                serviceZoneItems.getSize()
387            ));
388        }
389        
390        return stats;
391    }
392
393    private long _countAllServices(String serviceId)
394    {
395        String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'"
396                + (serviceId != null ? " and @ametys-internal:service='" + serviceId + "'" : "")
397                + "]";
398        try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query))
399        {
400            return serviceZoneItems.getSize();
401        }
402    }
403    
404    private Map<String, Long> _processContentsForSites()
405    {
406        Map<String, Long> values = new HashMap<>();
407        values.put("COUNT", 0L);
408        values.put("ORPHANS", 0L);
409        values.put("EXTERNAL", 0L);
410        
411        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
412        {
413            for (Site site : sites)
414            {
415                _processContents (site, values);
416            }
417        }
418        catch (Exception e)
419        {
420            throw new IllegalStateException(e);
421        }
422        
423        return values;
424    }
425    
426    private void _processContents (Site site, Map<String, Long> values)
427    {
428        // Number of contents
429        AmetysObjectIterable<Content> contents = site.getContents();
430        values.put("COUNT", values.get("COUNT") + contents.getSize());
431        
432        int orphans = 0;
433        int external = 0;
434        for (Content content : contents)
435        {
436            if (content instanceof WebContent webContent
437                && webContent.getReferencingPages().size() == 0)
438            {
439                orphans++;
440            }
441            if (content instanceof SharedContent)
442            {
443                external++;
444            }
445        }
446        
447        // Number of orphan contents
448        values.put("ORPHANS", values.get("ORPHANS") + orphans);
449        
450        // Number of shared contents
451        values.put("EXTERNAL", values.get("EXTERNAL") + external);
452    }
453    
454    private Statistics _processResources()
455    {
456        HashMap<String, Long> values = new HashMap<>();
457        values.put("FOLDERCOUNT", 0L);
458        values.put("RESOURCECOUNT", 0L);
459        values.put("TOTALSIZE", 0L);
460        
461        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
462        {
463            for (Site site : sites)
464            {
465                _processResources (site.getRootResources(), values);
466            }
467        }
468        
469        return new StatisticsNode(
470            "explorer",
471            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_LABEL"),
472            "ametysicon-folder249",
473            values.get("TOTALSIZE"),
474            List.of(
475                new StatisticsValue(
476                    "folders",
477                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FOLDER_LABEL"),
478                    "ametysicon-folder250",
479                    values.get("FOLDERCOUNT")
480                ),
481                new StatisticsValue(
482                    "files",
483                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FILES_LABEL"),
484                    "ametysicon-document77",
485                    values.get("RESOURCECOUNT")
486                )
487            ),
488            false
489        );
490    }
491    
492    private void _processResources(TraversableAmetysObject resourceContainer, HashMap<String, Long> values)
493    {
494        AmetysObjectIterable<? extends AmetysObject> objects = resourceContainer.getChildren();
495        
496        for (AmetysObject object : objects)
497        {
498            // Traverse the child nodes if depth < 0 (generate all) or depth > 0 (we're not in the last level).
499            if (object instanceof ResourceCollection)
500            {
501                values.put("FOLDERCOUNT", values.get("FOLDERCOUNT") + 1);
502                
503                _processResources((ResourceCollection) object, values);
504            }
505            else if (object instanceof Resource)
506            {
507                Resource resource = (Resource) object;
508                
509                values.put("RESOURCECOUNT", values.get("RESOURCECOUNT") + 1);
510                values.put("TOTALSIZE", values.get("TOTALSIZE") + resource.getLength());
511            }
512        }
513    }
514
515    private I18nizableText _getGDPRGlobal()
516    {
517        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
518        {
519            return _gDPRComponentEnumerator.getEntry(Config.getInstance().getValue("plugins.web.gdpr.choice"));
520        }
521        catch (Exception e)
522        {
523            throw new IllegalStateException(e);
524        }
525    }
526    
527    private List<Statistics> _getGDPRBySite()
528    {
529        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
530        {
531            List<Statistics> gdpr = new ArrayList<>();
532
533            Map<String, Long> gdprChoiceNumber = sites.stream()
534                    .map(site -> (String) (site.hasValue("gdpr-component-choice") ? site.getValue("gdpr-component-choice") : "_ametys_general_configuration"))
535                    .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
536
537            gdpr.add(new StatisticsValue("_ametys_general_configuration",
538                    new I18nizableText("plugin.web", "PLUGINS_WEB_SITE_COOKIES_GENERAL_CONFIGURATION"),
539                    "ametysicon-system-monitoring",
540                    gdprChoiceNumber.getOrDefault("_ametys_general_configuration", 0L)));
541            for (Entry<String, I18nizableText> entry : _gDPRComponentEnumerator.getEntries().entrySet())
542            {
543                gdpr.add(new StatisticsValue(entry.getKey(),
544                        entry.getValue(),
545                        "ametysicon-system-monitoring",
546                        gdprChoiceNumber.getOrDefault(entry.getKey(), 0L)));
547            }
548            return gdpr;
549        }
550        catch (Exception e)
551        {
552            throw new IllegalStateException(e);
553        }
554        
555    }
556}