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.plugins.webanalytics.matomo;
017
018import java.io.IOException;
019import java.time.Duration;
020import java.time.LocalDate;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024
025import org.apache.avalon.framework.activity.Disposable;
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.lang.StringUtils;
032import org.apache.http.client.config.RequestConfig;
033import org.apache.http.client.methods.HttpPost;
034import org.apache.http.impl.client.CloseableHttpClient;
035import org.apache.http.impl.client.HttpClientBuilder;
036import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
037import org.apache.http.util.EntityUtils;
038
039import org.ametys.core.cache.AbstractCacheManager;
040import org.ametys.core.cache.Cache;
041import org.ametys.core.util.JSONUtils;
042import org.ametys.core.util.LambdaUtils;
043import org.ametys.core.util.LambdaUtils.LambdaException;
044import org.ametys.core.util.URIUtils;
045import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
046import org.ametys.runtime.config.Config;
047import org.ametys.runtime.i18n.I18nizableText;
048import org.ametys.runtime.plugin.component.AbstractLogEnabled;
049import org.ametys.web.repository.site.Site;
050
051/**
052 * The matomo data helper
053 */
054public class MatomoDataHelper extends AbstractLogEnabled implements Component, Initializable, Serviceable, Disposable
055{
056    /** The component role. */
057    public static final String ROLE = MatomoDataHelper.class.getName();
058    
059    /** The Matomo URL config parameter name */
060    public static final String MATOMO_URL_CONFIG = "piwik-url";
061    
062    /** The Matomo site id config parameter name */
063    public static final String MATOMO_SITE_ID_SITE_CONFIG = "piwik-id";
064
065    private static final String __MATOMO_TOKEN_CONFIG = "matomo-token";
066    private static final String __MATOMO_SITE_LIVE_STATS_CONFIG = "matomo-live-stats";
067    private static final String __MATOMO_LAST_DAYS_SITE_CONFIG = "matomo-last-days";
068    private static final String __MATOMO_BEGIN_YEAR_SITE_CONFIG = "matomo-begin-year";
069    
070    private static final String __CACHE_COMPUTED_VISITS = "computed-visits";
071    private static final String __CACHE_LIVE_VISITS = "live-visits";
072    
073    /** The cache manager */
074    protected AbstractCacheManager _cacheManager;
075    
076    /** JSON Utils */
077    protected JSONUtils _jsonUtils;
078    
079    private CloseableHttpClient _httpClient;
080    private String _matomoServerURL; 
081    private String _matomoToken; 
082    
083    /**
084     * The computed matomo request type.
085     */
086    private enum RequestType
087    {
088        /** Computed visits from begin year */
089        BEGIN_YEAR,
090        /** Computed visits of the current day */
091        CURRENT_DAY,
092        /** Computed visits of from the last days */
093        LAST_DAYS;
094    }
095    
096    
097    public void service(ServiceManager manager) throws ServiceException
098    {
099        _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE);
100        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
101    }
102    
103    @Override
104    public void initialize() throws Exception
105    {
106        // The duration of 6 hours is an arbitrary value because computed data in Matomo are re-computed every 6 hours. But the cache need to be recalculate only once every day.
107        // But if the value is put in the cache at 23pm, we don't want the cache to be recalculate at 23pm the next day ... 6 hours seems to be fine ...
108        _cacheManager.createMemoryCache(__CACHE_COMPUTED_VISITS, 
109                new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_COMPUTED_VISITS_LABEL"),
110                new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_COMPUTED_VISITS_DESC"),
111                true,
112                Duration.ofHours(6)); 
113        
114        // The duration is fixed at 5 minutes. Live stats for pages will be computed every 5 minutes.
115        _cacheManager.createMemoryCache(__CACHE_LIVE_VISITS, 
116                new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_LIVE_VISITS_LABEL"),
117                new I18nizableText("plugin.web-analytics", "PLUGINS_WEB_ANALTICS_CACHE_MATOMO_LIVE_VISITS_DESC"),
118                true,
119                Duration.ofMinutes(5)); 
120        
121        _httpClient = _createHttpClient(5, 5); // Max 25 attempts simultany and 5 sec of timeout
122        _matomoServerURL = Config.getInstance().getValue(MATOMO_URL_CONFIG);
123        _matomoToken = Config.getInstance().getValue(__MATOMO_TOKEN_CONFIG);
124    }
125    
126    private CloseableHttpClient _createHttpClient(int maxConnections, int timeout)
127    {
128        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
129        connectionManager.setMaxTotal(maxConnections);
130        connectionManager.setDefaultMaxPerRoute(maxConnections);
131        
132        RequestConfig config = RequestConfig.custom()
133                .setConnectTimeout(timeout * 1000)
134                .setConnectionRequestTimeout(timeout * 1000)
135                .setSocketTimeout(timeout * 1000)
136                .build();
137        
138        return HttpClientBuilder.create()
139                .setDefaultRequestConfig(config)
140                .setConnectionManager(connectionManager)
141                .build();
142    }
143    
144    private void _checkMatomoConfig(Site site) throws MatomoException
145    {
146        if (!site.getValue(__MATOMO_SITE_LIVE_STATS_CONFIG, true, false))
147        {
148            throw new MatomoException("Can't request the matomo stats because the site does not enable it.");
149        }
150        
151        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
152        if (StringUtils.isBlank(_matomoServerURL) || StringUtils.isBlank(_matomoToken) || StringUtils.isBlank(siteId))
153        {
154            throw new MatomoException("One or more of these following parameters are empty: the matamo server URL, the matomo token or the matomo site id. Can't access to matomo data without these parameters");
155        }
156    }
157    
158    /**
159     * Get the number of days from now to computed the number of visits
160     * @param site the site
161     * @return the number of days
162     */
163    public long getMatomoSiteNbLastDays(Site site)
164    {
165        return site.getValue(__MATOMO_LAST_DAYS_SITE_CONFIG, true, 30L);
166    }
167    
168    /**
169     * Get the number of visits since the begin year for the given page and site
170     * @param site the site 
171     * @param pageUrl the page URL
172     * @return the number of visits
173     * @throws MatomoException if an error with Matomo occurred
174     */
175    public int getNbTotalVisits(Site site, String pageUrl) throws MatomoException
176    {
177        _checkMatomoConfig(site);
178
179        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
180        String beginYear = site.getValue(__MATOMO_BEGIN_YEAR_SITE_CONFIG);
181        
182        // Number of visits of the current day (Live from Matomo)
183        Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl);
184        
185        // Number of visits of the current day (Computed from Matomo)
186        Integer nbComputedVisitsToday = _getComputedVisitsOfCurrentDay(siteId, pageUrl);
187        
188        // Number of visits from the begin years but with the computed current day
189        Integer nbComputedVisitsFromLastYears = _getComputedVisitsFromBeginYear(siteId, pageUrl, beginYear);
190        
191        return nbComputedVisitsFromLastYears - nbComputedVisitsToday + liveVisitsOfDay;
192    }
193    
194    /**
195     * Get the number of visits from the last days for the given page and site 
196     * @param site the site 
197     * @param pageUrl the page URL
198     * @return the number of visits
199     * @throws MatomoException if an error with Matomo occurred
200     */
201    public int getNbVisitsFromLastDays(Site site, String pageUrl) throws MatomoException
202    {
203        _checkMatomoConfig(site);
204        
205        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
206        long lastDays = getMatomoSiteNbLastDays(site);
207        
208        // Number of visits of the current day (Live from Matomo)
209        Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl);
210
211        // Visits from the last n days (starting from yesterday)
212        Integer computedVisitsFromLastDays = _getComputedVisitsFromLastDays(siteId, pageUrl, lastDays);
213        
214        return computedVisitsFromLastDays + liveVisitsOfDay;
215    }
216    
217    /**
218     * Get the live number of visits of today for the given page and site
219     * @param siteId the site identifier in Matomo
220     * @param pageUrl the page URL
221     * @return the number of visits
222     * @throws MatomoException if an error with Matomo occurred
223     */
224    protected int _getLiveVisitsOfDay(String siteId, String pageUrl) throws MatomoException
225    {
226        StringBuilder url = new StringBuilder(_matomoServerURL);
227        url.append("/?module=API&method=Live.getLastVisitsDetails");
228        url.append("&format=JSON");
229        url.append("&period=day");
230        url.append("&date=today");
231        url.append("&idSite=");
232        url.append(siteId);
233        url.append("&token_auth=");
234        url.append(_matomoToken);
235        
236        try
237        {
238            List<Map<String, Object>> liveVisits = _getLiveVisitsCache().get(siteId, LambdaUtils.wrap(k -> _requestLiveVisitsOfDay(url.toString())));
239            return _getTodayNbVisits(liveVisits, pageUrl);
240        }
241        catch (LambdaException e) 
242        {
243            if (e.getCause() instanceof RuntimeException re) 
244            {
245                throw re;
246            }
247            else if (e.getCause() instanceof MatomoException m) 
248            {
249                throw m;
250            }
251            else 
252            {
253                throw new RuntimeException(e.getCause());
254            }
255        }
256    }
257    
258    @SuppressWarnings("unchecked")
259    private List<Map<String, Object>> _requestLiveVisitsOfDay(String url) throws MatomoException
260    {
261        return (List<Map<String, Object>>) _executeMatomoRequest(url, "list");
262    }
263    
264    /**
265     * Get the computed number of visits from the last years starting from the begin year for the given page and site
266     * @param siteId the site identifier in matomo
267     * @param pageUrl the page URL
268     * @param beginYear the begin year
269     * @return the number of visits
270     * @throws MatomoException if an error with Matomo occurred
271     */
272    protected int _getComputedVisitsFromBeginYear(String siteId, String pageUrl, String beginYear) throws MatomoException
273    {
274        String truncateURL = _truncateURL(pageUrl);
275
276        LocalDate beginDate = LocalDate.of(Integer.valueOf(beginYear), 1, 1);
277        LocalDate now = LocalDate.now();
278        
279        int nbYear = now.getYear() - beginDate.getYear() + 1;
280        
281        StringBuilder url = new StringBuilder(_matomoServerURL);
282        url.append("/?module=API&method=Actions.getPageUrl");
283        url.append("&format=JSON");
284        url.append("&period=year");
285        url.append("&date=last");
286        url.append(nbYear);
287        url.append("&idSite=");
288        url.append(siteId);
289        url.append("&pageUrl=");
290        url.append(URIUtils.encodeParameter(truncateURL));
291        url.append("&token_auth=");
292        url.append(_matomoToken);
293        
294        try
295        {
296            MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.BEGIN_YEAR, truncateURL);
297            return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsFromBeginYear(url.toString())));
298        }
299        catch (LambdaException e)
300        {
301            if (e.getCause() instanceof RuntimeException re) 
302            {
303                throw re;
304            }
305            else if (e.getCause() instanceof MatomoException m) 
306            {
307                throw m;
308            }
309            else 
310            {
311                throw new RuntimeException(e.getCause());
312            }
313        }
314    }
315    
316    private Integer _requestComputedVisitsFromBeginYear(String url) throws MatomoException
317    {
318        @SuppressWarnings("unchecked")
319        Map<String, Object> json = (Map<String, Object>) _executeMatomoRequest(url, "map");
320        return _getNbVisitsFromMap(json);
321    }
322    
323    /**
324     * Get the computed number of visits of today for the given page and site
325     * @param siteId the site identifier in matomo
326     * @param pageUrl the page URL
327     * @return the number of visits
328     * @throws MatomoException if an error with Matomo occurred
329     */
330    protected int _getComputedVisitsOfCurrentDay(String siteId, String pageUrl) throws MatomoException
331    {
332        String truncateURL = _truncateURL(pageUrl);
333
334        StringBuilder url = new StringBuilder(_matomoServerURL);
335        url.append("/?module=API&method=Actions.getPageUrl");
336        url.append("&format=JSON");
337        url.append("&period=day");
338        url.append("&date=today");
339        url.append("&idSite=");
340        url.append(siteId);
341        url.append("&pageUrl=");
342        url.append(URIUtils.encodeParameter(truncateURL));
343        url.append("&token_auth=");
344        url.append(_matomoToken);
345        
346        try
347        {
348            MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.CURRENT_DAY, truncateURL);
349            return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsOfCurrentDay(url.toString())));
350        }
351        catch (LambdaException e) 
352        {
353            if (e.getCause() instanceof RuntimeException re) 
354            {
355                throw re;
356            }
357            else if (e.getCause() instanceof MatomoException m) 
358            {
359                throw m;
360            }
361            else 
362            {
363                throw new RuntimeException(e.getCause());
364            }
365        }
366    }
367    
368    private Integer _requestComputedVisitsOfCurrentDay(String url) throws MatomoException
369    {
370        @SuppressWarnings("unchecked")
371        List<Map<String, Object>> json = (List<Map<String, Object>>) _executeMatomoRequest(url, "list");
372        return _getNbVisitsFromList(json);
373    }
374    
375    /**
376     * Get the computed number of visits from the last n days (starting from yesterday) for the given page and site
377     * @param siteId the site identifier in matomo
378     * @param pageUrl the page URL
379     * @param nbDay the number of day
380     * @return the number of visits
381     * @throws MatomoException if an error with Matomo occurred
382     */
383    protected int _getComputedVisitsFromLastDays(String siteId, String pageUrl, long nbDay) throws MatomoException
384    {
385        String truncateURL = _truncateURL(pageUrl);
386
387        StringBuilder url = new StringBuilder(_matomoServerURL);
388        url.append("/?module=API&method=Actions.getPageUrl");
389        url.append("&format=JSON");
390        url.append("&period=day");
391        url.append("&date=previous");
392        url.append(nbDay);
393        url.append("&idSite=");
394        url.append(siteId);
395        url.append("&pageUrl=");
396        url.append(URIUtils.encodeParameter(truncateURL));
397        url.append("&token_auth=");
398        url.append(_matomoToken);
399        
400        try
401        {
402            MatomoComputedCacheKey key = MatomoComputedCacheKey.of(RequestType.LAST_DAYS, truncateURL);
403            return _getComputedVisitsCache().get(key, LambdaUtils.wrap(k -> _requestComputedVisitsFromLastDays(url.toString())));
404        }
405        catch (LambdaException e) 
406        {
407            if (e.getCause() instanceof RuntimeException re) 
408            {
409                throw re;
410            }
411            else if (e.getCause() instanceof MatomoException m) 
412            {
413                throw m;
414            }
415            else 
416            {
417                throw new RuntimeException(e.getCause());
418            }
419        }
420    }
421    
422    private Integer _requestComputedVisitsFromLastDays(String url) throws MatomoException
423    {
424        @SuppressWarnings("unchecked")
425        Map<String, Object> json = (Map<String, Object>) _executeMatomoRequest(url, "map");
426        return _getNbVisitsFromMap(json);
427    }
428    
429    private Object _executeMatomoRequest(String matomoUrl, String returnType) throws MatomoException
430    {
431        try
432        {
433            if (getLogger().isInfoEnabled())
434            {
435                getLogger().info("A request is send to Matomo with url '" + matomoUrl + "'");
436            }
437            
438            HttpPost post = new HttpPost(matomoUrl);
439            return _httpClient.execute(post, httpResponse -> {
440                if (httpResponse.getStatusLine().getStatusCode() != 200)
441                {
442                    throw new IllegalStateException("Could not join matomo for URL " + matomoUrl + ". Error code " + httpResponse.getStatusLine().getStatusCode());
443                }
444                
445                if ("map".equals(returnType))
446                {
447                    return _jsonUtils.convertJsonToMap(EntityUtils.toString(httpResponse.getEntity()));
448                }
449                else if ("list".equals(returnType))
450                {
451                    return _jsonUtils.convertJsonToList(EntityUtils.toString(httpResponse.getEntity()));
452                }
453                
454                throw new IllegalArgumentException("Invalid return type for request with url '" + matomoUrl + "'");
455            });
456        }
457        catch (Exception e)
458        {
459            throw new MatomoException("An error occurred requesting Matomo for url '" + matomoUrl + "'", e);
460        }
461    }
462    
463    @SuppressWarnings("unchecked")
464    private int _getNbVisitsFromMap(Map<String, Object> jsonAsMap)
465    {
466        int nbVisits = 0;
467        for (String key : jsonAsMap.keySet())
468        {
469            nbVisits += _getNbVisitsFromList((List<Map<String, Object>>) jsonAsMap.get(key));
470        }
471        
472        return nbVisits;
473    }
474    
475    private int _getNbVisitsFromList(List<Map<String, Object>> data)
476    {
477        if (!data.isEmpty())
478        {
479            return (Integer) data.get(0).getOrDefault("nb_visits", 0);
480        }
481        
482        return 0;
483    }
484    
485    private int _getTodayNbVisits(List<Map<String, Object>> jsonAsMap, String pageUrl)
486    {
487        int nbVisits = 0;
488        if (!jsonAsMap.isEmpty())
489        {
490            String truncateURL = _truncateURL(pageUrl);
491            
492            LocalDate today = LocalDate.now();
493            for (Map<String, Object> visitInfo : jsonAsMap)
494            {
495                String visitDateAsString = (String) visitInfo.get("serverDate");
496                LocalDate visitDate = LocalDate.parse(visitDateAsString);
497                if (visitDate.equals(today))
498                {
499                    @SuppressWarnings("unchecked")
500                    List<Map<String, Object>> actions = (List<Map<String, Object>>) visitInfo.get("actionDetails");
501                    Optional<String> pageVisits = actions.stream()
502                            .map(a -> (String) a.get("url"))
503                            .map(this::_truncateURL)
504                            .filter(url -> truncateURL.equals(url))
505                            .findAny();
506                    
507                    if (pageVisits.isPresent())
508                    {
509                        nbVisits++;
510                    }
511                }
512            }
513        }
514        return nbVisits;
515    }
516    
517    /**
518     * Truncate parameters and fragments of the URL
519     * @param pageUrl the page URL
520     * @return the page URL without parameters and fragments
521     */
522    protected String _truncateURL(String pageUrl)
523    {
524        return StringUtils.substringBefore(StringUtils.substringBefore(pageUrl, "?"), "#");
525    }
526    
527    private Cache<MatomoComputedCacheKey, Integer> _getComputedVisitsCache()
528    {
529        return _cacheManager.get(__CACHE_COMPUTED_VISITS);
530    }
531    
532    private Cache<String, List<Map<String, Object>>> _getLiveVisitsCache()
533    {
534        return _cacheManager.get(__CACHE_LIVE_VISITS);
535    }
536    
537    private static final class MatomoComputedCacheKey extends AbstractCacheKey
538    {
539        public MatomoComputedCacheKey(RequestType type, String pageUrl)
540        {
541            super(type, pageUrl);
542        }
543        
544        public static MatomoComputedCacheKey of(RequestType type, String pageUrl)
545        {
546            return new MatomoComputedCacheKey(type, pageUrl);
547        }
548    }
549    
550    public void dispose()
551    {
552        try
553        {
554            _httpClient.close();
555        }
556        catch (IOException e)
557        {
558            getLogger().error("An error occurred closing the http client", e);
559        }
560    }
561}