PluginProbe ʕ •ᴥ•ʔ
Matomo Analytics – Powerful, Privacy-First Insights for WordPress / 5.8.2
Matomo Analytics – Powerful, Privacy-First Insights for WordPress v5.8.2
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 2 months ago
Sync.php
394 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 public function __construct() {
51 $this->logger = new Logger();
52 }
53
54 public function is_active() {
55 return is_admin();
56 }
57
58 public function register_hooks() {
59 add_action( 'add_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
60 add_action( 'remove_user_role', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
61 add_action( 'add_user_to_blog', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
62 add_action( 'remove_user_from_blog', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
63 add_action( 'user_register', [ $this, 'sync_current_users_1000' ], $prio = 10, $args = 0 );
64 add_action( 'update_option_WPLANG', [ $this, 'on_site_language_change' ], $prio = 10, $args = 0 );
65 add_action( 'profile_update', [ $this, 'sync_maybe_background' ], $prio = 10, $args = 0 );
66 }
67
68 public function sync_maybe_background() {
69 global $pagenow;
70 if ( is_admin() && 'users.php' === $pagenow ) {
71 // eg for profile update we don't want to sync directly see #365 as it could cause issues with other plugins
72 // if they eg alter `get_users` option
73 wp_schedule_single_event( time() + 5, ScheduledTasks::EVENT_SYNC );
74 } else {
75 $this->sync_current_users_1000();
76 }
77 }
78
79 public function on_site_language_change() {
80 unset( $GLOBALS['locale'] ); // same thing that's done after saving in options.php
81
82 $this->sync_current_users_1000();
83 }
84
85 public function sync_all() {
86 if ( function_exists( 'is_multisite' ) && is_multisite() ) {
87 foreach ( get_sites() as $site ) {
88 if ( 1 === (int) $site->deleted ) {
89 continue;
90 }
91
92 switch_to_blog( $site->blog_id );
93
94 $idsite = Site::get_matomo_site_id( $site->blog_id );
95
96 try {
97 if ( $idsite ) {
98 $users = $this->get_users( [ 'blog_id' => $site->blog_id ] );
99 $this->sync_users( $users, $idsite );
100 }
101 } catch ( Exception $e ) {
102 // we don't want to rethrow exception otherwise some other blogs might never sync
103 $this->logger->log_exception( 'user_sync ', $e );
104 }
105
106 restore_current_blog();
107 }
108 } else {
109 $this->sync_current_users();
110 }
111 }
112
113 private function get_users( $options = [] ) {
114 /** @var WP_User[] $users */
115 $users = get_users( $options );
116
117 $current_user = wp_get_current_user();
118 if ( ! empty( $current_user ) && ! empty( $current_user->user_login ) ) {
119 // refs https://github.com/matomo-org/matomo-for-wordpress/issues/365
120 // some other plugins may under circumstances overwrite the get_users query and not return all users
121 // as a result we would delete some users in the matomo users table. this way we make sure at least the current
122 // user will be added and not deleted even if the list of users is not complete
123 $found = false;
124 foreach ( $users as $user ) {
125 if ( $user->user_login === $current_user->user_login ) {
126 $found = true;
127 break;
128 }
129 }
130 if ( ! $found ) {
131 $users[] = $current_user;
132 }
133 }
134
135 if ( is_multisite() ) {
136 $super_admins = get_super_admins();
137 if ( ! empty( $super_admins ) ) {
138 foreach ( $super_admins as $super_admin ) {
139 $found = false;
140 foreach ( $users as $user ) {
141 if ( $user->user_login === $super_admin ) {
142 $found = true;
143 break;
144 }
145 }
146 if ( ! $found ) {
147 $user = get_user_by( 'login', $super_admin );
148 if ( ! empty( $user ) ) {
149 $users[] = $user;
150 }
151 }
152 }
153 }
154 }
155
156 return $users;
157 }
158
159 public function sync_current_users() {
160 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
161 if ( $idsite ) {
162 $users = $this->get_users();
163 $this->sync_users( $users, $idsite );
164 }
165 }
166
167 /**
168 * similar method to sync_current_users which synchronise on the fly only if we have less than 1000 users.
169 * Otherwise it will be done by a background task
170 *
171 * @return void
172 * @see https://github.com/matomo-org/matomo-for-wordpress/issues/460
173 * @see Sync::sync_current_users()
174 */
175 public function sync_current_users_1000() {
176 if ( ! is_plugin_active( 'matomo/matomo.php' ) ) {
177 // @see https://github.com/matomo-org/matomo-for-wordpress/issues/577
178 return;
179 }
180 $idsite = Site::get_matomo_site_id( get_current_blog_id() );
181 if ( $idsite ) {
182 $num_users = count_users();
183 $num_users = $num_users['total_users'];
184 if ( $num_users < 1000 ) {
185 $users = $this->get_users();
186 $this->sync_users( $users, $idsite );
187 }
188 }
189 }
190
191 /**
192 * Sync all users. Make sure to always pass all sites that exist within a given site... you cannot just sync an individual
193 * user... we would delete all other users
194 *
195 * @param WP_User[] $users
196 * @param int|string $idsite
197 */
198 protected function sync_users( $users, $idsite ) {
199 Bootstrap::do_bootstrap();
200
201 $this->logger->log( 'Matomo will now sync ' . count( $users ) . ' users' );
202
203 $super_users = [];
204 $logins_with_some_view_access = [ 'anonmyous' ]; // may or may not exist... we don't want to delete this user though
205 $user_model = new Model();
206
207 // need to make sure we recreate new instance later with latest dependencies in case they changed
208 API::unsetInstance();
209
210 foreach ( $users as $user ) {
211 $user_id = $user->ID;
212
213 // todo if we used transactions we could commit it after a possibly new access has been added
214 // to prevent UI preventing randomly saying no access between deleting and adding access
215
216 $mapped_matomo_login = User::get_matomo_user_login( $user_id );
217
218 $matomo_login = null;
219
220 if ( user_can( $user, Capabilities::KEY_SUPERUSER ) ) {
221 $matomo_login = $this->ensure_user_exists( $user );
222 $super_users[ $matomo_login ] = $user;
223 $logins_with_some_view_access[] = $matomo_login;
224 } elseif ( user_can( $user, Capabilities::KEY_ADMIN ) ) {
225 $matomo_login = $this->ensure_user_exists( $user );
226 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
227 $user_model->addUserAccess( $matomo_login, Admin::ID, [ $idsite ] );
228 $user_model->setSuperUserAccess( $matomo_login, false );
229 $logins_with_some_view_access[] = $matomo_login;
230 } elseif ( user_can( $user, Capabilities::KEY_WRITE ) ) {
231 $matomo_login = $this->ensure_user_exists( $user );
232 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
233 $user_model->addUserAccess( $matomo_login, Write::ID, [ $idsite ] );
234 $user_model->setSuperUserAccess( $matomo_login, false );
235 $logins_with_some_view_access[] = $matomo_login;
236 } elseif ( user_can( $user, Capabilities::KEY_VIEW ) ) {
237 $matomo_login = $this->ensure_user_exists( $user );
238 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
239 $user_model->addUserAccess( $matomo_login, View::ID, [ $idsite ] );
240 $user_model->setSuperUserAccess( $matomo_login, false );
241 $logins_with_some_view_access[] = $matomo_login;
242 } elseif ( $mapped_matomo_login ) {
243 $user_model->deleteUserAccess( $mapped_matomo_login, [ $idsite ] );
244 }
245
246 if ( $matomo_login ) {
247 $locale = get_user_locale( $user->ID );
248 $lang = self::get_matomo_lang_from_locale( $locale );
249 if (
250 ! empty( $lang )
251 && Plugin\Manager::getInstance()->isPluginActivated( 'LanguagesManager' )
252 && Plugin\Manager::getInstance()->isPluginInstalled( 'LanguagesManager' )
253 && API::getInstance()->isLanguageAvailable( $lang )
254 ) {
255 $user_lang_model = new \Piwik\Plugins\LanguagesManager\Model();
256 $user_lang_model->setLanguageForUser( $matomo_login, $lang );
257 }
258 }
259 // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
260 if ( 1 != $idsite ) {
261 // only needed if the actual site is not the default site... makes sure when they click in Matomo
262 // UI on "Dashboard" that the correct site is being opened by default
263 // eg if the linked site is actually idSite=2.
264 Access::doAsSuperUser(
265 function () use ( $matomo_login, &$idsite ) {
266 try {
267 UsersManager\API::unsetInstance();
268 // we need to unset the instance to make sure it fetches the
269 // up to date dependencies eg current plugin manager etc
270
271 UsersManager\API::getInstance()->setUserPreference(
272 $matomo_login,
273 UsersManager\API::PREFERENCE_DEFAULT_REPORT,
274 $idsite
275 );
276 //phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
277 } catch ( Exception $e ) {
278 // ignore any error for now
279 }
280 }
281 );
282 }
283 }
284
285 foreach ( $super_users as $matomo_login => $user ) {
286 $user_model->setSuperUserAccess( $matomo_login, true );
287 }
288
289 $logins_with_some_view_access = array_unique( $logins_with_some_view_access );
290 $all_users = $user_model->getUsers( [] );
291 foreach ( $all_users as $all_user ) {
292 if ( ! in_array( $all_user['login'], $logins_with_some_view_access, true )
293 && ! empty( $all_user['login'] ) ) {
294 Access::doAsSuperUser(
295 function () use ( $user_model, $all_user ) {
296 $user_model->deleteUserOnly( $all_user['login'] );
297 $user_model->deleteUserOptions( $all_user['login'] );
298 $user_model->deleteUserAccess( $all_user['login'] );
299 }
300 );
301 }
302 }
303 }
304
305 /**
306 * @param WP_User $wp_user
307 */
308 protected function ensure_user_exists( $wp_user ) {
309 $user_model = new Model();
310 $user_id = $wp_user->ID;
311 $login = $wp_user->user_login;
312
313 $matomo_user_login = User::get_matomo_user_login( $user_id );
314 $user_in_matomo = null;
315
316 if ( $matomo_user_login ) {
317 $user_in_matomo = $user_model->getUser( $matomo_user_login );
318 } else {
319 $user_by_email = $user_model->getUserByEmail( $wp_user->user_email );
320
321 // the user was deleted without matomo being notified. delete user so we can recreate it
322 // below.
323 //
324 // note: it's also possible there are multiple users with the same email address,
325 // but this is currently unsupported in matomo so we don't take that into consideration.
326 if ( $user_by_email ) {
327 $this->logger->log_exception(
328 'user_sync',
329 new \Exception(
330 'Syncing user with email identical to a user already synced in Matomo. ' .
331 'This means there are multiple WP users with the same email, which Matomo ' .
332 'does not support, or something has deleted the WP option mapping WP user ' .
333 'to Matomo user. Assuming this is a new user to sync and deleting existing user ' .
334 'preferences and options.'
335 )
336 );
337
338 $user_model->deleteUser( $user_by_email['login'] );
339 }
340
341 // wp usernames may include whitespace etc
342 $login = preg_replace( '/[^A-Za-zÄäÖöÜüß0-9_.@+-]+/D', '_', $login );
343 $login = substr( $login, 0, self::MAX_USER_NAME_LENGTH );
344
345 if ( ! $user_model->getUser( $login ) ) {
346 // username is available...
347 $matomo_user_login = $login;
348 } else {
349 // this username seems taken... lets create another one
350
351 $index = 0;
352 do {
353 if ( ! $index ) {
354 $matomo_user_login = 'wp_' . $login;
355 } else {
356 $matomo_user_login = 'wp_' . $login . $index;
357 }
358
359 $index ++;
360 } while ( $user_model->getUser( $matomo_user_login ) );
361 }
362 }
363
364 if ( ! $matomo_user_login || empty( $user_in_matomo ) ) {
365 $this->logger->log( 'Matomo is now creating a user for user id ' . $user_id . ' with matomo login ' . $matomo_user_login );
366
367 $now = Date::now()->getDatetime();
368 $password = new Password();
369 // we generate some random password since log in using matomo won't be happening anyway
370 $password = $password->hash( $login . $now . Common::getRandomString( 200 ) . microtime( true ) . Common::generateUniqId() );
371
372 $user_model->addUser( $matomo_user_login, $password, $wp_user->user_email, $now );
373
374 User::map_matomo_user_login( $user_id, $matomo_user_login );
375 } elseif ( $user_in_matomo['email'] !== $wp_user->user_email ) {
376 $this->logger->log( 'Matomo is now updating the email for wpUserID ' . $user_id . ' matomo login ' . $matomo_user_login );
377 $user_model->updateUserFields( $matomo_user_login, [ 'email' => $wp_user->user_email ] );
378 }
379
380 return $matomo_user_login;
381 }
382
383 public static function get_matomo_lang_from_locale( $locale ) {
384 $locale_dash = Common::mb_strtolower( str_replace( '_', '-', $locale ) );
385 $parts = [];
386 if ( $locale && in_array( $locale_dash, [ 'zh-cn', 'zh-tw', 'pt-br', 'es-ar' ], true ) ) {
387 $parts = [ $locale_dash ];
388 } elseif ( ! empty( $locale ) && is_string( $locale ) ) {
389 $parts = explode( '_', $locale );
390 }
391 return ! empty( $parts[0] ) ? $parts[0] : null;
392 }
393 }
394