diff --git a/plugins/ExampleVisualization/ExampleVisualization.php b/plugins/ExampleVisualization/ExampleVisualization.php new file mode 100644 index 0000000000000000000000000000000000000000..1a03abab57b35c096b7c907a60d5a6bf0456cb3c --- /dev/null +++ b/plugins/ExampleVisualization/ExampleVisualization.php @@ -0,0 +1,14 @@ +requestConfig->filter_sort_order = 'desc'; + } + + public function beforeGenericFiltersAreAppliedToLoadedDataTable() + { + // this hook is executed before generic filters like "filter_limit" and "filter_offset" are applied + // Usage: + // $this->dateTable->filter($nameOrClosure); + } + + public function afterGenericFiltersAreAppliedToLoadedDataTable() + { + // this hook is executed after generic filters like "filter_limit" and "filter_offset" are applied + // Usage: + // $this->dateTable->filter($nameOrClosure, $parameters); + } + + public function afterAllFiltersAreApplied() + { + // this hook is executed after the data table is loaded and after all filteres are applied. + // format your data here that you want to pass to the view + + $this->assignTemplateVar('vizTitle', 'MyAwesomeTitle'); + } + + public function beforeRender() + { + // Configure how your visualization should look like, for instance you can disable search + // By defining the config properties shortly before rendering you make sure the config properties have a certain + // value because they could be changed by a report or by request parameters ($_GET / $_POST) before. + // $this->config->show_search = false + } + + public static function canDisplayViewDataTable(ViewDataTable $view) + { + // You usually do not need to implement this method. Here you can define whether your visualization can display + // a specific data table or not. For instance you may only display your visualization in case a single data + // table is requested. Example: + // return $view->isRequestingSingleDataTable(); + + return parent::canDisplayViewDataTable($view); + } +} diff --git a/plugins/ExampleVisualization/config/config.php b/plugins/ExampleVisualization/config/config.php new file mode 100644 index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66 --- /dev/null +++ b/plugins/ExampleVisualization/config/config.php @@ -0,0 +1,3 @@ + +
| {{ properties.translations[name]|translate }} | + {% else %} +{{ name }} | + {% endif %} + {% endfor %} +
|---|---|
| {{ tableRow.getColumn(column)|default('-')|truncate(50)|rawSafeDecoded }} | + {% endfor %} +
| {{ translate('General_Name') }} | +{{ translate('General_Date') }} in UTC timezone | +
|---|---|
| {{ task.name}} | +{{ task.executionDate }} + + (in ) + + |
+
{{ translate('TasksTimetable_NothingScheduled') }} |
+ |
+ {{ translate('TwoFactorAuth_VerifyIdentifyExplanation') }}
+
+
+
+ {{ translate('TwoFactorAuth_DontHaveYourMobileDevice') }}
+
+
+ {{ translate('TwoFactorAuth_EnterRecoveryCodeInstead') }}
+
+
+
+ {{ translate('TwoFactorAuth_AskSuperUserResetAuthenticationCode') }}
+
+
+ {{ translate('General_Logout') }}
+
{{ translate('TwoFactorAuth_SetupAuthenticatorOnDeviceStep1') }} andOTP, Authy, 1Password, LastPass Authenticator, {{ translate('General_Or') }} Google Authenticator. +
+ + + diff --git a/plugins/TwoFactorAuth/vue/src/SetupTwoFactorAuth/SetupTwoFactorAuth.vue b/plugins/TwoFactorAuth/vue/src/SetupTwoFactorAuth/SetupTwoFactorAuth.vue new file mode 100644 index 0000000000000000000000000000000000000000..885da51adb733e7075a6e07213203888bdfc26ed --- /dev/null +++ b/plugins/TwoFactorAuth/vue/src/SetupTwoFactorAuth/SetupTwoFactorAuth.vue @@ -0,0 +1,303 @@ + + + ++ {{ translate('TwoFactorAuth_SetupIntroFollowSteps') }} +
++ +
+ +
+
+
+
{{ translate('TwoFactorAuth_VerifyAuthCodeIntro') }}
+ + +{{ translate('TwoFactorAuth_ShowCodeModalInstructions1') }}
++ +
+{{ translate('TwoFactorAuth_ShowCodeModalInstructions2') }}
+ +{{ newSecret}}
+ {{ translate('TwoFactorAuth_RecoveryCodesExplanation') }}
+
+
+
+
+
{{ translate('TwoFactorAuth_GenerateNewRecoveryCodesInfo') }}
+ + {{ translate('TwoFactorAuth_TwoFactorAuthenticationIsEnabled') }} + +
+ +
+
+ {{ translate('TwoFactorAuth_TwoFactorAuthenticationRequired') }}
+
+
+ {{ translate('TwoFactorAuth_ConfigureDifferentDevice') }}
+
+
+ {{ translate('TwoFactorAuth_ConfigureDifferentDevice') }}
+
+
+
+
+ {{ translate('TwoFactorAuth_ShowRecoveryCodes') }}
+
+ {{ translate('TwoFactorAuth_TwoFactorAuthenticationIsDisabled') }}
+
+
+ {{ translate('TwoFactorAuth_EnableTwoFA') }}
+
' . Piwik::translate('UserCountry_DefaultLocationProviderDesc1') . ' ' + . Piwik::translate( + 'UserCountry_DefaultLocationProviderDesc2', + ['', '', '', ''] + ) + . '
' . Url::getExternalLinkTag('https://matomo.org/faq/how-to/faq_163') + . Piwik::translate('UserCountry_HowToInstallGeoIPDatabases') + . '
'; + return ['id' => self::ID, 'title' => self::TITLE, 'description' => $desc, 'order' => 1]; + } + + public function getUsageWarning(): ?string + { + $comment = Piwik::translate('UserCountry_DefaultLocationProviderDesc1') . ' '; + $comment .= Piwik::translate('UserCountry_DefaultLocationProviderDesc2', [ + Url::getExternalLinkTag('https://matomo.org/docs/geo-locate/'), '', '', '', + ]); + + return $comment; + } +} diff --git a/plugins/UserCountry/LocationProvider/DisabledProvider.php b/plugins/UserCountry/LocationProvider/DisabledProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..f0e2016f0998e25308aa3e2b9d246e02f12b04fb --- /dev/null +++ b/plugins/UserCountry/LocationProvider/DisabledProvider.php @@ -0,0 +1,97 @@ + 'default', + * 'title' => '...', + * 'description' => '...' + * ); + * + * @return array + */ + public function getInfo() + { + $desc = Piwik::translate('UserCountry_DisabledLocationProvider'); + return array('id' => self::ID, 'title' => self::TITLE, 'description' => $desc, 'order' => 0); + } + + public function getUsageWarning(): ?string + { + $comment = Piwik::translate('UserCountry_DefaultLocationProviderDesc1') . ' '; + $comment .= Piwik::translate('UserCountry_DefaultLocationProviderDesc2', array( + Url::getExternalLinkTag('https://matomo.org/docs/geo-locate/'), '', '', '', + )); + + return $comment; + } +} diff --git a/plugins/UserCountry/Menu.php b/plugins/UserCountry/Menu.php new file mode 100644 index 0000000000000000000000000000000000000000..8c6e6e787d01bf49fa389564629cc5e9c3d7c67a --- /dev/null +++ b/plugins/UserCountry/Menu.php @@ -0,0 +1,27 @@ +addSystemItem( + 'UserCountry_Geolocation', + $this->urlForAction('adminIndex'), + $order = 30 + ); + } + } +} diff --git a/plugins/UserCountry/ProfileSummary/LocationSummary.php b/plugins/UserCountry/ProfileSummary/LocationSummary.php new file mode 100644 index 0000000000000000000000000000000000000000..38180270a0e2517972de615d0f72f2bb512ade04 --- /dev/null +++ b/plugins/UserCountry/ProfileSummary/LocationSummary.php @@ -0,0 +1,79 @@ +profile['countries'])) { + return ''; + } + + $view = new View('@UserCountry/_profileSummary.twig'); + $view->visitorData = $this->profile; + + if ( + Common::getRequestVar('showMap', 1) == 1 + && !empty($view->visitorData['hasLatLong']) + && \Piwik\Plugin\Manager::getInstance()->isPluginLoaded('UserCountryMap') + ) { + $view->userCountryMapUrl = $this->getUserCountryMapUrlForVisitorProfile(); + } + + return $view->render(); + } + + private function getUserCountryMapUrlForVisitorProfile() + { + $params = array( + 'module' => 'UserCountryMap', + 'action' => 'realtimeMap', + 'segment' => Live\Live::getSegmentWithVisitorId(), + 'visitorId' => false, + 'changeVisitAlpha' => 0, + 'removeOldVisits' => 0, + 'realtimeWindow' => 'false', + 'showFooterMessage' => 0, + 'showDateTime' => 0, + 'doNotRefreshVisits' => 1, + ); + return Url::getCurrentQueryStringWithParametersModified($params); + } + + /** + * @inheritdoc + */ + public function getOrder() + { + return 100; + } +} diff --git a/plugins/UserCountry/RecordBuilders/Locations.php b/plugins/UserCountry/RecordBuilders/Locations.php new file mode 100644 index 0000000000000000000000000000000000000000..b764dfd96b760e4077dfa98d385f8c7558d36446 --- /dev/null +++ b/plugins/UserCountry/RecordBuilders/Locations.php @@ -0,0 +1,195 @@ + Archiver::COUNTRY_FIELD, + Archiver::REGION_RECORD_NAME => Archiver::REGION_FIELD, + Archiver::CITY_RECORD_NAME => Archiver::CITY_FIELD, + ]; + + private $latLongForCities = []; + + public function __construct() + { + parent::__construct(); + + $this->columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS; + } + + public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array + { + $maxRowsInTable = PiwikConfig::getInstance()->General['datatable_archiving_maximum_rows_standard']; + + return [ + Record::make(Record::TYPE_BLOB, Archiver::COUNTRY_RECORD_NAME), + Record::make(Record::TYPE_BLOB, Archiver::REGION_RECORD_NAME) + ->setMaxRowsInTable($maxRowsInTable), + Record::make(Record::TYPE_BLOB, Archiver::CITY_RECORD_NAME) + ->setMaxRowsInTable($maxRowsInTable), + + Record::make(Record::TYPE_NUMERIC, Archiver::DISTINCT_COUNTRIES_METRIC) + ->setIsCountOfBlobRecordRows(Archiver::COUNTRY_RECORD_NAME), + ]; + } + + protected function aggregate(ArchiveProcessor $archiveProcessor): array + { + try { + /** @var DataTable[] $records */ + $records = []; + foreach ($this->dimensions as $recordName => $dimension) { + $records[$recordName] = new DataTable(); + } + + $logAggregator = $archiveProcessor->getLogAggregator(); + + $this->aggregateFromVisits($records, $logAggregator); + $this->aggregateFromConversions($records, $logAggregator); + + $this->setLatitudeLongitude($records[Archiver::CITY_RECORD_NAME]); + + $records[Archiver::DISTINCT_COUNTRIES_METRIC] = $records[Archiver::COUNTRY_RECORD_NAME]->getRowsCount(); + + return $records; + } finally { + unset($this->latLongForCities); + } + } + + /** + * @param DataTable[] $records + */ + protected function aggregateFromVisits(array $records, LogAggregator $logAggregator): void + { + $additionalSelects = array('MAX(log_visit.location_latitude) as location_latitude', + 'MAX(log_visit.location_longitude) as location_longitude'); + $query = $logAggregator->queryVisitsByDimension(array_values($this->dimensions), $where = false, $additionalSelects); + if ($query === false) { + return; + } + + while ($row = $query->fetch()) { + $this->makeRegionCityLabelsUnique($row); + $this->rememberCityLatLong($row); + + $columns = [ + Metrics::INDEX_NB_UNIQ_VISITORS => $row[Metrics::INDEX_NB_UNIQ_VISITORS], + Metrics::INDEX_NB_VISITS => $row[Metrics::INDEX_NB_VISITS], + Metrics::INDEX_NB_ACTIONS => $row[Metrics::INDEX_NB_ACTIONS], + Metrics::INDEX_NB_USERS => $row[Metrics::INDEX_NB_USERS], + Metrics::INDEX_MAX_ACTIONS => $row[Metrics::INDEX_MAX_ACTIONS], + Metrics::INDEX_SUM_VISIT_LENGTH => $row[Metrics::INDEX_SUM_VISIT_LENGTH], + Metrics::INDEX_BOUNCE_COUNT => $row[Metrics::INDEX_BOUNCE_COUNT], + Metrics::INDEX_NB_VISITS_CONVERTED => $row[Metrics::INDEX_NB_VISITS_CONVERTED], + ]; + + foreach ($records as $recordName => $dataTable) { + $dimension = $this->dimensions[$recordName]; + $dataTable->sumRowWithLabel($row[$dimension], $columns); + } + } + } + + /** + * Makes sure the region and city of a query row are unique. + * + * @param array $row + */ + private function makeRegionCityLabelsUnique(array &$row): void + { + // remove the location separator from the region/city/country we get from the query + foreach ($this->dimensions as $column) { + $row[$column] = str_replace(Archiver::LOCATION_SEPARATOR, '', $row[$column] ?? ''); + } + + // set city first, as containing region might be manipulated afterwards if not empty + if (!empty($row[Archiver::CITY_FIELD])) { + $row[Archiver::CITY_FIELD] = $row[Archiver::CITY_FIELD] . Archiver::LOCATION_SEPARATOR . $row[Archiver::REGION_FIELD] . Archiver::LOCATION_SEPARATOR . $row[Archiver::COUNTRY_FIELD]; + } + + if (!empty($row[Archiver::REGION_FIELD])) { + $row[Archiver::REGION_FIELD] = $row[Archiver::REGION_FIELD] . Archiver::LOCATION_SEPARATOR . $row[Archiver::COUNTRY_FIELD]; + } + } + + protected function rememberCityLatLong(array $row): void + { + if ( + !empty($row[Archiver::CITY_FIELD]) + && !empty($row[Archiver::LATITUDE_FIELD]) + && !empty($row[Archiver::LONGITUDE_FIELD]) + && empty($this->latLongForCities[$row[Archiver::CITY_FIELD]]) + ) { + $this->latLongForCities[$row[Archiver::CITY_FIELD]] = array($row[Archiver::LATITUDE_FIELD], $row[Archiver::LONGITUDE_FIELD]); + } + } + + /** + * @param DataTable[] $records + */ + protected function aggregateFromConversions(array $records, LogAggregator $logAggregator): void + { + $query = $logAggregator->queryConversionsByDimension(array_values($this->dimensions)); + + while ($row = $query->fetch()) { + $this->makeRegionCityLabelsUnique($row); + + foreach ($records as $recordName => $table) { + $idGoal = (int) $row['idgoal']; + $columns = [ + Metrics::INDEX_GOALS => [ + $idGoal => Metrics::makeGoalColumnsRow($idGoal, $row), + ], + ]; + + $dimension = $this->dimensions[$recordName]; + $table->sumRowWithLabel($row[$dimension], $columns); + } + } + + foreach ($records as $table) { + $table->filter(DataTable\Filter\EnrichRecordWithGoalMetricSums::class); + } + } + + /** + * Utility method, appends latitude/longitude pairs to city table labels, if that data + * exists for the city. + */ + private function setLatitudeLongitude(DataTable $tableCity): void + { + foreach ($tableCity->getRows() as $row) { + $label = $row->getColumn('label'); + if (isset($this->latLongForCities[$label])) { + // get lat/long for city + list($lat, $long) = $this->latLongForCities[$label]; + $lat = round($lat, LocationProvider::GEOGRAPHIC_COORD_PRECISION); + $long = round($long, LocationProvider::GEOGRAPHIC_COORD_PRECISION); + + // set latitude + longitude metadata + $row->setMetadata('lat', $lat); + $row->setMetadata('long', $long); + } + } + } +} diff --git a/plugins/UserCountry/Reports/Base.php b/plugins/UserCountry/Reports/Base.php new file mode 100644 index 0000000000000000000000000000000000000000..8e453f2563dd8d196c6c2e36530d6295bfe5272a --- /dev/null +++ b/plugins/UserCountry/Reports/Base.php @@ -0,0 +1,77 @@ +categoryId = 'General_Visitors'; + $this->subcategoryId = 'UserCountry_SubmenuLocations'; + $this->hasGoalMetrics = true; + } + + protected function getGeoIPReportDocSuffix() + { + return Piwik::translate( + 'UserCountry_GeoIPDocumentationSuffix', + [ + Url::getExternalLinkTag('http://www.maxmind.com/?rId=piwik'), + '', + Url::getExternalLinkTag('http://www.maxmind.com/en/city_accuracy?rId=piwik'), + '', + ] + ); + } + + /** + * Checks if a datatable for a view is empty and if so, displays a message in the footer + * telling users to configure GeoIP. + */ + protected function checkIfNoDataForGeoIpReport(ViewDataTable $view) + { + $view->config->filters[] = function ($dataTable) use ($view) { + // if there's only one row whose label is 'Unknown', display a message saying there's no data + if ( + $dataTable->getRowsCount() == 1 + && $dataTable->getFirstRow()->getColumn('label') == Piwik::translate('General_Unknown') + ) { + $footerMessage = Piwik::translate('UserCountry_NoDataForGeoIPReport1'); + + $userCountry = new UserCountry(); + // if GeoIP is working, don't display this part of the message + if (!$userCountry->isGeoIPWorking()) { + $params = ['module' => 'UserCountry', 'action' => 'adminIndex']; + $footerMessage .= ' ' . Piwik::translate( + 'UserCountry_NoDataForGeoIPReport2', + [ + '', + '', + Url::getExternalLinkTag('https://db-ip.com/?refid=mtm'), + '', + ] + ); + } else { + $footerMessage .= ' ' . Piwik::translate( + 'UserCountry_ToGeolocateOldVisits', + [Url::getExternalLinkTag('https://matomo.org/faq/how-to/faq_167'), ''] + ); + } + + $view->config->show_footer_message = $footerMessage; + } + }; + } +} diff --git a/plugins/UserCountry/Reports/GetCity.php b/plugins/UserCountry/Reports/GetCity.php new file mode 100644 index 0000000000000000000000000000000000000000..bd6b2b067e1112b6351b49c99143b8c5d9f33779 --- /dev/null +++ b/plugins/UserCountry/Reports/GetCity.php @@ -0,0 +1,38 @@ +dimension = new City(); + $this->name = Piwik::translate('UserCountry_City'); + $this->documentation = Piwik::translate('UserCountry_getCityDocumentation') . '
+ {%- for entry in visitorData.countries -%}
+
+ {% set entryCity -%}
+ {% if entry.cities is defined and 1 == entry.cities|length and entry.cities|join -%}
+ {{ entry.cities|join }}
+ {%- elseif entry.cities is defined and 1 < entry.cities|length -%}
+ {{ 'UserCountry_FromDifferentCities'|translate }}
+ {%- endif %}
+ {%- endset %}
+
+ {% set entryVisits -%}
+
+ {% if entry.nb_visits == 1 -%}
+ {{ 'General_OneVisit'|translate }}
+ {%- else -%}
+ {{ 'General_NVisits'|translate(entry.nb_visits) }}
+ {%- endif -%}
+
+ {%- endset %}
+
+ {% set entryCountry -%}
+ {%- if entryCity -%}
+ {{ 'UserCountry_CityAndCountry'|translate(entryCity, entry.prettyName)|raw }}
+ {%- else -%}
+ {{ entry.prettyName }}
+ {%- endif -%}
+
+
+ {%- endset %}
+
+ {{- 'General_XFromY'|translate(entryVisits, entryCountry)|raw -}}{% if not loop.last %}, {% endif %}
+ {%- endfor %}
+ ({{ 'Live_ShowMap'|translate|replace({' ': ' '})|raw }})
+
+
{{ translate('UserCountry_GeolocationPageDesc') }}
++ +
++ + {{ translate('General_NotInstalled') }} + + + {{ translate('General_Installed') }} + + + {{ translate('General_Broken') }} + +
+