From f2c1d99e225dd91932980d7d69cd773a24587a44 Mon Sep 17 00:00:00 2001
From: Paul Biron <paul@sparrowhawkcomputing.com>
Date: Sat, 28 Jun 2025 16:21:32 -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       | 54 ++++++++++++----
 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                      | 62 +++++++++++++------
 10 files changed, 163 insertions(+), 33 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 c96d80a9b5..716ba4580e 100644
--- a/src/wp-admin/erase-personal-data.php
+++ b/src/wp-admin/erase-personal-data.php
@@ -110,7 +110,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>
 		<div class="wp-privacy-request-form-field">
 			<table class="form-table">
diff --git a/src/wp-admin/export-personal-data.php b/src/wp-admin/export-personal-data.php
index 64b9653c3c..6cc7a644a1 100644
--- a/src/wp-admin/export-personal-data.php
+++ b/src/wp-admin/export-personal-data.php
@@ -110,7 +110,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>
 		<div class="wp-privacy-request-form-field">
 		<table class="form-table">
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 18a82590be..3b7b35023b 100644
--- a/src/wp-admin/includes/class-wp-privacy-requests-table.php
+++ b/src/wp-admin/includes/class-wp-privacy-requests-table.php
@@ -375,6 +375,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 d5853185eb..015acf587e 100644
--- a/src/wp-admin/includes/privacy-tools.php
+++ b/src/wp-admin/includes/privacy-tools.php
@@ -146,7 +146,13 @@ function _wp_personal_data_handle_actions() {
 					break;
 				}
 
-				$request_id = wp_create_user_request( $email_address, $action_type, array(), $status );
+				$request_id = wp_create_user_request(
+					$email_address,
+					$action_type,
+					array(),
+					is_network_admin() ? 0 : get_current_blog_id()
+				);
+
 				$message    = '';
 
 				if ( is_wp_error( $request_id ) ) {
@@ -196,20 +202,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 ee987c83c6..83cd06e631 100644
--- a/src/wp-admin/network/menu.php
+++ b/src/wp-admin/network/menu.php
@@ -111,6 +111,11 @@ $submenu['plugins.php'][5]  = array( __( 'Installed Plugins' ), 'manage_network_
 $submenu['plugins.php'][10] = array( __( 'Add Plugin' ), 'install_plugins', 'plugin-install.php' );
 $submenu['plugins.php'][15] = array( __( 'Plugin File 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 5fe00e9801..bafacab415 100644
--- a/src/wp-includes/admin-bar.php
+++ b/src/wp-includes/admin-bar.php
@@ -646,6 +646,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 dc8ca7cdbd..1ca1b9c583 100644
--- a/src/wp-includes/class-wp-user-request.php
+++ b/src/wp-includes/class-wp-user-request.php
@@ -98,6 +98,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.
 	 *
@@ -117,5 +127,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 ecd22ab38c..8c58139452 100644
--- a/src/wp-includes/user.php
+++ b/src/wp-includes/user.php
@@ -4698,19 +4698,24 @@ 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.7.0 Added the `$status` parameter.
+ * @since x.y.z 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 string $status                  Optional request status (pending or confirmed). Default 'pending'.
- * @return int|WP_Error                   Returns the request ID if successful, or a WP_Error object on failure.
+ * @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 string $status        Optional request status (pending or confirmed). Default 'pending'.
+ * @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(), $status = 'pending' ) {
+function wp_create_user_request( $email_address = '', $action_name = '', $request_data = array(), $status = 'pending', $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.' ) );
@@ -4724,22 +4729,40 @@ function wp_create_user_request( $email_address = '', $action_name = '', $reques
 		return new WP_Error( 'invalid_status', __( 'Invalid request status.' ) );
 	}
 
+	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 personal data request for this email address already exists.' ) );
@@ -4755,6 +4778,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.37.0.windows.1

From 774d4585663ef9049b4ef6477a4c833d2196ae84 Mon Sep 17 00:00:00 2001
From: Paul Biron <paul@sparrowhawkcomputing.com>
Date: Sun, 29 Jun 2025 12:03:15 -0600
Subject: [PATCH] Add an empty network/tools.php screen.

---
 src/wp-admin/network/tools.php | 58 ++++++++++++++++++++++++++++++++++
 1 file changed, 58 insertions(+)
 create mode 100644 src/wp-admin/network/tools.php

diff --git a/src/wp-admin/network/tools.php b/src/wp-admin/network/tools.php
new file mode 100644
index 0000000000..911c61ff2c
--- /dev/null
+++ b/src/wp-admin/network/tools.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Multisite Tools Administration Screen.
+ *
+ * @package WordPress
+ * @subpackage Administration
+ */
+
+if ( isset( $_GET['page'] ) && ! empty( $_POST ) ) {
+	// Ensure POST-ing to `tools.php?page=export_personal_data` and `tools.php?page=remove_personal_data`
+	// continues to work after creating the new files for exporting and erasing of personal data.
+	if ( 'export_personal_data' === $_GET['page'] ) {
+		require_once ABSPATH . 'wp-admin/network/export-personal-data.php';
+		return;
+	} elseif ( 'remove_personal_data' === $_GET['page'] ) {
+		require_once ABSPATH . 'wp-admin/network/erase-personal-data.php';
+		return;
+	}
+}
+
+if ( isset( $_GET['page'] ) ) {
+	// These were also moved to files in WP 5.3.
+	if ( 'export_personal_data' === $_GET['page'] ) {
+		require_once dirname( __DIR__ ) . '/wp-load.php';
+		wp_redirect( admin_url( 'network/export-personal-data.php' ), 301 );
+		exit;
+	} elseif ( 'remove_personal_data' === $_GET['page'] ) {
+		require_once dirname( __DIR__ ) . '/wp-load.php';
+		wp_redirect( admin_url( 'network/erase-personal-data.php' ), 301 );
+		exit;
+	}
+}
+
+/** WordPress Administration Bootstrap */
+require_once __DIR__ . '/admin.php';
+
+// Used in the HTML title tag.
+$title = __( 'Tools' );
+
+require_once ABSPATH . 'wp-admin/admin-header.php';
+
+?>
+<div class="wrap">
+<h1><?php echo esc_html( $title ); ?></h1>
+<?php
+
+/**
+ * Fires at the end of the Tools Administration screen.
+ *
+ * @since x.y.z
+ */
+do_action( 'network_tool_box' );
+
+?>
+</div>
+<?php
+
+require_once ABSPATH . 'wp-admin/admin-footer.php';
-- 
2.37.0.windows.1

From 2981b870b052c5bed48dde535db37e2e6781d675 Mon Sep 17 00:00:00 2001
From: Paul Biron <paul@sparrowhawkcomputing.com>
Date: Wed, 2 Jul 2025 11:53:56 -0600
Subject: [PATCH] Bug fix: in wp_personal_data_handle_actions(), pass $status
 as the 4th param to wp_create_user_request().

This was accidentally left out when refreshing the patch the other day.
---
 src/wp-admin/includes/privacy-tools.php | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/wp-admin/includes/privacy-tools.php b/src/wp-admin/includes/privacy-tools.php
index 015acf587e..27844a3d9b 100644
--- a/src/wp-admin/includes/privacy-tools.php
+++ b/src/wp-admin/includes/privacy-tools.php
@@ -150,6 +150,7 @@ function _wp_personal_data_handle_actions() {
 					$email_address,
 					$action_type,
 					array(),
+					$status,
 					is_network_admin() ? 0 : get_current_blog_id()
 				);
 
-- 
2.37.0.windows.1

