PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / trunk
Matomo Analytics – Powerful, Privacy-First Insights for WordPress vtrunk
5.12.1 5.12.0 5.11.1 5.11.0 5.10.2 5.10.1 trunk 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.3.2 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.1.0 4.1.1 4.1.2 4.1.3 4.10.0 4.11.0 4.12.0 4.13.0 4.13.2 4.13.3 4.13.4 4.13.5 4.14.0 4.14.1 4.14.2 4.15.0 4.15.1 4.15.2 4.15.3 4.2.0 4.3.0 4.3.1 4.4.1 4.4.2 4.5.0 4.6.0 5.0.1 5.0.2 5.0.3 5.0.4 5.0.5 5.0.6 5.0.7 5.0.8 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.10.0 5.2.0 5.2.1 5.2.2 5.3.0 5.3.1 5.3.2 5.3.3 5.6.0 5.6.1 5.7.0 5.7.1 5.8.0 5.8.1 5.8.2
matomo / classes / WpMatomo / User / Sync.php
matomo / classes / WpMatomo / User Last commit date
Sync.php 4 days ago
Sync.php
447 lines
1 <?php
2 /**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 * @package matomo
8 */
9
10 namespace WpMatomo\User;
11
12 use Exception;
13 use Piwik\Access;
14 use Piwik\Access\Role\Admin;
15 use Piwik\Access\Role\View;
16 use Piwik\Access\Role\Write;
17 use Piwik\Auth\Password;
18 use Piwik\Common;
19 use Piwik\Date;
20 use Piwik\Plugin;
21 use Piwik\Plugins\LanguagesManager\API;
22 use Piwik\Plugins\UsersManager;
23 use Piwik\Plugins\UsersManager\Model;
24 use WP_User;
25 use WpMatomo\Bootstrap;
26 use WpMatomo\Capabilities;
27 use WpMatomo\Feature;
28 use WpMatomo\Logger;
29 use WpMatomo\ScheduledTasks;
30 use WpMatomo\Site;
31 use WpMatomo\User;
32
33 if ( ! defined( 'ABSPATH' ) ) {
34 exit; // if accessed directly
35 }
36
37 class Sync extends Feature {
38
39 /**
40 * actually allowed is 100 characters...
41 * but we do -5 to have some room to append `wp_`.$login.XYZ if needed
42 */
43 const MAX_USER_NAME_LENGTH = 95;
44
45 /**
46 * @var Logger
47 */
48 private $logger;
49
50 /**
51 * @var User
52 */
53 private $user;
54
55 public function __construct() {
56 $this->logger = new Logger();
57 $this->user = new User();
58 }
59
60 public function is_active() {
61 return is_admin();
62 }
63
64 public function register_hooks() {
65 add_action( 'add_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
66 add_action( 'remove_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
67 add_action( 'add_user_to_blog', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
68 add_action( 'remove_user_from_blog', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
69 add_action( 'user_register', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
70 add_action( 'update_option_WPLANG', [ $this, 'on_site_language_change' ], $prio = 10, $args = 0 );
71 add_action( 'profile_update', [ $this, 'sync_maybe_background' ], $prio = 10, $args = 0 );
72 }
73
74 public function sync_maybe_background() {
75 global $pagenow;
76 if ( is_admin() && 'users.php' === $pagenow ) {
77 // eg for profile update we don't want to sync directly see #365 as it could cause issues with other plugins
78 // if they eg alter `get_users` option
79 wp_schedule_single_event( time() + 5, ScheduledTasks::EVENT_SYNC );
80 } else {
81 $this->sync_current_users_1000();
82 }
83 }
84
85 public function on_site_language_change() {
86 unset( $GLOBALS['locale'] ); // same thing that's done after saving in options.php
87
88 $this->sync_current_users_1000();
89 }
90
91 public function sync_all() {
92 if ( function_exists( 'is_multisite' ) && is_multisite() ) {
93 foreach ( get_sites() as $site ) {
94 if ( 1 === (int) $site->deleted ) {
95 continue;
96 }
97
98 switch_to_blog( $site->blog_id );
99
100 $idsite = Site::get_matomo_site_id( $site->blog_id );
101
102 try {
103 if ( $idsite ) {
104 $users = $this->get_users( [ 'blog_id' => $site->blog_id ] );
105 $this->sync_users( $users, $idsite );
106 }
107 } catch ( Exception $e ) {
108 // we don't want to rethrow exception otherwise some other blogs might never sync
109 $this->logger->log_exception( 'user_sync ', $e );
110 }
111
112 restore_current_blog();
113 }
114 } else {
115 $this->sync_current_users();
116 }
117 }
118
119 private function get_users( $options = [] ) {
120 /** @var WP_User[] $users */
121 $users = get_users( $options );
122
123 $current_user = wp_get_current_user();
124 if ( ! empty( $current_user ) && ! empty( $current_user->user_login ) ) {
125 // refs https://github.com/matomo-org/matomo-for-wordpress/issues/365
126 // some other plugins may under circumstances overwrite the get_users query and not return all users
127 // as a result we would delete some users in the matomo users table. this way we make sure at least the current
128 // user will be added and not deleted even if the list of users is not complete
129 $found = false;
130 foreach ( $users as $user ) {
131 if ( $user->user_login === $current_user->user_login ) {
132 $found = true;
133 break;
134 }
135 }
136 if ( ! $found ) {
137 $users[] = $current_user;
138 }
139 }
140
141 if ( is_multisite() ) {
142 $super_admins = get_super_admins();
143 if ( ! empty( $super_admins ) ) {
144 foreach ( $super_admins as $super_admin ) {
145 $found = false;
146 foreach ( $users as $user ) {
147 if ( $user->user_login === $super_admin ) {
148 $found = true;
149 break;
150 }
151 }
152 if ( ! $found ) {
153 $user = get_user_by( 'login', $super_admin );
154 if ( ! empty( $user ) ) {
155 $users[] = $user;
156 }
157 }
158 }
159 }
160 }
161
162 return $users;
163 }
164
165 public function sync_current_users() {
166 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
167 if ( $idsite ) {
168 $users = $this->get_users();
169 $this->sync_users( $users, $idsite );
170 }
171 }
172
173 /**
174 * similar method to sync_current_users which synchronise on the fly only if we have less than 1000 users.
175 * Otherwise it will be done by a background task
176 *
177 * @return void
178 * @see https://github.com/matomo-org/matomo-for-wordpress/issues/460
179 * @see Sync::sync_current_users()
180 */
181 public function sync_current_users_1000() {
182 if ( ! is_plugin_active( 'matomo/matomo.php' ) ) {
183 // @see https://github.com/matomo-org/matomo-for-wordpress/issues/577
184 return;
185 }
186 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
187 if ( $idsite ) {
188 $num_users = count_users();
189 $num_users = $num_users['total_users'];
190 if ( $num_users < 1000 ) {
191 $users = $this->get_users();
192 $this->sync_users( $users, $idsite );
193 }
194 }
195 }
196
197 /**
198 * Sync all users. Make sure to always pass all sites that exist within a given site... you cannot just sync an individual
199 * user... we would delete all other users
200 *
201 * @param WP_User[] $users
202 * @param int|string $idsite
203 */
204 protected function sync_users( $users, $idsite ) {
205 Bootstrap::do_bootstrap();
206
207 $this->logger->log( 'Matomo will now sync ' . count( $users ) . ' users' );
208
209 $super_users = [];
210 $logins_with_some_view_access = [ 'anonmyous' ]; // may or may not exist... we don't want to delete this user though
211 $user_model = new Model();
212
213 // need to make sure we recreate new instance later with latest dependencies in case they changed
214 API::unsetInstance();
215
216 foreach ( $users as $user ) {
217 $user_id = $user->ID;
218
219 // todo if we used transactions we could commit it after a possibly new access has been added
220 // to prevent UI preventing randomly saying no access between deleting and adding access
221
222 $mapped_matomo_login = User::get_matomo_user_login( $user_id );
223
224 $matomo_login = null;
225
226 if ( user_can( $user, Capabilities::KEY_SUPERUSER ) ) {
227 $matomo_login = $this->ensure_user_exists( $user );
228 $super_users[ $matomo_login ] = $user;
229 $logins_with_some_view_access[] = $matomo_login;
230 } elseif ( user_can( $user, Capabilities::KEY_ADMIN ) ) {
231 $matomo_login = $this->ensure_user_exists( $user );
232 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
233 $user_model->addUserAccess( $matomo_login, Admin::ID, [ $idsite ] );
234 $user_model->setSuperUserAccess( $matomo_login, false );
235 $logins_with_some_view_access[] = $matomo_login;
236 } elseif ( user_can( $user, Capabilities::KEY_WRITE ) ) {
237 $matomo_login = $this->ensure_user_exists( $user );
238 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
239 $user_model->addUserAccess( $matomo_login, Write::ID, [ $idsite ] );
240 $user_model->setSuperUserAccess( $matomo_login, false );
241 $logins_with_some_view_access[] = $matomo_login;
242 } elseif ( user_can( $user, Capabilities::KEY_VIEW ) ) {
243 $matomo_login = $this->ensure_user_exists( $user );
244 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
245 $user_model->addUserAccess( $matomo_login, View::ID, [ $idsite ] );
246 $user_model->setSuperUserAccess( $matomo_login, false );
247 $logins_with_some_view_access[] = $matomo_login;
248 } elseif ( $mapped_matomo_login ) {
249 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
250 }
251
252 if ( $matomo_login ) {
253 $locale = get_user_locale( $user->ID );
254 $lang = self::get_matomo_lang_from_locale( $locale );
255 if (
256 ! empty( $lang )
257 && Plugin\Manager::getInstance()->isPluginActivated( 'LanguagesManager' )
258 && Plugin\Manager::getInstance()->isPluginInstalled( 'LanguagesManager' )
259 && API::getInstance()->isLanguageAvailable( $lang )
260 ) {
261 $user_lang_model = new \Piwik\Plugins\LanguagesManager\Model();
262 $user_lang_model->setLanguageForUser( $matomo_login, $lang );
263 }
264 }
265 // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
266 if ( 1 != $idsite ) {
267 // only needed if the actual site is not the default site... makes sure when they click in Matomo
268 // UI on "Dashboard" that the correct site is being opened by default
269 // eg if the linked site is actually idSite=2.
270 Access::doAsSuperUser(
271 function () use ( $matomo_login, &$idsite ) {
272 try {
273 UsersManager\API::unsetInstance();
274 // we need to unset the instance to make sure it fetches the
275 // up to date dependencies eg current plugin manager etc
276
277 UsersManager\API::getInstance()->setUserPreference(
278 $matomo_login,
279 UsersManager\API::PREFERENCE_DEFAULT_REPORT,
280 $idsite
281 );
282 //phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
283 } catch ( Exception $e ) {
284 // ignore any error for now
285 }
286 }
287 );
288 }
289 }
290
291 foreach ( $super_users as $matomo_login => $user ) {
292 $user_model->setSuperUserAccess( $matomo_login, true );
293 }
294
295 $logins_with_some_view_access = array_unique( $logins_with_some_view_access );
296 $all_users = $user_model->getUsers( [] );
297 foreach ( $all_users as $all_user ) {
298 if ( ! in_array( $all_user['login'], $logins_with_some_view_access, true )
299 && ! empty( $all_user['login'] ) ) {
300 Access::doAsSuperUser(
301 function () use ( $user_model, $all_user ) {
302 $user_model->deleteUserOnly( $all_user['login'] );
303 $user_model->deleteUserOptions( $all_user['login'] );
304 $user_model->deleteUserAccess( $all_user['login'] );
305 }
306 );
307 // the WP -> Matomo mapping is cleaned up via the UsersManager.deleteUser event
308 // that deleteUserOnly() fires (see WordPress::onDeleteMatomoUser).
309 }
310 }
311 }
312
313 /**
314 * @param WP_User $wp_user
315 */
316 protected function ensure_user_exists( $wp_user ) {
317 $user_model = new Model();
318 $user_id = $wp_user->ID;
319 $login = $wp_user->user_login;
320
321 $matomo_user_login = User::get_matomo_user_login( $user_id );
322 $user_in_matomo = null;
323
324 // sanity check: make sure the matomo user login we found (if we found one) belongs
325 // to the WP user being synced. if it does not, delete the mapping.
326 if ( $matomo_user_login && $this->is_matomo_login_owned_by_other_wp_user( $matomo_user_login, $user_id ) ) {
327 User::map_matomo_user_login( $user_id, null );
328 $matomo_user_login = null;
329 }
330
331 if ( $matomo_user_login ) {
332 $user_in_matomo = $user_model->getUser( $matomo_user_login );
333 } else {
334 $user_by_email = $user_model->getUserByEmail( $wp_user->user_email );
335
336 // the user was deleted without matomo being notified. delete user so we can recreate it
337 // below.
338 //
339 // note: it's also possible there are multiple users with the same email address,
340 // but this is currently unsupported in matomo so we don't take that into consideration.
341 if ( $user_by_email ) {
342 $this->logger->log_exception(
343 'user_sync',
344 new \Exception(
345 'Syncing user with email identical to a user already synced in Matomo. ' .
346 'This means there are multiple WP users with the same email, which Matomo ' .
347 'does not support, or something has deleted the WP option mapping WP user ' .
348 'to Matomo user. Assuming this is a new user to sync and deleting existing user ' .
349 'preferences and options.'
350 )
351 );
352
353 // note: login mappings are deleted in the UsersManager.deleteUser event.
354 $user_model->deleteUser( $user_by_email['login'] );
355 }
356
357 // wp usernames may include whitespace etc
358 $login = preg_replace( '/[^A-Za-zÄäÖöÜüß0-9_.@+-]+/D', '_', $login );
359 $login = substr( $login, 0, self::MAX_USER_NAME_LENGTH );
360
361 if ( ! $this->is_matomo_login_taken( $user_model, $login, $user_id ) ) {
362 // username is available...
363 $matomo_user_login = $login;
364 } else {
365 // this username seems taken... lets create another one
366
367 $index = 0;
368 do {
369 if ( ! $index ) {
370 $matomo_user_login = 'wp_' . $login;
371 } else {
372 $matomo_user_login = 'wp_' . $login . $index;
373 }
374
375 ++$index;
376 } while ( $this->is_matomo_login_taken( $user_model, $matomo_user_login, $user_id ) );
377 }
378 }
379
380 if ( ! $matomo_user_login || empty( $user_in_matomo ) ) {
381 $this->logger->log( 'Matomo is now creating a user for user id ' . $user_id . ' with matomo login ' . $matomo_user_login );
382
383 $now = Date::now()->getDatetime();
384 $password = new Password();
385 // we generate some random password since log in using matomo won't be happening anyway
386 $password = $password->hash( $login . $now . Common::getRandomString( 200 ) . microtime( true ) . Common::generateUniqId() );
387
388 $user_model->addUser( $matomo_user_login, $password, $wp_user->user_email, $now );
389
390 User::map_matomo_user_login( $user_id, $matomo_user_login );
391 } elseif ( $user_in_matomo['email'] !== $wp_user->user_email ) {
392 $this->logger->log( 'Matomo is now updating the email for wpUserID ' . $user_id . ' matomo login ' . $matomo_user_login );
393 $user_model->updateUserFields( $matomo_user_login, [ 'email' => $wp_user->user_email ] );
394 }
395
396 return $matomo_user_login;
397 }
398
399 /**
400 * @param Model $user_model
401 * @param string $candidate_login
402 * @param int $wp_user_id
403 * @return bool
404 */
405 private function is_matomo_login_taken( $user_model, $candidate_login, $wp_user_id ) {
406 if ( $user_model->getUser( $candidate_login ) ) {
407 return true; // matomo user exists
408 }
409
410 // sanity check: matomo user does not exist, but another WP user is somehow mapped to
411 // this login
412 return $this->is_matomo_login_owned_by_other_wp_user( $candidate_login, $wp_user_id );
413 }
414
415 /**
416 * @param string $matomo_user_login
417 * @param int $wp_user_id
418 *
419 * @return bool
420 */
421 private function is_matomo_login_owned_by_other_wp_user( $matomo_user_login, $wp_user_id ) {
422 $wp_user_ids_mapped_to_matomo_login = $this->user->get_wp_user_ids_for_matomo_login( $matomo_user_login );
423
424 if ( empty( $wp_user_ids_mapped_to_matomo_login ) ) {
425 return false; // no mapping exists, matomo login not owned by anyone
426 }
427
428 if ( count( $wp_user_ids_mapped_to_matomo_login ) > 1 ) {
429 return true; // more than one user mapped to login, not owned solely by this user
430 }
431
432 // the login is owned by another WP user if the single mapped user is not the requested user
433 return (int) reset( $wp_user_ids_mapped_to_matomo_login ) !== (int) $wp_user_id;
434 }
435
436 public static function get_matomo_lang_from_locale( $locale ) {
437 $locale_dash = Common::mb_strtolower( str_replace( '_', '-', $locale ) );
438 $parts = [];
439 if ( $locale && in_array( $locale_dash, [ 'zh-cn', 'zh-tw', 'pt-br', 'es-ar' ], true ) ) {
440 $parts = [ $locale_dash ];
441 } elseif ( ! empty( $locale ) && is_string( $locale ) ) {
442 $parts = explode( '_', $locale );
443 }
444 return ! empty( $parts[0] ) ? $parts[0] : null;
445 }
446 }
447