From aba945444eff7308957b642d17e40bda7ff72ae8 Mon Sep 17 00:00:00 2001
From: Paul Biron <paul@sparrowhawkcomputing.com>
Date: Thu, 2 Apr 2020 13:22:18 -0600
Subject: [PATCH] Add UI for managing requests at the network-level.

---
 src/wp-admin/erase-personal-data.php          |  2 +-
 src/wp-admin/export-personal-data.php         |  2 +-
 .../class-wp-privacy-requests-table.php       | 22 ++++++++
 src/wp-admin/includes/privacy-tools.php       | 53 ++++++++++++++-----
 src/wp-admin/network/erase-personal-data.php  | 12 +++++
 src/wp-admin/network/export-personal-data.php | 12 +++++
 src/wp-admin/network/menu.php                 |  5 ++
 src/wp-includes/admin-bar.php                 | 11 ++++
 src/wp-includes/class-wp-user-request.php     | 14 +++++
 src/wp-includes/user.php                      | 46 ++++++++++++----
 10 files changed, 153 insertions(+), 26 deletions(-)
 create mode 100644 src/wp-admin/network/erase-personal-data.php
 create mode 100644 src/wp-admin/network/export-personal-data.php

diff --git a/src/wp-admin/erase-personal-data.php b/src/wp-admin/erase-personal-data.php
index d674633f0f..08601d9b7f 100644
--- a/src/wp-admin/erase-personal-data.php
+++ b/src/wp-admin/erase-personal-data.php
@@ -56,7 +56,7 @@ require_once ABSPATH . 'wp-admin/admin-header.php';
 
 	<?php settings_errors(); ?>
 
-	<form action="<?php echo esc_url( admin_url( 'erase-personal-data.php' ) ); ?>" method="post" class="wp-privacy-request-form">
+	<form action="<?php echo esc_url( self_admin_url( 'erase-personal-data.php' ) ); ?>" method="post" class="wp-privacy-request-form">
 		<h2><?php esc_html_e( 'Add Data Erasure Request' ); ?></h2>
 		<p><?php esc_html_e( 'An email will be sent to the user at this email address asking them to verify the request.' ); ?></p>
 
diff --git a/src/wp-admin/export-personal-data.php b/src/wp-admin/export-personal-data.php
index 8e44345a99..bea49bccf8 100644
--- a/src/wp-admin/export-personal-data.php
+++ b/src/wp-admin/export-personal-data.php
@@ -56,7 +56,7 @@ require_once ABSPATH . 'wp-admin/admin-header.php';
 
 	<?php settings_errors(); ?>
 
-	<form action="<?php echo esc_url( admin_url( 'export-personal-data.php' ) ); ?>" method="post" class="wp-privacy-request-form">
+	<form action="<?php echo esc_url( self_admin_url( 'export-personal-data.php' ) ); ?>" method="post" class="wp-privacy-request-form">
 		<h2><?php esc_html_e( 'Add Data Export Request' ); ?></h2>
 		<p><?php esc_html_e( 'An email will be sent to the user at this email address asking them to verify the request.' ); ?></p>
 
diff --git a/src/wp-admin/includes/class-wp-privacy-requests-table.php b/src/wp-admin/includes/class-wp-privacy-requests-table.php
index 23c31b1f31..4302a7af91 100644
--- a/src/wp-admin/includes/class-wp-privacy-requests-table.php
+++ b/src/wp-admin/includes/class-wp-privacy-requests-table.php
@@ -279,6 +279,28 @@ abstract class WP_Privacy_Requests_Table extends WP_List_Table {
 			'post_status'    => 'any',
 			's'              => isset( $_REQUEST['s'] ) ? sanitize_text_field( $_REQUEST['s'] ) : '',
 		);
+		if ( is_network_admin() ) {
+			$args['meta_query'] = array(
+				array(
+					'key'   => '_wp_user_request_blog_id',
+					'value' => 0,
+				),
+			);
+		}
+		else {
+			$args['meta_query'] = array(
+				'relation' => 'OR',
+				array(
+					'key'     => '_wp_user_request_blog_id',
+					'value'   => get_current_blog_id(),
+				),
+				// The meta key will not exist for requests submitted before 5.5.
+				array(
+					'key'     => '_wp_user_request_blog_id',
+					'compare' => 'NOT EXISTS',
+				),
+			);
+		}
 
 		$orderby_mapping = array(
 			'requester' => 'post_title',
diff --git a/src/wp-admin/includes/privacy-tools.php b/src/wp-admin/includes/privacy-tools.php
index 2d35984499..50ab29e9e3 100644
--- a/src/wp-admin/includes/privacy-tools.php
+++ b/src/wp-admin/includes/privacy-tools.php
@@ -141,7 +141,12 @@ function _wp_personal_data_handle_actions() {
 					break;
 				}
 
-				$request_id = wp_create_user_request( $email_address, $action_type );
+				$request_id = wp_create_user_request(
+					$email_address,
+					$action_type,
+					array(),
+					is_network_admin() ? 0 : get_current_blog_id()
+				);
 
 				if ( is_wp_error( $request_id ) ) {
 					add_settings_error(
@@ -184,20 +189,42 @@ function _wp_personal_data_cleanup_requests() {
 	/** This filter is documented in wp-includes/user.php */
 	$expires = (int) apply_filters( 'user_request_key_expiration', DAY_IN_SECONDS );
 
-	$requests_query = new WP_Query(
-		array(
-			'post_type'      => 'user_request',
-			'posts_per_page' => -1,
-			'post_status'    => 'request-pending',
-			'fields'         => 'ids',
-			'date_query'     => array(
-				array(
-					'column' => 'post_modified_gmt',
-					'before' => $expires . ' seconds ago',
-				),
+	$args = array(
+		'post_type'      => 'user_request',
+		'posts_per_page' => -1,
+		'post_status'    => 'request-pending',
+		'fields'         => 'ids',
+		'date_query'     => array(
+			array(
+				'column' => 'post_modified_gmt',
+				'before' => $expires . ' seconds ago',
 			),
-		)
+		),
 	);
+	if ( is_network_admin() ) {
+		$args['meta_query'] = array(
+			array(
+				'key'   => '_wp_user_request_blog_id',
+				'value' => 0,
+			),
+		);
+	}
+	else {
+		$args['meta_query'] = array(
+			'relation' => 'OR',
+			array(
+				'key'     => '_wp_user_request_blog_id',
+				'value'   => get_current_blog_id(),
+			),
+			// The meta key will not exist for requests submitted before 5.5.
+			array(
+				'key'     => '_wp_user_request_blog_id',
+				'compare' => 'NOT EXISTS',
+			),
+		);
+	}
+
+	$requests_query = new WP_Query( $args );
 
 	$request_ids = $requests_query->posts;
 
diff --git a/src/wp-admin/network/erase-personal-data.php b/src/wp-admin/network/erase-personal-data.php
new file mode 100644
index 0000000000..30b1d5446b
--- /dev/null
+++ b/src/wp-admin/network/erase-personal-data.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Privacy tools, Erase Personal Data screen.
+ *
+ * @package WordPress
+ * @subpackage Administration
+ */
+
+/** Load WordPress Administration Bootstrap */
+require_once __DIR__ . '/admin.php';
+
+require ABSPATH . 'wp-admin/erase-personal-data.php';
diff --git a/src/wp-admin/network/export-personal-data.php b/src/wp-admin/network/export-personal-data.php
new file mode 100644
index 0000000000..12a46152b1
--- /dev/null
+++ b/src/wp-admin/network/export-personal-data.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Privacy tools, Export Personal Data screen.
+ *
+ * @package WordPress
+ * @subpackage Administration
+ */
+
+/** Load WordPress Administration Bootstrap */
+require_once __DIR__ . '/admin.php';
+
+require ABSPATH . 'wp-admin/export-personal-data.php';
diff --git a/src/wp-admin/network/menu.php b/src/wp-admin/network/menu.php
index 28509b1769..de2029d7ef 100644
--- a/src/wp-admin/network/menu.php
+++ b/src/wp-admin/network/menu.php
@@ -106,6 +106,11 @@ $submenu['plugins.php'][5]  = array( __( 'Installed Plugins' ), 'manage_network_
 $submenu['plugins.php'][10] = array( _x( 'Add New', 'plugin' ), 'install_plugins', 'plugin-install.php' );
 $submenu['plugins.php'][15] = array( __( 'Plugin Editor' ), 'edit_plugins', 'plugin-editor.php' );
 
+$menu[21]                 = array( __( 'Tools' ), 'edit_posts', 'tools.php', '', 'menu-top menu-icon-tools', 'menu-tools', 'dashicons-admin-tools' );
+$submenu['tools.php'][5]  = array( __( 'Available Tools' ), 'edit_posts', 'tools.php' );
+$submenu['tools.php'][25] = array( __( 'Export Personal Data' ), 'export_others_personal_data', 'export-personal-data.php' );
+$submenu['tools.php'][30] = array( __( 'Erase Personal Data' ), 'erase_others_personal_data', 'erase-personal-data.php' );
+
 $menu[25] = array( __( 'Settings' ), 'manage_network_options', 'settings.php', '', 'menu-top menu-icon-settings', 'menu-settings', 'dashicons-admin-settings' );
 if ( defined( 'MULTISITE' ) && defined( 'WP_ALLOW_MULTISITE' ) && WP_ALLOW_MULTISITE ) {
 	$submenu['settings.php'][5]  = array( __( 'Network Settings' ), 'manage_network_options', 'settings.php' );
diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php
index 753e5ab5be..4dbcdad4a1 100644
--- a/src/wp-includes/admin-bar.php
+++ b/src/wp-includes/admin-bar.php
@@ -556,6 +556,17 @@ function wp_admin_bar_my_sites_menu( $wp_admin_bar ) {
 			);
 		}
 
+		if ( current_user_can( 'edit_posts' ) ) {
+			$wp_admin_bar->add_node(
+				array(
+					'parent' => 'network-admin',
+					'id'     => 'network-admin-tools',
+					'title'  => __( 'Tools' ),
+					'href'   => network_admin_url( 'tools.php' ),
+				)
+			);
+		}
+
 		if ( current_user_can( 'manage_network_options' ) ) {
 			$wp_admin_bar->add_node(
 				array(
diff --git a/src/wp-includes/class-wp-user-request.php b/src/wp-includes/class-wp-user-request.php
index 7e445cfa63..8935b30f25 100644
--- a/src/wp-includes/class-wp-user-request.php
+++ b/src/wp-includes/class-wp-user-request.php
@@ -95,6 +95,16 @@ final class WP_User_Request {
 	 */
 	public $confirm_key = '';
 
+	/**
+	 * Blog ID the request was submitted to.
+	 *
+	 * Will be 0 if the request was submitted at the network-level.
+	 *
+	 * @since 5.5
+	 * @var int
+	 */
+	public $blog_id;
+
 	/**
 	 * Constructor.
 	 *
@@ -114,5 +124,9 @@ final class WP_User_Request {
 		$this->completed_timestamp = (int) get_post_meta( $post->ID, '_wp_user_request_completed_timestamp', true );
 		$this->request_data        = json_decode( $post->post_content, true );
 		$this->confirm_key         = $post->post_password;
+
+		$blog_id                   = get_post_meta( $post->ID, '_wp_user_request_blog_id', true );
+		// $blog_id will be the empty string for requests submitted prior to 5.5.
+		$this->blog_id             = '' !== $blog_id ? (int) $blog_id : get_current_blog_id();
 	}
 }
diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php
index f0cc8c33bd..ddb0e2e050 100644
--- a/src/wp-includes/user.php
+++ b/src/wp-includes/user.php
@@ -3554,15 +3554,18 @@ function _wp_privacy_account_request_confirmed_message( $request_id ) {
  * users on the site, or guests without a user account.
  *
  * @since 4.9.6
+ * @since 5.5.0 Added `$blog_id` parameter.
  *
  * @param string $email_address User email address. This can be the address of a registered or non-registered user.
  * @param string $action_name   Name of the action that is being confirmed. Required.
  * @param array  $request_data  Misc data you want to send with the verification request and pass to the actions once the request is confirmed.
+ * @param int|null $blog_id     Blog ID to create request in.  Use 0 for a network-level requests.  Default is null, indicating the current blog ID.
  * @return int|WP_Error Returns the request ID if successful, or a WP_Error object on failure.
  */
-function wp_create_user_request( $email_address = '', $action_name = '', $request_data = array() ) {
+function wp_create_user_request( $email_address = '', $action_name = '', $request_data = array(), $blog_id = null ) {
 	$email_address = sanitize_email( $email_address );
 	$action_name   = sanitize_key( $action_name );
+	$blog_id       = null === $blog_id ? get_current_blog_id() : (int) $blog_id;
 
 	if ( ! is_email( $email_address ) ) {
 		return new WP_Error( 'invalid_email', __( 'Invalid email address.' ) );
@@ -3572,22 +3575,40 @@ function wp_create_user_request( $email_address = '', $action_name = '', $reques
 		return new WP_Error( 'invalid_action', __( 'Invalid action name.' ) );
 	}
 
+	if ( $blog_id && ! get_site( $blog_id ) ) {
+		return new WP_Error( 'invalid_blog_id', __( 'Invalid blog ID.' ) );
+	}
+
 	$user    = get_user_by( 'email', $email_address );
 	$user_id = $user && ! is_wp_error( $user ) ? $user->ID : 0;
 
 	// Check for duplicates.
-	$requests_query = new WP_Query(
-		array(
-			'post_type'     => 'user_request',
-			'post_name__in' => array( $action_name ), // Action name stored in post_name column.
-			'title'         => $email_address,        // Email address stored in post_title column.
-			'post_status'   => array(
-				'request-pending',
-				'request-confirmed',
+	$args = array(
+		'post_type'     => 'user_request',
+		'post_name__in' => array( $action_name ), // Action name stored in post_name column.
+		'title'         => $email_address,        // Email address stored in post_title column.
+		'post_status'   => array(
+			'request-pending',
+			'request-confirmed',
+		),
+		'fields'        => 'ids',
+		'meta_query'    => array(
+			array(
+				'key'   => '_wp_user_request_blog_id',
+				'value' => $blog_id,
 			),
-			'fields'        => 'ids',
-		)
+		),
 	);
+	if ( $blog_id ) {
+		// add a check for requests submitted prior to 5.5.
+		$args['meta_query']['relation'] = 'OR';
+		$args['meta_query'][] = array(
+			'key'     => '_wp_user_request_blog_id',
+			'compare' => 'NOT EXISTS',
+		);
+	}
+
+	$requests_query = new WP_Query( $args );
 
 	if ( $requests_query->found_posts ) {
 		return new WP_Error( 'duplicate_request', __( 'An incomplete request for this email address already exists.' ) );
@@ -3603,6 +3624,9 @@ function wp_create_user_request( $email_address = '', $action_name = '', $reques
 			'post_type'     => 'user_request',
 			'post_date'     => current_time( 'mysql', false ),
 			'post_date_gmt' => current_time( 'mysql', true ),
+			'meta_input'    => array(
+				'_wp_user_request_blog_id' => $blog_id,
+			),
 		),
 		true
 	);
-- 
2.19.0.windows.1

