class-yoast-notification-center.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  1. <?php
  2. /**
  3. * WPSEO plugin file.
  4. *
  5. * @package WPSEO\Admin\Notifications
  6. */
  7. /**
  8. * Handles notifications storage and display.
  9. */
  10. class Yoast_Notification_Center {
  11. /**
  12. * Option name to store notifications on.
  13. *
  14. * @var string
  15. */
  16. const STORAGE_KEY = 'yoast_notifications';
  17. /**
  18. * The singleton instance of this object.
  19. *
  20. * @var \Yoast_Notification_Center
  21. */
  22. private static $instance = null;
  23. /**
  24. * Holds the notifications.
  25. *
  26. * @var \Yoast_Notification[]
  27. */
  28. private $notifications = [];
  29. /**
  30. * Notifications there are newly added.
  31. *
  32. * @var array
  33. */
  34. private $new = [];
  35. /**
  36. * Notifications that were resolved this execution.
  37. *
  38. * @var array
  39. */
  40. private $resolved = 0;
  41. /**
  42. * Internal storage for transaction before notifications have been retrieved from storage.
  43. *
  44. * @var array
  45. */
  46. private $queued_transactions = [];
  47. /**
  48. * Internal flag for whether notifications have been retrieved from storage.
  49. *
  50. * @var bool
  51. */
  52. private $notifications_retrieved = false;
  53. /**
  54. * Construct.
  55. */
  56. private function __construct() {
  57. add_action( 'init', [ $this, 'setup_current_notifications' ], 1 );
  58. add_action( 'all_admin_notices', [ $this, 'display_notifications' ] );
  59. add_action( 'wp_ajax_yoast_get_notifications', [ $this, 'ajax_get_notifications' ] );
  60. add_action( 'wpseo_deactivate', [ $this, 'deactivate_hook' ] );
  61. add_action( 'shutdown', [ $this, 'update_storage' ] );
  62. }
  63. /**
  64. * Singleton getter.
  65. *
  66. * @return Yoast_Notification_Center
  67. */
  68. public static function get() {
  69. if ( null === self::$instance ) {
  70. self::$instance = new self();
  71. }
  72. return self::$instance;
  73. }
  74. /**
  75. * Dismiss a notification.
  76. */
  77. public static function ajax_dismiss_notification() {
  78. $notification_center = self::get();
  79. $notification_id = filter_input( INPUT_POST, 'notification' );
  80. if ( empty( $notification_id ) ) {
  81. die( '-1' );
  82. }
  83. $notification = $notification_center->get_notification_by_id( $notification_id );
  84. if ( false === ( $notification instanceof Yoast_Notification ) ) {
  85. // Permit legacy.
  86. $options = [
  87. 'id' => $notification_id,
  88. 'dismissal_key' => $notification_id,
  89. ];
  90. $notification = new Yoast_Notification( '', $options );
  91. }
  92. if ( self::maybe_dismiss_notification( $notification ) ) {
  93. die( '1' );
  94. }
  95. die( '-1' );
  96. }
  97. /**
  98. * Check if the user has dismissed a notification.
  99. *
  100. * @param Yoast_Notification $notification The notification to check for dismissal.
  101. * @param null|int $user_id User ID to check on.
  102. *
  103. * @return bool
  104. */
  105. public static function is_notification_dismissed( Yoast_Notification $notification, $user_id = null ) {
  106. $user_id = ( ! is_null( $user_id ) ? $user_id : get_current_user_id() );
  107. $dismissal_key = $notification->get_dismissal_key();
  108. // This checks both the site-specific user option and the meta value.
  109. $current_value = get_user_option( $dismissal_key, $user_id );
  110. // Migrate old user meta to user option on-the-fly.
  111. if ( ! empty( $current_value )
  112. && metadata_exists( 'user', $user_id, $dismissal_key )
  113. && update_user_option( $user_id, $dismissal_key, $current_value ) ) {
  114. delete_user_meta( $user_id, $dismissal_key );
  115. }
  116. return ! empty( $current_value );
  117. }
  118. /**
  119. * Checks if the notification is being dismissed.
  120. *
  121. * @param string|Yoast_Notification $notification Notification to check dismissal of.
  122. * @param string $meta_value Value to set the meta value to if dismissed.
  123. *
  124. * @return bool True if dismissed.
  125. */
  126. public static function maybe_dismiss_notification( Yoast_Notification $notification, $meta_value = 'seen' ) {
  127. // Only persistent notifications are dismissible.
  128. if ( ! $notification->is_persistent() ) {
  129. return false;
  130. }
  131. // If notification is already dismissed, we're done.
  132. if ( self::is_notification_dismissed( $notification ) ) {
  133. return true;
  134. }
  135. $dismissal_key = $notification->get_dismissal_key();
  136. $notification_id = $notification->get_id();
  137. $is_dismissing = ( $dismissal_key === self::get_user_input( 'notification' ) );
  138. if ( ! $is_dismissing ) {
  139. $is_dismissing = ( $notification_id === self::get_user_input( 'notification' ) );
  140. }
  141. // Fallback to ?dismissal_key=1&nonce=bla when JavaScript fails.
  142. if ( ! $is_dismissing ) {
  143. $is_dismissing = ( '1' === self::get_user_input( $dismissal_key ) );
  144. }
  145. if ( ! $is_dismissing ) {
  146. return false;
  147. }
  148. $user_nonce = self::get_user_input( 'nonce' );
  149. if ( false === wp_verify_nonce( $user_nonce, $notification_id ) ) {
  150. return false;
  151. }
  152. return self::dismiss_notification( $notification, $meta_value );
  153. }
  154. /**
  155. * Dismisses a notification.
  156. *
  157. * @param Yoast_Notification $notification Notification to dismiss.
  158. * @param string $meta_value Value to save in the dismissal.
  159. *
  160. * @return bool True if dismissed, false otherwise.
  161. */
  162. public static function dismiss_notification( Yoast_Notification $notification, $meta_value = 'seen' ) {
  163. // Dismiss notification.
  164. return update_user_option( get_current_user_id(), $notification->get_dismissal_key(), $meta_value ) !== false;
  165. }
  166. /**
  167. * Restores a notification.
  168. *
  169. * @param Yoast_Notification $notification Notification to restore.
  170. *
  171. * @return bool True if restored, false otherwise.
  172. */
  173. public static function restore_notification( Yoast_Notification $notification ) {
  174. $user_id = get_current_user_id();
  175. $dismissal_key = $notification->get_dismissal_key();
  176. // Restore notification.
  177. $restored = delete_user_option( $user_id, $dismissal_key );
  178. // Delete unprefixed user meta too for backward-compatibility.
  179. if ( metadata_exists( 'user', $user_id, $dismissal_key ) ) {
  180. $restored = delete_user_meta( $user_id, $dismissal_key ) && $restored;
  181. }
  182. return $restored;
  183. }
  184. /**
  185. * Clear dismissal information for the specified Notification.
  186. *
  187. * When a cause is resolved, the next time it is present we want to show
  188. * the message again.
  189. *
  190. * @param string|Yoast_Notification $notification Notification to clear the dismissal of.
  191. *
  192. * @return bool
  193. */
  194. public function clear_dismissal( $notification ) {
  195. global $wpdb;
  196. if ( $notification instanceof Yoast_Notification ) {
  197. $dismissal_key = $notification->get_dismissal_key();
  198. }
  199. if ( is_string( $notification ) ) {
  200. $dismissal_key = $notification;
  201. }
  202. if ( empty( $dismissal_key ) ) {
  203. return false;
  204. }
  205. // Remove notification dismissal for all users.
  206. $deleted = delete_metadata( 'user', 0, $wpdb->get_blog_prefix() . $dismissal_key, '', true );
  207. // Delete unprefixed user meta too for backward-compatibility.
  208. $deleted = delete_metadata( 'user', 0, $dismissal_key, '', true ) || $deleted;
  209. return $deleted;
  210. }
  211. /**
  212. * Retrieves notifications from the storage and merges in previous notification changes.
  213. *
  214. * The current user in WordPress is not loaded shortly before the 'init' hook, but the plugin
  215. * sometimes needs to add or remove notifications before that. In such cases, the transactions
  216. * are not actually executed, but added to a queue. That queue is then handled in this method,
  217. * after notifications for the current user have been set up.
  218. *
  219. * @return void
  220. */
  221. public function setup_current_notifications() {
  222. $this->retrieve_notifications_from_storage();
  223. foreach ( $this->queued_transactions as $transaction ) {
  224. list( $callback, $args ) = $transaction;
  225. call_user_func_array( $callback, $args );
  226. }
  227. $this->queued_transactions = [];
  228. }
  229. /**
  230. * Add notification to the cookie.
  231. *
  232. * @param Yoast_Notification $notification Notification object instance.
  233. */
  234. public function add_notification( Yoast_Notification $notification ) {
  235. $callback = [ $this, __METHOD__ ];
  236. $args = func_get_args();
  237. if ( $this->queue_transaction( $callback, $args ) ) {
  238. return;
  239. }
  240. // Don't add if the user can't see it.
  241. if ( ! $notification->display_for_current_user() ) {
  242. return;
  243. }
  244. $notification_id = $notification->get_id();
  245. // Empty notifications are always added.
  246. if ( $notification_id !== '' ) {
  247. // If notification ID exists in notifications, don't add again.
  248. $present_notification = $this->get_notification_by_id( $notification_id );
  249. if ( ! is_null( $present_notification ) ) {
  250. $this->remove_notification( $present_notification, false );
  251. }
  252. if ( is_null( $present_notification ) ) {
  253. $this->new[] = $notification_id;
  254. }
  255. }
  256. // Add to list.
  257. $this->notifications[] = $notification;
  258. }
  259. /**
  260. * Get the notification by ID.
  261. *
  262. * @param string $notification_id The ID of the notification to search for.
  263. *
  264. * @return null|Yoast_Notification
  265. */
  266. public function get_notification_by_id( $notification_id ) {
  267. foreach ( $this->notifications as & $notification ) {
  268. if ( $notification_id === $notification->get_id() ) {
  269. return $notification;
  270. }
  271. }
  272. return null;
  273. }
  274. /**
  275. * Display the notifications.
  276. *
  277. * @param bool $echo_as_json True when notifications should be printed directly.
  278. *
  279. * @return void
  280. */
  281. public function display_notifications( $echo_as_json = false ) {
  282. // Never display notifications for network admin.
  283. if ( function_exists( 'is_network_admin' ) && is_network_admin() ) {
  284. return;
  285. }
  286. $sorted_notifications = $this->get_sorted_notifications();
  287. $notifications = array_filter( $sorted_notifications, [ $this, 'is_notification_persistent' ] );
  288. if ( empty( $notifications ) ) {
  289. return;
  290. }
  291. array_walk( $notifications, [ $this, 'remove_notification' ] );
  292. $notifications = array_unique( $notifications );
  293. if ( $echo_as_json ) {
  294. $notification_json = [];
  295. foreach ( $notifications as $notification ) {
  296. $notification_json[] = $notification->render();
  297. }
  298. // phpcs:ignore WordPress.Security.EscapeOutput -- Reason: WPSEO_Utils::format_json_encode is safe.
  299. echo WPSEO_Utils::format_json_encode( $notification_json );
  300. return;
  301. }
  302. foreach ( $notifications as $notification ) {
  303. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Reason: Temporarily disabled, see: https://github.com/Yoast/wordpress-seo-premium/issues/2510 and https://github.com/Yoast/wordpress-seo-premium/issues/2511.
  304. echo $notification;
  305. }
  306. }
  307. /**
  308. * Remove notification after it has been displayed.
  309. *
  310. * @param Yoast_Notification $notification Notification to remove.
  311. * @param bool $resolve Resolve as fixed.
  312. */
  313. public function remove_notification( Yoast_Notification $notification, $resolve = true ) {
  314. $callback = [ $this, __METHOD__ ];
  315. $args = func_get_args();
  316. if ( $this->queue_transaction( $callback, $args ) ) {
  317. return;
  318. }
  319. $index = false;
  320. // Match persistent Notifications by ID, non persistent by item in the array.
  321. if ( $notification->is_persistent() ) {
  322. foreach ( $this->notifications as $current_index => $present_notification ) {
  323. if ( $present_notification->get_id() === $notification->get_id() ) {
  324. $index = $current_index;
  325. break;
  326. }
  327. }
  328. }
  329. else {
  330. $index = array_search( $notification, $this->notifications, true );
  331. }
  332. if ( false === $index ) {
  333. return;
  334. }
  335. if ( $notification->is_persistent() && $resolve ) {
  336. $this->resolved++;
  337. $this->clear_dismissal( $notification );
  338. }
  339. unset( $this->notifications[ $index ] );
  340. $this->notifications = array_values( $this->notifications );
  341. }
  342. /**
  343. * Removes a notification by its ID.
  344. *
  345. * @param string $notification_id The notification id.
  346. * @param bool $resolve Resolve as fixed.
  347. *
  348. * @return void
  349. */
  350. public function remove_notification_by_id( $notification_id, $resolve = true ) {
  351. $notification = $this->get_notification_by_id( $notification_id );
  352. if ( $notification === null ) {
  353. return;
  354. }
  355. $this->remove_notification( $notification, $resolve );
  356. }
  357. /**
  358. * Get the notification count.
  359. *
  360. * @param bool $dismissed Count dismissed notifications.
  361. *
  362. * @return int Number of notifications
  363. */
  364. public function get_notification_count( $dismissed = false ) {
  365. $notifications = $this->get_notifications();
  366. $notifications = array_filter( $notifications, [ $this, 'filter_persistent_notifications' ] );
  367. if ( ! $dismissed ) {
  368. $notifications = array_filter( $notifications, [ $this, 'filter_dismissed_notifications' ] );
  369. }
  370. return count( $notifications );
  371. }
  372. /**
  373. * Get the number of notifications resolved this execution.
  374. *
  375. * These notifications have been resolved and should be counted when active again.
  376. *
  377. * @return int
  378. */
  379. public function get_resolved_notification_count() {
  380. return $this->resolved;
  381. }
  382. /**
  383. * Return the notifications sorted on type and priority.
  384. *
  385. * @return array|Yoast_Notification[] Sorted Notifications
  386. */
  387. public function get_sorted_notifications() {
  388. $notifications = $this->get_notifications();
  389. if ( empty( $notifications ) ) {
  390. return [];
  391. }
  392. // Sort by severity, error first.
  393. usort( $notifications, [ $this, 'sort_notifications' ] );
  394. return $notifications;
  395. }
  396. /**
  397. * AJAX display notifications.
  398. */
  399. public function ajax_get_notifications() {
  400. $echo = filter_input( INPUT_POST, 'version' ) === '2';
  401. // Display the notices.
  402. $this->display_notifications( $echo );
  403. // AJAX die.
  404. exit;
  405. }
  406. /**
  407. * Remove storage when the plugin is deactivated.
  408. */
  409. public function deactivate_hook() {
  410. $this->clear_notifications();
  411. }
  412. /**
  413. * Save persistent notifications to storage.
  414. *
  415. * We need to be able to retrieve these so they can be dismissed at any time during the execution.
  416. *
  417. * @since 3.2
  418. *
  419. * @return void
  420. */
  421. public function update_storage() {
  422. $notifications = $this->get_notifications();
  423. /**
  424. * Filter: 'yoast_notifications_before_storage' - Allows developer to filter notifications before saving them.
  425. *
  426. * @api Yoast_Notification[] $notifications
  427. */
  428. $notifications = apply_filters( 'yoast_notifications_before_storage', $notifications );
  429. // No notifications to store, clear storage if it was previously present.
  430. if ( empty( $notifications ) ) {
  431. $this->remove_storage();
  432. return;
  433. }
  434. $notifications = array_map( [ $this, 'notification_to_array' ], $notifications );
  435. // Save the notifications to the storage.
  436. update_user_option( get_current_user_id(), self::STORAGE_KEY, $notifications );
  437. }
  438. /**
  439. * Provide a way to verify present notifications.
  440. *
  441. * @return array|Yoast_Notification[] Registered notifications.
  442. */
  443. public function get_notifications() {
  444. return $this->notifications;
  445. }
  446. /**
  447. * Get newly added notifications.
  448. *
  449. * @return array
  450. */
  451. public function get_new_notifications() {
  452. return array_map( [ $this, 'get_notification_by_id' ], $this->new );
  453. }
  454. /**
  455. * Get information from the User input.
  456. *
  457. * @param string $key Key to retrieve.
  458. *
  459. * @return mixed value of key if set.
  460. */
  461. private static function get_user_input( $key ) {
  462. $filter_input_type = INPUT_GET;
  463. if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) {
  464. $filter_input_type = INPUT_POST;
  465. }
  466. return filter_input( $filter_input_type, $key );
  467. }
  468. /**
  469. * Retrieve the notifications from storage.
  470. *
  471. * @return array|void Yoast_Notification[] Notifications.
  472. */
  473. private function retrieve_notifications_from_storage() {
  474. if ( $this->notifications_retrieved ) {
  475. return;
  476. }
  477. $this->notifications_retrieved = true;
  478. $stored_notifications = get_user_option( self::STORAGE_KEY, get_current_user_id() );
  479. // Check if notifications are stored.
  480. if ( empty( $stored_notifications ) ) {
  481. return;
  482. }
  483. if ( is_array( $stored_notifications ) ) {
  484. $notifications = array_map( [ $this, 'array_to_notification' ], $stored_notifications );
  485. // Apply array_values to ensure we get a 0-indexed array.
  486. $notifications = array_values( array_filter( $notifications, [ $this, 'filter_notification_current_user' ] ) );
  487. $this->notifications = $notifications;
  488. }
  489. }
  490. /**
  491. * Sort on type then priority.
  492. *
  493. * @param Yoast_Notification $a Compare with B.
  494. * @param Yoast_Notification $b Compare with A.
  495. *
  496. * @return int 1, 0 or -1 for sorting offset.
  497. */
  498. private function sort_notifications( Yoast_Notification $a, Yoast_Notification $b ) {
  499. $a_type = $a->get_type();
  500. $b_type = $b->get_type();
  501. if ( $a_type === $b_type ) {
  502. return WPSEO_Utils::calc( $b->get_priority(), 'compare', $a->get_priority() );
  503. }
  504. if ( 'error' === $a_type ) {
  505. return -1;
  506. }
  507. if ( 'error' === $b_type ) {
  508. return 1;
  509. }
  510. return 0;
  511. }
  512. /**
  513. * Clear local stored notifications.
  514. */
  515. private function clear_notifications() {
  516. $this->notifications = [];
  517. $this->notifications_retrieved = false;
  518. }
  519. /**
  520. * Filter out non-persistent notifications.
  521. *
  522. * @param Yoast_Notification $notification Notification to test for persistent.
  523. *
  524. * @since 3.2
  525. *
  526. * @return bool
  527. */
  528. private function filter_persistent_notifications( Yoast_Notification $notification ) {
  529. return $notification->is_persistent();
  530. }
  531. /**
  532. * Filter out dismissed notifications.
  533. *
  534. * @param Yoast_Notification $notification Notification to check.
  535. *
  536. * @return bool
  537. */
  538. private function filter_dismissed_notifications( Yoast_Notification $notification ) {
  539. return ! $this->maybe_dismiss_notification( $notification );
  540. }
  541. /**
  542. * Convert Notification to array representation.
  543. *
  544. * @param Yoast_Notification $notification Notification to convert.
  545. *
  546. * @since 3.2
  547. *
  548. * @return array
  549. */
  550. private function notification_to_array( Yoast_Notification $notification ) {
  551. $notification_data = $notification->to_array();
  552. if ( isset( $notification_data['nonce'] ) ) {
  553. unset( $notification_data['nonce'] );
  554. }
  555. return $notification_data;
  556. }
  557. /**
  558. * Convert stored array to Notification.
  559. *
  560. * @param array $notification_data Array to convert to Notification.
  561. *
  562. * @return Yoast_Notification
  563. */
  564. private function array_to_notification( $notification_data ) {
  565. if ( isset( $notification_data['options']['nonce'] ) ) {
  566. unset( $notification_data['options']['nonce'] );
  567. }
  568. return new Yoast_Notification(
  569. $notification_data['message'],
  570. $notification_data['options']
  571. );
  572. }
  573. /**
  574. * Filter notifications that should not be displayed for the current user.
  575. *
  576. * @param Yoast_Notification $notification Notification to test.
  577. *
  578. * @return bool
  579. */
  580. private function filter_notification_current_user( Yoast_Notification $notification ) {
  581. return $notification->display_for_current_user();
  582. }
  583. /**
  584. * Checks if given notification is persistent.
  585. *
  586. * @param Yoast_Notification $notification The notification to check.
  587. *
  588. * @return bool True when notification is not persistent.
  589. */
  590. private function is_notification_persistent( Yoast_Notification $notification ) {
  591. return ! $notification->is_persistent();
  592. }
  593. /**
  594. * Queues a notification transaction for later execution if notifications are not yet set up.
  595. *
  596. * @param callable $callback Callback that performs the transaction.
  597. * @param array $args Arguments to pass to the callback.
  598. *
  599. * @return bool True if transaction was queued, false if it can be performed immediately.
  600. */
  601. private function queue_transaction( $callback, $args ) {
  602. if ( $this->notifications_retrieved ) {
  603. return false;
  604. }
  605. $this->add_transaction_to_queue( $callback, $args );
  606. return true;
  607. }
  608. /**
  609. * Adds a notification transaction to the queue for later execution.
  610. *
  611. * @param callable $callback Callback that performs the transaction.
  612. * @param array $args Arguments to pass to the callback.
  613. */
  614. private function add_transaction_to_queue( $callback, $args ) {
  615. $this->queued_transactions[] = [ $callback, $args ];
  616. }
  617. /**
  618. * Removes all notifications from storage.
  619. *
  620. * @return bool True when notifications got removed.
  621. */
  622. protected function remove_storage() {
  623. if ( ! $this->has_stored_notifications() ) {
  624. return false;
  625. }
  626. delete_user_option( get_current_user_id(), self::STORAGE_KEY );
  627. return true;
  628. }
  629. /**
  630. * Checks if there are stored notifications.
  631. *
  632. * @return bool True when there are stored notifications.
  633. */
  634. protected function has_stored_notifications() {
  635. $stored_notifications = $this->get_stored_notifications();
  636. return ! empty( $stored_notifications );
  637. }
  638. /**
  639. * Retrieves the stored notifications.
  640. *
  641. * @codeCoverageIgnore
  642. *
  643. * @return array|false Array with notifications or false when not set.
  644. */
  645. protected function get_stored_notifications() {
  646. return get_user_option( self::STORAGE_KEY, get_current_user_id() );
  647. }
  648. }