PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.13.0
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.13.0
5.13.0 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 5 days ago
Sync.php
901 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\Auth\Password;
15 use Piwik\Common;
16 use Piwik\Date;
17 use Piwik\Db;
18 use Piwik\Plugin;
19 use Piwik\Plugins\LanguagesManager\API;
20 use Piwik\Plugins\UsersManager;
21 use Piwik\Plugins\UsersManager\Model;
22 use Piwik\Tracker\Cache as TrackerCache;
23 use WP_User;
24 use WpMatomo\Bootstrap;
25 use WpMatomo\Capabilities;
26 use WpMatomo\Feature;
27 use WpMatomo\Logger;
28 use WpMatomo\Request;
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 * Above this many users on a blog, syncing them all is too much work for the request that
47 * happened to change one of them. The scheduled task does it instead.
48 */
49 const MAX_INLINE_SYNC_USERS = 1000;
50
51 /**
52 * @var Logger
53 */
54 private $logger;
55
56 /**
57 * @var User
58 */
59 private $user;
60
61 /**
62 * Blogs a user is being removed from in this request, as [ wp_user_id ][ blog_id ] => true.
63 * A single request can remove the same user from several blogs, see wpmu_delete_user().
64 *
65 * @var array
66 */
67 private $pending_removals = [];
68
69 public function __construct() {
70 $this->logger = new Logger();
71 $this->user = new User();
72 }
73
74 public function register_hooks() {
75 add_action( 'add_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
76 add_action( 'remove_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
77 add_action( 'add_user_to_blog', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
78 add_action( 'remove_user_from_blog', [ $this, 'on_remove_user_from_blog' ], $prio = 10, $args = 2 );
79 add_action( 'clean_user_cache', [ $this, 'on_clean_user_cache' ], $prio = 10, $args = 1 );
80 add_action( 'deleted_user_meta', [ $this, 'on_deleted_user_meta' ], $prio = 10, $args = 3 );
81 add_action( 'deleted_user', [ $this, 'on_deleted_user' ], $prio = 10, $args = 1 );
82 add_action( 'user_register', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
83 add_action( 'granted_super_admin', [ $this, 'on_super_admin_change' ], $prio = 10, $args = 1 );
84 add_action( 'revoked_super_admin', [ $this, 'on_super_admin_change' ], $prio = 10, $args = 1 );
85 add_action( 'update_option_WPLANG', [ $this, 'on_site_language_change' ], $prio = 10, $args = 0 );
86 add_action( 'profile_update', [ $this, 'sync_maybe_background' ], $prio = 10, $args = 0 );
87
88 foreach ( Site\Sync::RETURN_TO_SERVICE_ACTIONS as $blog_action ) {
89 // must run after the site sync hook: the users are synced against the Matomo site that
90 // is created during the site sync
91 add_action( $blog_action, [ $this, 'on_blog_returned_to_service' ], $prio = Site\Sync::RETURN_TO_SERVICE_PRIORITY + 1, $args = 1 );
92 }
93 }
94
95 /**
96 * Tests only
97 *
98 * @internal
99 */
100 public function remove_hooks() {
101 remove_action( 'add_user_role', [ $this, 'sync_current_users_1000' ], 10 );
102 remove_action( 'remove_user_role', [ $this, 'sync_current_users_1000' ], 10 );
103 remove_action( 'add_user_to_blog', [ $this, 'sync_current_users_1000' ], 10 );
104 remove_action( 'remove_user_from_blog', [ $this, 'on_remove_user_from_blog' ], 10 );
105 remove_action( 'clean_user_cache', [ $this, 'on_clean_user_cache' ], 10 );
106 remove_action( 'deleted_user_meta', [ $this, 'on_deleted_user_meta' ], 10 );
107 remove_action( 'deleted_user', [ $this, 'on_deleted_user' ], 10 );
108 remove_action( 'user_register', [ $this, 'sync_current_users_1000' ], 10 );
109 remove_action( 'granted_super_admin', [ $this, 'on_super_admin_change' ], 10 );
110 remove_action( 'revoked_super_admin', [ $this, 'on_super_admin_change' ], 10 );
111 remove_action( 'update_option_WPLANG', [ $this, 'on_site_language_change' ], 10 );
112 remove_action( 'profile_update', [ $this, 'sync_maybe_background' ], 10 );
113 foreach ( Site\Sync::RETURN_TO_SERVICE_ACTIONS as $blog_action ) {
114 remove_action( $blog_action, [ $this, 'on_blog_returned_to_service' ], Site\Sync::RETURN_TO_SERVICE_PRIORITY + 1 );
115 }
116 }
117
118 /**
119 * WordPress fires this action before it removes the user's capabilities, so we can't sync the
120 * user here, the access it has to the site would not be removed. Instead, we remember them
121 * and have flush_pending_removal_for_current_blog() do the actual re-syncing once the
122 * capabilities are gone.
123 *
124 * @param int $wp_user_id
125 * @param int $blog_id
126 */
127 public function on_remove_user_from_blog( $wp_user_id, $blog_id ) {
128 if ( ! $this->is_sync_allowed_for_request() ) {
129 return;
130 }
131
132 $blog_id = (int) $blog_id;
133 $blog_id = $blog_id ? $blog_id : get_current_blog_id();
134
135 $this->pending_removals[ (int) $wp_user_id ][ $blog_id ] = true;
136 }
137
138 /**
139 * Runs while WordPress is still switched to the blog the user was removed from, after their
140 * capabilities are gone. Other callers of clean_user_cache() (wp_insert_user(),
141 * add_user_to_blog(), ...) are ignored because they didn't queue anything.
142 *
143 * @param int $wp_user_id
144 */
145 public function on_clean_user_cache( $wp_user_id ) {
146 $this->flush_pending_removal_for_current_blog( $wp_user_id );
147 }
148
149 /**
150 * Required since remove_user_from_blog() only calls clean_user_cache() since WordPress 6.1.
151 * On older versions the capabilities meta deleted by WP_User::remove_all_caps(), so we have
152 * to try to sync from both places.
153 *
154 * Whichever of the two fires first does the sync, the other then finds nothing queued.
155 *
156 * @param array $meta_ids
157 * @param int $wp_user_id
158 * @param string $meta_key
159 */
160 public function on_deleted_user_meta( $meta_ids, $wp_user_id, $meta_key ) {
161 global $wpdb;
162
163 $blog_prefix = $wpdb->get_blog_prefix();
164
165 if ( $blog_prefix . 'capabilities' !== $meta_key && $blog_prefix . 'user_level' !== $meta_key ) {
166 return;
167 }
168
169 $this->flush_pending_removal_for_current_blog( $wp_user_id );
170 }
171
172 /**
173 * Syncs a user queued by on_remove_user_from_blog(), if the blog WordPress is currently
174 * switched to is one they were queued for.
175 *
176 * @param int $wp_user_id
177 */
178 private function flush_pending_removal_for_current_blog( $wp_user_id ) {
179 $wp_user_id = (int) $wp_user_id;
180 $blog_id = get_current_blog_id();
181
182 if ( empty( $this->pending_removals[ $wp_user_id ][ $blog_id ] ) ) {
183 return;
184 }
185
186 // only this blog is done, the same user may still be queued for others
187 unset( $this->pending_removals[ $wp_user_id ][ $blog_id ] );
188
189 $this->sync_user_for_current_blog( $wp_user_id );
190 }
191
192 /**
193 * @param int $wp_user_id
194 */
195 public function on_deleted_user( $wp_user_id ) {
196 if ( ! $this->is_sync_allowed_for_request() ) {
197 return;
198 }
199
200 if ( get_userdata( $wp_user_id ) ) {
201 // user still exists, do not delete user from matomo (edge case that can happen
202 // on multisite installs)
203 return;
204 }
205
206 try {
207 $this->delete_matomo_user_for_current_blog( $wp_user_id );
208 } catch ( Exception $e ) {
209 // deleting a WordPress user must not fail because the Matomo cleanup did
210 $this->logger->log_exception( 'user_sync', $e );
211 }
212 }
213
214 /**
215 * @param int $blog_id
216 */
217 public function on_blog_returned_to_service( $blog_id ) {
218 if ( ! $this->is_sync_allowed_for_request() ) {
219 return;
220 }
221
222 // only one of the three flags was cleared, and the blog stays out of service while any
223 // of the others is still set
224 if ( Site::is_blog_out_of_service( $blog_id ) ) {
225 return;
226 }
227
228 switch_to_blog( $blog_id );
229
230 try {
231 $this->sync_current_users_1000();
232 } catch ( Exception $e ) {
233 // restoring a blog must not fail because Matomo could not be synced for it
234 $this->logger->log_exception( 'user_sync', $e );
235 }
236
237 restore_current_blog();
238 }
239
240 /**
241 * @param int $wp_user_id
242 */
243 public function on_super_admin_change( $wp_user_id ) {
244 if ( ! $this->is_sync_allowed_for_request() ) {
245 return;
246 }
247
248 if ( ! function_exists( 'is_multisite' ) || ! is_multisite() ) {
249 return;
250 }
251
252 foreach ( get_sites( [ 'number' => 0 ] ) as $site ) {
253 // a revoked super admin keeps their row on a blog skipped here, which is only safe
254 // because authenticating downgrades it first
255 if ( Site::is_blog_out_of_service( $site ) ) {
256 continue;
257 }
258
259 switch_to_blog( $site->blog_id );
260
261 try {
262 $this->sync_user_for_current_blog( $wp_user_id );
263 } catch ( Exception $e ) {
264 // one blog failing must not stop the rest from being corrected
265 $this->logger->log_exception( 'user_sync', $e );
266 }
267
268 restore_current_blog();
269 }
270 }
271
272 /**
273 * Corrects a Matomo access row that grants the user more than their live WordPress capabilities
274 * do, before anything reads it.
275 *
276 * Only ever downgrades. A user promoted in WordPress still waits for a regular sync.
277 *
278 * @param int $wp_user_id
279 * @param array|null $matomo_user the already fetched Matomo user row, to save a query
280 *
281 * @return bool whether the user was re-synced
282 */
283 public function sync_user_if_access_exceeds_capabilities( $wp_user_id, $matomo_user = null ) {
284 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
285 if ( ! $idsite ) {
286 return false;
287 }
288
289 $matomo_login = User::get_matomo_user_login( $wp_user_id );
290 if ( ! $matomo_login ) {
291 return false; // nothing was ever persisted for this user
292 }
293
294 $wp_user = get_userdata( $wp_user_id );
295 if ( empty( $wp_user ) ) {
296 return false;
297 }
298
299 Bootstrap::do_bootstrap();
300
301 $user_model = new Model();
302
303 if ( null === $matomo_user ) {
304 $matomo_user = $user_model->getUser( $matomo_login );
305 }
306
307 if ( empty( $matomo_user ) ) {
308 return false;
309 }
310
311 $live_rank = Capabilities::get_role_ranking( Capabilities::get_highest_role_for_user( $wp_user ) );
312 $persisted_rank = $this->get_persisted_role_rank( $matomo_login, $matomo_user, $idsite );
313
314 if ( $persisted_rank <= $live_rank ) {
315 return false;
316 }
317
318 $this->sync_user_for_current_blog( $wp_user_id );
319
320 return true;
321 }
322
323 /**
324 * @param string $matomo_login
325 * @param array $matomo_user
326 * @param int $idsite
327 *
328 * @return int
329 */
330 private function get_persisted_role_rank( $matomo_login, $matomo_user, $idsite ) {
331 if ( ! empty( $matomo_user['superuser_access'] ) ) {
332 return Capabilities::get_role_ranking( Capabilities::ROLE_SUPERUSER );
333 }
334
335 $rows = Db::fetchAll(
336 'SELECT access FROM ' . Common::prefixTable( 'access' ) . ' WHERE login = ? AND idsite = ?',
337 [ $matomo_login, (int) $idsite ]
338 );
339
340 $rank = 0;
341 foreach ( $rows as $row ) {
342 $rank = max( $rank, Capabilities::get_role_ranking( $row['access'] ) );
343 }
344
345 return $rank;
346 }
347
348 /**
349 * @param int $wp_user_id
350 */
351 private function sync_user_for_current_blog( $wp_user_id ) {
352 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
353 if ( ! $idsite ) {
354 return;
355 }
356
357 $wp_user = get_userdata( $wp_user_id );
358 if ( empty( $wp_user ) ) {
359 return;
360 }
361
362 Bootstrap::do_bootstrap();
363
364 $user_model = new Model();
365
366 Access::doAsSuperUser(
367 function () use ( $user_model, $wp_user, $wp_user_id, $idsite ) {
368 $mapped_matomo_login = $this->get_own_matomo_user_login( $wp_user_id );
369
370 $has_access = (bool) $this->sync_user_access_for_site( $wp_user, $idsite, $user_model );
371 if ( $has_access ) { // user has access
372 return;
373 }
374
375 // user has no access but was never mapped originally, and thus has no matomo user
376 if ( ! $mapped_matomo_login ) {
377 return;
378 }
379
380 // user still exists in matomo but has no access to matomo when determined by
381 // WP roles
382
383 // user does not have super user access (otherwise, a login would have been returned above)
384 $user_model->setSuperUserAccess( $mapped_matomo_login, false );
385
386 // user may still have access to other sites, but if they don't, delete the user entirely
387 if ( ! $user_model->getSiteAccessCount( $mapped_matomo_login ) ) {
388 $this->delete_matomo_user( $user_model, $mapped_matomo_login );
389 }
390 }
391 );
392
393 $this->invalidate_tracker_cache( $idsite );
394 }
395
396 /**
397 * Returns the matomo login mapped to the given user, if and only if the matomo login
398 * is not currently mapped to another user. (should not normally happen unless in a
399 * corrupted state)
400 *
401 * @param int $wp_user_id
402 * @return string|null
403 */
404 private function get_own_matomo_user_login( $wp_user_id ) {
405 $matomo_login = User::get_matomo_user_login( $wp_user_id );
406
407 if ( ! $matomo_login || $this->is_matomo_login_owned_by_other_wp_user( $matomo_login, $wp_user_id ) ) {
408 return null;
409 }
410
411 return $matomo_login;
412 }
413
414 /**
415 * @param int $wp_user_id
416 */
417 private function delete_matomo_user_for_current_blog( $wp_user_id ) {
418 $matomo_login = $this->get_own_matomo_user_login( $wp_user_id );
419 if ( ! $matomo_login ) {
420 // either never mapped, or the login belongs to another WP user.
421 // should not delete the matomo user in this case. instead we remove the
422 // corrupted mapping.
423 User::map_matomo_user_login( $wp_user_id, null );
424
425 return;
426 }
427
428 Bootstrap::do_bootstrap();
429
430 $user_model = new Model();
431
432 Access::doAsSuperUser(
433 function () use ( $user_model, $matomo_login ) {
434 $this->delete_matomo_user( $user_model, $matomo_login );
435 }
436 );
437
438 // in case a token somehow has been created for the user, invalidate the
439 // tracker cache so it will not be used in the tracker
440 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
441 if ( $idsite ) {
442 $this->invalidate_tracker_cache( $idsite );
443 }
444 }
445
446 /**
447 * Callers are responsible for being inside Access::doAsSuperUser().
448 *
449 * @param Model $user_model
450 * @param string $matomo_login
451 */
452 private function delete_matomo_user( $user_model, $matomo_login ) {
453 $user_model->deleteUserOnly( $matomo_login );
454 $user_model->deleteUserOptions( $matomo_login );
455 $user_model->deleteUserAccess( $matomo_login );
456 }
457
458 public function sync_maybe_background() {
459 global $pagenow;
460 if ( is_admin() && 'users.php' === $pagenow ) {
461 // eg for profile update we don't want to sync directly see #365 as it could cause issues with other plugins
462 // if they eg alter `get_users` option
463 wp_schedule_single_event( time() + 5, ScheduledTasks::EVENT_SYNC );
464 } else {
465 $this->sync_current_users_1000();
466 }
467 }
468
469 public function on_site_language_change() {
470 unset( $GLOBALS['locale'] ); // same thing that's done after saving in options.php
471
472 $this->sync_current_users_1000();
473 }
474
475 public function sync_all() {
476 if ( function_exists( 'is_multisite' ) && is_multisite() ) {
477 // number => 0 means no limit. WP_Site_Query defaults to 100, which would silently leave
478 // every blog after that unsynced
479 foreach ( get_sites( [ 'number' => 0 ] ) as $site ) {
480 if ( Site::is_blog_out_of_service( $site ) ) {
481 continue;
482 }
483
484 switch_to_blog( $site->blog_id );
485
486 $idsite = Site::get_matomo_site_id( $site->blog_id );
487
488 try {
489 if ( $idsite ) {
490 $users = $this->get_users( [ 'blog_id' => $site->blog_id ] );
491 $this->sync_users( $users, $idsite );
492 }
493 } catch ( Exception $e ) {
494 // we don't want to rethrow exception otherwise some other blogs might never sync
495 $this->logger->log_exception( 'user_sync ', $e );
496 }
497
498 restore_current_blog();
499 }
500 } else {
501 $this->sync_current_users();
502 }
503 }
504
505 private function get_users( $options = [] ) {
506 /** @var WP_User[] $users */
507 $users = get_users( $options );
508
509 $current_user = wp_get_current_user();
510 if ( ! empty( $current_user ) && ! empty( $current_user->user_login ) ) {
511 // refs https://github.com/matomo-org/matomo-for-wordpress/issues/365
512 // some other plugins may under circumstances overwrite the get_users query and not return all users
513 // as a result we would delete some users in the matomo users table. this way we make sure at least the current
514 // user will be added and not deleted even if the list of users is not complete
515 $found = false;
516 foreach ( $users as $user ) {
517 if ( $user->user_login === $current_user->user_login ) {
518 $found = true;
519 break;
520 }
521 }
522 if ( ! $found ) {
523 $users[] = $current_user;
524 }
525 }
526
527 if ( is_multisite() ) {
528 $super_admins = get_super_admins();
529 if ( ! empty( $super_admins ) ) {
530 foreach ( $super_admins as $super_admin ) {
531 $found = false;
532 foreach ( $users as $user ) {
533 if ( $user->user_login === $super_admin ) {
534 $found = true;
535 break;
536 }
537 }
538 if ( ! $found ) {
539 $user = get_user_by( 'login', $super_admin );
540 if ( ! empty( $user ) ) {
541 $users[] = $user;
542 }
543 }
544 }
545 }
546 }
547
548 return $users;
549 }
550
551 public function sync_current_users() {
552 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
553 if ( $idsite ) {
554 $users = $this->get_users();
555 $this->sync_users( $users, $idsite );
556 }
557 }
558
559 /**
560 * similar method to sync_current_users which synchronise on the fly only if we have less than 1000 users.
561 * Otherwise it will be done by a background task
562 *
563 * @return void
564 * @see https://github.com/matomo-org/matomo-for-wordpress/issues/460
565 * @see Sync::sync_current_users()
566 */
567 public function sync_current_users_1000() {
568 if ( ! $this->is_sync_allowed_for_request() ) {
569 return;
570 }
571
572 if ( ! function_exists( 'is_plugin_active' ) ) {
573 // these hooks are not admin only, so this may run somewhere wp-admin/includes is not loaded
574 require_once ABSPATH . 'wp-admin/includes/plugin.php';
575 }
576
577 if ( ! is_plugin_active( 'matomo/matomo.php' ) ) {
578 // @see https://github.com/matomo-org/matomo-for-wordpress/issues/577
579 return;
580 }
581 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
582 if ( $idsite ) {
583 $num_users = count_users();
584 $num_users = $num_users['total_users'];
585 if ( $num_users < self::MAX_INLINE_SYNC_USERS ) {
586 $users = $this->get_users();
587 $this->sync_users( $users, $idsite );
588 } else {
589 // too expensive to sync in this request
590 $this->logger->log( 'Deferring user sync to a scheduled task, since this blog has ' . $num_users . ' users' );
591
592 wp_schedule_single_event( time() + 5, ScheduledTasks::EVENT_SYNC );
593 }
594 }
595 }
596
597 /**
598 * Sync all users. Make sure to always pass all sites that exist within a given site... you cannot just sync an individual
599 * user... we would delete all other users
600 *
601 * @param WP_User[] $users
602 * @param int|string $idsite
603 */
604 protected function sync_users( $users, $idsite ) {
605 Bootstrap::do_bootstrap();
606
607 $this->logger->log( 'Matomo will now sync ' . count( $users ) . ' users' );
608
609 $logins_with_some_view_access = [ 'anonymous' ]; // may or may not exist... we don't want to delete this user though
610 $user_model = new Model();
611
612 // need to make sure we recreate new instance later with latest dependencies in case they changed
613 API::unsetInstance();
614 UsersManager\API::unsetInstance();
615
616 foreach ( $users as $user ) {
617 // todo if we used transactions we could commit it after a possibly new access has been added
618 // to prevent UI preventing randomly saying no access between deleting and adding access
619
620 try {
621 if ( defined( 'MATOMO_PHPUNIT_TEST' ) && MATOMO_PHPUNIT_TEST ) {
622 /**
623 * @internal tests only
624 * @param WP_User $user
625 * @param int|string $idsite
626 */
627 do_action( 'matomo_before_sync_user', $user, $idsite );
628 }
629
630 $matomo_login = $this->sync_user_access_for_site( $user, $idsite, $user_model );
631
632 if ( $matomo_login ) {
633 $logins_with_some_view_access[] = $matomo_login;
634
635 $locale = get_user_locale( $user->ID );
636 $lang = self::get_matomo_lang_from_locale( $locale );
637 if (
638 ! empty( $lang )
639 && Plugin\Manager::getInstance()->isPluginActivated( 'LanguagesManager' )
640 && Plugin\Manager::getInstance()->isPluginInstalled( 'LanguagesManager' )
641 && API::getInstance()->isLanguageAvailable( $lang )
642 ) {
643 $user_lang_model = new \Piwik\Plugins\LanguagesManager\Model();
644 $user_lang_model->setLanguageForUser( $matomo_login, $lang );
645 }
646
647 // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
648 if ( 1 != $idsite ) {
649 // only needed if the actual site is not the default site... makes sure when they click in Matomo
650 // UI on "Dashboard" that the correct site is being opened by default
651 // eg if the linked site is actually idSite=2.
652 Access::doAsSuperUser(
653 function () use ( $matomo_login, &$idsite ) {
654 try {
655 UsersManager\API::getInstance()->setUserPreference(
656 $matomo_login,
657 UsersManager\API::PREFERENCE_DEFAULT_REPORT,
658 $idsite
659 );
660 } catch ( Exception $e ) {
661 // a preference is not worth failing the sync over, but it should not
662 // disappear silently either
663 $this->logger->log_exception( 'user_sync', $e );
664 }
665 }
666 );
667 }
668 }
669 } catch ( Exception $e ) {
670 // one user sync failure must not abort the whole sync
671 $this->logger->log_exception( 'user_sync', $e );
672
673 // make sure this user for whom syncing failed is not deleted
674 $login_to_keep = User::get_matomo_user_login( $user->ID );
675 if ( $login_to_keep ) {
676 $logins_with_some_view_access[] = $login_to_keep;
677 }
678 }
679 }
680
681 $logins_with_some_view_access = array_unique( $logins_with_some_view_access );
682 $all_users = $user_model->getUsers( [] );
683 foreach ( $all_users as $all_user ) {
684 if ( ! in_array( $all_user['login'], $logins_with_some_view_access, true )
685 && ! empty( $all_user['login'] ) ) {
686 try {
687 Access::doAsSuperUser(
688 function () use ( $user_model, $all_user ) {
689 $this->delete_matomo_user( $user_model, $all_user['login'] );
690 }
691 );
692 // the WP -> Matomo mapping is cleaned up via the UsersManager.deleteUser event
693 // that deleteUserOnly() fires (see WordPress::onDeleteMatomoUser).
694 } catch ( Exception $e ) {
695 // do not abort entirely if a single delete fails
696 $this->logger->log_exception( 'user_sync', $e );
697 }
698 }
699 }
700
701 $this->invalidate_tracker_cache( $idsite );
702 }
703
704 /**
705 * This plugin does not provide token auths to authenticate with, but in case an
706 * attacker is somehow able to create one, we want to make sure it can't be used,
707 * so after syncing, we clear the tracker cache.
708 *
709 * @param int|string $idsite
710 */
711 private function invalidate_tracker_cache( $idsite ) {
712 try {
713 TrackerCache::deleteCacheWebsiteAttributes( $idsite );
714 } catch ( Exception $e ) {
715 // the sync itself is done, so a cache that could not be cleared must not fail the
716 // request that triggered it
717 $this->logger->log_exception( 'user_sync', $e );
718 }
719 }
720
721 /**
722 * @param WP_User $user
723 * @param int|string $idsite
724 * @param Model $user_model
725 *
726 * @return string|null matomo login or null when the user has no access
727 */
728 protected function sync_user_access_for_site( $user, $idsite, $user_model ) {
729 $role = Capabilities::get_highest_role_for_user( $user );
730
731 if ( Capabilities::ROLE_SUPERUSER === $role ) {
732 $matomo_login = $this->ensure_user_exists( $user );
733
734 $user_model->setSuperUserAccess( $matomo_login, true );
735
736 // superuser_access already grants every site, so a per site row is redundant here. Left
737 // behind it outlives the superuser flag and goes on granting this site by itself, and it
738 // keeps getSiteAccessCount() non zero, which is what stops the identity being cleaned up
739 $user_model->deleteUserAccess( $matomo_login );
740
741 return $matomo_login;
742 }
743
744 if ( null === $role ) {
745 // make sure the mapped matomo login is actually for the current WP user
746 $mapped_matomo_login = $this->get_own_matomo_user_login( $user->ID );
747 if ( $mapped_matomo_login ) {
748 $user_model->deleteUserAccess( $mapped_matomo_login );
749 }
750
751 return null;
752 }
753
754 // note: matomo_login may not be the same as the login this user was mapped to on the way in
755 $matomo_login = $this->ensure_user_exists( $user );
756 $user_model->deleteUserAccess( $matomo_login );
757 $user_model->addUserAccess( $matomo_login, $role, [ $idsite ] );
758 $user_model->setSuperUserAccess( $matomo_login, false );
759
760 return $matomo_login;
761 }
762
763 /**
764 * @param WP_User $wp_user
765 */
766 protected function ensure_user_exists( $wp_user ) {
767 $user_model = new Model();
768 $user_id = $wp_user->ID;
769 $login = $wp_user->user_login;
770
771 $matomo_user_login = User::get_matomo_user_login( $user_id );
772 $user_in_matomo = null;
773
774 // sanity check: make sure the matomo user login we found (if we found one) belongs
775 // to the WP user being synced. if it does not, delete the mapping.
776 if ( $matomo_user_login && $this->is_matomo_login_owned_by_other_wp_user( $matomo_user_login, $user_id ) ) {
777 User::map_matomo_user_login( $user_id, null );
778 $matomo_user_login = null;
779 }
780
781 if ( $matomo_user_login ) {
782 $user_in_matomo = $user_model->getUser( $matomo_user_login );
783 } else {
784 $user_by_email = $user_model->getUserByEmail( $wp_user->user_email );
785
786 // the user was deleted without matomo being notified. delete user so we can recreate it
787 // below.
788 //
789 // note: it's also possible there are multiple users with the same email address,
790 // but this is currently unsupported in matomo so we don't take that into consideration.
791 if ( $user_by_email ) {
792 $this->logger->log_exception(
793 'user_sync',
794 new \Exception(
795 'Syncing user with email identical to a user already synced in Matomo. ' .
796 'This means there are multiple WP users with the same email, which Matomo ' .
797 'does not support, or something has deleted the WP option mapping WP user ' .
798 'to Matomo user. Assuming this is a new user to sync and deleting existing user ' .
799 'preferences and options.'
800 )
801 );
802
803 // note: login mappings are deleted in the UsersManager.deleteUser event.
804 $user_model->deleteUser( $user_by_email['login'] );
805 }
806
807 // wp usernames may include whitespace etc
808 $login = preg_replace( '/[^A-Za-zÄäÖöÜüß0-9_.@+-]+/D', '_', $login );
809 $login = substr( $login, 0, self::MAX_USER_NAME_LENGTH );
810
811 if ( ! $this->is_matomo_login_taken( $user_model, $login, $user_id ) ) {
812 // username is available...
813 $matomo_user_login = $login;
814 } else {
815 // this username seems taken... lets create another one
816
817 $index = 0;
818 do {
819 if ( ! $index ) {
820 $matomo_user_login = 'wp_' . $login;
821 } else {
822 $matomo_user_login = 'wp_' . $login . $index;
823 }
824
825 ++$index;
826 } while ( $this->is_matomo_login_taken( $user_model, $matomo_user_login, $user_id ) );
827 }
828 }
829
830 if ( ! $matomo_user_login || empty( $user_in_matomo ) ) {
831 $this->logger->log( 'Matomo is now creating a user for user id ' . $user_id . ' with matomo login ' . $matomo_user_login );
832
833 $now = Date::now()->getDatetime();
834 $password = new Password();
835 // we generate some random password since log in using matomo won't be happening anyway
836 $password = $password->hash( $login . $now . Common::getRandomString( 200 ) . microtime( true ) . Common::generateUniqId() );
837
838 $user_model->addUser( $matomo_user_login, $password, $wp_user->user_email, $now );
839
840 User::map_matomo_user_login( $user_id, $matomo_user_login );
841 } elseif ( $user_in_matomo['email'] !== $wp_user->user_email ) {
842 $this->logger->log( 'Matomo is now updating the email for wpUserID ' . $user_id . ' matomo login ' . $matomo_user_login );
843 $user_model->updateUserFields( $matomo_user_login, [ 'email' => $wp_user->user_email ] );
844 }
845
846 return $matomo_user_login;
847 }
848
849 /**
850 * @param Model $user_model
851 * @param string $candidate_login
852 * @param int $wp_user_id
853 * @return bool
854 */
855 private function is_matomo_login_taken( $user_model, $candidate_login, $wp_user_id ) {
856 if ( $user_model->getUser( $candidate_login ) ) {
857 return true; // matomo user exists
858 }
859
860 // sanity check: matomo user does not exist, but another WP user is somehow mapped to
861 // this login
862 return $this->is_matomo_login_owned_by_other_wp_user( $candidate_login, $wp_user_id );
863 }
864
865 /**
866 * @param string $matomo_user_login
867 * @param int $wp_user_id
868 *
869 * @return bool
870 */
871 private function is_matomo_login_owned_by_other_wp_user( $matomo_user_login, $wp_user_id ) {
872 $wp_user_ids_mapped_to_matomo_login = $this->user->get_wp_user_ids_for_matomo_login( $matomo_user_login );
873
874 if ( empty( $wp_user_ids_mapped_to_matomo_login ) ) {
875 return false; // no mapping exists, matomo login not owned by anyone
876 }
877
878 if ( count( $wp_user_ids_mapped_to_matomo_login ) > 1 ) {
879 return true; // more than one user mapped to login, not owned solely by this user
880 }
881
882 // the login is owned by another WP user if the single mapped user is not the requested user
883 return (int) reset( $wp_user_ids_mapped_to_matomo_login ) !== (int) $wp_user_id;
884 }
885
886 public static function get_matomo_lang_from_locale( $locale ) {
887 $locale_dash = Common::mb_strtolower( str_replace( '_', '-', $locale ) );
888 $parts = [];
889 if ( $locale && in_array( $locale_dash, [ 'zh-cn', 'zh-tw', 'pt-br', 'es-ar' ], true ) ) {
890 $parts = [ $locale_dash ];
891 } elseif ( ! empty( $locale ) && is_string( $locale ) ) {
892 $parts = explode( '_', $locale );
893 }
894 return ! empty( $parts[0] ) ? $parts[0] : null;
895 }
896
897 private function is_sync_allowed_for_request() {
898 return ! Request::is_frontend();
899 }
900 }
901