Merge pull request #20214 from ixiam/dev#issue_2584
[civicrm-core.git] / CRM / Utils / System / WordPress.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 /**
19 * WordPress specific stuff goes here
20 */
21 class CRM_Utils_System_WordPress extends CRM_Utils_System_Base {
22
23 /**
24 * Get a normalized version of the wpBasePage.
25 */
26 public static function getBasePage() {
27 return strtolower(rtrim(Civi::settings()->get('wpBasePage'), '/'));
28 }
29
30 /**
31 */
32 public function __construct() {
33 /**
34 * deprecated property to check if this is a drupal install. The correct method is to have functions on the UF classes for all UF specific
35 * functions and leave the codebase oblivious to the type of CMS
36 * @deprecated
37 * @var bool
38 */
39 $this->is_drupal = FALSE;
40 $this->is_wordpress = TRUE;
41 }
42
43 public function initialize() {
44 parent::initialize();
45 $this->registerPathVars();
46 }
47
48 /**
49 * Specify the default computation for various paths/URLs.
50 */
51 protected function registerPathVars():void {
52 $isNormalBoot = function_exists('get_option');
53 if ($isNormalBoot) {
54 // Normal mode - CMS boots first, then calls Civi. "Normal" web pages and newer extern routes.
55 // To simplify the code-paths, some items are re-registered with WP-specific functions.
56 $cmsRoot = function() {
57 return [
58 'path' => untrailingslashit(ABSPATH),
59 'url' => home_url(),
60 ];
61 };
62 Civi::paths()->register('cms', $cmsRoot);
63 Civi::paths()->register('cms.root', $cmsRoot);
64 Civi::paths()->register('civicrm.root', function () {
65 return [
66 'path' => CIVICRM_PLUGIN_DIR . 'civicrm' . DIRECTORY_SEPARATOR,
67 'url' => CIVICRM_PLUGIN_URL . 'civicrm/',
68 ];
69 });
70 Civi::paths()->register('wp.frontend.base', function () {
71 return [
72 'url' => home_url('/'),
73 ];
74 });
75 Civi::paths()->register('wp.frontend', function () {
76 $config = CRM_Core_Config::singleton();
77 $basepage = get_page_by_path($config->wpBasePage);
78 return [
79 'url' => get_permalink($basepage->ID),
80 ];
81 });
82 Civi::paths()->register('wp.backend.base', function () {
83 return [
84 'url' => admin_url(),
85 ];
86 });
87 Civi::paths()->register('wp.backend', function() {
88 return [
89 'url' => admin_url('admin.php'),
90 ];
91 });
92 Civi::paths()->register('civicrm.files', function () {
93 $upload_dir = wp_get_upload_dir();
94
95 $old = CRM_Core_Config::singleton()->userSystem->getDefaultFileStorage();
96 $new = [
97 'path' => $upload_dir['basedir'] . DIRECTORY_SEPARATOR . 'civicrm' . DIRECTORY_SEPARATOR,
98 'url' => $upload_dir['baseurl'] . '/civicrm/',
99 ];
100
101 if ($old['path'] === $new['path']) {
102 return $new;
103 }
104
105 $oldExists = file_exists($old['path']);
106 $newExists = file_exists($new['path']);
107
108 if ($oldExists && !$newExists) {
109 return $old;
110 }
111 elseif (!$oldExists && $newExists) {
112 return $new;
113 }
114 elseif (!$oldExists && !$newExists) {
115 // neither exists. but that's ok. we're in one of these two cases:
116 // - we're just starting installation... which will get sorted in a moment
117 // when someone calls mkdir().
118 // - we're running a bespoke setup... which will get sorted in a moment
119 // by applying $civicrm_paths.
120 return $new;
121 }
122 elseif ($oldExists && $newExists) {
123 // situation ambiguous. encourage admin to set value explicitly.
124 if (!isset($GLOBALS['civicrm_paths']['civicrm.files'])) {
125 \Civi::log()->warning("The system has data from both old+new conventions. Please use civicrm.settings.php to set civicrm.files explicitly.");
126 }
127 return $new;
128 }
129 });
130 }
131 else {
132 // Legacy support - only relevant for older extern routes.
133 Civi::paths()
134 ->register('wp.frontend.base', function () {
135 return ['url' => rtrim(CIVICRM_UF_BASEURL, '/') . '/'];
136 })
137 ->register('wp.frontend', function () {
138 $config = \CRM_Core_Config::singleton();
139 $suffix = defined('CIVICRM_UF_WP_BASEPAGE') ? CIVICRM_UF_WP_BASEPAGE : $config->wpBasePage;
140 return [
141 'url' => Civi::paths()->getVariable('wp.frontend.base', 'url') . $suffix,
142 ];
143 })
144 ->register('wp.backend.base', function () {
145 return ['url' => rtrim(CIVICRM_UF_BASEURL, '/') . '/wp-admin/'];
146 })
147 ->register('wp.backend', function () {
148 return [
149 'url' => Civi::paths()->getVariable('wp.backend.base', 'url') . 'admin.php',
150 ];
151 });
152 }
153 }
154
155 /**
156 * @inheritDoc
157 */
158 public function setTitle($title, $pageTitle = NULL) {
159 if (!$pageTitle) {
160 $pageTitle = $title;
161 }
162
163 // FIXME: Why is this global?
164 global $civicrm_wp_title;
165 $civicrm_wp_title = $title;
166
167 // yes, set page title, depending on context
168 $context = civi_wp()->civicrm_context_get();
169 switch ($context) {
170 case 'admin':
171 case 'shortcode':
172 $template = CRM_Core_Smarty::singleton();
173 $template->assign('pageTitle', $pageTitle);
174 }
175 }
176
177 /**
178 * Moved from CRM_Utils_System_Base
179 */
180 public function getDefaultFileStorage() {
181 // NOTE: On WordPress, this will be circumvented in the future. However,
182 // should retain it to allow transitional/upgrade code determine the old value.
183
184 $config = CRM_Core_Config::singleton();
185 $cmsUrl = CRM_Utils_System::languageNegotiationURL($config->userFrameworkBaseURL, FALSE, TRUE);
186 $cmsPath = $this->cmsRootPath();
187 $filesPath = CRM_Utils_File::baseFilePath();
188 $filesRelPath = CRM_Utils_File::relativize($filesPath, $cmsPath);
189 $filesURL = rtrim($cmsUrl, '/') . '/' . ltrim($filesRelPath, ' /');
190 return [
191 'url' => CRM_Utils_File::addTrailingSlash($filesURL, '/'),
192 'path' => CRM_Utils_File::addTrailingSlash($filesPath),
193 ];
194 }
195
196 /**
197 * Determine the location of the CiviCRM source tree.
198 *
199 * @return array
200 * - url: string. ex: "http://example.com/sites/all/modules/civicrm"
201 * - path: string. ex: "/var/www/sites/all/modules/civicrm"
202 */
203 public function getCiviSourceStorage() {
204 global $civicrm_root;
205
206 // Don't use $config->userFrameworkBaseURL; it has garbage on it.
207 // More generally, we shouldn't be using $config here.
208 if (!defined('CIVICRM_UF_BASEURL')) {
209 throw new RuntimeException('Undefined constant: CIVICRM_UF_BASEURL');
210 }
211
212 $cmsPath = $this->cmsRootPath();
213
214 // $config = CRM_Core_Config::singleton();
215 // overkill? // $cmsUrl = CRM_Utils_System::languageNegotiationURL($config->userFrameworkBaseURL, FALSE, TRUE);
216 $cmsUrl = CIVICRM_UF_BASEURL;
217 if (CRM_Utils_System::isSSL()) {
218 $cmsUrl = str_replace('http://', 'https://', $cmsUrl);
219 }
220 $civiRelPath = CRM_Utils_File::relativize(realpath($civicrm_root), realpath($cmsPath));
221 $civiUrl = rtrim($cmsUrl, '/') . '/' . ltrim($civiRelPath, ' /');
222 return [
223 'url' => CRM_Utils_File::addTrailingSlash($civiUrl, '/'),
224 'path' => CRM_Utils_File::addTrailingSlash($civicrm_root),
225 ];
226 }
227
228 /**
229 * @inheritDoc
230 */
231 public function appendBreadCrumb($breadCrumbs) {
232 $breadCrumb = wp_get_breadcrumb();
233
234 if (is_array($breadCrumbs)) {
235 foreach ($breadCrumbs as $crumbs) {
236 if (stripos($crumbs['url'], 'id%%')) {
237 $args = ['cid', 'mid'];
238 foreach ($args as $a) {
239 $val = CRM_Utils_Request::retrieve($a, 'Positive', CRM_Core_DAO::$_nullObject,
240 FALSE, NULL, $_GET
241 );
242 if ($val) {
243 $crumbs['url'] = str_ireplace("%%{$a}%%", $val, $crumbs['url']);
244 }
245 }
246 }
247 $breadCrumb[] = "<a href=\"{$crumbs['url']}\">{$crumbs['title']}</a>";
248 }
249 }
250
251 $template = CRM_Core_Smarty::singleton();
252 $template->assign_by_ref('breadcrumb', $breadCrumb);
253 wp_set_breadcrumb($breadCrumb);
254 }
255
256 /**
257 * @inheritDoc
258 */
259 public function resetBreadCrumb() {
260 $bc = [];
261 wp_set_breadcrumb($bc);
262 }
263
264 /**
265 * @inheritDoc
266 */
267 public function addHTMLHead($head) {
268 static $registered = FALSE;
269 if (!$registered) {
270 // front-end view
271 add_action('wp_head', [__CLASS__, '_showHTMLHead']);
272 // back-end views
273 add_action('admin_head', [__CLASS__, '_showHTMLHead']);
274 $registered = TRUE;
275 }
276 CRM_Core_Region::instance('wp_head')->add([
277 'markup' => $head,
278 ]);
279 }
280
281 /**
282 * WP action callback.
283 */
284 public static function _showHTMLHead() {
285 $region = CRM_Core_Region::instance('wp_head', FALSE);
286 if ($region) {
287 echo $region->render('');
288 }
289 }
290
291 /**
292 * @inheritDoc
293 */
294 public function mapConfigToSSL() {
295 global $base_url;
296 $base_url = str_replace('http://', 'https://', $base_url);
297 }
298
299 /**
300 * @inheritDoc
301 */
302 public function url(
303 $path = NULL,
304 $query = NULL,
305 $absolute = FALSE,
306 $fragment = NULL,
307 $frontend = FALSE,
308 $forceBackend = FALSE,
309 $htmlize = TRUE
310 ) {
311 $config = CRM_Core_Config::singleton();
312 $script = '';
313 $separator = '&';
314 $fragment = isset($fragment) ? ('#' . $fragment) : '';
315 $path = CRM_Utils_String::stripPathChars($path);
316 $basepage = FALSE;
317
318 // FIXME: Why bootstrap in url()?
319 // Generally want to define 1-2 strategic places to put bootstrap.
320 if (!function_exists('get_option')) {
321 $this->loadBootStrap();
322 }
323
324 // When on the front-end.
325 if ($config->userFrameworkFrontend) {
326
327 // Try and find the "calling" page/post.
328 global $post;
329 if ($post) {
330 $script = get_permalink($post->ID);
331 if ($config->wpBasePage == $post->post_name) {
332 $basepage = TRUE;
333 }
334 }
335
336 }
337 else {
338
339 // Get the Base Page URL for building front-end URLs.
340 if ($frontend && !$forceBackend) {
341 $script = $this->getBasePageUrl();
342 $basepage = TRUE;
343 }
344
345 }
346
347 // Get either the relative Base Page URL or the relative Admin Page URL.
348 $base = $this->getBaseUrl($absolute, $frontend, $forceBackend);
349
350 // Overwrite base URL if we already have a front-end URL.
351 if (!$forceBackend && $script != '') {
352 $base = $script;
353 }
354
355 $queryParts = [];
356 $admin_request = ((is_admin() && !$frontend) || $forceBackend);
357
358 if (
359 // If not using Clean URLs.
360 !$config->cleanURL
361 // Or requesting an admin URL.
362 || $admin_request
363 // Or this is a Shortcode.
364 || (!$basepage && $script != '')
365 ) {
366
367 // Build URL according to pre-existing logic.
368 if (!empty($path)) {
369 // Admin URLs still need "page=CiviCRM", front-end URLs do not.
370 if ($admin_request) {
371 $queryParts[] = 'page=CiviCRM';
372 }
373 else {
374 $queryParts[] = 'civiwp=CiviCRM';
375 }
376 $queryParts[] = 'q=' . rawurlencode($path);
377 }
378 if (!empty($query)) {
379 $queryParts[] = $query;
380 }
381
382 // Append our query parts, taking Permlink Structure into account.
383 if (get_option('permalink_structure') == '' && !$admin_request) {
384 $final = $base . $separator . implode($separator, $queryParts) . $fragment;
385 }
386 else {
387 $final = $base . '?' . implode($separator, $queryParts) . $fragment;
388 }
389
390 }
391 else {
392
393 // Build Clean URL.
394 if (!empty($path)) {
395 $base = trailingslashit($base) . str_replace('civicrm/', '', $path) . '/';
396 }
397 if (!empty($query)) {
398 $query = ltrim($query, '=?&');
399 $queryParts[] = $query;
400 }
401
402 if (!empty($queryParts)) {
403 $final = $base . '?' . implode($separator, $queryParts) . $fragment;
404 }
405 else {
406 $final = $base . $fragment;
407 }
408
409 }
410
411 return $final;
412 }
413
414 /**
415 * Get either the relative Base Page URL or the relative Admin Page URL.
416 *
417 * @param bool $absolute
418 * Whether to force the output to be an absolute link beginning with http(s).
419 * @param bool $frontend
420 * True if this link should be to the CMS front end.
421 * @param bool $forceBackend
422 * True if this link should be to the CMS back end.
423 *
424 * @return mixed|null|string
425 */
426 public function getBaseUrl($absolute, $frontend, $forceBackend) {
427 $config = CRM_Core_Config::singleton();
428 if ((is_admin() && !$frontend) || $forceBackend) {
429 return Civi::paths()->getUrl('[wp.backend]/.', $absolute ? 'absolute' : 'relative');
430 }
431 else {
432 return Civi::paths()->getUrl('[wp.frontend]/.', $absolute ? 'absolute' : 'relative');
433 }
434 }
435
436 /**
437 * Get the URL of the WordPress Base Page.
438 *
439 * @return string|bool
440 * The Base Page URL, or false on failure.
441 */
442 public function getBasePageUrl() {
443 static $basepage_url = '';
444 if ($basepage_url === '') {
445
446 // Get the Base Page config setting.
447 $config = CRM_Core_Config::singleton();
448 $basepage_slug = $config->wpBasePage;
449
450 // Did we get a value?
451 if (!empty($basepage_slug)) {
452
453 // Query for our Base Page.
454 $pages = get_posts([
455 'post_type' => 'page',
456 'name' => strtolower($basepage_slug),
457 'post_status' => 'publish',
458 'posts_per_page' => 1,
459 ]);
460
461 // Find the Base Page object and set the URL.
462 if (!empty($pages) && is_array($pages)) {
463 $basepage = array_pop($pages);
464 if ($basepage instanceof WP_Post) {
465 $basepage_url = get_permalink($basepage->ID);
466 }
467 }
468
469 }
470
471 }
472
473 return $basepage_url;
474 }
475
476 /**
477 * @inheritDoc
478 */
479 public function getNotifyUrl(
480 $path = NULL,
481 $query = NULL,
482 $absolute = FALSE,
483 $fragment = NULL,
484 $frontend = FALSE,
485 $forceBackend = FALSE,
486 $htmlize = TRUE
487 ) {
488 $config = CRM_Core_Config::singleton();
489 $separator = '&';
490 $fragment = isset($fragment) ? ('#' . $fragment) : '';
491 $path = CRM_Utils_String::stripPathChars($path);
492 $queryParts = [];
493
494 // Get the Base Page URL.
495 $base = $this->getBasePageUrl();
496
497 // If not using Clean URLs.
498 if (!$config->cleanURL) {
499
500 // Build URL according to pre-existing logic.
501 if (!empty($path)) {
502 $queryParts[] = 'civiwp=CiviCRM';
503 $queryParts[] = 'q=' . rawurlencode($path);
504 }
505 if (!empty($query)) {
506 $queryParts[] = $query;
507 }
508
509 // Append our query parts, taking Permlink Structure into account.
510 if (get_option('permalink_structure') == '') {
511 $final = $base . $separator . implode($separator, $queryParts) . $fragment;
512 }
513 else {
514 $final = $base . '?' . implode($separator, $queryParts) . $fragment;
515 }
516
517 }
518 else {
519
520 // Build Clean URL.
521 if (!empty($path)) {
522 $base = trailingslashit($base) . str_replace('civicrm/', '', $path) . '/';
523 }
524 if (!empty($query)) {
525 $query = ltrim($query, '=?&');
526 $queryParts[] = $query;
527 }
528
529 if (!empty($queryParts)) {
530 $final = $base . '?' . implode($separator, $queryParts) . $fragment;
531 }
532 else {
533 $final = $base . $fragment;
534 }
535
536 }
537
538 return $final;
539 }
540
541 /**
542 * @inheritDoc
543 */
544 public function authenticate($name, $password, $loadCMSBootstrap = FALSE, $realPath = NULL) {
545 $config = CRM_Core_Config::singleton();
546
547 if ($loadCMSBootstrap) {
548 $config->userSystem->loadBootStrap([
549 'name' => $name,
550 'pass' => $password,
551 ]);
552 }
553
554 $user = wp_authenticate($name, $password);
555 if (is_a($user, 'WP_Error')) {
556 return FALSE;
557 }
558
559 // TODO: need to change this to make sure we matched only one row
560
561 CRM_Core_BAO_UFMatch::synchronizeUFMatch($user->data, $user->data->ID, $user->data->user_email, 'WordPress');
562 $contactID = CRM_Core_BAO_UFMatch::getContactId($user->data->ID);
563 if (!$contactID) {
564 return FALSE;
565 }
566 return [$contactID, $user->data->ID, mt_rand()];
567 }
568
569 /**
570 * FIXME: Do something
571 *
572 * @param string $message
573 */
574 public function setMessage($message) {
575 }
576
577 /**
578 * @param \string $user
579 *
580 * @return bool
581 */
582 public function loadUser($user) {
583 $userdata = get_user_by('login', $user);
584 if (!$userdata->data->ID) {
585 return FALSE;
586 }
587
588 $uid = $userdata->data->ID;
589 wp_set_current_user($uid);
590 $contactID = CRM_Core_BAO_UFMatch::getContactId($uid);
591
592 // lets store contact id and user id in session
593 $session = CRM_Core_Session::singleton();
594 $session->set('ufID', $uid);
595 $session->set('userID', $contactID);
596 return TRUE;
597 }
598
599 /**
600 * FIXME: Use CMS-native approach
601 * @throws \CRM_Core_Exception
602 */
603 public function permissionDenied() {
604 status_header(403);
605 throw new CRM_Core_Exception(ts('You do not have permission to access this page.'));
606 }
607
608 /**
609 * Determine the native ID of the CMS user.
610 *
611 * @param string $username
612 *
613 * @return int|null
614 */
615 public function getUfId($username) {
616 $userdata = get_user_by('login', $username);
617 if (!$userdata->data->ID) {
618 return NULL;
619 }
620 return $userdata->data->ID;
621 }
622
623 /**
624 * @inheritDoc
625 */
626 public function logout() {
627 // destroy session
628 if (session_id()) {
629 session_destroy();
630 }
631 wp_logout();
632 wp_redirect(wp_login_url());
633 }
634
635 /**
636 * @inheritDoc
637 */
638 public function getUFLocale() {
639 // Bail early if method is called when WordPress isn't bootstrapped.
640 // Additionally, the function checked here is located in pluggable.php
641 // and is required by wp_get_referer() - so this also bails early if it is
642 // called too early in the request lifecycle.
643 // @see https://core.trac.wordpress.org/ticket/25294
644 if (!function_exists('wp_validate_redirect')) {
645 return NULL;
646 }
647
648 // Default to WordPress User locale.
649 $locale = get_user_locale();
650
651 // Is this a "back-end" AJAX call?
652 $is_backend = FALSE;
653 if (wp_doing_ajax() && FALSE !== strpos(wp_get_referer(), admin_url())) {
654 $is_backend = TRUE;
655 }
656
657 // Ignore when in WordPress admin or it's a "back-end" AJAX call.
658 if (!(is_admin() || $is_backend)) {
659
660 // Reaching here means it is very likely to be a front-end context.
661
662 // Default to WordPress locale.
663 $locale = get_locale();
664
665 // Maybe override with the locale that Polylang reports.
666 if (function_exists('pll_current_language')) {
667 $pll_locale = pll_current_language('locale');
668 if (!empty($pll_locale)) {
669 $locale = $pll_locale;
670 }
671 }
672
673 // Maybe override with the locale that WPML reports.
674 elseif (defined('ICL_LANGUAGE_CODE')) {
675 $languages = apply_filters('wpml_active_languages', NULL);
676 foreach ($languages as $language) {
677 if ($language['active']) {
678 $locale = $language['default_locale'];
679 break;
680 }
681 }
682 }
683
684 // TODO: Set locale for other WordPress plugins.
685 // @see https://wordpress.org/plugins/tags/multilingual/
686 // A hook would be nice here.
687
688 }
689
690 if (!empty($locale)) {
691 // If for some reason only we get a language code, convert it to a locale.
692 if (2 === strlen($locale)) {
693 $locale = CRM_Core_I18n_PseudoConstant::longForShort($locale);
694 }
695 return $locale;
696 }
697 else {
698 return NULL;
699 }
700 }
701
702 /**
703 * @inheritDoc
704 */
705 public function setUFLocale($civicrm_language) {
706 // TODO (probably not possible with WPML?)
707 return TRUE;
708 }
709
710 /**
711 * Load wordpress bootstrap.
712 *
713 * @param array $params
714 * Optional credentials
715 * - name: string, cms username
716 * - pass: string, cms password
717 * @param bool $loadUser
718 * @param bool $throwError
719 * @param mixed $realPath
720 *
721 * @return bool
722 * @throws \CRM_Core_Exception
723 */
724 public function loadBootStrap($params = [], $loadUser = TRUE, $throwError = TRUE, $realPath = NULL) {
725 global $wp, $wp_rewrite, $wp_the_query, $wp_query, $wpdb, $current_site, $current_blog, $current_user;
726
727 $name = $params['name'] ?? NULL;
728 $pass = $params['pass'] ?? NULL;
729
730 if (!defined('WP_USE_THEMES')) {
731 define('WP_USE_THEMES', FALSE);
732 }
733
734 $cmsRootPath = $this->cmsRootPath();
735 if (!$cmsRootPath) {
736 throw new CRM_Core_Exception("Could not find the install directory for WordPress");
737 }
738 $path = Civi::settings()->get('wpLoadPhp');
739 if (!empty($path)) {
740 require_once $path;
741 }
742 elseif (file_exists($cmsRootPath . DIRECTORY_SEPARATOR . 'wp-load.php')) {
743 require_once $cmsRootPath . DIRECTORY_SEPARATOR . 'wp-load.php';
744 }
745 else {
746 throw new CRM_Core_Exception("Could not find the bootstrap file for WordPress");
747 }
748 $wpUserTimezone = get_option('timezone_string');
749 if ($wpUserTimezone) {
750 date_default_timezone_set($wpUserTimezone);
751 CRM_Core_Config::singleton()->userSystem->setMySQLTimeZone();
752 }
753 require_once $cmsRootPath . DIRECTORY_SEPARATOR . 'wp-includes/pluggable.php';
754 $uid = $params['uid'] ?? NULL;
755 if (!$uid) {
756 $name = $name ? $name : trim(CRM_Utils_Array::value('name', $_REQUEST));
757 $pass = $pass ? $pass : trim(CRM_Utils_Array::value('pass', $_REQUEST));
758 if ($name) {
759 $uid = wp_authenticate($name, $pass);
760 if (!$uid) {
761 if ($throwError) {
762 echo '<br />Sorry, unrecognized username or password.';
763 exit();
764 }
765 return FALSE;
766 }
767 }
768 }
769 if ($uid) {
770 if ($uid instanceof WP_User) {
771 $account = wp_set_current_user($uid->ID);
772 }
773 else {
774 $account = wp_set_current_user($uid);
775 }
776 if ($account && $account->data->ID) {
777 global $user;
778 $user = $account;
779 return TRUE;
780 }
781 }
782 return TRUE;
783 }
784
785 /**
786 * @param $dir
787 *
788 * @return bool
789 */
790 public function validInstallDir($dir) {
791 $includePath = "$dir/wp-includes";
792 if (@file_exists("$includePath/version.php")) {
793 return TRUE;
794 }
795 return FALSE;
796 }
797
798 /**
799 * Determine the location of the CMS root.
800 *
801 * @return string|NULL
802 * local file system path to CMS root, or NULL if it cannot be determined
803 */
804 public function cmsRootPath() {
805
806 // Return early if the path is already set.
807 global $civicrm_paths;
808 if (!empty($civicrm_paths['cms.root']['path'])) {
809 return $civicrm_paths['cms.root']['path'];
810 }
811
812 // Return early if constant has been defined.
813 if (defined('CIVICRM_CMSDIR')) {
814 if ($this->validInstallDir(CIVICRM_CMSDIR)) {
815 return CIVICRM_CMSDIR;
816 }
817 }
818
819 // Return early if path to wp-load.php can be retrieved from settings.
820 $setting = Civi::settings()->get('wpLoadPhp');
821 if (!empty($setting)) {
822 $path = str_replace('wp-load.php', '', $setting);
823 $cmsRoot = rtrim($path, '/\\');
824 if ($this->validInstallDir($cmsRoot)) {
825 return $cmsRoot;
826 }
827 }
828
829 /*
830 * Keep previous logic as fallback of last resort.
831 *
832 * At some point, it would be good to remove this because there are serious
833 * problems in correctly locating WordPress in this manner. In summary, it
834 * is impossible to do so reliably.
835 *
836 * @see https://github.com/civicrm/civicrm-wordpress/pull/63#issuecomment-61792328
837 * @see https://github.com/civicrm/civicrm-core/pull/11086#issuecomment-335454992
838 */
839 $cmsRoot = $valid = NULL;
840
841 $pathVars = explode('/', str_replace('\\', '/', $_SERVER['SCRIPT_FILENAME']));
842
843 // Might be Windows installation.
844 $firstVar = array_shift($pathVars);
845 if ($firstVar) {
846 $cmsRoot = $firstVar;
847 }
848
849 // Start with CMS dir search.
850 foreach ($pathVars as $var) {
851 $cmsRoot .= "/$var";
852 if ($this->validInstallDir($cmsRoot)) {
853 // Stop as we found bootstrap.
854 $valid = TRUE;
855 break;
856 }
857 }
858
859 return ($valid) ? $cmsRoot : NULL;
860 }
861
862 /**
863 * @inheritDoc
864 */
865 public function createUser(&$params, $mail) {
866 $user_data = [
867 'ID' => '',
868 'user_login' => $params['cms_name'],
869 'user_email' => $params[$mail],
870 'nickname' => $params['cms_name'],
871 'role' => get_option('default_role'),
872 ];
873
874 // If there's a password add it, otherwise generate one.
875 if (!empty($params['cms_pass'])) {
876 $user_data['user_pass'] = $params['cms_pass'];
877 }
878 else {
879 $user_data['user_pass'] = wp_generate_password(12, FALSE);;
880 }
881
882 // Assign WordPress User "name" field(s).
883 if (isset($params['contactID'])) {
884 $contactType = CRM_Contact_BAO_Contact::getContactType($params['contactID']);
885 if ($contactType == 'Individual') {
886 $user_data['first_name'] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact',
887 $params['contactID'], 'first_name'
888 );
889 $user_data['last_name'] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact',
890 $params['contactID'], 'last_name'
891 );
892 }
893 if ($contactType == 'Organization') {
894 $user_data['first_name'] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact',
895 $params['contactID'], 'organization_name'
896 );
897 }
898 if ($contactType == 'Household') {
899 $user_data['first_name'] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact',
900 $params['contactID'], 'household_name'
901 );
902 }
903 }
904
905 /**
906 * Broadcast that CiviCRM is about to create a WordPress User.
907 *
908 * @since 5.37
909 */
910 do_action('civicrm_pre_create_user');
911
912 // Remove the CiviCRM-WordPress listeners.
913 $this->hooks_core_remove();
914
915 // Now go ahead and create a WordPress User.
916 $uid = wp_insert_user($user_data);
917
918 /*
919 * Call wp_signon if we aren't already logged in.
920 * For example, we might be creating a new user from the Contact record.
921 */
922 if (!current_user_can('create_users')) {
923 $creds = [];
924 $creds['user_login'] = $params['cms_name'];
925 $creds['remember'] = TRUE;
926 wp_signon($creds, FALSE);
927 }
928
929 // Fire the new user action. Sends notification email by default.
930 do_action('register_new_user', $uid);
931
932 // Restore the CiviCRM-WordPress listeners.
933 $this->hooks_core_add();
934
935 /**
936 * Broadcast that CiviCRM has creates a WordPress User.
937 *
938 * @since 5.37
939 */
940 do_action('civicrm_post_create_user');
941
942 return $uid;
943 }
944
945 /**
946 * @inheritDoc
947 */
948 public function updateCMSName($ufID, $ufName) {
949 // CRM-10620
950 if (function_exists('wp_update_user')) {
951 $ufID = CRM_Utils_Type::escape($ufID, 'Integer');
952 $ufName = CRM_Utils_Type::escape($ufName, 'String');
953
954 $values = ['ID' => $ufID, 'user_email' => $ufName];
955 if ($ufID) {
956 wp_update_user($values);
957 }
958 }
959 }
960
961 /**
962 * @param array $params
963 * @param $errors
964 * @param string $emailName
965 */
966 public function checkUserNameEmailExists(&$params, &$errors, $emailName = 'email') {
967 $config = CRM_Core_Config::singleton();
968
969 $dao = new CRM_Core_DAO();
970 $name = $dao->escape(CRM_Utils_Array::value('name', $params));
971 $email = $dao->escape(CRM_Utils_Array::value('mail', $params));
972
973 if (!empty($params['name'])) {
974 if (!validate_username($params['name'])) {
975 $errors['cms_name'] = ts("Your username contains invalid characters");
976 }
977 elseif (username_exists(sanitize_user($params['name']))) {
978 $errors['cms_name'] = ts('The username %1 is already taken. Please select another username.', [1 => $params['name']]);
979 }
980 }
981
982 if (!empty($params['mail'])) {
983 if (!is_email($params['mail'])) {
984 $errors[$emailName] = "Your email is invaid";
985 }
986 elseif (email_exists($params['mail'])) {
987 $errors[$emailName] = ts('The email address %1 already has an account associated with it. <a href="%2">Have you forgotten your password?</a>',
988 [1 => $params['mail'], 2 => wp_lostpassword_url()]
989 );
990 }
991 }
992 }
993
994 /**
995 * @inheritDoc
996 */
997 public function isUserLoggedIn() {
998 $isloggedIn = FALSE;
999 if (function_exists('is_user_logged_in')) {
1000 $isloggedIn = is_user_logged_in();
1001 }
1002
1003 return $isloggedIn;
1004 }
1005
1006 /**
1007 * @inheritDoc
1008 */
1009 public function isUserRegistrationPermitted() {
1010 if (!get_option('users_can_register')) {
1011 return FALSE;
1012 }
1013 return TRUE;
1014 }
1015
1016 /**
1017 * @inheritDoc
1018 */
1019 public function isPasswordUserGenerated() {
1020 return FALSE;
1021 }
1022
1023 /**
1024 * @return mixed
1025 */
1026 public function getLoggedInUserObject() {
1027 if (function_exists('is_user_logged_in') &&
1028 is_user_logged_in()
1029 ) {
1030 global $current_user;
1031 }
1032 return $current_user;
1033 }
1034
1035 /**
1036 * @inheritDoc
1037 */
1038 public function getLoggedInUfID() {
1039 $ufID = NULL;
1040 $current_user = $this->getLoggedInUserObject();
1041 return $current_user->ID ?? NULL;
1042 }
1043
1044 /**
1045 * @inheritDoc
1046 */
1047 public function getLoggedInUniqueIdentifier() {
1048 $user = $this->getLoggedInUserObject();
1049 return $this->getUniqueIdentifierFromUserObject($user);
1050 }
1051
1052 /**
1053 * Get User ID from UserFramework system (Joomla)
1054 * @param object $user
1055 * Object as described by the CMS.
1056 *
1057 * @return int|null
1058 */
1059 public function getUserIDFromUserObject($user) {
1060 return !empty($user->ID) ? $user->ID : NULL;
1061 }
1062
1063 /**
1064 * @inheritDoc
1065 */
1066 public function getUniqueIdentifierFromUserObject($user) {
1067 return empty($user->user_email) ? NULL : $user->user_email;
1068 }
1069
1070 /**
1071 * @inheritDoc
1072 */
1073 public function getLoginURL($destination = '') {
1074 return wp_login_url($destination);
1075 }
1076
1077 /**
1078 * @param \CRM_Core_Form $form
1079 *
1080 * @return NULL|string
1081 */
1082 public function getLoginDestination(&$form) {
1083 $args = NULL;
1084
1085 $id = $form->get('id');
1086 if ($id) {
1087 $args .= "&id=$id";
1088 }
1089 else {
1090 $gid = $form->get('gid');
1091 if ($gid) {
1092 $args .= "&gid=$gid";
1093 }
1094 else {
1095 // Setup Personal Campaign Page link uses pageId
1096 $pageId = $form->get('pageId');
1097 if ($pageId) {
1098 $component = $form->get('component');
1099 $args .= "&pageId=$pageId&component=$component&action=add";
1100 }
1101 }
1102 }
1103
1104 $destination = NULL;
1105 if ($args) {
1106 // append destination so user is returned to form they came from after login
1107 $destination = CRM_Utils_System::url(CRM_Utils_System::currentPath(), 'reset=1' . $args);
1108 }
1109 return $destination;
1110 }
1111
1112 /**
1113 * @inheritDoc
1114 */
1115 public function getVersion() {
1116 if (function_exists('get_bloginfo')) {
1117 return get_bloginfo('version', 'display');
1118 }
1119 else {
1120 return 'Unknown';
1121 }
1122 }
1123
1124 /**
1125 * @inheritDoc
1126 */
1127 public function getTimeZoneString() {
1128 return get_option('timezone_string');
1129 }
1130
1131 /**
1132 * @inheritDoc
1133 */
1134 public function getUserRecordUrl($contactID) {
1135 $uid = CRM_Core_BAO_UFMatch::getUFId($contactID);
1136 if (CRM_Core_Session::singleton()
1137 ->get('userID') == $contactID || CRM_Core_Permission::checkAnyPerm(['cms:administer users'])
1138 ) {
1139 return CRM_Core_Config::singleton()->userFrameworkBaseURL . "wp-admin/user-edit.php?user_id=" . $uid;
1140 }
1141 }
1142
1143 /**
1144 * Append WP js to coreResourcesList.
1145 *
1146 * @param \Civi\Core\Event\GenericHookEvent $e
1147 */
1148 public function appendCoreResources(\Civi\Core\Event\GenericHookEvent $e) {
1149 $e->list[] = 'js/crm.wordpress.js';
1150 }
1151
1152 /**
1153 * @inheritDoc
1154 */
1155 public function alterAssetUrl(\Civi\Core\Event\GenericHookEvent $e) {
1156 // Set menubar breakpoint to match WP admin theme
1157 if ($e->asset == 'crm-menubar.css') {
1158 $e->params['breakpoint'] = 783;
1159 }
1160 }
1161
1162 /**
1163 * @inheritDoc
1164 */
1165 public function checkPermissionAddUser() {
1166 return current_user_can('create_users');
1167 }
1168
1169 /**
1170 * @inheritDoc
1171 */
1172 public function synchronizeUsers() {
1173 $config = CRM_Core_Config::singleton();
1174 if (PHP_SAPI != 'cli') {
1175 set_time_limit(300);
1176 }
1177 $id = 'ID';
1178 $mail = 'user_email';
1179
1180 $uf = $config->userFramework;
1181 $contactCount = 0;
1182 $contactCreated = 0;
1183 $contactMatching = 0;
1184
1185 // Previously used the $wpdb global - which means WordPress *must* be bootstrapped.
1186 $wpUsers = get_users(array(
1187 'blog_id' => get_current_blog_id(),
1188 'number' => -1,
1189 ));
1190
1191 foreach ($wpUsers as $wpUserData) {
1192 $contactCount++;
1193 if ($match = CRM_Core_BAO_UFMatch::synchronizeUFMatch($wpUserData,
1194 $wpUserData->$id,
1195 $wpUserData->$mail,
1196 $uf,
1197 1,
1198 'Individual',
1199 TRUE
1200 )
1201 ) {
1202 $contactCreated++;
1203 }
1204 else {
1205 $contactMatching++;
1206 }
1207 if (is_object($match)) {
1208 $match->free();
1209 }
1210 }
1211
1212 return [
1213 'contactCount' => $contactCount,
1214 'contactMatching' => $contactMatching,
1215 'contactCreated' => $contactCreated,
1216 ];
1217 }
1218
1219 /**
1220 * Send an HTTP Response base on PSR HTTP RespnseInterface response.
1221 *
1222 * @param \Psr\Http\Message\ResponseInterface $response
1223 */
1224 public function sendResponse(\Psr\Http\Message\ResponseInterface $response) {
1225 // use WordPress function status_header to ensure 404 response is sent
1226 status_header($response->getStatusCode());
1227 foreach ($response->getHeaders() as $name => $values) {
1228 CRM_Utils_System::setHttpHeader($name, implode(', ', (array) $values));
1229 }
1230 echo $response->getBody();
1231 CRM_Utils_System::civiExit();
1232 }
1233
1234 /**
1235 * Start a new session if there's no existing session ID.
1236 *
1237 * Checks are needed to prevent sessions being started when not necessary.
1238 */
1239 public function sessionStart() {
1240 $session_id = session_id();
1241
1242 // Check WordPress pseudo-cron.
1243 $wp_cron = FALSE;
1244 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
1245 $wp_cron = TRUE;
1246 }
1247
1248 // Check WP-CLI.
1249 $wp_cli = FALSE;
1250 if (defined('WP_CLI') && WP_CLI) {
1251 $wp_cli = TRUE;
1252 }
1253
1254 // Check PHP on the command line - e.g. `cv`.
1255 $php_cli = TRUE;
1256 if (PHP_SAPI !== 'cli') {
1257 $php_cli = FALSE;
1258 }
1259
1260 // Maybe start session.
1261 if (empty($session_id) && !$wp_cron && !$wp_cli && !$php_cli) {
1262 session_start();
1263 }
1264 }
1265
1266 /**
1267 * Perform any necessary actions prior to redirecting via POST.
1268 *
1269 * Redirecting via POST means that cookies need to be sent with SameSite=None.
1270 */
1271 public function prePostRedirect() {
1272 // Get User Agent string.
1273 $rawUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
1274 $userAgent = mb_convert_encoding($rawUserAgent, 'UTF-8');
1275
1276 // Bail early if User Agent does not support `SameSite=None`.
1277 $shouldUseSameSite = CRM_Utils_SameSite::shouldSendSameSiteNone($userAgent);
1278 if (!$shouldUseSameSite) {
1279 return;
1280 }
1281
1282 // Make sure session cookie is present in header.
1283 $cookie_params = session_name() . '=' . session_id() . '; SameSite=None; Secure';
1284 CRM_Utils_System::setHttpHeader('Set-Cookie', $cookie_params);
1285
1286 // Add WordPress auth cookies when user is logged in.
1287 $user = wp_get_current_user();
1288 if ($user->exists()) {
1289 self::setAuthCookies($user->ID, TRUE, TRUE);
1290 }
1291 }
1292
1293 /**
1294 * Explicitly set WordPress authentication cookies.
1295 *
1296 * Chrome 84 introduced a cookie policy change which prevents cookies for the
1297 * session and for WordPress user authentication from being indentified when
1298 * a purchaser returns to the site from PayPal using the "Back to Merchant"
1299 * button.
1300 *
1301 * In order to comply with this policy, cookies need to be sent with their
1302 * "SameSite" attribute set to "None" and with the "Secure" flag set, but this
1303 * isn't possible to do via `wp_set_auth_cookie()` as it stands.
1304 *
1305 * This method is a modified clone of `wp_set_auth_cookie()` which satisfies
1306 * the Chrome policy.
1307 *
1308 * @see wp_set_auth_cookie()
1309 *
1310 * The $remember parameter increases the time that the cookie will be kept. The
1311 * default the cookie is kept without remembering is two days. When $remember is
1312 * set, the cookies will be kept for 14 days or two weeks.
1313 *
1314 * @param int $user_id The WordPress User ID.
1315 * @param bool $remember Whether to remember the user.
1316 * @param bool|string $secure Whether the auth cookie should only be sent over
1317 * HTTPS. Default is an empty string which means the
1318 * value of `is_ssl()` will be used.
1319 * @param string $token Optional. User's session token to use for this cookie.
1320 */
1321 private function setAuthCookies($user_id, $remember = FALSE, $secure = '', $token = '') {
1322 if ($remember) {
1323 /** This filter is documented in wp-includes/pluggable.php */
1324 $expiration = time() + apply_filters('auth_cookie_expiration', 14 * DAY_IN_SECONDS, $user_id, $remember);
1325
1326 /*
1327 * Ensure the browser will continue to send the cookie after the expiration time is reached.
1328 * Needed for the login grace period in wp_validate_auth_cookie().
1329 */
1330 $expire = $expiration + (12 * HOUR_IN_SECONDS);
1331 }
1332 else {
1333 /** This filter is documented in wp-includes/pluggable.php */
1334 $expiration = time() + apply_filters('auth_cookie_expiration', 2 * DAY_IN_SECONDS, $user_id, $remember);
1335 $expire = 0;
1336 }
1337
1338 if ('' === $secure) {
1339 $secure = is_ssl();
1340 }
1341
1342 // Front-end cookie is secure when the auth cookie is secure and the site's home URL is forced HTTPS.
1343 $secure_logged_in_cookie = $secure && 'https' === parse_url(get_option('home'), PHP_URL_SCHEME);
1344
1345 /** This filter is documented in wp-includes/pluggable.php */
1346 $secure = apply_filters('secure_auth_cookie', $secure, $user_id);
1347
1348 /** This filter is documented in wp-includes/pluggable.php */
1349 $secure_logged_in_cookie = apply_filters('secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure);
1350
1351 if ($secure) {
1352 $auth_cookie_name = SECURE_AUTH_COOKIE;
1353 $scheme = 'secure_auth';
1354 }
1355 else {
1356 $auth_cookie_name = AUTH_COOKIE;
1357 $scheme = 'auth';
1358 }
1359
1360 if ('' === $token) {
1361 $manager = WP_Session_Tokens::get_instance($user_id);
1362 $token = $manager->create($expiration);
1363 }
1364
1365 $auth_cookie = wp_generate_auth_cookie($user_id, $expiration, $scheme, $token);
1366 $logged_in_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $token);
1367
1368 /** This filter is documented in wp-includes/pluggable.php */
1369 do_action('set_auth_cookie', $auth_cookie, $expire, $expiration, $user_id, $scheme, $token);
1370
1371 /** This filter is documented in wp-includes/pluggable.php */
1372 do_action('set_logged_in_cookie', $logged_in_cookie, $expire, $expiration, $user_id, 'logged_in', $token);
1373
1374 /** This filter is documented in wp-includes/pluggable.php */
1375 if (!apply_filters('send_auth_cookies', TRUE)) {
1376 return;
1377 }
1378
1379 $base_options = [
1380 'expires' => $expire,
1381 'domain' => COOKIE_DOMAIN,
1382 'httponly' => TRUE,
1383 'samesite' => 'None',
1384 ];
1385
1386 self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => PLUGINS_COOKIE_PATH]);
1387 self::setAuthCookie($auth_cookie_name, $auth_cookie, $base_options + ['secure' => $secure, 'path' => ADMIN_COOKIE_PATH]);
1388 self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => COOKIEPATH]);
1389 if (COOKIEPATH != SITECOOKIEPATH) {
1390 self::setAuthCookie(LOGGED_IN_COOKIE, $logged_in_cookie, $base_options + ['secure' => $secure_logged_in_cookie, 'path' => SITECOOKIEPATH]);
1391 }
1392 }
1393
1394 /**
1395 * Set cookie with "SameSite" flag.
1396 *
1397 * The method here is compatible with all versions of PHP. Needed because it
1398 * is only as of PHP 7.3.0 that the setcookie() method supports the "SameSite"
1399 * attribute in its options and will accept "None" as a valid value.
1400 *
1401 * @param $name The name of the cookie.
1402 * @param $value The value of the cookie.
1403 * @param array $options The header options for the cookie.
1404 */
1405 private function setAuthCookie($name, $value, $options) {
1406 $header = 'Set-Cookie: ';
1407 $header .= rawurlencode($name) . '=' . rawurlencode($value) . '; ';
1408 $header .= 'expires=' . gmdate('D, d-M-Y H:i:s T', $options['expires']) . '; ';
1409 $header .= 'Max-Age=' . max(0, (int) ($options['expires'] - time())) . '; ';
1410 $header .= 'path=' . rawurlencode($options['path']) . '; ';
1411 $header .= 'domain=' . rawurlencode($options['domain']) . '; ';
1412
1413 if (!empty($options['secure'])) {
1414 $header .= 'secure; ';
1415 }
1416 $header .= 'httponly; ';
1417 $header .= 'SameSite=' . rawurlencode($options['samesite']);
1418
1419 header($header, FALSE);
1420 $_COOKIE[$name] = $value;
1421 }
1422
1423 /**
1424 * Return the CMS-specific url for its permissions page
1425 * @return array
1426 */
1427 public function getCMSPermissionsUrlParams() {
1428 return ['ufAccessURL' => CRM_Utils_System::url('civicrm/admin/access/wp-permissions', 'reset=1')];
1429 }
1430
1431 /**
1432 * Remove CiviCRM's callbacks.
1433 *
1434 * These may cause recursive updates when creating or editing a WordPress
1435 * user. This doesn't seem to have been necessary in the past, but seems
1436 * to be causing trouble when newer versions of BuddyPress and CiviCRM are
1437 * active.
1438 *
1439 * Based on the civicrm-wp-profile-sync plugin by Christian Wach.
1440 *
1441 * @see self::hooks_core_add()
1442 */
1443 public function hooks_core_remove() {
1444 $civicrm = civi_wp();
1445
1446 // Remove current CiviCRM plugin filters.
1447 remove_action('user_register', [$civicrm->users, 'update_user']);
1448 remove_action('profile_update', [$civicrm->users, 'update_user']);
1449 }
1450
1451 /**
1452 * Add back CiviCRM's callbacks.
1453 * This method undoes the removal of the callbacks above.
1454 *
1455 * @see self::hooks_core_remove()
1456 */
1457 public function hooks_core_add() {
1458 $civicrm = civi_wp();
1459
1460 // Re-add current CiviCRM plugin filters.
1461 add_action('user_register', [$civicrm->users, 'update_user']);
1462 add_action('profile_update', [$civicrm->users, 'update_user']);
1463 }
1464
1465 }