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.InputStream;
019import java.net.HttpURLConnection;
020import java.net.URL;
021import java.time.LocalDate;
022import java.time.ZonedDateTime;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.concurrent.ExecutionException;
027import java.util.concurrent.TimeUnit;
028
029import org.apache.avalon.framework.activity.Initializable;
030import org.apache.avalon.framework.component.Component;
031import org.apache.commons.lang.StringUtils;
032
033import org.ametys.plugins.core.impl.cache.AbstractCacheKey;
034import org.ametys.runtime.config.Config;
035import org.ametys.runtime.plugin.component.AbstractLogEnabled;
036import org.ametys.web.repository.site.Site;
037
038import com.fasterxml.jackson.core.JsonFactory;
039import com.fasterxml.jackson.core.JsonParser;
040import com.fasterxml.jackson.databind.ObjectMapper;
041import com.google.common.cache.CacheBuilder;
042import com.google.common.cache.CacheLoader;
043import com.google.common.cache.LoadingCache;
044
045/**
046 * The matomo data helper
047 */
048public class MatomoDataHelper extends AbstractLogEnabled implements Component, Initializable
049{
050    /** The Matomo URL config parameter name */
051    public static final String MATOMO_URL_CONFIG = "piwik-url";
052    
053    /** The Matomo site id config parameter name */
054    public static final String MATOMO_SITE_ID_SITE_CONFIG = "piwik-id";
055
056    private static final String __MATOMO_TOKEN_CONFIG = "matomo-token";
057    private static final String __MATOMO_SITE_LIVE_STATS_CONFIG = "matomo-live-stats";
058    private static final String __MATOMO_LAST_DAYS_SITE_CONFIG = "matomo-last-days";
059    private static final String __MATOMO_BEGIN_YEAR_SITE_CONFIG = "matomo-begin-year";
060    private static final String __MATOMO_CACHE_LIFETIME_SITE_CONFIG = "matomo-cache-lifetime";
061    
062    private static final long __CACHE_LIFETIME_DEFAULT_VALUE = 5L;
063    
064    /** The component role. */
065    public static final String ROLE = MatomoDataHelper.class.getName();
066    
067    /** The user information cache. */
068    protected LoadingCache<MatomoCacheKey, NbVisits> _dataCache;
069    
070    private String _matomoServerURL; 
071    private String _matomoToken; 
072    
073    @Override
074    public void initialize() throws Exception
075    {
076        NbVisitsCacheLoader loader = new NbVisitsCacheLoader();
077        
078        CacheBuilder<Object, Object> computedDataCacheBuilder = CacheBuilder.newBuilder()
079                .expireAfterWrite(6, TimeUnit.HOURS);
080        _dataCache = computedDataCacheBuilder.build(loader);
081        
082        _matomoServerURL = Config.getInstance().getValue(MATOMO_URL_CONFIG);
083        _matomoToken = Config.getInstance().getValue(__MATOMO_TOKEN_CONFIG);
084    }
085    
086    private void _checkMatomoConfig(Site site) throws MatomoException
087    {
088        if (!site.getValue(__MATOMO_SITE_LIVE_STATS_CONFIG, true, false))
089        {
090            throw new MatomoException("Can't request the matomo stats because the site does not enable it.");
091        }
092        
093        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
094        if (StringUtils.isBlank(_matomoServerURL) || StringUtils.isBlank(_matomoToken) || StringUtils.isBlank(siteId))
095        {
096            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");
097        }
098    }
099    
100    /**
101     * Get the number of days from now to computed the number of visits
102     * @param site the site
103     * @return the number of days
104     */
105    public long getMatomoSiteNbLastDays(Site site)
106    {
107        return site.getValue(__MATOMO_LAST_DAYS_SITE_CONFIG, true, 30L);
108    }
109    
110    /**
111     * Get the number of visits since the begin year for the given page and site
112     * @param site the site 
113     * @param pageUrl the page URL
114     * @return the number of visits
115     * @throws MatomoException if an error with Matomo occurred
116     */
117    public int getNbTotalVisits(Site site, String pageUrl) throws MatomoException
118    {
119        _checkMatomoConfig(site);
120
121        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
122        long cacheLifeTime = site.getValue(__MATOMO_CACHE_LIFETIME_SITE_CONFIG, true, __CACHE_LIFETIME_DEFAULT_VALUE);
123        String beginYear = site.getValue(__MATOMO_BEGIN_YEAR_SITE_CONFIG);
124        
125        // Number of visits of the current day (Live from Matomo)
126        Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl, cacheLifeTime);
127        
128        // Number of visits of the current day (Computed from Matomo)
129        Integer nbComputedVisitsToday = _getComputedVisitsOfCurrentDay(siteId, pageUrl);
130        
131        // Number of visits from the begin years but with the computed current day
132        Integer nbComputedVisitsFromLastYears = _getComputedVisitsFromBeginYear(siteId, pageUrl, beginYear);
133        
134        return nbComputedVisitsFromLastYears - nbComputedVisitsToday + liveVisitsOfDay;
135    }
136    
137    /**
138     * Get the number of visits from the last days for the given page and site 
139     * @param site the site 
140     * @param pageUrl the page URL
141     * @return the number of visits
142     * @throws MatomoException if an error with Matomo occurred
143     */
144    public int getNbVisitsFromLastDays(Site site, String pageUrl) throws MatomoException
145    {
146        _checkMatomoConfig(site);
147        
148        String siteId = site.getValue(MATOMO_SITE_ID_SITE_CONFIG);
149        long cacheLifeTime = site.getValue(__MATOMO_CACHE_LIFETIME_SITE_CONFIG, true, __CACHE_LIFETIME_DEFAULT_VALUE);
150        long lastDays = getMatomoSiteNbLastDays(site);
151        
152        // Number of visits of the current day (Live from Matomo)
153        Integer liveVisitsOfDay = _getLiveVisitsOfDay(siteId, pageUrl, cacheLifeTime);
154
155        // Visits from the last n days (starting from yesterday)
156        Integer computedVisitsFromLastDays = _getComputedVisitsFromLastDays(siteId, pageUrl, lastDays);
157        
158        return computedVisitsFromLastDays + liveVisitsOfDay;
159    }
160    
161    /**
162     * Get the live number of visits of today for the given page and site
163     * @param siteId the site identifier in Matomo
164     * @param pageUrl the page URL
165     * @param cacheValidityInMinutes the number of minute of the cache validity
166     * @return the number of visits
167     * @throws MatomoException if an error with Matomo occurred
168     */
169    protected int _getLiveVisitsOfDay(String siteId, String pageUrl, Long cacheValidityInMinutes) throws MatomoException
170    {
171        StringBuilder url = new StringBuilder(_matomoServerURL);
172        url.append("/?module=API&method=Live.getLastVisitsDetails");
173        url.append("&format=JSON");
174        url.append("&period=day");
175        url.append("&date=today");
176        url.append("&idSite=");
177        url.append(siteId);
178        url.append("&token_auth=");
179        url.append(_matomoToken);
180        
181        MatomoCacheKey key = MatomoCacheKey.of(url.toString(), pageUrl);
182        try
183        {
184            NbVisits nbVisits = _dataCache.get(key);
185            // Clear the cache after nbMinutes
186            if (nbVisits.date().plusMinutes(cacheValidityInMinutes).isBefore(ZonedDateTime.now()))
187            {
188                _dataCache.invalidate(key);
189                nbVisits = _dataCache.get(key);
190            }
191            
192            return nbVisits.number();
193        }
194        catch (ExecutionException e)
195        {
196            throw new MatomoException("An error occurred getting the number of visits of today for page url '" + pageUrl + "'", e);
197        }
198    }
199    
200    /**
201     * Get the computed number of visits from the last years starting from the begin year for the given page and site
202     * @param siteId the site identifier in matomo
203     * @param pageUrl the page URL
204     * @param beginYear the begin year
205     * @return the number of visits
206     * @throws MatomoException if an error with Matomo occurred
207     */
208    protected int _getComputedVisitsFromBeginYear(String siteId, String pageUrl, String beginYear) throws MatomoException
209    {
210        LocalDate beginDate = LocalDate.of(Integer.valueOf(beginYear), 1, 1);
211        LocalDate now = LocalDate.now();
212        
213        int nbYear = now.getYear() - beginDate.getYear() + 1;
214        
215        StringBuilder url = new StringBuilder(_matomoServerURL);
216        url.append("/?module=API&method=Actions.getPageUrl");
217        url.append("&format=JSON");
218        url.append("&period=year");
219        url.append("&date=last");
220        url.append(nbYear);
221        url.append("&idSite=");
222        url.append(siteId);
223        url.append("&pageUrl=");
224        url.append(pageUrl);
225        url.append("&token_auth=");
226        url.append(_matomoToken);
227        
228        return _getComputedNbVisits(url, pageUrl);
229    }
230    
231    /**
232     * Get the computed number of visits of today for the given page and site
233     * @param siteId the site identifier in matomo
234     * @param pageUrl the page URL
235     * @return the number of visits
236     * @throws MatomoException if an error with Matomo occurred
237     */
238    protected int _getComputedVisitsOfCurrentDay(String siteId, String pageUrl) throws MatomoException
239    {
240        StringBuilder url = new StringBuilder(_matomoServerURL);
241        url.append("/?module=API&method=Actions.getPageUrl");
242        url.append("&format=JSON");
243        url.append("&period=day");
244        url.append("&date=today");
245        url.append("&idSite=");
246        url.append(siteId);
247        url.append("&pageUrl=");
248        url.append(pageUrl);
249        url.append("&token_auth=");
250        url.append(_matomoToken);
251        
252        return _getComputedNbVisits(url, pageUrl);
253    }
254    
255    /**
256     * Get the computed number of visits from the last n days (starting from yesterday) for the given page and site
257     * @param siteId the site identifier in matomo
258     * @param pageUrl the page URL
259     * @param nbDay the number of day
260     * @return the number of visits
261     * @throws MatomoException if an error with Matomo occurred
262     */
263    protected int _getComputedVisitsFromLastDays(String siteId, String pageUrl, long nbDay) throws MatomoException
264    {
265        StringBuilder url = new StringBuilder(_matomoServerURL);
266        url.append("/?module=API&method=Actions.getPageUrl");
267        url.append("&format=JSON");
268        url.append("&period=day");
269        url.append("&date=previous");
270        url.append(nbDay);
271        url.append("&idSite=");
272        url.append(siteId);
273        url.append("&pageUrl=");
274        url.append(pageUrl);
275        url.append("&token_auth=");
276        url.append(_matomoToken);
277        
278        return _getComputedNbVisits(url, pageUrl);
279    }
280    
281    private int _getComputedNbVisits(StringBuilder url, String pageUrl) throws MatomoException
282    {
283        MatomoCacheKey key = MatomoCacheKey.of(url.toString(), pageUrl);
284        try
285        {
286            NbVisits nbVisits = _dataCache.get(key);
287            return nbVisits.number();
288        }
289        catch (ExecutionException e)
290        {
291            throw new MatomoException("An error occurred getting the number of visits for page url '" + pageUrl + "'", e);
292        }
293    }
294    
295    private NbVisits _getNbVisitsFromMatomo(MatomoCacheKey key) throws MatomoException
296    {
297        String matomoUrl = (String) key.getFields().get(0);
298        String pageUrl = (String) key.getFields().get(1);
299        try
300        {
301            int nbVisits = 0;
302            
303            URL url = new URL(matomoUrl);
304            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
305            connection.setConnectTimeout(1000);
306            connection.setReadTimeout(5000);
307            try (InputStream is = connection.getInputStream())
308            {
309                JsonParser parser = new JsonFactory().createParser(is);
310                if (StringUtils.contains(matomoUrl, "Live.getLastVisitsDetails"))
311                {
312                    nbVisits = _getTodayNbVisits(new ObjectMapper().readValue(parser, List.class), pageUrl);
313                }
314                else if (StringUtils.contains(matomoUrl, "date=today")) 
315                {
316                    nbVisits = _getNbVisitsFromList(new ObjectMapper().readValue(parser, List.class));
317                }
318                else
319                {
320                    nbVisits = _getNbVisitsFromMap(new ObjectMapper().readValue(parser, HashMap.class));
321                }
322            }
323            
324            return new NbVisits(nbVisits, ZonedDateTime.now());
325        }
326        catch (Exception e)
327        {
328            throw new MatomoException("Can't parse matomo JSON result for url '" + matomoUrl + "'", e);
329        }
330    }
331    
332    @SuppressWarnings("unchecked")
333    private int _getNbVisitsFromMap(Map<String, Object> jsonAsMap)
334    {
335        int nbVisits = 0;
336        for (String key : jsonAsMap.keySet())
337        {
338            nbVisits += _getNbVisitsFromList((List<Map<String, Object>>) jsonAsMap.get(key));
339        }
340        
341        return nbVisits;
342    }
343    
344    private int _getNbVisitsFromList(List<Map<String, Object>> data)
345    {
346        if (!data.isEmpty())
347        {
348            return (Integer) data.get(0).getOrDefault("nb_visits", 0);
349        }
350        
351        return 0;
352    }
353    
354    private int _getTodayNbVisits(List<Map<String, Object>> jsonAsMap, String pageUrl)
355    {
356        int nbVisits = 0;
357        if (!jsonAsMap.isEmpty())
358        {
359            LocalDate today = LocalDate.now();
360            for (Map<String, Object> visitInfo : jsonAsMap)
361            {
362                @SuppressWarnings("unchecked")
363                List<Map<String, Object>> actions = (List<Map<String, Object>>) visitInfo.get("actionDetails");
364                boolean present = actions.stream()
365                    .filter(a -> a.get("url").equals(pageUrl))
366                    .findAny()
367                    .isPresent();
368                
369                if (present)
370                {
371                    String visitDateAsString = (String) visitInfo.get("serverDate");
372                    LocalDate visitDate = LocalDate.parse(visitDateAsString);
373                    if (visitDate.equals(today))
374                    {
375                        nbVisits++;
376                    }
377                }
378            }
379        }
380        return nbVisits;
381    }
382    
383    /**
384     * The cache loader for the number of visits
385     */
386    protected class NbVisitsCacheLoader extends CacheLoader<MatomoCacheKey, NbVisits>
387    {
388        @Override
389        public NbVisits load(MatomoCacheKey url) throws Exception
390        {
391            return _getNbVisitsFromMatomo(url);
392        }
393    }
394    
395    private static final class MatomoCacheKey extends AbstractCacheKey
396    {
397        public MatomoCacheKey(String requestUrl, String pageUrl)
398        {
399            super(requestUrl, pageUrl);
400        }
401        
402        public static MatomoCacheKey of(String requestUrl, String pageUrl)
403        {
404            return new MatomoCacheKey(requestUrl, pageUrl);
405        }
406    }
407    
408    /**
409     * Record representing the number of visits at the given local date
410     * @param number the number of visits
411     * @param date the date when the the number of visits has been computed
412     */
413    public record NbVisits(int number, ZonedDateTime date) { /* */ }
414}