Selaa lähdekoodia

Analytics: pre-PR review fixes for privacy, trusted headers and report cost

Stored paths also drop UNA's invite code (icode) and parameter names that
end in key, code, token, secret, password, session, email and the like
without a separator, so short invite keys no longer reach Pages and
Journeys.

The visitor location can come from one CDN's headers only (Cloudflare,
Vercel or CloudFront), since an edge passes other CDNs' headers through.
NEO's web proxy forwards to UNA only the address and location headers the
edge in front of it set (NEO_EDGE: vercel, cloudflare, cloudfront, proxy
or none); UNA trusts them on requests with the API key, and a browser
talking to next start directly could otherwise pick its own address.

Dropping cached reports starts a new generation instead of deleting by
prefix, which empties a whole Memcache(d) server and scans every Redis key
on each profile deletion. Cache names keep only the parameters a report
reads, not the whole page URL. Subscriptions skip unpaid ones that lapsed
before the period, goals are capped at 20, forgetting a profile finds its
creation by a new events key (type, name, object_id), and new dashboards
keep each block's own active_api. Fire-and-forget promises in the
dashboard and Studio tools scripts get rejection handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 viikko sitten
vanhempi
sitoutus
e4a44e5146

+ 14 - 10
modules/boonex/analytics/classes/BxAnalyticsCollector.php

@@ -99,14 +99,17 @@ class BxAnalyticsCollector
      */
      */
     protected $_aSecretParams = [
     protected $_aSecretParams = [
         'key', 'code', 'token', 'hash', 'secret', 'password', 'pass', 'passwd', 'pwd', 'auth', 'sid', 'sk',
         'key', 'code', 'token', 'hash', 'secret', 'password', 'pass', 'passwd', 'pwd', 'auth', 'sid', 'sk',
-        'otp', 'email', 'session', 'ticket', 'invite', 'confirm', 'signature', 'sig',
+        'otp', 'email', 'session', 'ticket', 'invite', 'icode', 'confirm', 'signature', 'sig',
         '_hsenc', '_hsmi', 'mkt_tok', '_ke',
         '_hsenc', '_hsmi', 'mkt_tok', '_ke',
     ];
     ];
 
 
     /**
     /**
-     * Compound names of such parameters: api_key, access_token, reset-code, user.email...
+     * Compound names of such parameters, with or without a separator: api_key, access_token,
+     * reset-code, user.email, apikey, icode (an invitation), promocode, passcode... A few
+     * harmless names that end the same way (zipcode) are dropped too. `sid` only after a
+     * separator, as too many words end in it.
      */
      */
-    protected $_sSecretParamsPattern = '/[_.-](key|code|token|hash|secret|password|passwd|pwd|auth|sid|session|otp|email|signature)$/';
+    protected $_sSecretParamsPattern = '/(?:key|code|token|hash|secret|password|passwd|pwd|auth|session|otp|email|signature|[_.-]sid)$/';
 
 
     /**
     /**
      * Seconds between a guest visit's last hit and a member's hit for the visit to become the
      * Seconds between a guest visit's last hit and a member's hit for the visit to become the
@@ -171,7 +174,7 @@ class BxAnalyticsCollector
             'browser' => $aClient['browser'],
             'browser' => $aClient['browser'],
             'os' => $aClient['os'],
             'os' => $aClient['os'],
             'device' => $aClient['device'],
             'device' => $aClient['device'],
-            'country' => $this->_oConfig->getGeoSource() == BX_ANALYTICS_GEO_HEADERS ? $this->_oRequest->getCountry() : '',
+            'country' => ($sEdge = $this->_oConfig->getGeoEdge()) !== false ? $this->_oRequest->getCountry($sEdge) : '',
         ];
         ];
 
 
         $aSession = $this->_getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp);
         $aSession = $this->_getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp);
@@ -214,7 +217,8 @@ class BxAnalyticsCollector
         $aAccept = $this->_accept(is_array($aPayload) ? $aPayload : [], $aContext, true);
         $aAccept = $this->_accept(is_array($aPayload) ? $aPayload : [], $aContext, true);
         if($this->_isOverLimit($aAccept['ip']))
         if($this->_isOverLimit($aAccept['ip']))
             $aAccept['reasons'][] = 'rate_limited';
             $aAccept['reasons'][] = 'rate_limited';
-        $aCountry = $this->_oRequest->getCountryHeader();
+        $sEdge = $this->_oConfig->getGeoEdge();
+        $aCountry = $this->_oRequest->getCountryHeader($sEdge !== false ? $sEdge : '');
 
 
         return [
         return [
             'reasons' => !empty($aAccept['reasons']) ? $aAccept['reasons'] : ['ok'],
             'reasons' => !empty($aAccept['reasons']) ? $aAccept['reasons'] : ['ok'],
@@ -582,12 +586,12 @@ class BxAnalyticsCollector
         $sScreen = isset($aPayload['screen']) && is_string($aPayload['screen']) && preg_match('/^\d{2,5}x\d{2,5}$/', $aPayload['screen']) ? $aPayload['screen'] : '';
         $sScreen = isset($aPayload['screen']) && is_string($aPayload['screen']) && preg_match('/^\d{2,5}x\d{2,5}$/', $aPayload['screen']) ? $aPayload['screen'] : '';
 
 
         $aGeo = ['country' => '', 'region' => '', 'city' => '', 'lat' => null, 'lng' => null];
         $aGeo = ['country' => '', 'region' => '', 'city' => '', 'lat' => null, 'lng' => null];
-        if($this->_oConfig->getGeoSource() == BX_ANALYTICS_GEO_HEADERS) {
-            $aCoordinates = $this->_oRequest->getCoordinates();
+        if(($sEdge = $this->_oConfig->getGeoEdge()) !== false) {
+            $aCoordinates = $this->_oRequest->getCoordinates($sEdge);
             $aGeo = [
             $aGeo = [
-                'country' => $this->_oRequest->getCountry(),
-                'region' => $this->_oRequest->getRegion(),
-                'city' => $this->_oRequest->getCity(),
+                'country' => $this->_oRequest->getCountry($sEdge),
+                'region' => $this->_oRequest->getRegion($sEdge),
+                'city' => $this->_oRequest->getCity($sEdge),
                 'lat' => $aCoordinates ? $aCoordinates[0] : null,
                 'lat' => $aCoordinates ? $aCoordinates[0] : null,
                 'lng' => $aCoordinates ? $aCoordinates[1] : null,
                 'lng' => $aCoordinates ? $aCoordinates[1] : null,
             ];
             ];

+ 27 - 1
modules/boonex/analytics/classes/BxAnalyticsConfig.php

@@ -23,6 +23,9 @@ define('BX_ANALYTICS_IDENTITY_PERSISTENT', 'persistent');
 
 
 define('BX_ANALYTICS_GEO_NONE', 'none');
 define('BX_ANALYTICS_GEO_NONE', 'none');
 define('BX_ANALYTICS_GEO_HEADERS', 'headers');
 define('BX_ANALYTICS_GEO_HEADERS', 'headers');
+define('BX_ANALYTICS_GEO_CLOUDFLARE', 'cloudflare');
+define('BX_ANALYTICS_GEO_VERCEL', 'vercel');
+define('BX_ANALYTICS_GEO_CLOUDFRONT', 'cloudfront');
 
 
 // hit and event types: pageviews, custom events, page leaves (engaged time), site actions
 // hit and event types: pageviews, custom events, page leaves (engaged time), site actions
 define('BX_ANALYTICS_HIT_PAGEVIEW', 'pageview');
 define('BX_ANALYTICS_HIT_PAGEVIEW', 'pageview');
@@ -98,6 +101,7 @@ class BxAnalyticsConfig extends BxBaseModGeneralConfig
 
 
             // funnel steps at most
             // funnel steps at most
             'MAX_FUNNEL_STEPS' => 6,
             'MAX_FUNNEL_STEPS' => 6,
+            'MAX_GOALS' => 20,
 
 
             // Query: values drawn on the chart, and rows in a CSV download at most
             // Query: values drawn on the chart, and rows in a CSV download at most
             'QUERY_SERIES_VALUES' => 5,
             'QUERY_SERIES_VALUES' => 5,
@@ -265,9 +269,31 @@ class BxAnalyticsConfig extends BxBaseModGeneralConfig
         return $this->_getList('bx_analytics_trusted_proxies');
         return $this->_getList('bx_analytics_trusted_proxies');
     }
     }
 
 
+    /**
+     * Where the visitor's location comes from: none, the headers of any CDN (the first one
+     * found), or the headers of one CDN only (the others can be sent by the client, since an
+     * edge sets its own headers and passes the rest through).
+     */
     public function getGeoSource()
     public function getGeoSource()
     {
     {
-        return getParam('bx_analytics_geo_source') == BX_ANALYTICS_GEO_HEADERS ? BX_ANALYTICS_GEO_HEADERS : BX_ANALYTICS_GEO_NONE;
+        $sSource = getParam('bx_analytics_geo_source');
+
+        return in_array($sSource, [BX_ANALYTICS_GEO_HEADERS, BX_ANALYTICS_GEO_CLOUDFLARE, BX_ANALYTICS_GEO_VERCEL, BX_ANALYTICS_GEO_CLOUDFRONT], true) ? $sSource : BX_ANALYTICS_GEO_NONE;
+    }
+
+    /**
+     * The CDN whose location headers are read ('' for any), or false when the location isn't
+     * read at all.
+     *
+     * @return string|false
+     */
+    public function getGeoEdge()
+    {
+        $sSource = $this->getGeoSource();
+        if($sSource == BX_ANALYTICS_GEO_NONE)
+            return false;
+
+        return $sSource == BX_ANALYTICS_GEO_HEADERS ? '' : $sSource;
     }
     }
 
 
     public function getRetentionDays()
     public function getRetentionDays()

+ 35 - 7
modules/boonex/analytics/classes/BxAnalyticsDb.php

@@ -964,9 +964,17 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
      * Goals and funnels
      * Goals and funnels
      */
      */
 
 
-    public function getGoals()
+    /**
+     * Goals in their order, the first `$iLimit` only when given.
+     */
+    public function getGoals($iLimit = 0)
+    {
+        return $this->getAll("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "` ORDER BY `order`, `id`" . ((int)$iLimit > 0 ? " LIMIT " . (int)$iLimit : ''));
+    }
+
+    public function getGoalsCount()
     {
     {
-        return $this->getAll("SELECT * FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "` ORDER BY `order`, `id`");
+        return (int)$this->getOne("SELECT COUNT(*) FROM `" . $this->_oConfig->CNF['TABLE_GOALS'] . "`");
     }
     }
 
 
     public function getGoal($iId)
     public function getGoal($iId)
@@ -1349,7 +1357,8 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
 
 
     /**
     /**
      * A new page of the module, with copies of the blocks in some cells of another page.
      * A new page of the module, with copies of the blocks in some cells of another page.
-     * The copies are shown in NEO (active_api) and can't be copied again from the builder.
+     * The copies are shown in NEO when their source is (active_api: a block NEO has no
+     * element for shows as raw data there) and can't be copied again from the builder.
      *
      *
      * @param array $aPage object, uri, title_system, title, module, layout_id, author
      * @param array $aPage object, uri, title_system, title, module, layout_id, author
      * @param string $sFrom the page whose blocks are copied
      * @param string $sFrom the page whose blocks are copied
@@ -1376,7 +1385,7 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
         $sIn = $this->_getInSql(array_map('intval', $aCells), 'cell', $aBindings);
         $sIn = $this->_getInSql(array_map('intval', $aCells), 'cell', $aBindings);
 
 
         return (int)$this->query("INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`)
         return (int)$this->query("INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`)
-            SELECT :object, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, 1, 0, 1, 1, `order`
+            SELECT :object, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `class`, `visible_for_levels`, `type`, `content`, 1, 0, 1, `active_api`, `order`
             FROM `sys_pages_blocks` WHERE `object` = :from AND `active` = 1 AND `cell_id` IN (" . $sIn . ") ORDER BY `cell_id`, `order`", $aBindings);
             FROM `sys_pages_blocks` WHERE `object` = :from AND `active` = 1 AND `cell_id` IN (" . $sIn . ") ORDER BY `cell_id`, `order`", $aBindings);
     }
     }
 
 
@@ -2450,9 +2459,12 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
 
 
     /**
     /**
      * Subscriptions that were paying or in trial at some time in a period: paid or started
      * Subscriptions that were paying or in trial at some time in a period: paid or started
-     * with a trial before its end, and not cancelled before its start.
+     * with a trial before its end, and not cancelled or lapsed before its start.
+     *
+     * @param array $aEnd when an unpaid subscription stops paying: unit_seconds [period unit =>
+     *      seconds], unpaid (the status), grace (seconds); none to read lapsed ones too
      */
      */
-    public function getSubscriptionRows($aFilter)
+    public function getSubscriptionRows($aFilter, $aEnd = [])
     {
     {
         $aBindings = ['from' => (int)$aFilter['from'], 'to' => (int)$aFilter['to'], 'payment' => isset($aFilter['payment']) ? $aFilter['payment'] : ''];
         $aBindings = ['from' => (int)$aFilter['from'], 'to' => (int)$aFilter['to'], 'payment' => isset($aFilter['payment']) ? $aFilter['payment'] : ''];
         $sPlatform = '';
         $sPlatform = '';
@@ -2461,7 +2473,23 @@ class BxAnalyticsDb extends BxBaseModGeneralDb
             $aBindings['platform'] = $aFilter['platform'];
             $aBindings['platform'] = $aFilter['platform'];
         }
         }
 
 
-        return $this->getAll("SELECT `pending_id`, `module`, `item_id`, `period`, `period_unit`, `trial`, `mrr_default`, `started`, `first_paid`, `last_paid`, `canceled`, `status` FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment" . $sPlatform . " AND (`canceled` = 0 OR `canceled` >= :from) AND ((`first_paid` > 0 AND `first_paid` < :to) OR ((`trial` > 0 OR `status` = 'trial') AND `started` < :to))", $aBindings);
+        // an unpaid subscription stopped paying when its last paid period and the grace time
+        // ended, without a cancellation: those that stopped before the period stay out, or
+        // every lapsed one would be read forever (see BxAnalyticsReports::_getSubscriptionEnd)
+        $sLapsed = '';
+        if(!empty($aEnd['unit_seconds'])) {
+            $aUnits = [];
+            foreach($aEnd['unit_seconds'] as $sUnit => $iSeconds) {
+                $sBinding = 'unit' . count($aUnits);
+                $aUnits[] = 'WHEN :' . $sBinding . ' THEN ' . (int)$iSeconds;
+                $aBindings[$sBinding] = $sUnit;
+            }
+            $sLapsed = " AND NOT (`canceled` = 0 AND `status` = :unpaid AND `last_paid` > 0 AND `last_paid` + :grace + GREATEST(1, `period`) * (CASE `period_unit` " . implode(' ', $aUnits) . " ELSE 0 END) < :from)";
+            $aBindings['unpaid'] = $aEnd['unpaid'];
+            $aBindings['grace'] = (int)$aEnd['grace'];
+        }
+
+        return $this->getAll("SELECT `pending_id`, `module`, `item_id`, `period`, `period_unit`, `trial`, `mrr_default`, `started`, `first_paid`, `last_paid`, `canceled`, `status` FROM `" . $this->_oConfig->CNF['TABLE_SUBSCRIPTIONS'] . "` WHERE `payment` = :payment" . $sPlatform . " AND (`canceled` = 0 OR `canceled` >= :from)" . $sLapsed . " AND ((`first_paid` > 0 AND `first_paid` < :to) OR ((`trial` > 0 OR `status` = 'trial') AND `started` < :to))", $aBindings);
     }
     }
 
 
     /**
     /**

+ 53 - 8
modules/boonex/analytics/classes/BxAnalyticsModule.php

@@ -103,6 +103,15 @@ class BxAnalyticsModule extends BxDolModule
      * over HTTP shows nothing past it.
      * over HTTP shows nothing past it.
      */
      */
     const REPORT_CACHE_GUARD = "<?php exit; ?>\n";
     const REPORT_CACHE_GUARD = "<?php exit; ?>\n";
+
+    /**
+     * Cache name of the reports' generation (not under REPORT_CACHE_PREFIX, so pruning the
+     * expired reports keeps it) and how long it is kept: long past any report.
+     */
+    const REPORT_CACHE_GENERATION = 'bx_analytics_reports_generation';
+    const REPORT_CACHE_GENERATION_TTL = 2592000;
+
+    protected $_sReportCacheGeneration = null;
           
           
     function __construct(&$aModule)
     function __construct(&$aModule)
     {
     {
@@ -1429,6 +1438,9 @@ class BxAnalyticsModule extends BxDolModule
         return [
         return [
             BX_ANALYTICS_GEO_NONE => _t('_bx_analytics_geo_none'),
             BX_ANALYTICS_GEO_NONE => _t('_bx_analytics_geo_none'),
             BX_ANALYTICS_GEO_HEADERS => _t('_bx_analytics_geo_headers'),
             BX_ANALYTICS_GEO_HEADERS => _t('_bx_analytics_geo_headers'),
+            BX_ANALYTICS_GEO_CLOUDFLARE => _t('_bx_analytics_geo_cloudflare'),
+            BX_ANALYTICS_GEO_VERCEL => _t('_bx_analytics_geo_vercel'),
+            BX_ANALYTICS_GEO_CLOUDFRONT => _t('_bx_analytics_geo_cloudfront'),
         ];
         ];
     }
     }
 
 
@@ -1512,16 +1524,37 @@ class BxAnalyticsModule extends BxDolModule
 
 
     /**
     /**
      * Drop every cached report, after a change that reports show (goals, funnels, the status
      * Drop every cached report, after a change that reports show (goals, funnels, the status
-     * of an error). Keys of the database cache are `db_<name>_<site hash>.php` (see
-     * BxDolDb::genDbCacheKey), so the prefix is the name's start after `db_`.
+     * of an error, a deleted member). Report names carry a generation, and a new one leaves
+     * the old copies to expire: Memcache and Memcached delete by prefix by emptying the whole
+     * server, and Redis by a KEYS scan of every key, so nothing is deleted by prefix there.
+     * Files are deleted too (keys of the database cache are `db_<name>_<site hash>`, see
+     * BxDolDb::genDbCacheKey, so the prefix is the name's start after `db_`).
      */
      */
     public function cleanReportCache()
     public function cleanReportCache()
     {
     {
         $oCache = $this->_getReportCacheEngine();
         $oCache = $this->_getReportCacheEngine();
         if(!$oCache)
         if(!$oCache)
-            $oCache = $this->_oDb->getDbCacheObject();
-        if($oCache)
+            return;
+
+        if($oCache instanceof BxDolCacheFileHtml)
             $oCache->removeAllByPrefix('db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX']);
             $oCache->removeAllByPrefix('db_' . $this->_oConfig->CNF['REPORT_CACHE_PREFIX']);
+
+        $this->_sReportCacheGeneration = sprintf('%.6F', microtime(true));
+        $oCache->setData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION), $this->_sReportCacheGeneration, self::REPORT_CACHE_GENERATION_TTL);
+    }
+
+    /**
+     * The generation of cached reports: changed by cleanReportCache(), '' before the first.
+     */
+    protected function _getReportCacheGeneration()
+    {
+        if($this->_sReportCacheGeneration !== null)
+            return $this->_sReportCacheGeneration;
+
+        $oCache = $this->_getReportCacheEngine();
+        $sGeneration = $oCache ? $oCache->getData($this->_oDb->genDbCacheKey(self::REPORT_CACHE_GENERATION)) : '';
+
+        return $this->_sReportCacheGeneration = is_string($sGeneration) ? $sGeneration : '';
     }
     }
 
 
     /**
     /**
@@ -1598,8 +1631,9 @@ class BxAnalyticsModule extends BxDolModule
 
 
     /**
     /**
      * The cache name of a report: the report, the resolved filter (not the current second,
      * The cache name of a report: the report, the resolved filter (not the current second,
-     * so a cached copy serves the whole period), its parameters, the language and what the
-     * viewer may see (money, people, managing, administration), since reports differ by those.
+     * so a cached copy serves the whole period), the parameters the report reads, the
+     * language, what the viewer may see (money, people, managing, administration), since
+     * reports differ by those, and the generation (see cleanReportCache).
      */
      */
     protected function _getReportCacheName($sReport, $oFilter, $aParams)
     protected function _getReportCacheName($sReport, $oFilter, $aParams)
     {
     {
@@ -1607,7 +1641,7 @@ class BxAnalyticsModule extends BxDolModule
         unset($aFilter['now'], $aFilter['buckets']);
         unset($aFilter['now'], $aFilter['buckets']);
 
 
         $aOwn = [];
         $aOwn = [];
-        foreach($aParams as $sKey => $mixedValue)
+        foreach($this->getReports()->getCacheParams($sReport, $aParams) as $sKey => $mixedValue)
             if(is_string($sKey) && $sKey !== '' && $sKey[0] != '_')
             if(is_string($sKey) && $sKey !== '' && $sKey[0] != '_')
                 $aOwn[$sKey] = $mixedValue;
                 $aOwn[$sKey] = $mixedValue;
         ksort($aOwn);
         ksort($aOwn);
@@ -1619,7 +1653,7 @@ class BxAnalyticsModule extends BxDolModule
             $this->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
             $this->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
         ];
         ];
 
 
-        return $this->_oConfig->CNF['REPORT_CACHE_PREFIX'] . hash('sha256', json_encode([$sReport, $aFilter, $aOwn, bx_lang_name(), $aViewer]));
+        return $this->_oConfig->CNF['REPORT_CACHE_PREFIX'] . hash('sha256', json_encode([$sReport, $aFilter, $aOwn, bx_lang_name(), $aViewer, $this->_getReportCacheGeneration()]));
     }
     }
 
 
     /**
     /**
@@ -2127,6 +2161,8 @@ class BxAnalyticsModule extends BxDolModule
                 $sName = trim((string)bx_get('name', 'post'));
                 $sName = trim((string)bx_get('name', 'post'));
                 if($aGoal === false || $sName === '' || get_mb_len($sName) > 128)
                 if($aGoal === false || $sName === '' || get_mb_len($sName) > 128)
                     $aResult = ['error' => _t('_bx_analytics_txt_goal_invalid')];
                     $aResult = ['error' => _t('_bx_analytics_txt_goal_invalid')];
+                else if(!$iId && $this->isGoalLimitReached())
+                    $aResult = ['error' => _t('_bx_analytics_txt_goal_limit', $this->_oConfig->CNF['MAX_GOALS'])];
                 else {
                 else {
                     $iId = $this->_oDb->saveGoal($iId, ['name' => $sName] + $aGoal);
                     $iId = $this->_oDb->saveGoal($iId, ['name' => $sName] + $aGoal);
                     $aResult = ['ok' => (bool)$iId, 'id' => $iId];
                     $aResult = ['ok' => (bool)$iId, 'id' => $iId];
@@ -2138,6 +2174,15 @@ class BxAnalyticsModule extends BxDolModule
         $this->_outputJson($aResult);
         $this->_outputJson($aResult);
     }
     }
 
 
+    /**
+     * Whether there are as many goals as there can be: the Goals report counts each one in
+     * both periods, a query per goal and period.
+     */
+    public function isGoalLimitReached()
+    {
+        return $this->_oDb->getGoalsCount() >= $this->_oConfig->CNF['MAX_GOALS'];
+    }
+
     /**
     /**
      * Save or delete a funnel: POST op, id, name, step_type[], step_pattern[], csrf_token.
      * Save or delete a funnel: POST op, id, name, step_type[], step_pattern[], csrf_token.
      */
      */

+ 48 - 2
modules/boonex/analytics/classes/BxAnalyticsReports.php

@@ -96,6 +96,48 @@ class BxAnalyticsReports
         return $sReport == 'map' && $this->_getTab($aParams, 'map', $this->_aReportTabs['map']) == 'live';
         return $sReport == 'map' && $this->_getTab($aParams, 'map', $this->_aReportTabs['map']) == 'live';
     }
     }
 
 
+    /**
+     * The parameters a report reads, past the filter: its cache name is made of them. Blocks
+     * send the whole page URL, which also holds other blocks' tabs and pages, the chart's
+     * metric and opened rows; those would make a copy per combination of things the report
+     * never shows. A report missing here keeps every parameter.
+     */
+    public function getCacheParams($sReport, $aParams)
+    {
+        $aKeys = [
+            'overview' => [],
+            'top' => ['group', 'tab'],
+            'table' => ['table', 'tab', 'page'],
+            'query' => ['q_dim', 'q_metric', 'q_dir', 'page', 'page_query'],
+            'map' => ['tab', 'tab_map'],
+            'bots' => ['tab', 'tab_bots', 'page_bot_list'],
+            'events' => ['event', 'tab', 'tab_events', 'page_events'],
+            'sessions' => ['session', 'page_sessions'],
+            'users' => ['user', 'session', 'page_users', 'page_sessions'],
+            'journeys' => ['jfrom', 'via'],
+            'retention' => ['tab', 'tab_retention'],
+            'goals' => ['edit_goal'],
+            'funnels' => ['funnel', 'edit_funnel'],
+            'revenue' => ['tab', 'tab_revenue'],
+            'subscriptions' => [],
+            'sales' => [],
+            'credits' => [],
+            'errors' => ['error', 'tab', 'tab_errors', 'page_errors'],
+            'performance' => ['vital', 'vital_device', 'tab', 'tab_performance', 'page_performance'],
+        ];
+        if(!isset($aKeys[$sReport]))
+            return $aParams;
+
+        // a top list's tab, and a table's tab and page, are named after the group or the table
+        $aOwn = array_merge($aKeys[$sReport], ['limit', 'meta']);
+        if($sReport == 'top' && ($sGroup = (string)$this->_param($aParams, 'group')) !== '')
+            $aOwn[] = 'tab_' . $sGroup;
+        if($sReport == 'table' && ($sTable = (string)$this->_param($aParams, 'table')) !== '')
+            array_push($aOwn, 'tab_' . $sTable, 'page_' . $sTable);
+
+        return array_intersect_key($aParams, array_flip($aOwn));
+    }
+
     public function getGroups()
     public function getGroups()
     {
     {
         return $this->_aGroups;
         return $this->_aGroups;
@@ -1418,7 +1460,7 @@ class BxAnalyticsReports
         $iSessionsPrevious = (int)$aTotalsPrevious['sessions'];
         $iSessionsPrevious = (int)$aTotalsPrevious['sessions'];
 
 
         $aGoals = [];
         $aGoals = [];
-        foreach($this->_oDb->getGoals() as $aGoal) {
+        foreach($this->_oDb->getGoals($this->_oConfig->CNF['MAX_GOALS']) as $aGoal) {
             $aCurrent = $this->_oDb->getGoalTotals($aFilter, $aGoal);
             $aCurrent = $this->_oDb->getGoalTotals($aFilter, $aGoal);
             $aBefore = $this->_oDb->getGoalTotals($aPrevious, $aGoal);
             $aBefore = $this->_oDb->getGoalTotals($aPrevious, $aGoal);
             $fRate = $iSessions ? round(100 * $aCurrent['sessions'] / $iSessions, 2) : 0;
             $fRate = $iSessions ? round(100 * $aCurrent['sessions'] / $iSessions, 2) : 0;
@@ -2075,7 +2117,11 @@ class BxAnalyticsReports
         bx_import('Revenue', $this->_oModule->_aModule);
         bx_import('Revenue', $this->_oModule->_aModule);
 
 
         // subscriptions alive in either period, read once
         // subscriptions alive in either period, read once
-        $aRows = $this->_oDb->getSubscriptionRows(array_merge($aFilter, ['from' => $aPrevious['from']]));
+        $aRows = $this->_oDb->getSubscriptionRows(array_merge($aFilter, ['from' => $aPrevious['from']]), [
+            'unit_seconds' => BxAnalyticsRevenue::getUnitSeconds(),
+            'unpaid' => BxAnalyticsRevenue::STATUS_UNPAID,
+            'grace' => BxAnalyticsRevenue::UNPAID_GRACE,
+        ]);
 
 
         list($aCurrent) = $this->_sweepSubscriptions($aRows, [[$aFilter['from'], min($aFilter['to'], $iNow)]]);
         list($aCurrent) = $this->_sweepSubscriptions($aRows, [[$aFilter['from'], min($aFilter['to'], $iNow)]]);
         list($aBefore) = $this->_sweepSubscriptions($aRows, [[$aPrevious['from'], min($aPrevious['to'], $iNow)]]);
         list($aBefore) = $this->_sweepSubscriptions($aRows, [[$aPrevious['from'], min($aPrevious['to'], $iNow)]]);

+ 70 - 24
modules/boonex/analytics/classes/BxAnalyticsRequest.php

@@ -32,14 +32,35 @@ class BxAnalyticsRequest
     protected static $_aInternalRanges = ['100.64.0.0/10'];
     protected static $_aInternalRanges = ['100.64.0.0/10'];
 
 
     /**
     /**
-     * Country headers set by CDNs and hosting edges.
+     * Location headers set by CDNs and hosting edges, per edge: country, region, city and
+     * coordinates. With no edge chosen, the first one found is read, in this order. An edge
+     * sets its own headers and passes the others through, so a client can add another CDN's.
      */
      */
-    protected static $_aCountryHeaders = [
-        'HTTP_CF_IPCOUNTRY',
-        'HTTP_X_VERCEL_IP_COUNTRY',
-        'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
-        'HTTP_X_COUNTRY_CODE',
-        'HTTP_X_APPENGINE_COUNTRY',
+    protected static $_aGeoHeaders = [
+        'cloudflare' => [
+            'country' => ['HTTP_CF_IPCOUNTRY'],
+            'region' => [],
+            'city' => ['HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
+        'vercel' => [
+            'country' => ['HTTP_X_VERCEL_IP_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE']],
+        ],
+        'cloudfront' => [
+            'country' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY'],
+            'region' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION'],
+            'city' => ['HTTP_CLOUDFRONT_VIEWER_CITY'],
+            'coordinates' => [['HTTP_CLOUDFRONT_VIEWER_LATITUDE', 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE']],
+        ],
+        '' => [
+            'country' => ['HTTP_CF_IPCOUNTRY', 'HTTP_X_VERCEL_IP_COUNTRY', 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_X_APPENGINE_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY', 'HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE'], ['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
     ];
     ];
 
 
     protected $_aServer;
     protected $_aServer;
@@ -123,10 +144,12 @@ class BxAnalyticsRequest
 
 
     /**
     /**
      * ISO 3166-1 alpha-2 country from CDN headers, or ''. "XX" and "T1" (Tor) mean unknown.
      * ISO 3166-1 alpha-2 country from CDN headers, or ''. "XX" and "T1" (Tor) mean unknown.
+     *
+     * @param string $sEdge the CDN whose headers are read (cloudflare, vercel, cloudfront), '' for any
      */
      */
-    public function getCountry()
+    public function getCountry($sEdge = '')
     {
     {
-        $aCountry = $this->getCountryHeader();
+        $aCountry = $this->getCountryHeader($sEdge);
 
 
         return $aCountry ? $aCountry['country'] : '';
         return $aCountry ? $aCountry['country'] : '';
     }
     }
@@ -134,11 +157,12 @@ class BxAnalyticsRequest
     /**
     /**
      * The CDN header the country comes from and its value, or null when there is none.
      * The CDN header the country comes from and its value, or null when there is none.
      *
      *
+     * @param string $sEdge as for getCountry()
      * @return array|null header (as in $_SERVER) and country
      * @return array|null header (as in $_SERVER) and country
      */
      */
-    public function getCountryHeader()
+    public function getCountryHeader($sEdge = '')
     {
     {
-        foreach(self::$_aCountryHeaders as $sHeader) {
+        foreach(self::_getGeoHeaders($sEdge, 'country') as $sHeader) {
             $sValue = mb_strtoupper(trim($this->_get($sHeader)));
             $sValue = mb_strtoupper(trim($this->_get($sHeader)));
             if(preg_match('/^[A-Z]{2}$/', $sValue) && !in_array($sValue, ['XX', 'T1', 'ZZ'], true))
             if(preg_match('/^[A-Z]{2}$/', $sValue) && !in_array($sValue, ['XX', 'T1', 'ZZ'], true))
                 return ['header' => $sHeader, 'country' => $sValue];
                 return ['header' => $sHeader, 'country' => $sValue];
@@ -148,31 +172,30 @@ class BxAnalyticsRequest
     }
     }
 
 
     /**
     /**
-     * Region and city where the edge provides them (Vercel), URL-decoded, or ''.
+     * Region and city where the edge provides them, URL-decoded, or ''.
+     *
+     * @param string $sEdge as for getCountry()
      */
      */
-    public function getRegion()
+    public function getRegion($sEdge = '')
     {
     {
-        return $this->_clean(rawurldecode($this->_get('HTTP_X_VERCEL_IP_COUNTRY_REGION')), 64);
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'region'))), 64);
     }
     }
 
 
-    public function getCity()
+    public function getCity($sEdge = '')
     {
     {
-        $sCity = $this->_get('HTTP_X_VERCEL_IP_CITY');
-        if($sCity === '')
-            $sCity = $this->_get('HTTP_CF_IPCITY');
-
-        return $this->_clean(rawurldecode($sCity), 64);
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'city'))), 64);
     }
     }
 
 
     /**
     /**
-     * Visitor coordinates from the edge (Vercel, or Cloudflare's visitor location headers),
-     * rounded to one decimal (about 10 km), or null.
+     * Visitor coordinates from the edge (Vercel, CloudFront, or Cloudflare's visitor location
+     * headers), rounded to one decimal (about 10 km), or null.
      *
      *
+     * @param string $sEdge as for getCountry()
      * @return array|null [lat, lng]
      * @return array|null [lat, lng]
      */
      */
-    public function getCoordinates()
+    public function getCoordinates($sEdge = '')
     {
     {
-        foreach([['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE'], ['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']] as list($sLat, $sLng)) {
+        foreach(self::_getGeoHeaders($sEdge, 'coordinates') as list($sLat, $sLng)) {
             $sLatValue = $this->_get($sLat);
             $sLatValue = $this->_get($sLat);
             $sLngValue = $this->_get($sLng);
             $sLngValue = $this->_get($sLng);
             if(!is_numeric($sLatValue) || !is_numeric($sLngValue))
             if(!is_numeric($sLatValue) || !is_numeric($sLngValue))
@@ -323,6 +346,29 @@ class BxAnalyticsRequest
         return isset($this->_aServer[$sKey]) && is_string($this->_aServer[$sKey]) ? trim($this->_aServer[$sKey]) : '';
         return isset($this->_aServer[$sKey]) && is_string($this->_aServer[$sKey]) ? trim($this->_aServer[$sKey]) : '';
     }
     }
 
 
+    /**
+     * The first of some headers that isn't empty, or ''.
+     */
+    protected function _getFirst($aKeys)
+    {
+        foreach($aKeys as $sKey)
+            if(($sValue = $this->_get($sKey)) !== '')
+                return $sValue;
+
+        return '';
+    }
+
+    /**
+     * Location headers of an edge ('' for any) of a kind: country, region, city, coordinates.
+     * An unknown edge has none.
+     */
+    protected static function _getGeoHeaders($sEdge, $sKind)
+    {
+        $sEdge = (string)$sEdge;
+
+        return isset(self::$_aGeoHeaders[$sEdge][$sKind]) ? self::$_aGeoHeaders[$sEdge][$sKind] : [];
+    }
+
     protected function _valid($sIp)
     protected function _valid($sIp)
     {
     {
         $sIp = trim((string)$sIp);
         $sIp = trim((string)$sIp);

+ 8 - 0
modules/boonex/analytics/classes/BxAnalyticsRevenue.php

@@ -136,6 +136,14 @@ class BxAnalyticsRevenue
         return round((float)$fAmount * $fFactor / max(1, (int)$iPeriod), 4);
         return round((float)$fAmount * $fFactor / max(1, (int)$iPeriod), 4);
     }
     }
 
 
+    /**
+     * Seconds per subscription period unit: [unit => seconds].
+     */
+    public static function getUnitSeconds()
+    {
+        return self::$_aUnitSeconds;
+    }
+
     /**
     /**
      * Length of a subscription period in seconds, or null for a unit the payment module doesn't use.
      * Length of a subscription period in seconds, or null for a unit the payment module doesn't use.
      */
      */

+ 1 - 1
modules/boonex/analytics/classes/BxAnalyticsTemplate.php

@@ -1811,7 +1811,7 @@ class BxAnalyticsTemplate extends BxBaseModGeneralTemplate
             ];
             ];
 
 
         $sGeo = _t('_bx_analytics_txt_check_geo_none');
         $sGeo = _t('_bx_analytics_txt_check_geo_none');
-        if($aCheck['geo_source'] != BX_ANALYTICS_GEO_HEADERS)
+        if($aCheck['geo_source'] == BX_ANALYTICS_GEO_NONE)
             $sGeo = _t('_bx_analytics_txt_check_geo_off');
             $sGeo = _t('_bx_analytics_txt_check_geo_off');
         elseif($aCheck['country_header'] !== '')
         elseif($aCheck['country_header'] !== '')
             $sGeo = _t('_bx_analytics_txt_check_geo_header', $this->_esc(str_replace('_', '-', preg_replace('/^HTTP_/', '', $aCheck['country_header']))), $this->_esc($aCheck['country']));
             $sGeo = _t('_bx_analytics_txt_check_geo_header', $this->_esc(str_replace('_', '-', preg_replace('/^HTTP_/', '', $aCheck['country_header']))), $this->_esc($aCheck['country']));

+ 6 - 2
modules/boonex/analytics/install/langs/en.xml

@@ -14,7 +14,7 @@
 	<string name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Addresses or CIDR ranges, separated by commas or spaces.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Addresses or CIDR ranges, separated by commas or spaces.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[For example, administrators, so your own visits don't count.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[For example, administrators, so your own visits don't count.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Path prefixes, or patterns with * (for example /page/*-admin), separated by commas or spaces.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Path prefixes, or patterns with * (for example /page/*-admin), separated by commas or spaces.]]></string>
-	<string name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[CDN headers: the country that Cloudflare, Vercel or CloudFront add to each request, and the region and city on Vercel.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[The country, region and city that Cloudflare, Vercel or CloudFront add to each request. A CDN sets its own headers and passes the others through, so a visitor could send another CDN's: choose the CDN your site and NEO run behind.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Cookieless: a hash of the address and browser that changes daily, with nothing stored on the device. A guest who comes back another day counts as a new visitor. Persistent: a random id kept in the browser or app, which allows guest retention but may need consent.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Cookieless: a hash of the address and browser that changes daily, with nothing stored on the device. A guest who comes back another day counts as a new visitor. Persistent: a random id kept in the browser or app, which allows guest retention but may need consent.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Left out of stored page paths, so one page is not counted as many. Separate with commas; * matches any characters. Campaign tags and ad click ids are always left out.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Left out of stored page paths, so one page is not counted as many. Separate with commas; * matches any characters. Campaign tags and ad click ids are always left out.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[How many pageviews, events, errors and timings the trackers may send from one address in a minute (an IPv6 address counts by its /64 network). More are dropped until the next minute, and the tracker keeps sending. Raise it when many visitors share one address, as in an office or a school. 0: no limit. Site actions and payments are never limited.]]></string>
 	<string name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[How many pageviews, events, errors and timings the trackers may send from one address in a minute (an IPv6 address counts by its /64 network). More are dropped until the next minute, and the tracker keeps sending. Raise it when many visitors share one address, as in an office or a school. 0: no limit. Site actions and payments are never limited.]]></string>
@@ -97,8 +97,11 @@
 	<string name="_bx_analytics_event_type_event"><![CDATA[Custom event]]></string>
 	<string name="_bx_analytics_event_type_event"><![CDATA[Custom event]]></string>
 	<string name="_bx_analytics_event_type_pageview"><![CDATA[Pageview]]></string>
 	<string name="_bx_analytics_event_type_pageview"><![CDATA[Pageview]]></string>
 	<string name="_bx_analytics_event_type_una"><![CDATA[Site action]]></string>
 	<string name="_bx_analytics_event_type_una"><![CDATA[Site action]]></string>
-	<string name="_bx_analytics_geo_headers"><![CDATA[CDN headers]]></string>
+	<string name="_bx_analytics_geo_cloudflare"><![CDATA[Cloudflare headers only]]></string>
+	<string name="_bx_analytics_geo_cloudfront"><![CDATA[CloudFront headers only]]></string>
+	<string name="_bx_analytics_geo_headers"><![CDATA[Any CDN's headers, the first one found]]></string>
 	<string name="_bx_analytics_geo_none"><![CDATA[Off]]></string>
 	<string name="_bx_analytics_geo_none"><![CDATA[Off]]></string>
+	<string name="_bx_analytics_geo_vercel"><![CDATA[Vercel headers only]]></string>
 	<string name="_bx_analytics_group_bots"><![CDATA[Bot categories]]></string>
 	<string name="_bx_analytics_group_bots"><![CDATA[Bot categories]]></string>
 	<string name="_bx_analytics_group_devices"><![CDATA[Devices]]></string>
 	<string name="_bx_analytics_group_devices"><![CDATA[Devices]]></string>
 	<string name="_bx_analytics_group_errors"><![CDATA[Errors by status]]></string>
 	<string name="_bx_analytics_group_errors"><![CDATA[Errors by status]]></string>
@@ -687,6 +690,7 @@
 	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string>
 	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string>
 	<string name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Delete the goal "{0}"?]]></string>
 	<string name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Delete the goal "{0}"?]]></string>
 	<string name="_bx_analytics_txt_goal_invalid"><![CDATA[Enter a name and a page path that starts with / (or *), or an action name of letters, digits and _ . : / -]]></string>
 	<string name="_bx_analytics_txt_goal_invalid"><![CDATA[Enter a name and a page path that starts with / (or *), or an action name of letters, digits and _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_limit"><![CDATA[There can be up to {0} goals. Delete one to add another.]]></string>
 	<string name="_bx_analytics_txt_goal_match"><![CDATA[Page path or action name]]></string>
 	<string name="_bx_analytics_txt_goal_match"><![CDATA[Page path or action name]]></string>
 	<string name="_bx_analytics_txt_goal_type"><![CDATA[Completed when a visitor]]></string>
 	<string name="_bx_analytics_txt_goal_type"><![CDATA[Completed when a visitor]]></string>
 	<string name="_bx_analytics_txt_goal_type_event"><![CDATA[Does an action]]></string>
 	<string name="_bx_analytics_txt_goal_type_event"><![CDATA[Does an action]]></string>

+ 2 - 0
modules/boonex/analytics/install/sql/install.sql

@@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS `bx_analytics_events` (
   -- the same for page titles: the titles tab, a title filter
   -- the same for page titles: the titles tab, a title filter
   KEY `title` (`type`, `title`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
   KEY `title` (`type`, `title`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
   KEY `name` (`type`, `name`, `ts`),
   KEY `name` (`type`, `name`, `ts`),
+  -- a site action about something: forgetting a deleted profile finds its creation by it
+  KEY `object` (`type`, `name`, `object_id`),
   -- a member's events: forgetting a deleted profile, the member's actions in a period
   -- a member's events: forgetting a deleted profile, the member's actions in a period
   KEY `profile_id` (`profile_id`, `ts`)
   KEY `profile_id` (`profile_id`, `ts`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 5 - 5
modules/boonex/analytics/js/dashboard.js

@@ -48,14 +48,14 @@ BxAnalyticsDashboard.prototype.init = function () {
             sName = $this.getBlockName(oBlock);
             sName = $this.getBlockName(oBlock);
             $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
             $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
             $this.setUrlParam('page_' + sName, null);
             $this.setUrlParam('page_' + sName, null);
-            $this.reload(oBlock, true);
+            $this.reload(oBlock, true).catch(function () {}); // reload() shows its own failure
             return;
             return;
         }
         }
 
 
         if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
         if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
             oBlock = oTarget.closest('[data-bx-analytics-block]');
             oBlock = oTarget.closest('[data-bx-analytics-block]');
             $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
             $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
-            $this.reload(oBlock, false);
+            $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
             return;
             return;
         }
         }
 
 
@@ -66,7 +66,7 @@ BxAnalyticsDashboard.prototype.init = function () {
             $this.setUrlParam(sName, sValue);
             $this.setUrlParam(sName, sValue);
             $this.reload(oBlock, false).then(function (oNew) {
             $this.reload(oBlock, false).then(function (oNew) {
                 $this.focusAfterOpen(oNew, sName, sValue);
                 $this.focusAfterOpen(oNew, sName, sValue);
-            });
+            }, function () {}); // reload() shows its own failure
             return;
             return;
         }
         }
 
 
@@ -120,7 +120,7 @@ BxAnalyticsDashboard.prototype.init = function () {
             var oBlock = oRefresh.closest('[data-bx-analytics-block]');
             var oBlock = oRefresh.closest('[data-bx-analytics-block]');
             var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
             var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
             if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
             if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
-                $this.reload(oBlock, false, true);
+                $this.reload(oBlock, false, true).catch(function () {}); // a quiet refresh tries again later
         });
         });
     }, 5000);
     }, 5000);
     this._iStarted = Date.now();
     this._iStarted = Date.now();
@@ -214,7 +214,7 @@ BxAnalyticsDashboard.prototype.setUrlParam = function (sName, sValue) {
 BxAnalyticsDashboard.prototype.reloadAll = function () {
 BxAnalyticsDashboard.prototype.reloadAll = function () {
     var $this = this;
     var $this = this;
     document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
     document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
-        $this.reload(oBlock, false);
+        $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
     });
     });
 };
 };
 
 

+ 4 - 2
modules/boonex/analytics/js/studio_tools.js

@@ -259,7 +259,8 @@ BxAnalyticsStudioTools.prototype.runApi = function (oForm) {
         })
         })
         .then(function () {
         .then(function () {
             oSubmit.disabled = false;
             oSubmit.disabled = false;
-        });
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
 };
 };
 
 
 BxAnalyticsStudioTools.prototype.formatBytes = function (iBytes) {
 BxAnalyticsStudioTools.prototype.formatBytes = function (iBytes) {
@@ -345,7 +346,8 @@ BxAnalyticsStudioTools.prototype.runSetup = function (oButton) {
         })
         })
         .then(function () {
         .then(function () {
             oButton.disabled = false;
             oButton.disabled = false;
-        });
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
 };
 };
 
 
 BxAnalyticsStudioTools.prototype.post = function (sAction, oData) {
 BxAnalyticsStudioTools.prototype.post = function (sAction, oData) {

+ 1 - 0
neo/README.md

@@ -59,6 +59,7 @@ APP_ORIGIN=neo://app
 | `EAS_PROJECT_ID` | Optional. EAS builds and updates. |
 | `EAS_PROJECT_ID` | Optional. EAS builds and updates. |
 | `REDIS_URL` | Multi-tenant mode only: the domain map. |
 | `REDIS_URL` | Multi-tenant mode only: the domain map. |
 | `PORT`, `HOST`, `PROTO`, `HTTPS` | Self-hosted web server (`apps/next/pm2.config.js`). |
 | `PORT`, `HOST`, `PROTO`, `HTTPS` | Self-hosted web server (`apps/next/pm2.config.js`). |
+| `NEO_EDGE` | Optional. What sits in front of the web server, so the proxy passes UNA only the visitor address and location headers it set: `vercel` (the default on Vercel), `cloudflare`, `cloudfront`, `proxy` (the default: a reverse proxy or load balancer that appends to `X-Forwarded-For`, such as nginx with `$proxy_add_x_forwarded_for`, Caddy or Railway) or `none` (browsers reach `next start` directly). |
 | `NEO_IMAGES_ALLOW_LOCAL_IP` | Local development only. `1` lets `next/image` load images from a UNA on your machine or network; see below. |
 | `NEO_IMAGES_ALLOW_LOCAL_IP` | Local development only. `1` lets `next/image` load images from a UNA on your machine or network; see below. |
 
 
 Next.js refuses to optimize an image whose host resolves to a private IP address (`127.0.0.1`, `::1`, `10.x`, `192.168.x`, ...). Against a local UNA (`UNA_URL=http://localhost:8088`), the dev server then logs "upstream image ... hostname resolved to private IP" and `/_next/image` answers 400 for every UNA image. Set `NEO_IMAGES_ALLOW_LOCAL_IP=1` in `.env.local` and restart the dev server; it turns on Next's `images.dangerouslyAllowLocalIP`.
 Next.js refuses to optimize an image whose host resolves to a private IP address (`127.0.0.1`, `::1`, `10.x`, `192.168.x`, ...). Against a local UNA (`UNA_URL=http://localhost:8088`), the dev server then logs "upstream image ... hostname resolved to private IP" and `/_next/image` answers 400 for every UNA image. Set `NEO_IMAGES_ALLOW_LOCAL_IP=1` in `.env.local` and restart the dev server; it turns on Next's `images.dangerouslyAllowLocalIP`.

+ 38 - 0
neo/apps/next/lib/client-headers.test.mjs

@@ -0,0 +1,38 @@
+// Run with `node --test apps/next/lib/client-headers.test.mjs` from the NEO root.
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { dropForeignClientHeaders, resolveEdge } from './client-headers.ts';
+
+const sent = () =>
+    new Headers({
+        'x-forwarded-for': '203.0.113.77',
+        'x-real-ip': '203.0.113.78',
+        'cf-connecting-ip': '203.0.113.79',
+        'true-client-ip': '203.0.113.80',
+        'cf-ipcountry': 'KP',
+        'cf-ipcity': 'Pyongyang',
+        'x-vercel-ip-country': 'DE',
+        'x-vercel-ip-city': 'Berlin',
+        'cloudfront-viewer-country': 'FR',
+        'x-country-code': 'US',
+        'accept-language': 'en',
+    });
+
+const names = (headers) => [...headers.keys()].sort();
+
+test('the edge comes from NEO_EDGE, then Vercel, then a proxy', () => {
+    assert.equal(resolveEdge({ NEO_EDGE: 'Cloudflare' }), 'cloudflare');
+    assert.equal(resolveEdge({ NEO_EDGE: 'none', VERCEL: '1' }), 'none');
+    assert.equal(resolveEdge({ VERCEL: '1' }), 'vercel');
+    assert.equal(resolveEdge({ NEO_EDGE: 'fastly' }), 'proxy');
+    assert.equal(resolveEdge({}), 'proxy');
+});
+
+test('only the edge headers reach UNA', () => {
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'vercel')), ['accept-language', 'x-forwarded-for', 'x-real-ip', 'x-vercel-ip-city', 'x-vercel-ip-country']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'cloudflare')), ['accept-language', 'cf-connecting-ip', 'cf-ipcity', 'cf-ipcountry', 'x-forwarded-for']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'cloudfront')), ['accept-language', 'cloudfront-viewer-country', 'x-forwarded-for']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'proxy')), ['accept-language', 'x-forwarded-for', 'x-real-ip']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'none')), ['accept-language']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'unknown')), ['accept-language']);
+});

+ 58 - 0
neo/apps/next/lib/client-headers.ts

@@ -0,0 +1,58 @@
+/**
+ * Request headers that tell UNA who the visitor is: the client address and the location a
+ * CDN adds. UNA reads them from requests the web proxy sends with the API key, so only the
+ * edge in front of NEO may set them. An edge sets its own headers and passes the rest
+ * through: behind Vercel a client can still add `CF-IPCountry`, and with nothing in front
+ * of Next (`next start` on a public port) a client's own `X-Forwarded-For` reaches UNA
+ * unchanged, since Next adds the socket address only when there is none.
+ *
+ * `NEO_EDGE` names the edge:
+ * - `vercel` (the default on Vercel): `X-Forwarded-For`, `X-Real-IP`, `X-Vercel-IP-*`
+ * - `cloudflare`: `X-Forwarded-For`, `CF-Connecting-IP`, `CF-IP*`
+ * - `cloudfront`: `X-Forwarded-For`, `CloudFront-Viewer-*`
+ * - `proxy` (the default elsewhere): `X-Forwarded-For` and `X-Real-IP` from a reverse proxy
+ *   or load balancer that appends the address it got the request from (nginx with
+ *   `$proxy_add_x_forwarded_for`, Caddy, Railway); no location
+ * - `none`: browsers reach Next directly, nothing is forwarded
+ */
+
+const ADDRESS = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip'];
+const LOCATION = ['x-country-code', 'x-appengine-country'];
+const LOCATION_PREFIXES = ['cf-ip', 'x-vercel-ip-', 'cloudfront-viewer-'];
+
+type EdgeRules = { names: string[]; prefixes: string[] };
+
+const EDGES: Record<string, EdgeRules> = {
+    vercel: { names: ['x-forwarded-for', 'x-real-ip'], prefixes: ['x-vercel-ip-'] },
+    cloudflare: { names: ['x-forwarded-for', 'cf-connecting-ip'], prefixes: ['cf-ip'] },
+    cloudfront: { names: ['x-forwarded-for'], prefixes: ['cloudfront-viewer-'] },
+    proxy: { names: ['x-forwarded-for', 'x-real-ip'], prefixes: [] },
+    none: { names: [], prefixes: [] },
+};
+
+/** The edge in front of NEO from the environment: `NEO_EDGE`, else `vercel` on Vercel, else `proxy`. */
+export function resolveEdge(env: Record<string, string | undefined> = {}): string {
+    const edge = String(env.NEO_EDGE || '').trim().toLowerCase();
+    if (Object.hasOwn(EDGES, edge)) return edge;
+    return env.VERCEL ? 'vercel' : 'proxy';
+}
+
+function isClientHeader(name: string): boolean {
+    return ADDRESS.includes(name) || LOCATION.includes(name) || LOCATION_PREFIXES.some((prefix) => name.startsWith(prefix));
+}
+
+function isEdgeHeader(name: string, edge: string): boolean {
+    const rules = EDGES[edge] ?? { names: [], prefixes: [] };
+    return rules.names.includes(name) || rules.prefixes.some((prefix) => name.startsWith(prefix));
+}
+
+/** Delete, in place, the address and location headers the edge didn't set. Returns the headers. */
+export function dropForeignClientHeaders(headers: Headers, edge: string): Headers {
+    const names: string[] = [];
+    headers.forEach((_value, name) => {
+        const lower = name.toLowerCase();
+        if (isClientHeader(lower) && !isEdgeHeader(lower, edge)) names.push(name);
+    });
+    for (const name of names) headers.delete(name);
+    return headers;
+}

+ 7 - 1
neo/apps/next/proxy.js

@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import { NextResponse } from 'next/server'
 import { UNA_URL, UNA_API_KEY, MULTITENANT, PREVIEW_DOMAIN, PREVIEW_API_KEY, joinUnaUrl } from 'app/config';
 import { UNA_URL, UNA_API_KEY, MULTITENANT, PREVIEW_DOMAIN, PREVIEW_API_KEY, joinUnaUrl } from 'app/config';
 import { getDomainByHostname } from './lib/domains/domains';
 import { getDomainByHostname } from './lib/domains/domains';
+import { dropForeignClientHeaders, resolveEdge } from './lib/client-headers';
 
 
 export const config = {
 export const config = {
     // Skip /.well-known/* so Digital Asset Links / AASA hit App Router routes
     // Skip /.well-known/* so Digital Asset Links / AASA hit App Router routes
@@ -9,6 +10,9 @@ export const config = {
     //runtime: 'experimental-edge',
     //runtime: 'experimental-edge',
 };
 };
 
 
+// The CDN or proxy in front of NEO, whose address and location headers UNA may read.
+const EDGE = resolveEdge(process.env);
+
 const TENANT_HEADERS = ['x-tenant-id', 'x-tenant-una-url', 'x-tenant-una-key', 'x-tenant-revision'];
 const TENANT_HEADERS = ['x-tenant-id', 'x-tenant-una-url', 'x-tenant-una-key', 'x-tenant-revision'];
 
 
 /**
 /**
@@ -82,7 +86,9 @@ function rewriteToUna(request, tenant, unaPath) {
         unaApiKey = tenant.unaApiKey;
         unaApiKey = tenant.unaApiKey;
     }
     }
 
 
-    const tmpHeaders = new Headers(request.headers);
+    // UNA trusts the visitor's address and location headers on requests with the API key:
+    // pass only those the edge set, not ones the browser added.
+    const tmpHeaders = dropForeignClientHeaders(new Headers(request.headers), EDGE);
     tmpHeaders.set('authorization', `Bearer ${unaApiKey}`);
     tmpHeaders.set('authorization', `Bearer ${unaApiKey}`);
     tmpHeaders.set('cookie', cookieHeaderFromRequest(request));
     tmpHeaders.set('cookie', cookieHeaderFromRequest(request));
 
 

+ 2 - 1
neo/turbo.json

@@ -24,7 +24,8 @@
     "NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID",
     "NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID",
     "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
     "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
     "REDIS_URL",
     "REDIS_URL",
-    "NEO_IMAGES_ALLOW_LOCAL_IP"
+    "NEO_IMAGES_ALLOW_LOCAL_IP",
+    "NEO_EDGE"
   ],
   ],
   "tasks": {
   "tasks": {
     "build": {
     "build": {

+ 34 - 0
tests/integration/analytics/AnalyticsCollectorTest.php

@@ -199,6 +199,12 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
             $this->assertNotContains($a['visitor'], [$sMemberVisitor, $sGuestVisitor]);
             $this->assertNotContains($a['visitor'], [$sMemberVisitor, $sGuestVisitor]);
         }
         }
         $this->assertSame([0, 0], array_map('intval', $oDb->getColumn("SELECT `object_id` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to", ['to' => self::NOW + 30 * 86400])), 'the account and the profile the actions were about');
         $this->assertSame([0, 0], array_map('intval', $oDb->getColumn("SELECT `object_id` FROM `bx_analytics_events` WHERE `type` = 'una' AND `ts` < :to", ['to' => self::NOW + 30 * 86400])), 'the account and the profile the actions were about');
+
+        // the profile's creation is found by a key, not among every profile created
+        $aKey = $oDb->getAll("SHOW INDEX FROM `bx_analytics_events` WHERE `Key_name` = 'object'");
+        $this->assertSame(['type', 'name', 'object_id'], array_column($aKey, 'Column_name'));
+        $aPlan = $oDb->getRow("EXPLAIN SELECT `id` FROM `bx_analytics_events` WHERE `type` = 'una' AND `name` = 'profile_add' AND `object_id` = :profile", ['profile' => $iProfileId]);
+        $this->assertContains('object', explode(',', (string)$aPlan['possible_keys']));
     }
     }
 
 
     public function testSignInTakesOverOnlyARecentGuestVisit()
     public function testSignInTakesOverOnlyARecentGuestVisit()
@@ -242,6 +248,8 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
             'http://una.test/page.php?i=unsubscribe-notifications&id=42&code=5f4dcc3b5aa765d61d8327deb882cf99',
             'http://una.test/page.php?i=unsubscribe-notifications&id=42&code=5f4dcc3b5aa765d61d8327deb882cf99',
             'http://una.test/page/oauth-callback?code=4%2F0AX4XfWh&state=xyz&access_token=ya29.secret',
             'http://una.test/page/oauth-callback?code=4%2F0AX4XfWh&state=xyz&access_token=ya29.secret',
             'http://una.test/page/invite?Email=jane%40example.com&api_key=k1&_hsenc=p2ANqtz',
             'http://una.test/page/invite?Email=jane%40example.com&api_key=k1&_hsenc=p2ANqtz',
+            'http://una.test/create-account?icode=Ab3kP9xQ&lang=en',
+            'http://una.test/page.php?i=pricing&promocode=SPRING26&apikey=k2&user_sid=9',
             'http://una.test/page/search?q=jane%40example.com',
             'http://una.test/page/search?q=jane%40example.com',
         ];
         ];
         foreach ($aUrls as $i => $sUrl)
         foreach ($aUrls as $i => $sUrl)
@@ -254,6 +262,8 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
             '/page.php?i=unsubscribe-notifications&id=42',
             '/page.php?i=unsubscribe-notifications&id=42',
             '/page/oauth-callback?state=xyz',
             '/page/oauth-callback?state=xyz',
             '/page/invite',
             '/page/invite',
+            '/create-account?lang=en',
+            '/page.php?i=pricing',
             '/page/search?q=<redacted>',
             '/page/search?q=<redacted>',
         ], $aPaths, 'keys, codes, tokens and addresses are dropped; what is left is redacted');
         ], $aPaths, 'keys, codes, tokens and addresses are dropped; what is left is redacted');
 
 
@@ -266,6 +276,21 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
         $this->assertSame(['/page.php?i=forgot-password'], BxDolDb::getInstance()->getColumn("SELECT `path` FROM `bx_analytics_bot_pages` WHERE `day` < '2002-01-01'"));
         $this->assertSame(['/page.php?i=forgot-password'], BxDolDb::getInstance()->getColumn("SELECT `path` FROM `bx_analytics_bot_pages` WHERE `day` < '2002-01-01'"));
     }
     }
 
 
+    public function testLocationFromTheChosenEdge()
+    {
+        // behind Vercel, a visitor sends Cloudflare's country header too
+        $aHeaders = ['HTTP_CF_IPCOUNTRY' => 'KP', 'HTTP_X_VERCEL_IP_COUNTRY' => 'DE', 'HTTP_X_VERCEL_IP_CITY' => 'Berlin'];
+        $aHit = ['platform' => 'next-web', 'hits' => [['type' => 'pageview', 'url' => 'https://neo.test/']]];
+
+        foreach (['vercel', 'headers', 'none'] as $i => $sSource) {
+            $this->_setOption('bx_analytics_geo_source', $sSource);
+            $this->_collect($this->_server('81.2.69.' . (150 + $i), $aHeaders), $aHit, self::NOW + $i * 10);
+        }
+
+        $aSessions = $this->_sessions();
+        $this->assertSame([['DE', 'Berlin'], ['KP', 'Berlin'], ['', '']], array_map(function ($a) { return [$a['country'], $a['city']]; }, $aSessions));
+    }
+
     public function testSiteActionsFollowTheVisitorsChoice()
     public function testSiteActionsFollowTheVisitorsChoice()
     {
     {
         $fAction = function ($aExtra = [], $sUserAgent = self::UA_SAFARI, $iProfileId = 987654321) {
         $fAction = function ($aExtra = [], $sUserAgent = self::UA_SAFARI, $iProfileId = 987654321) {
@@ -521,6 +546,15 @@ class AnalyticsCollectorTest extends BxDolIntegrationTestCase
         $this->assertSame(2, $aGoals[$iBuy]['conversions']);
         $this->assertSame(2, $aGoals[$iBuy]['conversions']);
         $this->assertEquals(66.67, $aGoals[$iBuy]['rate']);
         $this->assertEquals(66.67, $aGoals[$iBuy]['rate']);
 
 
+        // goals are capped: the report counts each one in both periods
+        $iMax = $this->_oModule->_oConfig->CNF['MAX_GOALS'];
+        $this->assertSame($oDb->getGoalsCount() >= $iMax, $this->_oModule->isGoalLimitReached());
+        for ($i = $oDb->getGoalsCount(); $i < $iMax + 1; $i++)
+            $this->_aGoals[] = $oDb->saveGoal(0, ['name' => 'Test 2001 goal ' . $i, 'type' => 'page', 'pattern' => '/goal-' . $i]);
+        $this->assertTrue($this->_oModule->isGoalLimitReached());
+        $this->assertCount($iMax, $oDb->getGoals($iMax));
+        $this->assertCount($iMax, $oReports->getGoals($oFilter)['goals']);
+
         $aFunnel = $oReports->getFunnels($oFilter, ['funnel' => $iFunnel]);
         $aFunnel = $oReports->getFunnels($oFilter, ['funnel' => $iFunnel]);
         $this->assertSame($iFunnel, $aFunnel['funnel']['id']);
         $this->assertSame($iFunnel, $aFunnel['funnel']['id']);
         $this->assertSame([2, 2, 1], array_column($aFunnel['steps'], 'sessions'), 'a step counts only after the previous one');
         $this->assertSame([2, 2, 1], array_column($aFunnel['steps'], 'sessions'), 'a step counts only after the previous one');

+ 12 - 2
tests/integration/analytics/AnalyticsRevenueTest.php

@@ -481,9 +481,9 @@ class AnalyticsRevenueTest extends BxDolIntegrationTestCase
 
 
     public function testUnpaidSubscriptionStopsPaying()
     public function testUnpaidSubscriptionStopsPaying()
     {
     {
-        // two monthly subscriptions whose charge failed: one paid 40 days ago, one 10 days ago
+        // monthly subscriptions whose charge failed: paid 200, 40 and 10 days ago
         $aIds = [];
         $aIds = [];
-        foreach ([40, 10] as $i => $iDays) {
+        foreach ([200, 40, 10] as $i => $iDays) {
             $iPaid = self::NOW - $iDays * 86400;
             $iPaid = self::NOW - $iDays * 86400;
             $aPending = $this->_order('recurring', 'credits', 'USD', 10);
             $aPending = $this->_order('recurring', 'credits', 'USD', 10);
             $aSubscription = ['subscription_id' => 'test-rev-sub-u' . $i, 'period' => 1, 'period_unit' => 'month', 'status' => 'active'];
             $aSubscription = ['subscription_id' => 'test-rev-sub-u' . $i, 'period' => 1, 'period_unit' => 'month', 'status' => 'active'];
@@ -503,6 +503,16 @@ class AnalyticsRevenueTest extends BxDolIntegrationTestCase
         $this->assertSame([1, 1], [$aTiles['active'], $aTiles['cancelled']]);
         $this->assertSame([1, 1], [$aTiles['active'], $aTiles['cancelled']]);
         $this->assertEquals([10.0, 50.0], [$aTiles['mrr'], $aTiles['churn']]);
         $this->assertEquals([10.0, 50.0], [$aTiles['mrr'], $aTiles['churn']]);
         $this->assertSame(1, array_sum(array_filter($aReport['movement']['datasets'][2]['values'])));
         $this->assertSame(1, array_sum(array_filter($aReport['movement']['datasets'][2]['values'])));
+
+        // the one that stopped paying months before isn't read at all: lapsed subscriptions
+        // are never cancelled, and would otherwise be read by every report forever
+        $aEnd = ['unit_seconds' => BxAnalyticsRevenue::getUnitSeconds(), 'unpaid' => BxAnalyticsRevenue::STATUS_UNPAID, 'grace' => BxAnalyticsRevenue::UNPAID_GRACE];
+        $aRange = ['from' => self::NOW - 7 * 86400, 'to' => self::NOW, 'payment' => 'bx_payment', 'platform' => ''];
+        $fPending = function ($aRows) { return array_map('intval', array_column($aRows, 'pending_id')); };
+        $this->assertSame([(int)$aIds[1]['id'], (int)$aIds[2]['id']], $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange, $aEnd)));
+        $this->assertSame(array_map('intval', array_column($aIds, 'id')), $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange)), 'without the rule');
+        $aRange['from'] = self::NOW - 170 * 86400; // its paid month and grace time ended 162 days ago
+        $this->assertSame(array_map('intval', array_column($aIds, 'id')), $fPending($this->_oModule->_oDb->getSubscriptionRows($aRange, $aEnd)), 'still paying in a longer period');
     }
     }
 
 
     public function testProfileDeletionKeepsTheMoney()
     public function testProfileDeletionKeepsTheMoney()

+ 44 - 1
tests/integration/analytics/AnalyticsScaleTest.php

@@ -133,6 +133,49 @@ class AnalyticsScaleTest extends BxDolIntegrationTestCase
             $this->assertSame([], array_values(array_intersect($aFiles, glob($sPattern))));
             $this->assertSame([], array_values(array_intersect($aFiles, glob($sPattern))));
         }
         }
 
 
+        // the cache name holds what the report reads: blocks send the whole page URL, with
+        // other blocks' tabs and pages, the chart's metric and opened rows
+        $oName = new ReflectionMethod($this->_oModule, '_getReportCacheName');
+        $fName = function ($sReport, $aParams) use ($oName) {
+            return $oName->invoke($this->_oModule, $sReport, $this->_oModule->getFilter($this->_aDay + $aParams), $this->_aDay + $aParams);
+        };
+        $aNoise = ['metric' => 'pageviews', 'tab_pages' => 'entry', 'page_sessions' => '2', 'session' => '7', 'i' => 'analytics', 'r' => 'analytics/report'];
+        $this->assertSame($fName('overview', []), $fName('overview', $aNoise));
+        $this->assertSame($fName('top', ['group' => 'sources']), $fName('top', ['group' => 'sources', 'page_sources' => '2'] + $aNoise));
+        $this->assertNotSame($fName('top', ['group' => 'sources']), $fName('top', ['group' => 'sources', 'tab_sources' => 'channels']));
+        $this->assertNotSame($fName('table', ['table' => 'sources']), $fName('table', ['table' => 'sources', 'page_sources' => '2']));
+        $this->assertNotSame($fName('sessions', []), $fName('sessions', ['session' => '7']));
+        $this->assertNotSame($fName('overview', []), $fName('overview', ['platform' => 'core-web']), 'filters count');
+
+        // dropping the cache starts a new generation instead of deleting by prefix, which
+        // empties a whole Memcache(d) server or scans every Redis key
+        $sBefore = $fName('overview', []);
+        $this->_oModule->cleanReportCache();
+        $this->assertNotSame($sBefore, $fName('overview', []));
+
+        $aModule = $this->_oModule->_aModule;
+        $oShared = new class extends BxDolCache {
+            public $aData = [];
+            public $iFlushes = 0;
+            public function getData($sKey, $iTTL = false) { return $this->aData[$sKey] ?? null; }
+            public function setData($sKey, $mixedData, $iTTL = false) { $this->aData[$sKey] = $mixedData; return true; }
+            public function delData($sKey) { unset($this->aData[$sKey]); return true; }
+            public function removeAllByPrefix($s) { $this->iFlushes++; $this->aData = []; return true; }
+        };
+        $oModule = new class($aModule, $oShared) extends BxAnalyticsModule {
+            protected $_oShared;
+            public function __construct(&$aModule, $oShared) { parent::__construct($aModule); $this->_oShared = $oShared; }
+            protected function _getReportCacheEngine() { return $this->_oShared; }
+        };
+        $oShared->aData['db_other_module_key'] = 'kept';
+        $this->assertSame(3, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value']);
+        $this->_session('e', $iDay + 12600);
+        $this->assertSame(3, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'from the shared cache');
+        $oModule->cleanReportCache();
+        $this->assertSame(4, $oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value'], 'a new generation');
+        $this->assertSame(0, $oShared->iFlushes);
+        $this->assertSame('kept', $oShared->aData['db_other_module_key']);
+
         // live figures are never cached
         // live figures are never cached
         $oReports = $this->_oModule->getReports();
         $oReports = $this->_oModule->getReports();
         foreach (['realtime' => [], 'filters' => [], 'map' => ['tab' => 'live']] as $sReport => $aParams)
         foreach (['realtime' => [], 'filters' => [], 'map' => ['tab' => 'live']] as $sReport => $aParams)
@@ -143,7 +186,7 @@ class AnalyticsScaleTest extends BxDolIntegrationTestCase
         // nothing is cached with the database cache off
         // nothing is cached with the database cache off
         $this->_setOption('sys_db_cache_enable', '');
         $this->_setOption('sys_db_cache_enable', '');
         $this->_session('d', $iDay + 14400);
         $this->_session('d', $iDay + 14400);
-        $this->assertSame(4, $this->_oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value']);
+        $this->assertSame(5, $this->_oModule->getReportData('overview', $this->_aDay)['kpis']['sessions']['value']);
     }
     }
 
 
     public function testFiltersAcrossVisitsAndPageviews()
     public function testFiltersAcrossVisitsAndPageviews()

+ 14 - 5
tests/integration/analytics/AnalyticsToolsTest.php

@@ -189,18 +189,27 @@ class AnalyticsToolsTest extends BxDolIntegrationTestCase
         $this->assertSame(['bx_analytics', 12, 1, 'page.php?i=' . $a['uri']], [$aPage['module'], (int)$aPage['layout_id'], (int)$aPage['deletable'], $aPage['url']]);
         $this->assertSame(['bx_analytics', 12, 1, 'page.php?i=' . $a['uri']], [$aPage['module'], (int)$aPage['layout_id'], (int)$aPage['deletable'], $aPage['url']]);
         $this->assertSame('PHPUnit analytics', _t($aPage['title']));
         $this->assertSame('PHPUnit analytics', _t($aPage['title']));
 
 
-        // the analytics page's center and right bar, all shown in NEO
-        $aSource = $oDb->getAll("SELECT `cell_id`, `content` FROM `sys_pages_blocks` WHERE `object` = 'bx_analytics_page' AND `active` = 1 AND `cell_id` IN (2, 3) ORDER BY `cell_id`, `order`");
+        // the analytics page's center and right bar, shown in NEO as their sources are
+        $aSource = $oDb->getAll("SELECT `cell_id`, `content`, `active_api` FROM `sys_pages_blocks` WHERE `object` = 'bx_analytics_page' AND `active` = 1 AND `cell_id` IN (2, 3) ORDER BY `cell_id`, `order`");
         $aBlocks = $oDb->getAll("SELECT `cell_id`, `content`, `active_api`, `copyable`, `module` FROM `sys_pages_blocks` WHERE `object` = :object ORDER BY `cell_id`, `order`", ['object' => $a['object']]);
         $aBlocks = $oDb->getAll("SELECT `cell_id`, `content`, `active_api`, `copyable`, `module` FROM `sys_pages_blocks` WHERE `object` = :object ORDER BY `cell_id`, `order`", ['object' => $a['object']]);
         $this->assertNotEmpty($aBlocks);
         $this->assertNotEmpty($aBlocks);
-        $this->assertSame($aSource, array_map(function ($aBlock) { return ['cell_id' => $aBlock['cell_id'], 'content' => $aBlock['content']]; }, $aBlocks));
+        $this->assertSame($aSource, array_map(function ($aBlock) { return ['cell_id' => $aBlock['cell_id'], 'content' => $aBlock['content'], 'active_api' => $aBlock['active_api']]; }, $aBlocks));
         foreach ($aBlocks as $aBlock)
         foreach ($aBlocks as $aBlock)
-            $this->assertSame([1, 0, 'bx_analytics'], [(int)$aBlock['active_api'], (int)$aBlock['copyable'], $aBlock['module']]);
+            $this->assertSame([0, 'bx_analytics'], [(int)$aBlock['copyable'], $aBlock['module']]);
 
 
         // listed, with its blocks
         // listed, with its blocks
         $aListed = array_column($this->_oModule->getDashboards(), null, 'object');
         $aListed = array_column($this->_oModule->getDashboards(), null, 'object');
         $this->assertSame(count($aBlocks), $aListed[$a['object']]['blocks']);
         $this->assertSame(count($aBlocks), $aListed[$a['object']]['blocks']);
-        $this->assertSame(count($aBlocks), $aListed[$a['object']]['api_blocks']);
+        $this->assertSame(count(array_filter(array_column($aBlocks, 'active_api'))), $aListed[$a['object']]['api_blocks']);
+
+        // a block NEO has no element for, added to the page in the builder, stays out of NEO
+        $oDb->query("INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`) VALUES ('bx_analytics_page', 2, 'bx_analytics', 'PHPUnit realtime', 11, 2147483647, 'service', :content, 1, 1, 1, 0, 999)", ['content' => 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:14:"get_block_test";}']);
+        $iAdded = (int)$oDb->lastId();
+        $e = $this->_oModule->createDashboard('PHPUnit analytics realtime');
+        $oDb->query("DELETE FROM `sys_pages_blocks` WHERE `id` = :id", ['id' => $iAdded]);
+        $this->assertIsArray($e);
+        $this->_aPages[] = $e['object'];
+        $this->assertSame(0, (int)$oDb->getOne("SELECT `active_api` FROM `sys_pages_blocks` WHERE `object` = :object AND `title` = 'PHPUnit realtime'", ['object' => $e['object']]));
         $this->assertSame('PHPUnit analytics', $aListed[$a['object']]['title']);
         $this->assertSame('PHPUnit analytics', $aListed[$a['object']]['title']);
 
 
         // disabling the module removes the page rows but keeps the title keys: the same
         // disabling the module removes the page rows but keeps the title keys: the same

+ 23 - 0
tests/units/modules/boonex/analytics/classes/BxAnalyticsRequestTest.php

@@ -60,6 +60,29 @@ class BxAnalyticsRequestTest extends \PHPUnit\Framework\TestCase
         $this->assertSame('São Paulo', (new BxAnalyticsRequest(['HTTP_X_VERCEL_IP_CITY' => 'S%C3%A3o%20Paulo']))->getCity());
         $this->assertSame('São Paulo', (new BxAnalyticsRequest(['HTTP_X_VERCEL_IP_CITY' => 'S%C3%A3o%20Paulo']))->getCity());
     }
     }
 
 
+    public function testLocationFromOneEdgeOnly()
+    {
+        // behind Vercel, a client adds Cloudflare's headers, which Vercel passes through
+        $oRequest = new BxAnalyticsRequest([
+            'HTTP_CF_IPCOUNTRY' => 'KP',
+            'HTTP_CF_IPCITY' => 'Pyongyang',
+            'HTTP_CF_IPLATITUDE' => '39.03',
+            'HTTP_CF_IPLONGITUDE' => '125.75',
+            'HTTP_X_VERCEL_IP_COUNTRY' => 'DE',
+            'HTTP_X_VERCEL_IP_COUNTRY_REGION' => 'BE',
+            'HTTP_X_VERCEL_IP_CITY' => 'Berlin',
+        ]);
+        $this->assertSame('KP', $oRequest->getCountry(), 'any edge: the first header found');
+        $this->assertSame(['DE', 'BE', 'Berlin', null], [$oRequest->getCountry('vercel'), $oRequest->getRegion('vercel'), $oRequest->getCity('vercel'), $oRequest->getCoordinates('vercel')]);
+        $this->assertSame(['header' => 'HTTP_X_VERCEL_IP_COUNTRY', 'country' => 'DE'], $oRequest->getCountryHeader('vercel'));
+        $this->assertSame(['KP', '', 'Pyongyang', [39.0, 125.8]], [$oRequest->getCountry('cloudflare'), $oRequest->getRegion('cloudflare'), $oRequest->getCity('cloudflare'), $oRequest->getCoordinates('cloudflare')]);
+        $this->assertSame(['', '', '', null], [$oRequest->getCountry('cloudfront'), $oRequest->getRegion('cloudfront'), $oRequest->getCity('cloudfront'), $oRequest->getCoordinates('cloudfront')]);
+        $this->assertSame('', $oRequest->getCountry('unknown'), 'an unknown edge has no headers');
+
+        $oRequest = new BxAnalyticsRequest(['HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'fr', 'HTTP_CLOUDFRONT_VIEWER_CITY' => 'Lyon', 'HTTP_CLOUDFRONT_VIEWER_LATITUDE' => '45.76', 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE' => '4.83']);
+        $this->assertSame(['FR', 'Lyon', [45.8, 4.8]], [$oRequest->getCountry('cloudfront'), $oRequest->getCity('cloudfront'), $oRequest->getCoordinates('cloudfront')]);
+    }
+
     public static function providerPlatform()
     public static function providerPlatform()
     {
     {
         return [
         return [