' );
+ $processor->next_tag();
+
+ $this->assertSame(
+ array( 'existing' ),
+ $processor->get_attribute_names_with_prefix( '' ),
+ 'Expected to only report the existing attribute: check test setup.'
+ );
+
+ $processor->add_class( 'added' );
+
+ $this->assertSame(
+ array( 'class' ),
+ $processor->get_attribute_names_with_prefix( 'class' ),
+ 'Failed to report the newly-added `class` attribute.'
+ );
+ }
+
+ /**
+ * Ensures that when the `class` attribute is emptied via remove_class(), that
+ * it’s reported by get_attribute_names_with_prefix() immediately after being added.
+ *
+ * @ticket 64567
+ *
+ * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
+ */
+ public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_removing_all_classes() {
+ $processor = new WP_HTML_Tag_Processor( '
+
diff --git a/src/wp-admin/setup-config.php b/src/wp-admin/setup-config.php
index dd6794d5f8e27..ec4d1a8bbbdbc 100644
--- a/src/wp-admin/setup-config.php
+++ b/src/wp-admin/setup-config.php
@@ -240,7 +240,7 @@ function setup_config_display_header( $body_classes = array() ) {
-
+
From d1c6be6bdae3fb1ef62321df1a8f36060ab26069 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Sun, 2 Aug 2026 22:23:00 +0000
Subject: [PATCH 107/151] Widgets: Show post excerpts in On This Day widget if
no title.
Match the behavior of posts in list tables by showing a short excerpt in the On This Day widget when the post does not have a saved title.
Developed in https://github.com/WordPress/wordpress-develop/pull/12581
Props alshakero, softglaze, iamraju, mirmpro, shailu25, bph, nazmulasif, wildworks, annezazu, mukesh27, peterwilsoncc, joedolson.
Fixes #65658.
git-svn-id: https://develop.svn.wordpress.org/trunk@62968 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/dashboard-on-this-day.php | 20 +-
.../tests/admin/wpDashboardOnThisDay.php | 191 +++++++++++++++++-
2 files changed, 200 insertions(+), 11 deletions(-)
diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php
index 1939f8ca4b0e3..e9557a60720c8 100644
--- a/src/wp-admin/includes/dashboard-on-this-day.php
+++ b/src/wp-admin/includes/dashboard-on-this-day.php
@@ -114,10 +114,19 @@ function wp_dashboard_on_this_day() {
ID ) && ! post_password_required( $year_post ) ) {
+ $excerpt = get_the_excerpt( $year_post );
+
+ if ( is_string( $excerpt ) && '' !== $excerpt ) {
+ $no_title_excerpt = wp_trim_words( $excerpt, 15 );
+ }
+ }
}
$author_id = (int) $year_post->post_author;
@@ -125,7 +134,14 @@ function wp_dashboard_on_this_day() {
$show_author = '' !== trim( $author_name ) && get_current_user_id() !== $author_id;
?>
-
+
+
+
+
' . esc_html(
diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
index 471324f9648ec..a2b1cdbfaff1f 100644
--- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
+++ b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
@@ -58,23 +58,28 @@ private function set_up_dashboard_screen() {
* @param string $title Post title.
* @param int $years_ago Number of years before today.
* @param string $time Post time.
+ * @param array $post_args Additional post arguments.
* @return int Post ID.
*/
private function create_matching_post(
int $author_id,
string $title = 'A memory from last year',
int $years_ago = 1,
- string $time = '12:00:00'
+ string $time = '12:00:00',
+ array $post_args = array()
): int {
$post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time;
return self::factory()->post->create(
- array(
- 'post_author' => $author_id,
- 'post_date' => $post_date,
- 'post_date_gmt' => get_gmt_from_date( $post_date ),
- 'post_status' => 'publish',
- 'post_title' => $title,
+ array_merge(
+ array(
+ 'post_author' => $author_id,
+ 'post_date' => $post_date,
+ 'post_date_gmt' => get_gmt_from_date( $post_date ),
+ 'post_status' => 'publish',
+ 'post_title' => $title,
+ ),
+ $post_args
)
);
}
@@ -338,6 +343,162 @@ public function test_widget_groups_posts_by_year() {
/**
* @ticket 65116
*
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_includes_trimmed_excerpt_for_untitled_posts() {
+ wp_set_current_user( self::$user_id );
+
+ $words = array();
+ for ( $n = 1; $n <= 20; $n++ ) {
+ $words[] = 'word' . $n;
+ }
+
+ $this->create_matching_post(
+ self::$user_id,
+ '',
+ 1,
+ '12:00:00',
+ array(
+ 'post_excerpt' => implode( ' ', $words ),
+ )
+ );
+
+ ob_start();
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '(no title)', $output );
+ $this->assertStringContainsString( 'word15', $output, 'The 15th word should be present.' );
+ $this->assertStringNotContainsString( 'word16', $output, 'The 16th word should be trimmed.' );
+ $this->assertStringContainsString( '…', $output, 'The excerpt should end with an ellipsis.' );
+ }
+
+ /**
+ * @ticket 65116
+ *
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_does_not_append_excerpt_to_titled_posts() {
+ wp_set_current_user( self::$user_id );
+
+ $this->create_matching_post(
+ self::$user_id,
+ 'A titled anniversary memory',
+ 1,
+ '12:00:00',
+ array(
+ 'post_excerpt' => 'This excerpt should not be shown.',
+ )
+ );
+
+ ob_start();
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'A titled anniversary memory', $output );
+ $this->assertStringNotContainsString( 'This excerpt should not be shown.', $output );
+ }
+
+ /**
+ * @ticket 65116
+ *
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() {
+ $this->set_up_dashboard_screen();
+
+ wp_set_current_user( self::$user_id );
+
+ $this->create_matching_post(
+ self::$user_id,
+ '',
+ 1,
+ '12:00:00',
+ array(
+ 'post_excerpt' => 'Readable private anniversary memory.',
+ 'post_status' => 'private',
+ )
+ );
+
+ add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
+
+ ob_start();
+ try {
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+ } finally {
+ remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
+ }
+
+ $this->assertStringContainsString( '(no title)', $output );
+ $this->assertStringContainsString( 'Readable private anniversary memory.', $output );
+ }
+
+ /**
+ * @ticket 65116
+ *
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() {
+ $this->set_up_dashboard_screen();
+
+ wp_set_current_user( self::$user_id );
+
+ $post_id = $this->create_matching_post(
+ self::$other_user_id,
+ '',
+ 1,
+ '12:00:00',
+ array(
+ 'post_excerpt' => 'Unreadable private anniversary memory.',
+ 'post_status' => 'private',
+ )
+ );
+
+ add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
+
+ ob_start();
+ try {
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+ } finally {
+ remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
+ }
+
+ $this->assertFalse( current_user_can( 'read_post', $post_id ) );
+ $this->assertStringContainsString( '(no title)', $output );
+ $this->assertStringNotContainsString( 'Unreadable private anniversary memory.', $output );
+ }
+
+ /**
+ * @ticket 65116
+ *
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() {
+ $this->set_up_dashboard_screen();
+
+ wp_set_current_user( self::$user_id );
+
+ $this->create_matching_post(
+ self::$user_id,
+ '',
+ 1,
+ '12:00:00',
+ array(
+ 'post_excerpt' => 'Private anniversary memory.',
+ 'post_password' => 'secret',
+ )
+ );
+
+ ob_start();
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertStringNotContainsString( 'Private anniversary memory.', $output );
+ }
+
+ /**
* @covers ::wp_dashboard_on_this_day
* @covers ::wp_dashboard_on_this_day_get_posts
*/
@@ -353,8 +514,20 @@ public function test_widget_limits_posts_to_ten() {
$output = ob_get_clean();
$this->assertStringContainsString( '10 posts have been published on ' . wp_date( 'F jS' ) . ' :', $output );
- $this->assertStringContainsString( 'Anniversary post 1<', $output );
- $this->assertStringContainsString( 'Anniversary post 10<', $output );
+ $this->assertMatchesRegularExpression( '/>\s*Anniversary post 1\s*<\/a>/', $output );
+ $this->assertMatchesRegularExpression( '/>\s*Anniversary post 10\s*<\/a>/', $output );
$this->assertStringNotContainsString( 'Anniversary post 11', $output );
}
+
+ /**
+ * Filters the On This Day query to include private posts.
+ *
+ * @param array $args WP_Query arguments.
+ * @return array Filtered query arguments.
+ */
+ public function filter_on_this_day_query_private_posts( $args ) {
+ $args['post_status'] = array( 'private' );
+
+ return $args;
+ }
}
From a379d09c5be455a9fdea5168c9380c0d98d593d4 Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Sun, 2 Aug 2026 23:48:34 +0000
Subject: [PATCH 108/151] Tests: Use `assertTrue()`/`assertFalse()` instead of
`assertSame()` with booleans.
Using the dedicated boolean assertions clarifies intent and produces more descriptive failure messages.
Follow-up to [51453].
Props Soean, mukesh27.
See #64894.
git-svn-id: https://develop.svn.wordpress.org/trunk@62969 602fd350-edb4-49c9-b593-d223f7449a82
---
tests/phpunit/tests/blocks/wpBlockType.php | 8 ++++----
.../interactivity-api/wpInteractivityAPI-wp-bind.php | 4 ++--
tests/phpunit/tests/rest-api/rest-post-meta-fields.php | 2 +-
tests/phpunit/tests/rest-api/wpRestMenusController.php | 2 +-
4 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/tests/phpunit/tests/blocks/wpBlockType.php b/tests/phpunit/tests/blocks/wpBlockType.php
index a73efa8ce8a7d..3e69b67d965db 100644
--- a/tests/phpunit/tests/blocks/wpBlockType.php
+++ b/tests/phpunit/tests/blocks/wpBlockType.php
@@ -528,9 +528,9 @@ public function test_variations_callback_are_lazy_loaded() {
)
);
- $this->assertSame( false, $callback_called, 'The callback should not be called before the variations are accessed.' );
+ $this->assertFalse( $callback_called, 'The callback should not be called before the variations are accessed.' );
$block_type->variations; // access the variations.
- $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' );
+ $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' );
}
/**
@@ -555,7 +555,7 @@ public function test_variations_precedence_over_callback_post_registration() {
// If the variations are defined after registration but before first access, the callback should not override it.
$this->assertSameSets( $test_variations, $block_type->get_variations(), 'Variations are same as variations set' );
- $this->assertSame( false, $callback_called, 'The callback was never called.' );
+ $this->assertFalse( $callback_called, 'The callback was never called.' );
}
/**
@@ -617,7 +617,7 @@ public function test_get_block_type_variations_filter_with_variation_callback()
$obtained_variations = $block_type->variations; // access the variations.
- $this->assertSame( true, $callback_called, 'The callback should be called when the variations are accessed.' );
+ $this->assertTrue( $callback_called, 'The callback should be called when the variations are accessed.' );
$this->assertSameSets( $obtained_variations, $expected_variations, 'The variations obtained from the callback should be filtered.' );
}
diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
index e80930357b6fc..1951919941a32 100644
--- a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
+++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php
@@ -454,7 +454,7 @@ public function test_wp_bind_handles_nested_bindings() {
public function test_wp_bind_handles_true_value() {
$html = '
';
list($p) = $this->process_directives( $html );
- $this->assertSame( true, $p->get_attribute( 'id' ) );
+ $this->assertTrue( $p->get_attribute( 'id' ) );
}
/**
@@ -467,7 +467,7 @@ public function test_wp_bind_handles_true_value() {
public function test_wp_bind_ignores_unique_ids() {
$html = '
';
list($p) = $this->process_directives( $html );
- $this->assertSame( true, $p->get_attribute( 'id' ) );
+ $this->assertTrue( $p->get_attribute( 'id' ) );
$html = '
';
list($p) = $this->process_directives( $html );
diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php
index 5ce72a57fa55f..0f8584f469892 100644
--- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php
+++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php
@@ -2348,7 +2348,7 @@ public function test_update_meta_with_unchanged_values_and_custom_authentication
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
- $this->assertSame( false, $data['meta']['authenticated'] );
+ $this->assertFalse( $data['meta']['authenticated'] );
}
/**
diff --git a/tests/phpunit/tests/rest-api/wpRestMenusController.php b/tests/phpunit/tests/rest-api/wpRestMenusController.php
index 864b09417d2cb..46f9877e3cfc0 100644
--- a/tests/phpunit/tests/rest-api/wpRestMenusController.php
+++ b/tests/phpunit/tests/rest-api/wpRestMenusController.php
@@ -316,7 +316,7 @@ public function test_update_item() {
$data = $response->get_data();
$this->assertSame( 'New Name', $data['name'] );
$this->assertSame( 'New Description', $data['description'] );
- $this->assertSame( true, $data['auto_add'] );
+ $this->assertTrue( $data['auto_add'] );
$this->assertSame( 'new-name', $data['slug'] );
$this->assertSame( 'just meta', $data['meta']['test_single_menu'] );
$this->assertFalse( isset( $data['meta']['test_cat_meta'] ) );
From 07220f23b7e49acf0195820471cc318e2556a639 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Maneiro?=
Date: Mon, 3 Aug 2026 10:36:23 +0000
Subject: [PATCH 109/151] View config filters: lowercase dynamic filter names.
Props oandregal, ntsekouras.
See #65577.
git-svn-id: https://develop.svn.wordpress.org/trunk@62970 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-view-config-data.php | 12 +++---
src/wp-includes/default-filters.php | 4 +-
src/wp-includes/view-config.php | 41 +++++++++++++++----
tests/phpunit/tests/view-config.php | 24 ++++++++++-
4 files changed, 64 insertions(+), 17 deletions(-)
diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php
index 8c3d255fab82d..fa04ced6de026 100644
--- a/src/wp-includes/class-wp-view-config-data.php
+++ b/src/wp-includes/class-wp-view-config-data.php
@@ -121,9 +121,9 @@ private function get_data() {
* Applies the entity view configuration filter and returns the result.
*
* Exposes the container through the dynamic
- * `get_entity_view_config_{$kind}_{$name}` filter so that core and third
- * parties can provide the configuration for a specific entity, then
- * reconciles the filtered container back into a plain configuration array,
+ * `get_entity_view_config_{$kind}_{$name}` filter (with the dynamic portions
+ * lowercased), so that core and third parties can provide the configuration for a specific entity,
+ * then reconciles the filtered container back into a plain configuration array,
* limited to the documented configuration keys.
*
* @since 7.1.0
@@ -137,7 +137,9 @@ public function apply_filters( $kind, $name ) {
* Filters the view configuration for a given entity.
*
* The dynamic portions of the hook name, `$kind` and `$name`, refer to the
- * entity kind (e.g. `postType`) and the entity name (e.g. `page`).
+ * entity kind (e.g. `postType`) and the entity name (e.g. `page`),
+ * lowercased — so the `postType`/`page` entity maps to the
+ * `get_entity_view_config_posttype_page` hook.
*
* Callbacks receive a WP_View_Config_Data object and change the
* configuration through its methods. Each write method takes the schema
@@ -183,7 +185,7 @@ public function apply_filters( $kind, $name ) {
* }
*/
apply_filters(
- "get_entity_view_config_{$kind}_{$name}",
+ wp_get_entity_view_config_hook_name( $kind, $name ),
$this,
array(
'kind' => $kind,
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 66504d37ad84d..ea6fee0dab3ad 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -827,8 +827,8 @@
// callbacks registered at the default compose on top of them
// regardless of registration order.
add_filter(
- "get_entity_view_config_postType_{$post_type}",
- "_wp_get_entity_view_config_post_type_{$post_type}",
+ "get_entity_view_config_posttype_{$post_type}",
+ "_wp_get_entity_view_config_posttype_{$post_type}",
5
);
}
diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php
index c4d20979b846f..b97b221ec2a32 100644
--- a/src/wp-includes/view-config.php
+++ b/src/wp-includes/view-config.php
@@ -4,12 +4,33 @@
*
* Builds the default view configuration for an entity and exposes it through
* the dynamic `get_entity_view_config_{$kind}_{$name}` filter so core and third
- * parties can provide the configuration for a specific entity.
+ * parties can provide the configuration for a specific entity. The dynamic
+ * portions of the hook name are lowercased, e.g.
+ * `get_entity_view_config_posttype_page` for the `page` post type.
*
* @package WordPress
* @since 7.1.0
*/
+/**
+ * Builds the name of the dynamic filter that provides the view configuration
+ * for an entity.
+ *
+ * The entity kind and name are embedded in the hook name lowercased, so the
+ * hook follows the WordPress convention of lowercase hook names regardless of
+ * how the entity identifiers are spelled: the `postType`/`page` entity maps to
+ * the `get_entity_view_config_posttype_page` hook.
+ *
+ * @since 7.1.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return string The filter name.
+ */
+function wp_get_entity_view_config_hook_name( $kind, $name ) {
+ return strtolower( "get_entity_view_config_{$kind}_{$name}" );
+}
+
/**
* Builds the default `form` configuration for post types that don't provide their own.
*
@@ -27,7 +48,7 @@
*
* @return array The default form configuration.
*/
-function _wp_get_default_post_type_form() {
+function _wp_get_default_posttype_form() {
return array(
'layout' => array( 'type' => 'panel' ),
'fields' => array(
@@ -97,8 +118,10 @@ function _wp_get_default_post_type_form() {
* Returns the view configuration for the given entity.
*
* Builds the default configuration shared by all entities and then exposes it
- * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter so that core
- * and third parties can provide the configuration for a specific entity.
+ * through the dynamic `get_entity_view_config_{$kind}_{$name}` filter — with the
+ * dynamic portions lowercased, see wp_get_entity_view_config_hook_name()
+ * — so that core and third parties can provide the configuration for a
+ * specific entity.
*
* @since 7.1.0
*
@@ -148,7 +171,7 @@ function wp_get_entity_view_config( $kind, $name ) {
'default_view' => $default_view,
'default_layouts' => $default_layouts,
'view_list' => $view_list,
- 'form' => 'postType' === $kind ? _wp_get_default_post_type_form() : array(),
+ 'form' => 'postType' === $kind ? _wp_get_default_posttype_form() : array(),
);
$data = new WP_View_Config_Data( $config );
@@ -164,7 +187,7 @@ function wp_get_entity_view_config( $kind, $name ) {
* @param WP_View_Config_Data $data The view configuration container for the entity.
* @return WP_View_Config_Data The updated view configuration container.
*/
-function _wp_get_entity_view_config_post_type_page( $data ) {
+function _wp_get_entity_view_config_posttype_page( $data ) {
$default_layouts = array(
'table' => array(
'layout' => array(
@@ -304,7 +327,7 @@ function _wp_get_entity_view_config_post_type_page( $data ) {
* @param WP_View_Config_Data $data The view configuration container for the entity.
* @return WP_View_Config_Data The updated view configuration container.
*/
-function _wp_get_entity_view_config_post_type_wp_block( $data ) {
+function _wp_get_entity_view_config_posttype_wp_block( $data ) {
$default_layouts = array(
'table' => array(
'layout' => array(
@@ -422,7 +445,7 @@ function _wp_get_entity_view_config_post_type_wp_block( $data ) {
* @param WP_View_Config_Data $data The view configuration container for the entity.
* @return WP_View_Config_Data The updated view configuration container.
*/
-function _wp_get_entity_view_config_post_type_wp_template_part( $data ) {
+function _wp_get_entity_view_config_posttype_wp_template_part( $data ) {
$default_layouts = array(
'table' => array(
'layout' => array(
@@ -524,7 +547,7 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $data ) {
* @param WP_View_Config_Data $data The view configuration container for the entity.
* @return WP_View_Config_Data The updated view configuration container.
*/
-function _wp_get_entity_view_config_post_type_wp_template( $data ) {
+function _wp_get_entity_view_config_posttype_wp_template( $data ) {
$default_view = array(
'type' => 'grid',
'perPage' => 20,
diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php
index 75e5c3327266b..cabd15d831494 100644
--- a/tests/phpunit/tests/view-config.php
+++ b/tests/phpunit/tests/view-config.php
@@ -69,7 +69,7 @@ class Tests_View_Config_API extends WP_UnitTestCase {
* Tears down each test.
*/
public function tear_down() {
- remove_all_filters( 'get_entity_view_config_postType_unregistered_cpt' );
+ remove_all_filters( 'get_entity_view_config_posttype_unregistered_cpt' );
remove_all_filters( 'get_entity_view_config_custom_kind_custom_name' );
parent::tear_down();
}
@@ -119,6 +119,28 @@ public function test_view_list_uses_post_type_all_items_label() {
unregister_post_type( 'view_config_cpt' );
}
+ /**
+ * The dynamic filter name lowercases the entity kind and name.
+ */
+ public function test_filter_hook_name_is_lowercased() {
+ $called = false;
+ add_filter(
+ 'get_entity_view_config_posttype_unregistered_cpt',
+ function ( $data ) use ( &$called ) {
+ $called = true;
+ return $data;
+ }
+ );
+
+ wp_get_entity_view_config( 'postType', 'Unregistered_CPT' );
+
+ $this->assertTrue( $called );
+ $this->assertSame(
+ 'get_entity_view_config_posttype_unregistered_cpt',
+ wp_get_entity_view_config_hook_name( 'postType', 'Unregistered_CPT' )
+ );
+ }
+
/**
* The dynamic filter receives the data container and the entity descriptor.
*/
From 3d9a592faf76bf2b24c5676347cc89418cb21952 Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Mon, 3 Aug 2026 11:35:52 +0000
Subject: [PATCH 110/151] Docs: Restore the `@return` tag for
`WP_Duotone::is_preset()`.
Removes a duplicate `@param` tag carrying an outdated type and restores the description of the returned value.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12791
Follow-up to [61603].
Props bejignesh, mukesh27, wildworks.
See #64896.
git-svn-id: https://develop.svn.wordpress.org/trunk@62971 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-duotone.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/wp-includes/class-wp-duotone.php b/src/wp-includes/class-wp-duotone.php
index b75b01619fbee..0e12e7ca7f306 100644
--- a/src/wp-includes/class-wp-duotone.php
+++ b/src/wp-includes/class-wp-duotone.php
@@ -569,8 +569,8 @@ private static function get_slug_from_attribute( $duotone_attr ) {
*
* @since 6.3.0
*
- * @param string $duotone_attr The duotone attribute from a block.
* @param string|string[] $duotone_attr The duotone attribute from a block.
+ * @return bool True if the duotone preset present and valid.
*/
private static function is_preset( $duotone_attr ) {
if ( ! is_string( $duotone_attr ) ) {
From be1c69c4c97a80a24620b960ff8719a01097751e Mon Sep 17 00:00:00 2001
From: Aki Hamano
Date: Mon, 3 Aug 2026 12:13:24 +0000
Subject: [PATCH 111/151] Administration: Fix install button icon alignment in
theme preview.
When installing a theme from the Details & Preview overlay, the animated icon shown in the Install button was taller than the button itself, so the button grew and its label shifted while the install was in progress. Matching the icon height to the button height keeps the button at a stable size and the icon aligned with the label throughout the updating and updated states.
Developed in: https://github.com/WordPress/wordpress-develop/pull/12799
Follow-up to [62516].
Props eishanoor, kosvrouvas, mosescursor, r1k0, shailu25, ugyensupport, vedantere, wildworks.
Fixes #65601.
git-svn-id: https://develop.svn.wordpress.org/trunk@62972 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/themes.css | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/wp-admin/css/themes.css b/src/wp-admin/css/themes.css
index be495568c89b7..24be8ac58c5be 100644
--- a/src/wp-admin/css/themes.css
+++ b/src/wp-admin/css/themes.css
@@ -1976,6 +1976,11 @@ body.full-overlay-active {
line-height: 2.30769231; /* 30px for 32px height with 13px font */
}
+.theme-install-overlay .wp-full-overlay-header .button.updating-message:before,
+.theme-install-overlay .wp-full-overlay-header .button.updated-message:before {
+ line-height: 1.5; /* 30px (20px * 1.5) - matches the button above */
+}
+
.theme-install-overlay .wp-full-overlay-sidebar {
background: #f0f0f1;
border-right: 1px solid #dcdcde;
From 5bc2c6228c78ca17552a906c2bb4e47a97e87bf8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Maneiro?=
Date: Mon, 3 Aug 2026 14:20:36 +0000
Subject: [PATCH 112/151] View config REST Endpoint: remove `search` and
`page`.
The `search` and `page` parameters source of truth is the URL,
and cannot be configured via the filters.
Props oandregal, ntsekouras, jorgefilipecosta.
Fixes #65577.
git-svn-id: https://develop.svn.wordpress.org/trunk@62973 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-view-config-data.php | 4 +--
.../class-wp-rest-view-config-controller.php | 9 +++----
.../rest-api/rest-view-config-controller.php | 25 +++++++++++++++++++
3 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php
index fa04ced6de026..be85e9dc10d60 100644
--- a/src/wp-includes/class-wp-view-config-data.php
+++ b/src/wp-includes/class-wp-view-config-data.php
@@ -355,13 +355,13 @@ public function replace( array $patch, int $version ) {
*
* ```php
* array(
- * 'default_view' => array( 'search' => 'new search', 'fields' => array( 'newField' ) ),
+ * 'default_view' => array( 'titleField' => 'newTitleField', 'fields' => array( 'newField' ) ),
* 'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ),
* 'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ),
* )
* ```
*
- * - default_view will be updated so the search string is 'new search' and the newField is appended to the list of fields.
+ * - default_view will be updated so the titleField is 'newTitleField' and the newField is appended to the list of fields.
* - default_layouts will be updated so that newField is appended to the badgeFields.
* - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'.
*
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
index 6b23f35cbaf47..64c1ebe1ba921 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php
@@ -391,15 +391,15 @@ public function get_item_schema() {
/**
* Returns the schema properties shared by all view types (ViewBase), excluding 'type'.
*
+ * Note that `search` and `page` are not part of the schema: they are managed
+ * via the URL, which is their only source of truth.
+ *
* @since 7.1.0
*
* @return array Schema properties for the base view configuration.
*/
protected function get_view_base_schema() {
return array(
- 'search' => array(
- 'type' => 'string',
- ),
'filters' => array(
'type' => 'array',
'items' => array(
@@ -444,9 +444,6 @@ protected function get_view_base_schema() {
),
),
),
- 'page' => array(
- 'type' => 'integer',
- ),
'perPage' => array(
'type' => 'integer',
),
diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php
index 9bfbdd67c7021..34fcd55e2466d 100644
--- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php
@@ -385,4 +385,29 @@ public function test_get_item_schema() {
array_keys( $schema['properties'] )
);
}
+
+ /**
+ * `search` and `page` are not part of the view schema: they are managed via
+ * the URL, which is their only source of truth.
+ *
+ * @covers ::get_item_schema
+ */
+ public function test_get_item_schema_excludes_url_managed_view_properties() {
+ $controller = new WP_REST_View_Config_Controller();
+ $schema = $controller->get_item_schema();
+
+ $views = array(
+ 'default_view' => $schema['properties']['default_view']['properties'],
+ 'view_list item view' => $schema['properties']['view_list']['items']['properties']['view']['properties'],
+ 'default_layouts.table' => $schema['properties']['default_layouts']['properties']['table']['properties'],
+ 'default_layouts.grid' => $schema['properties']['default_layouts']['properties']['grid']['properties'],
+ 'default_layouts.list' => $schema['properties']['default_layouts']['properties']['list']['properties'],
+ 'default_layouts.activity' => $schema['properties']['default_layouts']['properties']['activity']['properties'],
+ );
+
+ foreach ( $views as $label => $properties ) {
+ $this->assertArrayNotHasKey( 'search', $properties, "$label should not declare a `search` property." );
+ $this->assertArrayNotHasKey( 'page', $properties, "$label should not declare a `page` property." );
+ }
+ }
}
From dcf58dc786d736306609c4804464db501f74359e Mon Sep 17 00:00:00 2001
From: Jonathan Desrosiers
Date: Mon, 3 Aug 2026 16:56:56 +0000
Subject: [PATCH 113/151] Build/Test Tools: Make runner override variable more
general.
[62891] introduced the ability to override the runner used for a GitHub Actions job using a repository or organization variable. While initially named `PHPUNIT_RUNNER`, overriding the runner for a specific job could be useful in more situations.
This renames the variable chacked to `RUNNER_GROUP`.
Fixes #65749.
git-svn-id: https://develop.svn.wordpress.org/trunk@62974 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/reusable-phpunit-tests-v3.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml
index 4ce4e65b0ba12..abe472e03b6d1 100644
--- a/.github/workflows/reusable-phpunit-tests-v3.yml
+++ b/.github/workflows/reusable-phpunit-tests-v3.yml
@@ -129,7 +129,7 @@ jobs:
# - Submit the test results to the WordPress.org host test results.
phpunit-tests:
name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }}
- runs-on: ${{ vars.PHPUNIT_RUNNER || inputs.os }}
+ runs-on: ${{ vars.RUNNER_GROUP || inputs.os }}
timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }}
permissions:
contents: read
From 878af1bb999fabd044264fa1a63072b3b6cff851 Mon Sep 17 00:00:00 2001
From: Sergey Biryukov
Date: Mon, 3 Aug 2026 18:58:00 +0000
Subject: [PATCH 114/151] External Libraries: Upgrade PHPMailer to version
7.1.1.
This is a maintenance and minor security release.
References:
* [https://github.com/PHPMailer/PHPMailer/releases/tag/v7.1.1 PHPMailer 7.1.1 release notes]
* [https://github.com/PHPMailer/PHPMailer/releases/tag/v7.1.0 PHPMailer 7.1.0 release notes]
* [https://github.com/PHPMailer/PHPMailer/compare/v7.0.2...v7.1.1 Full list of changes in PHPMailer 7.1.1]
Follow-up to [54937], [55557], [56484], [57137], [59246], [59481], [60623], [60813], [60888], [61249], [61468].
Props hareesh-pillai, Synchro, jrf.
Fixes #65790.
git-svn-id: https://develop.svn.wordpress.org/trunk@62975 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/PHPMailer/PHPMailer.php | 99 ++++++++++++++++++++-----
src/wp-includes/PHPMailer/POP3.php | 25 +++++--
src/wp-includes/PHPMailer/SMTP.php | 4 +-
3 files changed, 101 insertions(+), 27 deletions(-)
diff --git a/src/wp-includes/PHPMailer/PHPMailer.php b/src/wp-includes/PHPMailer/PHPMailer.php
index 2bb3578c7e0d9..4900cbc43afef 100644
--- a/src/wp-includes/PHPMailer/PHPMailer.php
+++ b/src/wp-includes/PHPMailer/PHPMailer.php
@@ -59,6 +59,7 @@ class PHPMailer
const ICAL_METHOD_REFRESH = 'REFRESH';
const ICAL_METHOD_COUNTER = 'COUNTER';
const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER';
+ const RFC822_DATE_FORMAT = 'D, j M Y H:i:s O';
/**
* Email priority.
@@ -77,7 +78,7 @@ class PHPMailer
public $CharSet = self::CHARSET_ISO88591;
/**
- * The MIME Content-type of the message.
+ * The MIME Content-Type of the message.
*
* @var string
*/
@@ -159,7 +160,7 @@ class PHPMailer
public $Ical = '';
/**
- * Value-array of "method" in Contenttype header "text/calendar"
+ * Value-array of "method" in Content-Type header "text/calendar"
*
* @var string[]
*/
@@ -768,7 +769,7 @@ class PHPMailer
*
* @var string
*/
- const VERSION = '7.0.2';
+ const VERSION = '7.1.1';
/**
* Error severity: message only, continue processing.
@@ -1283,26 +1284,27 @@ protected function addAnAddress($kind, $address, $name = '')
/**
* Parse and validate a string containing one or more RFC822-style comma-separated email addresses
* of the form "display name " into an array of name/address pairs.
- * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available.
+ * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available and
+ * the deprecated $useimap argument is truthy.
* Note that quotes in the name part are removed.
*
* @see https://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation
*
* @param string $addrstr The address list string
- * @param null $useimap Unused. Argument has been deprecated in PHPMailer 6.11.0.
- * Previously this argument determined whether to use
- * the IMAP extension to parse the list and accepted a boolean value.
+ * @param bool|null $useimap Deprecated in PHPMailer 6.11.0.
+ * Truthy values request the deprecated IMAP parser
+ * and trigger a deprecation warning.
* @param string $charset The charset to use when decoding the address list string.
*
* @return array
*/
public static function parseAddresses($addrstr, $useimap = null, $charset = self::CHARSET_ISO88591)
{
- if ($useimap !== null) {
+ if ($useimap == true) {
trigger_error(self::lang('deprecated_argument') . '$useimap', E_USER_DEPRECATED);
}
$addresses = [];
- if (function_exists('imap_rfc822_parse_adrlist')) {
+ if ($useimap == true && function_exists('imap_rfc822_parse_adrlist')) {
//Use this built-in parser if it's available
// phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.imap_rfc822_parse_adrlistRemoved -- wrapped in function_exists()
$list = imap_rfc822_parse_adrlist($addrstr, '');
@@ -1779,6 +1781,8 @@ public function preSend()
//Trim subject consistently
$this->Subject = trim($this->Subject);
+
+
//Create body before headers in case body makes changes to headers (e.g. altering transfer encoding)
$this->MIMEHeader = '';
$this->MIMEBody = $this->createBody();
@@ -1853,7 +1857,7 @@ public function postSend()
return $this->mailSend($this->MIMEHeader, $this->MIMEBody);
default:
$sendMethod = $this->Mailer . 'Send';
- if (method_exists($this, $sendMethod)) {
+ if (!empty($this->Mailer) && method_exists($this, $sendMethod)) {
return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody);
}
@@ -1911,7 +1915,7 @@ protected function sendmailSend($header, $body)
// CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped.
// Also don't add the -f automatically unless it has been set either via Sender
- // or sendmail_path. Otherwise it can introduce new problems.
+ // or sendmail_path. Otherwise, it can introduce new problems.
// @see http://github.com/PHPMailer/PHPMailer/issues/2298
if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) {
$sendmailArgs[] = '-f' . $this->Sender;
@@ -2510,7 +2514,7 @@ public static function setLanguage($langcode = 'en', $lang_path = '')
'authenticate' => 'SMTP Error: Could not authenticate.',
'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' .
' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' .
- ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.',
+ ' your php.ini, switch to macOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.',
'connect_host' => 'SMTP Error: Could not connect to SMTP host.',
'data_not_accepted' => 'SMTP Error: data not accepted.',
'empty_message' => 'Message body empty',
@@ -2847,7 +2851,10 @@ public function createHeader()
{
$result = '';
- $result .= $this->headerLine('Date', '' === $this->MessageDate ? self::rfcDate() : $this->MessageDate);
+ $result .= $this->headerLine(
+ 'Date',
+ self::sanitiseDate($this->MessageDate)
+ );
//The To header is created automatically by mail(), so needs to be omitted here
if ('mail' !== $this->Mailer) {
@@ -2916,7 +2923,7 @@ public function createHeader()
);
} elseif (is_string($this->XMailer) && trim($this->XMailer) !== '') {
//Some string
- $result .= $this->headerLine('X-Mailer', trim($this->XMailer));
+ $result .= $this->headerLine('X-Mailer', $this->secureHeader(trim($this->XMailer)));
} //Other values result in no X-Mailer header
if ('' !== $this->ConfirmReadingTo) {
@@ -2966,13 +2973,20 @@ public function getMailMIME()
break;
default:
//Catches case 'plain': and case '':
- $result .= $this->textLine('Content-Type: ' . $this->ContentType . '; charset=' . $this->CharSet);
+ $result .= $this->textLine(
+ 'Content-Type: ' .
+ $this->secureHeader($this->ContentType) .
+ '; charset=' . $this->secureHeader($this->CharSet)
+ );
$ismultipart = false;
break;
}
+ if (!$this->validateEncoding($this->Encoding)) {
+ throw new Exception(self::lang('encoding') . $this->Encoding);
+ }
//RFC1341 part 5 says 7bit is assumed if not specified
if (static::ENCODING_7BIT !== $this->Encoding) {
- //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE
+ //RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit, or binary CTE
if ($ismultipart) {
if (static::ENCODING_8BIT === $this->Encoding) {
$result .= $this->headerLine('Content-Transfer-Encoding', static::ENCODING_8BIT);
@@ -3047,6 +3061,9 @@ public function createBody()
$this->setWordWrap();
+ if (!$this->validateEncoding($this->Encoding)) {
+ throw new Exception(self::lang('encoding') . $this->Encoding);
+ }
$bodyEncoding = $this->Encoding;
$bodyCharSet = $this->CharSet;
//Can we do a 7-bit downgrade?
@@ -4166,7 +4183,7 @@ public function addStringEmbeddedImage(
protected function validateEncoding($encoding)
{
return in_array(
- $encoding,
+ strtolower($encoding),
[
self::ENCODING_7BIT,
self::ENCODING_QUOTED_PRINTABLE,
@@ -4426,7 +4443,7 @@ protected function setError($msg)
}
/**
- * Return an RFC 822 formatted date.
+ * Return the current date and time as an RFC 822 formatted date.
*
* @return string
*/
@@ -4436,7 +4453,51 @@ public static function rfcDate()
//Will default to UTC if it's not set properly in php.ini
date_default_timezone_set(@date_default_timezone_get());
- return date('D, j M Y H:i:s O');
+ return date(self::RFC822_DATE_FORMAT);
+ }
+
+ /**
+ * Normalise a user-supplied date into a correctly-formatted RFC 5322 date value
+ * string suitable for use in the Date header.
+ *
+ * Accepts:
+ * - A {@see \DateTime} (or \DateTimeImmutable) object
+ * - Any date/time string understood by PHP's DateTime constructor (RFC 5322, ISO 8601,
+ * Unix timestamp with leading "@", natural-language strings, etc.)
+ *
+ * Dates in the future are not permitted for email headers; if the parsed date is later
+ * than "now" the method falls back to the current time via {@see self::rfcDate()}.
+ * An empty value, a non-string/non-DateTime argument, or any value that cannot be
+ * parsed will likewise fall back to {@see self::rfcDate()}.
+ *
+ * @param \DateTime|\DateTimeImmutable|string $date The date to normalise
+ *
+ * @return string An RFC 5322-formatted date string
+ */
+ private static function sanitiseDate($date)
+ {
+ try {
+ //Ensure the default timezone is set properly
+ date_default_timezone_set(@date_default_timezone_get());
+
+ if ($date instanceof \DateTimeInterface) {
+ $dt = $date;
+ } elseif (is_string($date) && $date !== '') {
+ $dt = new \DateTime($date);
+ } else {
+ //Empty string, null, or any unsupported type
+ return self::rfcDate();
+ }
+
+ //Reject future dates — they are invalid for outgoing message headers
+ if ($dt->getTimestamp() > time()) {
+ return self::rfcDate();
+ }
+
+ return $dt->format(self::RFC822_DATE_FORMAT);
+ } catch (\Exception $e) {
+ return self::rfcDate();
+ }
}
/**
diff --git a/src/wp-includes/PHPMailer/POP3.php b/src/wp-includes/PHPMailer/POP3.php
index 186fe9fe47ab7..0ba9678373217 100644
--- a/src/wp-includes/PHPMailer/POP3.php
+++ b/src/wp-includes/PHPMailer/POP3.php
@@ -47,7 +47,7 @@ class POP3
* @var string
* @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead.
*/
- const VERSION = '7.0.2';
+ const VERSION = '7.1.1';
/**
* Default POP3 port number.
@@ -212,9 +212,9 @@ public function authorise($host, $port = false, $timeout = false, $username = ''
} else {
$this->tval = (int) $timeout;
}
- $this->do_debug = $debug_level;
- $this->username = $username;
- $this->password = $password;
+ $this->do_debug = (int) $debug_level;
+ $this->username = self::stripControls($username);
+ $this->password = self::stripControls($password);
//Reset the error log
$this->errors = [];
//Connect
@@ -319,7 +319,8 @@ public function login($username = '', $password = '')
if (empty($password)) {
$password = $this->password;
}
-
+ $username = self::stripControls($username);
+ $password = self::stripControls($password);
//Send the Username
$this->sendString("USER $username" . static::LE);
$pop3_response = $this->getResponse();
@@ -407,7 +408,7 @@ protected function sendString($string)
/**
* Checks the POP3 server response.
- * Looks for for +OK or -ERR.
+ * Looks for +OK or -ERR.
*
* @param string $string
*
@@ -467,4 +468,16 @@ protected function catchWarning($errno, $errstr, $errfile, $errline)
"errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline"
);
}
+
+ /**
+ * Strip all control chars from a string.
+ *
+ * @param $string
+ *
+ * @return string
+ */
+ protected static function stripControls($string)
+ {
+ return preg_replace('/[\x00-\x1F\x7F]/u', '', $string);
+ }
}
diff --git a/src/wp-includes/PHPMailer/SMTP.php b/src/wp-includes/PHPMailer/SMTP.php
index 559b52c45e8f8..f0957b80a919f 100644
--- a/src/wp-includes/PHPMailer/SMTP.php
+++ b/src/wp-includes/PHPMailer/SMTP.php
@@ -36,7 +36,7 @@ class SMTP
* @var string
* @deprecated This constant will be removed in PHPMailer 8.0. Use `PHPMailer::VERSION` instead.
*/
- const VERSION = '7.0.2';
+ const VERSION = '7.1.1';
/**
* SMTP line break constant.
@@ -1289,7 +1289,7 @@ public function getServerExtList()
* 3. EHLO has been sent -
* $name == 'HELO'|'EHLO': returns the server name
* $name == any other string: if extension $name exists, returns True
- * or its options (e.g. AUTH mechanisms supported). Otherwise returns False.
+ * or its options (e.g. AUTH mechanisms supported). Otherwise, returns False.
*
* @param string $name Name of SMTP extension or 'HELO'|'EHLO'
*
From aea966d3d51a6d8495a08c6d0c22529ed6fa04f8 Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Mon, 3 Aug 2026 19:42:08 +0000
Subject: [PATCH 115/151] Media: Equalize padding for filter bar between list
and grid views.
The list and grid filter panels had different padding following [61757]. This is an undesirable difference, and should be equalized. Apply scoped padding to match the two filter bars.
Developed in https://github.com/WordPress/wordpress-develop/pull/12664
Props afercia, softglaze, khokansardar, joedolson.
Fixes #65697.
git-svn-id: https://develop.svn.wordpress.org/trunk@62976 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/media.css | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/wp-admin/css/media.css b/src/wp-admin/css/media.css
index 5a033b98ba350..3d7b0c9455c83 100644
--- a/src/wp-admin/css/media.css
+++ b/src/wp-admin/css/media.css
@@ -451,6 +451,16 @@ border color while dragging a file over the uploader drop area */
margin: 0 6px 0 0;
}
+/* Match the spacing the grid view toolbar gets from
+ `.attachments-browser .media-toolbar` in media-views.css, so the Media
+ Library filter bar is consistent in both modes. The grid view toolbar is
+ excluded so that rule stays the single source of its own padding: media.css
+ is printed after media-views.css here, so an unscoped rule would override
+ it. */
+.upload-php .wp-filter:not(.media-toolbar) {
+ padding: 12px 16px;
+}
+
/**
* Media Library grid view
*/
From 6693ea1fec9db0f9324262db344d6267abb6e22b Mon Sep 17 00:00:00 2001
From: Joe Dolson
Date: Mon, 3 Aug 2026 19:59:48 +0000
Subject: [PATCH 116/151] Widgets: Always render the On This Day widget.
While the intention was to only render the On This Day widget when it returned results, this proved to create a variety of implementation complications and some significant points of confusion for users.
Remove the conditional rendering of the On This Day widget. When active without posts, display a message inviting the user to publish a new post.
Developed in https://github.com/WordPress/wordpress-develop/pull/12575
Props iamchitti, mirmpro, shailu25, ugyensupport, iamraju, nazmulasif, wildworks, joedolson, mukesh27, annezazu, paaljoachim, joen.
Fixes #65647.
git-svn-id: https://develop.svn.wordpress.org/trunk@62977 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/dashboard-on-this-day.php | 59 +++------
src/wp-admin/includes/dashboard.php | 4 +-
.../tests/admin/wpDashboardOnThisDay.php | 116 +++++-------------
3 files changed, 46 insertions(+), 133 deletions(-)
diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php
index e9557a60720c8..948e128f72c59 100644
--- a/src/wp-admin/includes/dashboard-on-this-day.php
+++ b/src/wp-admin/includes/dashboard-on-this-day.php
@@ -7,48 +7,6 @@
* @since 7.1.0
*/
-/**
- * Registers the On This Day dashboard widget.
- *
- * Designed to be the single entry point called from the dashboard setup
- * routine. The widget is always registered so that it remains available in
- * Screen Options and keeps its user-customized position. When there are no
- * matching posts, a marker class is added to the postbox so the widget can be
- * hidden with CSS.
- *
- * @since 7.1.0
- */
-function wp_dashboard_on_this_day_setup() {
- add_filter( 'postbox_classes_dashboard_wp_dashboard_on_this_day', 'wp_dashboard_on_this_day_postbox_classes' );
-
- wp_add_dashboard_widget(
- 'wp_dashboard_on_this_day',
- __( 'On This Day' ),
- 'wp_dashboard_on_this_day'
- );
-}
-
-/**
- * Hides the On This Day postbox when there are no posts to show.
- *
- * Adds the core `hidden` class so the widget stays registered — preserving its
- * Screen Options entry and user-customized position — while being hidden when
- * empty. A user can still reveal it via Screen Options, in which case the
- * placeholder message is shown.
- *
- * @since 7.1.0
- *
- * @param string[] $classes An array of postbox classes.
- * @return string[] Filtered postbox classes.
- */
-function wp_dashboard_on_this_day_postbox_classes( $classes ) {
- if ( empty( wp_dashboard_on_this_day_get_posts() ) ) {
- $classes[] = 'hidden';
- }
-
- return $classes;
-}
-
/**
* Renders the On This Day dashboard widget.
*
@@ -60,9 +18,20 @@ function wp_dashboard_on_this_day() {
$posts = wp_dashboard_on_this_day_get_posts();
if ( empty( $posts ) ) {
- // Placeholder shown when a user reveals the hidden widget via Screen
- // Options on a day with no matching posts.
- echo '' . esc_html__( 'No posts were published on this day in previous years.' ) . '
';
+ // Placeholder shown on a day with no matching posts in previous years.
+ echo '';
+
+ if ( current_user_can( 'edit_posts' ) ) {
+ printf(
+ /* translators: %s: URL to the new post screen. */
+ __( 'No posts were published on this day in previous years. Write one today , and be reminded about it next year.' ),
+ esc_url( admin_url( 'post-new.php' ) )
+ );
+ } else {
+ echo esc_html__( 'No posts were published on this day in previous years.' );
+ }
+
+ echo '
';
return;
}
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index 5fdbaf7a4fa40..a0c2a23189644 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -89,11 +89,11 @@ function wp_dashboard_setup() {
}
// On This Day.
- if ( ! function_exists( 'wp_dashboard_on_this_day_setup' ) ) {
+ if ( ! function_exists( 'wp_dashboard_on_this_day' ) ) {
require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php';
}
- wp_dashboard_on_this_day_setup();
+ wp_add_dashboard_widget( 'wp_dashboard_on_this_day', __( 'On This Day' ), 'wp_dashboard_on_this_day' );
// WordPress Events and News.
wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
index a2b1cdbfaff1f..728b9bcef64d0 100644
--- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
+++ b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
@@ -10,6 +10,8 @@ class Tests_Admin_wpDashboardOnThisDay extends WP_UnitTestCase {
protected static int $other_user_id;
+ protected static int $subscriber_id;
+
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php';
@@ -25,30 +27,24 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
'role' => 'author',
)
);
+ self::$subscriber_id = $factory->user->create(
+ array(
+ 'display_name' => 'Reader',
+ 'role' => 'subscriber',
+ )
+ );
}
public static function wpTearDownAfterClass() {
self::delete_user( self::$user_id );
self::delete_user( self::$other_user_id );
+ self::delete_user( self::$subscriber_id );
}
- public function tear_down() {
- unset( $GLOBALS['wp_meta_boxes']['dashboard'] );
-
- parent::tear_down();
- }
-
- /**
- * Sets up the globals needed to register dashboard widgets.
- */
- private function set_up_dashboard_screen() {
- if ( ! function_exists( 'wp_add_dashboard_widget' ) ) {
- require_once ABSPATH . 'wp-admin/includes/dashboard.php';
- }
+ public function set_up() {
+ parent::set_up();
set_current_screen( 'dashboard' );
-
- $GLOBALS['wp_meta_boxes']['dashboard'] = array();
}
/**
@@ -119,71 +115,6 @@ private static function get_date_query_clause( string $date ): array {
return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) );
}
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day_setup
- */
- public function test_setup_always_registers_widget_and_postbox_class_filter() {
- $this->set_up_dashboard_screen();
-
- wp_set_current_user( self::$user_id );
-
- wp_dashboard_on_this_day_setup();
-
- $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array();
-
- $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets );
- $this->assertSame( 'On This Day', $dashboard_widgets['wp_dashboard_on_this_day']['title'] );
- $this->assertNotFalse(
- has_filter(
- 'postbox_classes_dashboard_wp_dashboard_on_this_day',
- 'wp_dashboard_on_this_day_postbox_classes'
- )
- );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day_postbox_classes
- */
- public function test_postbox_classes_hides_widget_without_matching_posts() {
- wp_set_current_user( self::$user_id );
-
- $this->assertContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day_postbox_classes
- */
- public function test_postbox_classes_does_not_hide_widget_with_matching_posts() {
- wp_set_current_user( self::$user_id );
- $this->create_matching_post( self::$user_id );
-
- $this->assertNotContains( 'hidden', wp_dashboard_on_this_day_postbox_classes( array( '' ) ) );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day_setup
- */
- public function test_setup_adds_dashboard_widget_with_matching_post_from_another_author() {
- $this->set_up_dashboard_screen();
-
- wp_set_current_user( self::$user_id );
- $this->create_matching_post( self::$other_user_id );
-
- wp_dashboard_on_this_day_setup();
-
- $dashboard_widgets = $GLOBALS['wp_meta_boxes']['dashboard']['normal']['core'] ?? array();
-
- $this->assertArrayHasKey( 'wp_dashboard_on_this_day', $dashboard_widgets );
- }
-
/**
* @ticket 65116
*
@@ -255,9 +186,28 @@ public function test_widget_outputs_placeholder_without_matching_posts() {
$output = ob_get_clean();
$this->assertStringContainsString( 'No posts were published on this day in previous years.', $output );
+ $this->assertStringContainsString( 'Write one today', $output );
+ $this->assertStringContainsString( admin_url( 'post-new.php' ), $output );
$this->assertStringNotContainsString( '', $output );
}
+ /**
+ * @ticket 65116
+ *
+ * @covers ::wp_dashboard_on_this_day
+ */
+ public function test_widget_placeholder_omits_link_without_edit_posts_capability() {
+ wp_set_current_user( self::$subscriber_id );
+
+ ob_start();
+ wp_dashboard_on_this_day();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output );
+ $this->assertStringNotContainsString( 'Write one today', $output );
+ $this->assertStringNotContainsString( admin_url( 'post-new.php' ), $output );
+ }
+
/**
* @ticket 65116
*
@@ -405,8 +355,6 @@ public function test_widget_does_not_append_excerpt_to_titled_posts() {
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() {
- $this->set_up_dashboard_screen();
-
wp_set_current_user( self::$user_id );
$this->create_matching_post(
@@ -440,8 +388,6 @@ public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() {
- $this->set_up_dashboard_screen();
-
wp_set_current_user( self::$user_id );
$post_id = $this->create_matching_post(
@@ -476,8 +422,6 @@ public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() {
* @covers ::wp_dashboard_on_this_day
*/
public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() {
- $this->set_up_dashboard_screen();
-
wp_set_current_user( self::$user_id );
$this->create_matching_post(
From 5c45958340bf33b11ebf60f05a98e933bca7534e Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Mon, 3 Aug 2026 20:38:03 +0000
Subject: [PATCH 117/151] Media: Normalize non-numeric attachment `filesize`
metadata.
The stored `filesize` attachment metadata was read without validation in `wp_prepare_attachment_for_js()` and `attachment_submitbox_metadata()`. Attachment metadata is untyped, and the `filesize` key is commonly written by offloading plugins from a remote storage API response, so it can arrive as a numeric string, or be empty, non-numeric, or negative when the remote lookup fails. A numeric string was passed through verbatim, making `filesizeInBytes` a string in the media modal while the `wp_filesize()` branch of the very same conditional yielded an `int`; a non-numeric value such as `'unknown'` was truthy and suppressed the fallback entirely, so `size_format()` returned `false` and the file size rendered empty even when the real file was readable.
Both call sites now only trust the stored value when it is numeric and casts to an integer greater than zero, and otherwise recompute the size with `wp_filesize()`. The fallback condition also replaces `file_exists()` with `is_readable()` guarded on a non-empty string, since `get_attached_file()` can be filtered to return a non-string. PHPUnit coverage is added for both functions.
Developed in https://github.com/WordPress/wordpress-develop/pull/12632.
Follow-up to r34258, r52837, r62813, r62815.
Props mukesh27, westonruter.
See #65670.
Fixes #65686.
git-svn-id: https://develop.svn.wordpress.org/trunk@62978 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/media.php | 6 +-
src/wp-includes/media.php | 6 +-
tests/phpunit/tests/admin/includesMedia.php | 177 ++++++++++++++++++++
tests/phpunit/tests/media.php | 162 ++++++++++++++++++
4 files changed, 345 insertions(+), 6 deletions(-)
create mode 100644 tests/phpunit/tests/admin/includesMedia.php
diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php
index c2d15d758ef2e..6c50a1daba4fd 100644
--- a/src/wp-admin/includes/media.php
+++ b/src/wp-admin/includes/media.php
@@ -3425,9 +3425,9 @@ function attachment_submitbox_metadata() {
$file_size = false;
- if ( isset( $meta['filesize'] ) ) {
- $file_size = $meta['filesize'];
- } elseif ( file_exists( $file ) ) {
+ if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) {
+ $file_size = (int) $meta['filesize'];
+ } elseif ( is_string( $file ) && '' !== $file && is_readable( $file ) ) {
$file_size = wp_filesize( $file );
}
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 7d0a0b5d0e737..9f98538d2757f 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -4716,9 +4716,9 @@ function wp_prepare_attachment_for_js( $attachment ) {
$attached_file = get_attached_file( $attachment->ID );
- if ( isset( $meta['filesize'] ) ) {
- $bytes = $meta['filesize'];
- } elseif ( file_exists( $attached_file ) ) {
+ if ( isset( $meta['filesize'] ) && is_numeric( $meta['filesize'] ) && (int) $meta['filesize'] > 0 ) {
+ $bytes = (int) $meta['filesize'];
+ } elseif ( is_string( $attached_file ) && '' !== $attached_file && is_readable( $attached_file ) ) {
$bytes = wp_filesize( $attached_file );
} else {
$bytes = '';
diff --git a/tests/phpunit/tests/admin/includesMedia.php b/tests/phpunit/tests/admin/includesMedia.php
new file mode 100644
index 0000000000000..35c542009db8d
--- /dev/null
+++ b/tests/phpunit/tests/admin/includesMedia.php
@@ -0,0 +1,177 @@
+|null $expected The expected file size in bytes, or null if none should be displayed.
+ */
+ public function test_attachment_submitbox_metadata_filesize( $filesize, ?int $expected ) {
+ $id = self::factory()->attachment->create_object(
+ array(
+ 'file' => 'test-image.jpg',
+ 'post_title' => 'Attachment Title',
+ 'post_parent' => 0,
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $this->assertIsInt( $id );
+
+ wp_update_attachment_metadata(
+ $id,
+ array(
+ 'width' => 50,
+ 'height' => 50,
+ 'file' => 'test-image.jpg',
+ 'filesize' => $filesize,
+ )
+ );
+
+ $GLOBALS['post'] = get_post( $id );
+
+ $output = get_echo( 'attachment_submitbox_metadata' );
+
+ if ( null === $expected ) {
+ $this->assertStringNotContainsString( 'misc-pub-filesize', $output, 'The file size should not have been displayed.' );
+ } else {
+ $this->assertStringContainsString( size_format( $expected ), $output, 'The displayed file size did not match the normalized file size.' );
+ }
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array|null }>
+ */
+ public function data_attachment_submitbox_metadata_filesize(): array {
+ return array(
+ 'an integer' => array(
+ 'filesize' => 12345,
+ 'expected' => 12345,
+ ),
+ 'a numeric string' => array(
+ 'filesize' => '12345',
+ 'expected' => 12345,
+ ),
+ 'a float' => array(
+ 'filesize' => 12345.6,
+ 'expected' => 12345,
+ ),
+ 'a float as a string' => array(
+ 'filesize' => '12345.6',
+ 'expected' => 12345,
+ ),
+ 'an exponential string' => array(
+ 'filesize' => '1e3',
+ 'expected' => 1000,
+ ),
+ 'a value smaller than a byte' => array(
+ 'filesize' => 0.5,
+ 'expected' => null,
+ ),
+ 'zero' => array(
+ 'filesize' => 0,
+ 'expected' => null,
+ ),
+ 'a negative integer' => array(
+ 'filesize' => -12345,
+ 'expected' => null,
+ ),
+ 'an empty string' => array(
+ 'filesize' => '',
+ 'expected' => null,
+ ),
+ 'a non-numeric string' => array(
+ 'filesize' => 'not-a-number',
+ 'expected' => null,
+ ),
+ 'an array' => array(
+ 'filesize' => array( 12345 ),
+ 'expected' => null,
+ ),
+ 'null' => array(
+ 'filesize' => null,
+ 'expected' => null,
+ ),
+ 'false' => array(
+ 'filesize' => false,
+ 'expected' => null,
+ ),
+ 'true' => array(
+ 'filesize' => true,
+ 'expected' => null,
+ ),
+ );
+ }
+
+ /**
+ * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file.
+ *
+ * @ticket 65686
+ *
+ * @covers ::attachment_submitbox_metadata
+ *
+ * @dataProvider data_attachment_submitbox_metadata_filesize_falls_back_to_the_file
+ *
+ * @param mixed $filesize The `filesize` value stored in the attachment metadata.
+ */
+ public function test_attachment_submitbox_metadata_filesize_falls_back_to_the_file( $filesize ) {
+ $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+ $this->assertIsInt( $id );
+ $file = get_attached_file( $id );
+ $this->assertIsString( $file );
+
+ $meta = wp_get_attachment_metadata( $id );
+ $this->assertIsArray( $meta );
+ $meta['filesize'] = $filesize;
+ wp_update_attachment_metadata( $id, $meta );
+
+ $GLOBALS['post'] = get_post( $id );
+
+ $output = get_echo( 'attachment_submitbox_metadata' );
+
+ $filesize = wp_filesize( $file );
+ $this->assertIsInt( $filesize );
+ $this->assertStringContainsString( size_format( $filesize ), $output );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_attachment_submitbox_metadata_filesize_falls_back_to_the_file(): array {
+ return array(
+ 'a value smaller than a byte' => array( 'filesize' => 0.5 ),
+ 'zero' => array( 'filesize' => 0 ),
+ 'a negative integer' => array( 'filesize' => -12345 ),
+ 'an empty string' => array( 'filesize' => '' ),
+ 'a non-numeric string' => array( 'filesize' => 'not-a-number' ),
+ 'an array' => array( 'filesize' => array( 12345 ) ),
+ 'null' => array( 'filesize' => null ),
+ 'false' => array( 'filesize' => false ),
+ 'true' => array( 'filesize' => true ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php
index 03fe3b4c02460..a492cf6da189f 100644
--- a/tests/phpunit/tests/media.php
+++ b/tests/phpunit/tests/media.php
@@ -667,6 +667,168 @@ public function test_wp_prepare_attachment_for_js_without_image_sizes() {
$this->assertArrayHasKey( 'sizes', $prepped );
}
+ /**
+ * Tests that a `filesize` stored in the attachment metadata is normalized to a positive integer.
+ *
+ * When the stored value cannot be normalized, it should be treated as missing so that the
+ * filesystem fallback runs instead.
+ *
+ * @ticket 65686
+ *
+ * @dataProvider data_wp_prepare_attachment_for_js_filesize
+ *
+ * @param mixed $filesize The `filesize` value stored in the attachment metadata.
+ * @param int<0, max>|null $expected The expected `filesizeInBytes` value, or null if it should not be set.
+ */
+ public function test_wp_prepare_attachment_for_js_filesize( $filesize, ?int $expected ) {
+ $id = self::factory()->attachment->create_object(
+ array(
+ 'file' => 'test-image.jpg',
+ 'post_title' => 'Attachment Title',
+ 'post_parent' => 0,
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+ $this->assertIsInt( $id );
+
+ wp_update_attachment_metadata(
+ $id,
+ array(
+ 'width' => 50,
+ 'height' => 50,
+ 'file' => 'test-image.jpg',
+ 'filesize' => $filesize,
+ )
+ );
+
+ $post = get_post( $id );
+ $this->assertInstanceOf( WP_Post::class, $post );
+ $prepped = wp_prepare_attachment_for_js( $post );
+ $this->assertIsArray( $prepped );
+
+ if ( null === $expected ) {
+ $this->assertArrayNotHasKey( 'filesizeInBytes', $prepped, 'The filesize should not have been set.' );
+ $this->assertArrayNotHasKey( 'filesizeHumanReadable', $prepped, 'The human readable filesize should not have been set.' );
+ } else {
+ $this->assertSame( $expected, $prepped['filesizeInBytes'], 'The filesize was not normalized to an integer.' );
+ $this->assertSame( size_format( $expected ), $prepped['filesizeHumanReadable'], 'The human readable filesize did not match the normalized filesize.' );
+ }
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array|null }>
+ */
+ public function data_wp_prepare_attachment_for_js_filesize(): array {
+ return array(
+ 'an integer' => array(
+ 'filesize' => 12345,
+ 'expected' => 12345,
+ ),
+ 'a numeric string' => array(
+ 'filesize' => '12345',
+ 'expected' => 12345,
+ ),
+ 'a float' => array(
+ 'filesize' => 12345.6,
+ 'expected' => 12345,
+ ),
+ 'a float as a string' => array(
+ 'filesize' => '12345.6',
+ 'expected' => 12345,
+ ),
+ 'an exponential string' => array(
+ 'filesize' => '1e3',
+ 'expected' => 1000,
+ ),
+ 'a value smaller than a byte' => array(
+ 'filesize' => 0.5,
+ 'expected' => null,
+ ),
+ 'zero' => array(
+ 'filesize' => 0,
+ 'expected' => null,
+ ),
+ 'a negative integer' => array(
+ 'filesize' => -12345,
+ 'expected' => null,
+ ),
+ 'an empty string' => array(
+ 'filesize' => '',
+ 'expected' => null,
+ ),
+ 'a non-numeric string' => array(
+ 'filesize' => 'not-a-number',
+ 'expected' => null,
+ ),
+ 'an array' => array(
+ 'filesize' => array( 12345 ),
+ 'expected' => null,
+ ),
+ 'null' => array(
+ 'filesize' => null,
+ 'expected' => null,
+ ),
+ 'false' => array(
+ 'filesize' => false,
+ 'expected' => null,
+ ),
+ 'true' => array(
+ 'filesize' => true,
+ 'expected' => null,
+ ),
+ );
+ }
+
+ /**
+ * Tests that an unusable `filesize` in the attachment metadata falls back to the size of the file.
+ *
+ * @ticket 65686
+ *
+ * @dataProvider data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file
+ *
+ * @param mixed $filesize The `filesize` value stored in the attachment metadata.
+ */
+ public function test_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file( $filesize ) {
+ $id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+ $this->assertIsInt( $id );
+ $post = get_post( $id );
+ $this->assertInstanceOf( WP_Post::class, $post );
+ $file = get_attached_file( $id );
+ $this->assertIsString( $file );
+
+ $meta = wp_get_attachment_metadata( $id );
+ $this->assertIsArray( $meta );
+ $meta['filesize'] = $filesize;
+ wp_update_attachment_metadata( $id, $meta );
+
+ $prepped = wp_prepare_attachment_for_js( $post );
+ $this->assertIsArray( $prepped );
+ $this->assertArrayHasKey( 'filesizeInBytes', $prepped );
+
+ $this->assertSame( wp_filesize( $file ), $prepped['filesizeInBytes'] );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_wp_prepare_attachment_for_js_filesize_falls_back_to_the_file(): array {
+ return array(
+ 'a value smaller than a byte' => array( 'filesize' => 0.5 ),
+ 'zero' => array( 'filesize' => 0 ),
+ 'a negative integer' => array( 'filesize' => -12345 ),
+ 'an empty string' => array( 'filesize' => '' ),
+ 'a non-numeric string' => array( 'filesize' => 'not-a-number' ),
+ 'an array' => array( 'filesize' => array( 12345 ) ),
+ 'null' => array( 'filesize' => null ),
+ 'false' => array( 'filesize' => false ),
+ 'true' => array( 'filesize' => true ),
+ );
+ }
+
/**
* @ticket 19067
* @expectedDeprecated wp_convert_bytes_to_hr
From 4af02ef896b2b7b75177a0406fd7b26379780506 Mon Sep 17 00:00:00 2001
From: Peter Wilson
Date: Tue, 4 Aug 2026 01:51:49 +0000
Subject: [PATCH 118/151] Widgets: Revert On This Day dashboard widget.
The On This Day dashboard widget has been bumped from the WordPress 7.1 release to the 7.2 release pending design and behavioural improvements.
This reverts r62977, r62968, r62852, r62681.
Props annezazu, matt, peterwilsoncc.
Fixes #65801.
git-svn-id: https://develop.svn.wordpress.org/trunk@63001 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/css/dashboard.css | 29 --
.../includes/dashboard-on-this-day.php | 223 --------
src/wp-admin/includes/dashboard.php | 7 -
.../tests/admin/wpDashboardOnThisDay.php | 477 ------------------
4 files changed, 736 deletions(-)
delete mode 100644 src/wp-admin/includes/dashboard-on-this-day.php
delete mode 100644 tests/phpunit/tests/admin/wpDashboardOnThisDay.php
diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css
index 860fe9696b873..17a9e312b85c6 100644
--- a/src/wp-admin/css/dashboard.css
+++ b/src/wp-admin/css/dashboard.css
@@ -1025,35 +1025,6 @@ body #dashboard-widgets .postbox form .submit {
top: 0;
}
-/* On This Day dashboard widget */
-
-#wp_dashboard_on_this_day h3 {
- font-weight: 600;
-}
-
-#wp_dashboard_on_this_day li {
- margin: 0;
- padding: 0;
-}
-
-#wp_dashboard_on_this_day ul ul {
- margin: 0 0 0 18px;
- padding: 0;
- list-style: disc;
-}
-
-#wp_dashboard_on_this_day ul ul li + li {
- margin-top: 6px;
-}
-
-#wp_dashboard_on_this_day .wp-on-this-day-widget > ul > li + li {
- margin-top: 16px;
-}
-
-#wp_dashboard_on_this_day .wp-on-this-day-post-author {
- color: #646970;
-}
-
/* Browse happy box */
#dashboard-widgets #dashboard_browser_nag.postbox .inside {
diff --git a/src/wp-admin/includes/dashboard-on-this-day.php b/src/wp-admin/includes/dashboard-on-this-day.php
deleted file mode 100644
index 948e128f72c59..0000000000000
--- a/src/wp-admin/includes/dashboard-on-this-day.php
+++ /dev/null
@@ -1,223 +0,0 @@
-';
-
- if ( current_user_can( 'edit_posts' ) ) {
- printf(
- /* translators: %s: URL to the new post screen. */
- __( 'No posts were published on this day in previous years. Write one today , and be reminded about it next year.' ),
- esc_url( admin_url( 'post-new.php' ) )
- );
- } else {
- echo esc_html__( 'No posts were published on this day in previous years.' );
- }
-
- echo '';
- return;
- }
-
- $posts_by_year = array();
- $post_count = count( $posts );
-
- foreach ( $posts as $post ) {
- $year = get_the_date( 'Y', $post );
-
- if ( ! isset( $posts_by_year[ $year ] ) ) {
- $posts_by_year[ $year ] = array();
- }
-
- $posts_by_year[ $year ][] = $post;
- }
-
- /* translators: Date format for the On This Day widget date, without year. See https://www.php.net/manual/datetime.format.php */
- $date = '' . esc_html( wp_date( _x( 'F jS', 'on this day date format' ) ) ) . ' ';
- ?>
-
- format( 'Y' );
- $date_query = array(
- 'relation' => 'AND',
- array(
- 'before' => array( 'year' => $year ),
- ),
- _wp_dashboard_on_this_day_date_query_clause( $today ),
- );
-
- $args = array(
- 'post_type' => 'post',
- 'post_status' => array( 'publish' ),
- 'posts_per_page' => 10,
- 'ignore_sticky_posts' => true,
- 'orderby' => 'date',
- 'order' => 'DESC',
- 'no_found_rows' => true,
- 'update_post_term_cache' => false,
- 'update_post_meta_cache' => false,
- 'date_query' => $date_query,
- );
-
- /**
- * Filters the arguments used to query posts for the On This Day dashboard widget.
- *
- * @since 7.1.0
- *
- * @param array $args WP_Query arguments.
- */
- $args = apply_filters( 'wp_dashboard_on_this_day_query_args', $args );
-
- $query = new WP_Query( $args );
-
- return $query->posts;
-}
-
-/**
- * Builds the date query clause for today's anniversary date.
- *
- * On February 28 in a non-leap year, February 29 posts are included so
- * leap-day anniversaries still appear.
- *
- * @since 7.1.0
- * @access private
- *
- * @param DateTimeInterface $date Date to build the clause for.
- * @return array Date query clause.
- */
-function _wp_dashboard_on_this_day_date_query_clause( $date ) {
- $month = (int) $date->format( 'm' );
- $day = (int) $date->format( 'd' );
- $clause = array(
- 'month' => $month,
- 'day' => $day,
- );
-
- // Display leap day posts on Feb 28 in non leap years.
- if (
- 28 === $day
- && 2 === $month
- && false === (bool) $date->format( 'L' )
- ) {
- $clause = array(
- 'relation' => 'OR',
- $clause,
- array(
- 'month' => 2,
- 'day' => 29,
- ),
- );
- }
-
- return $clause;
-}
diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php
index a0c2a23189644..0fe5c62064b64 100644
--- a/src/wp-admin/includes/dashboard.php
+++ b/src/wp-admin/includes/dashboard.php
@@ -88,13 +88,6 @@ function wp_dashboard_setup() {
wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' );
}
- // On This Day.
- if ( ! function_exists( 'wp_dashboard_on_this_day' ) ) {
- require_once ABSPATH . 'wp-admin/includes/dashboard-on-this-day.php';
- }
-
- wp_add_dashboard_widget( 'wp_dashboard_on_this_day', __( 'On This Day' ), 'wp_dashboard_on_this_day' );
-
// WordPress Events and News.
wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
diff --git a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php b/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
deleted file mode 100644
index 728b9bcef64d0..0000000000000
--- a/tests/phpunit/tests/admin/wpDashboardOnThisDay.php
+++ /dev/null
@@ -1,477 +0,0 @@
-user->create(
- array(
- 'display_name' => 'Current Writer',
- 'role' => 'author',
- )
- );
- self::$other_user_id = $factory->user->create(
- array(
- 'display_name' => 'Guest Writer',
- 'role' => 'author',
- )
- );
- self::$subscriber_id = $factory->user->create(
- array(
- 'display_name' => 'Reader',
- 'role' => 'subscriber',
- )
- );
- }
-
- public static function wpTearDownAfterClass() {
- self::delete_user( self::$user_id );
- self::delete_user( self::$other_user_id );
- self::delete_user( self::$subscriber_id );
- }
-
- public function set_up() {
- parent::set_up();
-
- set_current_screen( 'dashboard' );
- }
-
- /**
- * Creates a published post on the widget's prior-year calendar day.
- *
- * @param int $author_id Author ID.
- * @param string $title Post title.
- * @param int $years_ago Number of years before today.
- * @param string $time Post time.
- * @param array $post_args Additional post arguments.
- * @return int Post ID.
- */
- private function create_matching_post(
- int $author_id,
- string $title = 'A memory from last year',
- int $years_ago = 1,
- string $time = '12:00:00',
- array $post_args = array()
- ): int {
- $post_date = current_datetime()->modify( '-' . $years_ago . ' years' )->format( 'Y-m-d' ) . ' ' . $time;
-
- return self::factory()->post->create(
- array_merge(
- array(
- 'post_author' => $author_id,
- 'post_date' => $post_date,
- 'post_date_gmt' => get_gmt_from_date( $post_date ),
- 'post_status' => 'publish',
- 'post_title' => $title,
- ),
- $post_args
- )
- );
- }
-
- /**
- * Creates a published post near, but not on, today's prior-year calendar day.
- *
- * @param int $author_id Author ID.
- * @param string $title Post title.
- * @param int $day_offset Number of days from today's prior-year calendar day.
- * @return int Post ID.
- */
- private function create_nearby_post( int $author_id, string $title = 'Almost a memory', int $day_offset = 1 ): int {
- $post_date = current_datetime()
- ->modify( '-1 year' )
- ->modify( ( $day_offset >= 0 ? '+' : '' ) . $day_offset . ' days' )
- ->format( 'Y-m-d' ) . ' 12:00:00';
-
- return self::factory()->post->create(
- array(
- 'post_author' => $author_id,
- 'post_date' => $post_date,
- 'post_date_gmt' => get_gmt_from_date( $post_date ),
- 'post_status' => 'publish',
- 'post_title' => $title,
- )
- );
- }
-
- /**
- * Invokes _wp_dashboard_on_this_day_date_query_clause().
- *
- * @param string $date Date string.
- * @return array Date query clause.
- */
- private static function get_date_query_clause( string $date ): array {
- return _wp_dashboard_on_this_day_date_query_clause( new DateTimeImmutable( $date, wp_timezone() ) );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::_wp_dashboard_on_this_day_date_query_clause
- */
- public function test_get_date_query_clause_includes_february_29_on_february_28_in_non_leap_year() {
- $clause = self::get_date_query_clause( '2023-02-28 12:00:00' );
-
- $this->assertSame(
- array(
- 'relation' => 'OR',
- array(
- 'month' => 2,
- 'day' => 28,
- ),
- array(
- 'month' => 2,
- 'day' => 29,
- ),
- ),
- $clause
- );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::_wp_dashboard_on_this_day_date_query_clause
- */
- public function test_get_date_query_clause_does_not_include_february_29_on_february_28_in_leap_year() {
- $clause = self::get_date_query_clause( '2024-02-28 12:00:00' );
-
- $this->assertSame(
- array(
- 'month' => 2,
- 'day' => 28,
- ),
- $clause
- );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::_wp_dashboard_on_this_day_date_query_clause
- */
- public function test_get_date_query_clause_matches_february_29_on_leap_day() {
- $clause = self::get_date_query_clause( '2024-02-29 12:00:00' );
-
- $this->assertSame(
- array(
- 'month' => 2,
- 'day' => 29,
- ),
- $clause
- );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_outputs_placeholder_without_matching_posts() {
- wp_set_current_user( self::$user_id );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output );
- $this->assertStringContainsString( 'Write one today', $output );
- $this->assertStringContainsString( admin_url( 'post-new.php' ), $output );
- $this->assertStringNotContainsString( '', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_placeholder_omits_link_without_edit_posts_capability() {
- wp_set_current_user( self::$subscriber_id );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output );
- $this->assertStringNotContainsString( 'Write one today', $output );
- $this->assertStringNotContainsString( admin_url( 'post-new.php' ), $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_ignores_nearby_prior_year_posts() {
- wp_set_current_user( self::$user_id );
- $this->create_nearby_post( self::$user_id );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringNotContainsString( 'Almost a memory', $output );
- $this->assertStringContainsString( 'No posts were published on this day in previous years.', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_uses_singular_copy_for_a_single_post() {
- wp_set_current_user( self::$user_id );
- $this->create_matching_post( self::$user_id );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( 'One post has been published on ' . wp_date( 'F jS' ) . ' :', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_labels_posts_from_other_authors() {
- wp_set_current_user( self::$user_id );
-
- $this->create_matching_post( self::$user_id, 'A note from me' );
- $this->create_matching_post( self::$other_user_id, 'A note from someone else' );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( 'A note from me', $output );
- $this->assertStringNotContainsString( 'by Current Writer', $output );
- $this->assertStringContainsString( 'A note from someone else', $output );
- $this->assertStringContainsString( 'by Guest Writer', $output );
- $this->assertStringContainsString( 'by Guest Writer ', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_groups_posts_by_year() {
- wp_set_current_user( self::$user_id );
-
- $this->create_matching_post( self::$user_id, 'Pretending to meditate', 1, '12:00:00' );
- $this->create_matching_post( self::$user_id, 'Slow internet and good books', 1, '11:00:00' );
- $this->create_matching_post( self::$user_id, 'Late-night shipping log', 2, '12:00:00' );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $last_year = current_datetime()->modify( '-1 year' )->format( 'Y' );
- $two_years_ago = current_datetime()->modify( '-2 years' )->format( 'Y' );
-
- $this->assertStringContainsString( '3 posts have been published on ' . wp_date( 'F jS' ) . ' :', $output );
- $this->assertStringContainsString( '' . $last_year . ' ', $output );
- $this->assertStringContainsString( '' . $two_years_ago . ' ', $output );
- $this->assertStringContainsString( 'Pretending to meditate', $output );
- $this->assertStringContainsString( 'Slow internet and good books', $output );
- $this->assertStringContainsString( 'Late-night shipping log', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_includes_trimmed_excerpt_for_untitled_posts() {
- wp_set_current_user( self::$user_id );
-
- $words = array();
- for ( $n = 1; $n <= 20; $n++ ) {
- $words[] = 'word' . $n;
- }
-
- $this->create_matching_post(
- self::$user_id,
- '',
- 1,
- '12:00:00',
- array(
- 'post_excerpt' => implode( ' ', $words ),
- )
- );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( '(no title)', $output );
- $this->assertStringContainsString( 'word15', $output, 'The 15th word should be present.' );
- $this->assertStringNotContainsString( 'word16', $output, 'The 16th word should be trimmed.' );
- $this->assertStringContainsString( '…', $output, 'The excerpt should end with an ellipsis.' );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_does_not_append_excerpt_to_titled_posts() {
- wp_set_current_user( self::$user_id );
-
- $this->create_matching_post(
- self::$user_id,
- 'A titled anniversary memory',
- 1,
- '12:00:00',
- array(
- 'post_excerpt' => 'This excerpt should not be shown.',
- )
- );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( 'A titled anniversary memory', $output );
- $this->assertStringNotContainsString( 'This excerpt should not be shown.', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_includes_trimmed_excerpt_for_untitled_private_posts_authored_by_current_user() {
- wp_set_current_user( self::$user_id );
-
- $this->create_matching_post(
- self::$user_id,
- '',
- 1,
- '12:00:00',
- array(
- 'post_excerpt' => 'Readable private anniversary memory.',
- 'post_status' => 'private',
- )
- );
-
- add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
-
- ob_start();
- try {
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
- } finally {
- remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
- }
-
- $this->assertStringContainsString( '(no title)', $output );
- $this->assertStringContainsString( 'Readable private anniversary memory.', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_hides_untitled_post_excerpt_for_unreadable_posts() {
- wp_set_current_user( self::$user_id );
-
- $post_id = $this->create_matching_post(
- self::$other_user_id,
- '',
- 1,
- '12:00:00',
- array(
- 'post_excerpt' => 'Unreadable private anniversary memory.',
- 'post_status' => 'private',
- )
- );
-
- add_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
-
- ob_start();
- try {
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
- } finally {
- remove_filter( 'wp_dashboard_on_this_day_query_args', array( $this, 'filter_on_this_day_query_private_posts' ) );
- }
-
- $this->assertFalse( current_user_can( 'read_post', $post_id ) );
- $this->assertStringContainsString( '(no title)', $output );
- $this->assertStringNotContainsString( 'Unreadable private anniversary memory.', $output );
- }
-
- /**
- * @ticket 65116
- *
- * @covers ::wp_dashboard_on_this_day
- */
- public function test_widget_hides_untitled_post_excerpt_for_password_protected_posts() {
- wp_set_current_user( self::$user_id );
-
- $this->create_matching_post(
- self::$user_id,
- '',
- 1,
- '12:00:00',
- array(
- 'post_excerpt' => 'Private anniversary memory.',
- 'post_password' => 'secret',
- )
- );
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringNotContainsString( 'Private anniversary memory.', $output );
- }
-
- /**
- * @covers ::wp_dashboard_on_this_day
- * @covers ::wp_dashboard_on_this_day_get_posts
- */
- public function test_widget_limits_posts_to_ten() {
- wp_set_current_user( self::$user_id );
-
- for ( $years_ago = 1; $years_ago <= 11; $years_ago++ ) {
- $this->create_matching_post( self::$user_id, 'Anniversary post ' . $years_ago, $years_ago );
- }
-
- ob_start();
- wp_dashboard_on_this_day();
- $output = ob_get_clean();
-
- $this->assertStringContainsString( '10 posts have been published on ' . wp_date( 'F jS' ) . ' :', $output );
- $this->assertMatchesRegularExpression( '/>\s*Anniversary post 1\s*<\/a>/', $output );
- $this->assertMatchesRegularExpression( '/>\s*Anniversary post 10\s*<\/a>/', $output );
- $this->assertStringNotContainsString( 'Anniversary post 11', $output );
- }
-
- /**
- * Filters the On This Day query to include private posts.
- *
- * @param array $args WP_Query arguments.
- * @return array Filtered query arguments.
- */
- public function filter_on_this_day_query_private_posts( $args ) {
- $args['post_status'] = array( 'private' );
-
- return $args;
- }
-}
From c0155cd2bea0722270b5f15b2a9ce78b2d78e375 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Tue, 4 Aug 2026 01:56:04 +0000
Subject: [PATCH 119/151] Media: Normalize unusable `sizes` attachment
metadata.
Attachment metadata is untyped, and the `sizes` key is not guaranteed to be present or to hold an array. Sub-size generation can leave it out entirely, and a plugin filtering `wp_get_attachment_metadata` can replace it with anything. `wp_save_image()` validated only that the metadata itself was an array before passing `$meta['sizes']` to `array_merge()`, so an absent or scalar value raised a `TypeError` and the image editor returned an HTTP 500 mid-save. `wp_restore_image()` had the same gap at `$meta['sizes'][ $default_size ] = $data`, where a string raises "Cannot use a scalar value as an array" and `false` is deprecated as of PHP 8.1 and an error as of PHP 9.
`wp_get_attachment_metadata()` now returns `false` whenever the metadata is not an array, on the `$unfiltered` path as well as after the filter, matching the documented `array|false` return. A `sizes` key holding a non-array is replaced with an empty array, so every caller can rely on the key being an array whenever it is present. The key is not invented when it is absent: audio, video and document attachments legitimately store metadata without it, and callers such as `wp-admin/post.php` read the metadata unfiltered in order to modify it and write it back, so normalizing there would persist into the database.
The image editor entry points fill in the missing key themselves, and `wp_prepare_attachment_for_js()` now checks the dimensions of the `full` entry alongside its filename before reading them, removing the "Undefined array key" warnings raised for a `sizes` array that carries no usable `full` size. PHPUnit coverage is added for all three functions.
Developed in https://github.com/WordPress/wordpress-develop/pull/12744.
Follow-up to r11965, r23873, r38949, r49084, r62978.
Props josephscott, westonruter, mukesh27, irozum, ugyensupport, nazmulasif.
See #65686, #64898.
Fixes #65748.
git-svn-id: https://develop.svn.wordpress.org/trunk@63002 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/image-edit.php | 6 +-
src/wp-includes/media.php | 5 +-
src/wp-includes/post.php | 16 +-
.../phpunit/tests/ajax/wpAjaxImageEditor.php | 132 ++++++++
tests/phpunit/tests/media.php | 150 ++++++++++
.../tests/post/wpGetAttachmentMetadata.php | 281 ++++++++++++++++++
6 files changed, 586 insertions(+), 4 deletions(-)
create mode 100644 tests/phpunit/tests/post/wpGetAttachmentMetadata.php
diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php
index a192ef0000c17..2f6bc25740e2d 100644
--- a/src/wp-admin/includes/image-edit.php
+++ b/src/wp-admin/includes/image-edit.php
@@ -820,11 +820,13 @@ function wp_restore_image( $post_id ) {
$restored = false;
$msg = new stdClass();
- if ( ! is_array( $backup_sizes ) ) {
+ if ( ! is_array( $meta ) || ! is_array( $backup_sizes ) ) {
$msg->error = __( 'Cannot load image metadata.' );
return $msg;
}
+ $meta['sizes'] ??= array();
+
$parts = pathinfo( $file );
$suffix = time() . rand( 100, 999 );
$default_sizes = get_intermediate_image_sizes();
@@ -983,6 +985,8 @@ function wp_save_image( $post_id ) {
return $return;
}
+ $meta['sizes'] ??= array();
+
if ( ! is_array( $backup_sizes ) ) {
$backup_sizes = array();
}
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 9f98538d2757f..a31e77b00e26c 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -4814,7 +4814,10 @@ function wp_prepare_attachment_for_js( $attachment ) {
}
$response = array_merge( $response, $sizes['full'] );
- } elseif ( $meta['sizes']['full']['file'] ) {
+ } elseif (
+ ! empty( $meta['sizes']['full']['file'] ) &&
+ isset( $meta['sizes']['full']['width'], $meta['sizes']['full']['height'] )
+ ) {
$sizes['full'] = array(
'url' => esc_url_raw( $base_url . $meta['sizes']['full']['file'] ),
'height' => $meta['sizes']['full']['height'],
diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php
index da3abfbd7d61c..2db73e9a20476 100644
--- a/src/wp-includes/post.php
+++ b/src/wp-includes/post.php
@@ -7048,6 +7048,8 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) {
*
* @since 2.1.0
* @since 6.0.0 The `$filesize` value was added to the returned array.
+ * @since 7.1.0 `false` is now returned if the metadata is not an array, and when the result is
+ * filtered the `sizes` key is always an array when present.
*
* @param int $attachment_id Attachment post ID. Defaults to global $post.
* @param bool $unfiltered Optional. If true, filters are not run. Default false.
@@ -7111,7 +7113,7 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) {
$data = get_post_meta( $attachment_id, '_wp_attachment_metadata', true );
- if ( ! $data ) {
+ if ( ! is_array( $data ) || ! $data ) {
return false;
}
@@ -7127,7 +7129,17 @@ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) {
* @param array $data Array of meta data for the given attachment.
* @param int $attachment_id Attachment post ID.
*/
- return apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id );
+ $data = apply_filters( 'wp_get_attachment_metadata', $data, $attachment_id );
+
+ if ( ! is_array( $data ) ) {
+ return false;
+ }
+
+ if ( array_key_exists( 'sizes', $data ) && ! is_array( $data['sizes'] ) ) {
+ $data['sizes'] = array();
+ }
+
+ return $data;
}
/**
diff --git a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php
index 205f61636149c..03f14f6dd8fa8 100644
--- a/tests/phpunit/tests/ajax/wpAjaxImageEditor.php
+++ b/tests/phpunit/tests/ajax/wpAjaxImageEditor.php
@@ -194,4 +194,136 @@ public function test_filesize_restored_after_restoring_original_image() {
$this->assertSameSetsWithIndex( $pre_file_sizes, $post_restore_file_sizes, 'Filesize should have restored after restoring the original image.' );
}
+
+ /**
+ * Ensure editing an image does not fatal when the attachment metadata has no usable `sizes` data.
+ *
+ * Attachment metadata is not guaranteed to contain a `sizes` array. It can be missing when
+ * sub-size generation never ran or failed (for example `wp_create_image_subsizes()` returns an
+ * empty array when the file cannot be parsed), or when it is removed by a plugin filtering
+ * `wp_get_attachment_metadata`. `wp_save_image()` only validates that the metadata itself is an
+ * array, then passes `$meta['sizes']` straight to `array_merge()`.
+ *
+ * @ticket 65748
+ *
+ * @covers ::wp_save_image
+ *
+ * @dataProvider data_save_image_with_unusable_sizes_metadata
+ *
+ * @param array{ sizes?: mixed } $meta Attachment metadata to store before editing, minus the file-specific keys.
+ */
+ public function test_save_image_with_unusable_sizes_metadata( array $meta ) {
+ require_once ABSPATH . 'wp-admin/includes/image-edit.php';
+
+ $filename = DIR_TESTDATA . '/images/canola.jpg';
+ $contents = file_get_contents( $filename );
+ $this->assertIsString( $contents );
+
+ $upload = wp_upload_bits( wp_basename( $filename ), null, $contents );
+ $id = $this->_make_attachment( $upload );
+ $this->assertIsInt( $id );
+
+ $original_meta = wp_get_attachment_metadata( $id );
+ $this->assertIsArray( $original_meta );
+
+ // Keep the real file/dimension data, only make `sizes` unusable.
+ $meta = array_merge(
+ wp_array_slice_assoc( $original_meta, array( 'width', 'height', 'file', 'filesize' ) ),
+ $meta
+ );
+
+ wp_update_attachment_metadata( $id, $meta );
+
+ $_REQUEST['action'] = 'image-editor';
+ $_REQUEST['context'] = 'edit-attachment';
+ $_REQUEST['postid'] = $id;
+ $_REQUEST['target'] = 'all';
+ $_REQUEST['do'] = 'save';
+ $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]';
+
+ $ret = wp_save_image( $id );
+
+ $this->assertObjectNotHasProperty( 'error', $ret, 'Saving the image should not have returned an error.' );
+
+ $saved_meta = wp_get_attachment_metadata( $id );
+
+ $this->assertIsArray( $saved_meta, 'The saved attachment metadata should be an array.' );
+ $this->assertArrayHasKey( 'sizes', $saved_meta );
+ $this->assertIsArray( $saved_meta['sizes'], 'The saved attachment metadata should contain a `sizes` array.' );
+ $this->assertArrayHasKey( 'thumbnail', $saved_meta['sizes'], 'The edited image should have regenerated the thumbnail size.' );
+ }
+
+ /**
+ * Ensure restoring an image does not fatal when the attachment metadata has no usable `sizes` data.
+ *
+ * `wp_restore_image()` writes each backed up size with `$meta['sizes'][ $default_size ] = $data`
+ * without ever checking that `$meta['sizes']` is an array. A scalar value raises
+ * "Cannot use a scalar value as an array", and `false` is deprecated as of PHP 8.1 and
+ * an error as of PHP 9. The same metadata that fatals `wp_save_image()` reaches this code.
+ *
+ * @ticket 65748
+ *
+ * @covers ::wp_restore_image
+ *
+ * @dataProvider data_save_image_with_unusable_sizes_metadata
+ *
+ * @param array{ sizes?: mixed } $meta Replacement `sizes` metadata to store before restoring.
+ */
+ public function test_restore_image_with_unusable_sizes_metadata( array $meta ) {
+ require_once ABSPATH . 'wp-admin/includes/image-edit.php';
+
+ $filename = DIR_TESTDATA . '/images/canola.jpg';
+ $contents = file_get_contents( $filename );
+ $this->assertIsString( $contents );
+
+ $upload = wp_upload_bits( wp_basename( $filename ), null, $contents );
+ $id = $this->_make_attachment( $upload );
+ $this->assertIsInt( $id );
+
+ $_REQUEST['action'] = 'image-editor';
+ $_REQUEST['context'] = 'edit-attachment';
+ $_REQUEST['postid'] = $id;
+ $_REQUEST['target'] = 'all';
+ $_REQUEST['do'] = 'save';
+ $_REQUEST['history'] = '[{"c":{"x":5,"y":8,"w":289,"h":322}}]';
+
+ // Edit the image first so that `_wp_attachment_backup_sizes` holds the original sizes.
+ wp_save_image( $id );
+
+ $this->assertNotEmpty(
+ get_post_meta( $id, '_wp_attachment_backup_sizes', true ),
+ 'The image edit should have stored backup sizes to restore from.'
+ );
+
+ // Keep the metadata written by the edit, only make `sizes` unusable.
+ $edited_meta = wp_get_attachment_metadata( $id );
+ $this->assertIsArray( $edited_meta );
+ unset( $edited_meta['sizes'] );
+
+ wp_update_attachment_metadata( $id, array_merge( $edited_meta, $meta ) );
+
+ wp_restore_image( $id );
+
+ $restored_meta = wp_get_attachment_metadata( $id );
+ $this->assertIsArray( $restored_meta );
+
+ $this->assertArrayHasKey( 'sizes', $restored_meta );
+ $this->assertIsArray( $restored_meta['sizes'], 'The restored attachment metadata should contain a `sizes` array.' );
+ $this->assertArrayHasKey( 'thumbnail', $restored_meta['sizes'], 'The restored image should have the thumbnail size restored from the backup sizes.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_save_image_with_unusable_sizes_metadata(): array {
+ return array(
+ 'no sizes key' => array( array() ),
+ 'null sizes' => array( array( 'sizes' => null ) ),
+ 'empty string' => array( array( 'sizes' => '' ) ),
+ 'string sizes' => array( array( 'sizes' => 'not-an-array' ) ),
+ 'boolean sizes' => array( array( 'sizes' => false ) ),
+ );
+ }
}
diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php
index a492cf6da189f..5aee3f8b6955f 100644
--- a/tests/phpunit/tests/media.php
+++ b/tests/phpunit/tests/media.php
@@ -667,6 +667,153 @@ public function test_wp_prepare_attachment_for_js_without_image_sizes() {
$this->assertArrayHasKey( 'sizes', $prepped );
}
+ /**
+ * Tests that an unusable `full` entry in the `sizes` metadata is skipped.
+ *
+ * Attachments that are not images, such as PDFs, are handled by a separate branch that reads
+ * the `full` entry of the `sizes` metadata directly. That entry is not guaranteed to be there,
+ * nor to carry dimensions when it is, and reading it unconditionally raises "Undefined array
+ * key" warnings.
+ *
+ * @ticket 65748
+ *
+ * @dataProvider data_wp_prepare_attachment_for_js_unusable_full_size
+ *
+ * @covers ::wp_prepare_attachment_for_js
+ *
+ * @param array $sizes Value to store as the `sizes` metadata.
+ */
+ public function test_wp_prepare_attachment_for_js_with_an_unusable_full_size( array $sizes ) {
+ $id = $this->create_pdf_attachment( $sizes );
+
+ $prepped = wp_prepare_attachment_for_js( $id );
+
+ $this->assertIsArray( $prepped );
+ $this->assertArrayHasKey( 'sizes', $prepped );
+
+ $sizes = $prepped['sizes'];
+
+ $this->assertIsArray( $sizes );
+ $this->assertArrayNotHasKey( 'full', $sizes, 'An unusable `full` size should not have been exposed.' );
+ }
+
+ /**
+ * Tests that a usable `full` entry in the `sizes` metadata is still exposed.
+ *
+ * @ticket 65748
+ *
+ * @covers ::wp_prepare_attachment_for_js
+ */
+ public function test_wp_prepare_attachment_for_js_with_a_usable_full_size() {
+ $id = $this->create_pdf_attachment(
+ array(
+ 'full' => array(
+ 'file' => 'test-document-pdf.jpg',
+ 'width' => 232,
+ 'height' => 300,
+ 'mime-type' => 'image/jpeg',
+ ),
+ )
+ );
+
+ $prepped = wp_prepare_attachment_for_js( $id );
+
+ $this->assertIsArray( $prepped );
+ $this->assertArrayHasKey( 'sizes', $prepped );
+
+ $sizes = $prepped['sizes'];
+
+ $this->assertIsArray( $sizes );
+ $this->assertArrayHasKey( 'full', $sizes, 'A usable `full` size should have been exposed.' );
+
+ $full = $sizes['full'];
+
+ $this->assertIsArray( $full );
+ $this->assertSame( 232, $full['width'] );
+ $this->assertSame( 300, $full['height'] );
+ $this->assertSame( 'portrait', $full['orientation'] );
+ $this->assertIsString( $full['url'] );
+ $this->assertStringEndsWith( '/test-document-pdf.jpg', $full['url'] );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array }>
+ */
+ public function data_wp_prepare_attachment_for_js_unusable_full_size(): array {
+ return array(
+ 'no full size' => array(
+ array(
+ 'thumbnail' => array(
+ 'file' => 'test-document-pdf-116x150.jpg',
+ 'width' => 116,
+ 'height' => 150,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ ),
+ 'full without dimensions' => array(
+ array(
+ 'full' => array(
+ 'file' => 'test-document-pdf.jpg',
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ ),
+ 'full without a height' => array(
+ array(
+ 'full' => array(
+ 'file' => 'test-document-pdf.jpg',
+ 'width' => 232,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ ),
+ 'full with an empty file' => array(
+ array(
+ 'full' => array(
+ 'file' => '',
+ 'width' => 232,
+ 'height' => 300,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Creates a PDF attachment carrying the given `sizes` metadata.
+ *
+ * A PDF is used so that wp_prepare_attachment_for_js() takes the branch for attachments that
+ * are not images, which is the one that reads the `full` entry of the `sizes` metadata.
+ *
+ * @param array $sizes Value to store as the `sizes` metadata.
+ * @return int Attachment ID.
+ */
+ private function create_pdf_attachment( array $sizes ): int {
+ $id = wp_insert_attachment(
+ array(
+ 'post_title' => 'Attachment Title',
+ 'post_type' => 'attachment',
+ 'post_parent' => 0,
+ 'post_mime_type' => 'application/pdf',
+ 'guid' => home_url( '/wp-content/uploads/test-document.pdf' ),
+ )
+ );
+
+ wp_update_attachment_metadata(
+ $id,
+ array(
+ 'file' => 'test-document.pdf',
+ 'sizes' => $sizes,
+ )
+ );
+
+ return $id;
+ }
+
/**
* Tests that a `filesize` stored in the attachment metadata is normalized to a positive integer.
*
@@ -3178,6 +3325,9 @@ public function test_get_image_send_to_editor_defaults_no_caption_no_rel() {
*
* @ticket 36246
* @requires function imagejpeg
+ *
+ * @covers ::wp_get_attachment_image
+ * @covers ::wp_get_attachment_metadata
*/
public function test_wp_get_attachment_image_should_use_wp_get_attachment_metadata() {
add_filter( 'wp_get_attachment_metadata', array( $this, 'filter_36246' ), 10, 2 );
diff --git a/tests/phpunit/tests/post/wpGetAttachmentMetadata.php b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php
new file mode 100644
index 0000000000000..028356a04168d
--- /dev/null
+++ b/tests/phpunit/tests/post/wpGetAttachmentMetadata.php
@@ -0,0 +1,281 @@
+create_attachment();
+
+ $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) );
+ }
+
+ /**
+ * Ensure stored metadata that is not an array is reported as a failure.
+ *
+ * The documented return of `array|false` has to hold on the `$unfiltered` path too, since
+ * callers such as wp-admin/post.php read the metadata that way in order to modify it and
+ * pass it back to wp_update_attachment_metadata().
+ *
+ * @ticket 65748
+ *
+ * @dataProvider data_non_array_stored_metadata_values
+ *
+ * @param mixed $metadata Value to store as `_wp_attachment_metadata`.
+ */
+ public function test_should_return_false_when_the_stored_metadata_is_not_an_array( $metadata ) {
+ $attachment_id = $this->create_attachment();
+
+ update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata );
+
+ $this->assertFalse( wp_get_attachment_metadata( $attachment_id ), 'The filtered metadata should have been reported as missing.' );
+ $this->assertFalse( wp_get_attachment_metadata( $attachment_id, true ), 'The unfiltered metadata should have been reported as missing.' );
+ }
+
+ /**
+ * Ensure the `sizes` key is not invented for attachments that have no sub-sizes.
+ *
+ * An attachment is not necessarily an image. Audio, video and document attachments
+ * legitimately store metadata without a `sizes` key, and fabricating one would both
+ * blur that distinction and pollute the stored metadata for any caller that reads
+ * the metadata, modifies it, and passes it back to wp_update_attachment_metadata().
+ *
+ * @ticket 65748
+ */
+ public function test_should_not_add_a_sizes_key_when_the_metadata_has_none() {
+ $metadata = array(
+ 'bitrate' => 128000,
+ 'length' => 191,
+ 'fileformat' => 'mp3',
+ );
+
+ $attachment_id = $this->create_attachment( $metadata );
+
+ $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) );
+ }
+
+ /**
+ * Ensure a usable `sizes` array is passed through untouched.
+ *
+ * @ticket 65748
+ */
+ public function test_should_preserve_a_usable_sizes_array() {
+ $metadata = array(
+ 'file' => '2026/08/image.jpg',
+ 'sizes' => array(
+ 'thumbnail' => array(
+ 'file' => 'image-150x150.jpg',
+ 'width' => 150,
+ 'height' => 150,
+ 'mime-type' => 'image/jpeg',
+ ),
+ ),
+ );
+
+ $attachment_id = $this->create_attachment( $metadata );
+
+ $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id ) );
+ }
+
+ /**
+ * Ensure a `sizes` key holding something other than an array is replaced with an empty array.
+ *
+ * Callers such as wp_save_image() pass `$meta['sizes']` straight to array_merge(), which
+ * is a fatal error for a scalar. Guarding the value here means every caller can rely on
+ * `sizes` being an array whenever the key is present.
+ *
+ * @ticket 65748
+ *
+ * @dataProvider data_non_array_sizes_values
+ *
+ * @param mixed $sizes Value to store under the `sizes` key.
+ */
+ public function test_should_replace_a_non_array_sizes_value_with_an_empty_array( $sizes ) {
+ $attachment_id = $this->create_attachment(
+ array(
+ 'file' => '2026/08/image.jpg',
+ 'sizes' => $sizes,
+ )
+ );
+
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+
+ $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' );
+ $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' );
+ $this->assertSame( array(), $metadata['sizes'], 'The unusable `sizes` value should have been replaced.' );
+ }
+
+ /**
+ * Ensure the stored metadata is returned verbatim when filters are skipped.
+ *
+ * Passing `$unfiltered` as true is documented as skipping the filters, and callers such as
+ * wp-admin/post.php read the metadata this way in order to modify and re-save it. Normalizing
+ * the value here would write the normalization back into the database.
+ *
+ * @ticket 65748
+ *
+ * @dataProvider data_non_array_sizes_values
+ *
+ * @param mixed $sizes Value to store under the `sizes` key.
+ */
+ public function test_should_not_replace_a_non_array_sizes_value_when_unfiltered( $sizes ) {
+ $metadata = array(
+ 'file' => '2026/08/image.jpg',
+ 'sizes' => $sizes,
+ );
+
+ $attachment_id = $this->create_attachment( $metadata );
+
+ $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) );
+ }
+
+ /**
+ * Ensure a filtered value that is not an array is reported as a failure.
+ *
+ * The function documents a return of `array|false`, so a filter returning something else
+ * should surface as a failure rather than being handed to callers that expect an array.
+ *
+ * @ticket 65748
+ *
+ * @dataProvider data_non_array_filter_return_values
+ *
+ * @param mixed $value Value for the filter to return.
+ */
+ public function test_should_return_false_when_the_filter_returns_a_non_array( $value ) {
+ $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) );
+
+ add_filter(
+ 'wp_get_attachment_metadata',
+ static function () use ( $value ) {
+ return $value;
+ }
+ );
+
+ $this->assertFalse( wp_get_attachment_metadata( $attachment_id ) );
+ }
+
+ /**
+ * Ensure the `sizes` value is normalized after the filter has run, not before.
+ *
+ * @ticket 65748
+ */
+ public function test_should_normalize_a_sizes_value_introduced_by_the_filter() {
+ // Stored without a `sizes` key, so the key can only come from the filter.
+ $attachment_id = $this->create_attachment( array( 'file' => '2026/08/image.jpg' ) );
+
+ add_filter(
+ 'wp_get_attachment_metadata',
+ static function ( array $data ): array {
+ $data['sizes'] = 'not-an-array';
+ return $data;
+ }
+ );
+
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+
+ $this->assertIsArray( $metadata, 'The metadata should have been returned as an array.' );
+ $this->assertArrayHasKey( 'sizes', $metadata, 'The `sizes` key should still be present.' );
+ $this->assertSame( array(), $metadata['sizes'], 'The value set by the filter should have been replaced.' );
+ }
+
+ /**
+ * Ensure the filter is not applied when filters are skipped.
+ */
+ public function test_should_not_apply_the_filter_when_unfiltered() {
+ $metadata = array( 'file' => '2026/08/image.jpg' );
+
+ $attachment_id = $this->create_attachment( $metadata );
+
+ add_filter( 'wp_get_attachment_metadata', '__return_empty_array' );
+
+ $this->assertSame( $metadata, wp_get_attachment_metadata( $attachment_id, true ) );
+ }
+
+ /**
+ * Data provider.
+ *
+ * Only values that survive a round trip through the meta table are listed. A value that
+ * comes back falsy, such as an empty string, was already treated as missing metadata.
+ *
+ * @return array
+ */
+ public function data_non_array_stored_metadata_values(): array {
+ return array(
+ 'string' => array( 'not-an-array' ),
+ 'integer' => array( 1 ),
+ 'float' => array( 1.5 ),
+ 'object' => array( new stdClass() ),
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_non_array_sizes_values(): array {
+ return array(
+ 'null' => array( null ),
+ 'empty string' => array( '' ),
+ 'string' => array( 'not-an-array' ),
+ 'boolean false' => array( false ),
+ 'integer' => array( 0 ),
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_non_array_filter_return_values(): array {
+ return array(
+ 'null' => array( null ),
+ 'empty string' => array( '' ),
+ 'string' => array( 'not-an-array' ),
+ 'boolean false' => array( false ),
+ 'boolean true' => array( true ),
+ 'integer' => array( 1 ),
+ 'float' => array( 1.5 ),
+ 'object' => array( new stdClass() ),
+ );
+ }
+
+ /**
+ * Creates an attachment, optionally storing metadata for it.
+ *
+ * The metadata is stored with update_post_meta() rather than wp_update_attachment_metadata()
+ * so that it reaches the database without passing through the update filter, leaving the
+ * stored value entirely under the control of the test.
+ *
+ * @param array|null $metadata Optional. Metadata to store as
+ * `_wp_attachment_metadata`. Default null, meaning
+ * no metadata is stored at all.
+ * @return int Attachment ID.
+ */
+ private function create_attachment( ?array $metadata = null ): int {
+ $attachment_id = self::factory()->attachment->create_object(
+ array(
+ 'file' => '2026/08/image.jpg',
+ 'post_mime_type' => 'image/jpeg',
+ )
+ );
+
+ $this->assertIsInt( $attachment_id, 'Failed to create the attachment fixture.' );
+
+ if ( null !== $metadata ) {
+ update_post_meta( $attachment_id, '_wp_attachment_metadata', $metadata );
+ }
+
+ return $attachment_id;
+ }
+}
From 275a37a6b1663031f01ff4481acf0aec5bb4bc0c Mon Sep 17 00:00:00 2001
From: Jonathan Desrosiers
Date: Tue, 4 Aug 2026 02:03:30 +0000
Subject: [PATCH 120/151] Build/Test Tools: Further refine runner override
variable name.
This changes `RUNNER_GROUP` to `RUNNERS_NAME` to avoid confusion with the `runs-on.group` setting, which is configured in a completely different way.
Props lancewillet.
See #65749.
git-svn-id: https://develop.svn.wordpress.org/trunk@63003 602fd350-edb4-49c9-b593-d223f7449a82
---
.github/workflows/reusable-phpunit-tests-v3.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml
index abe472e03b6d1..6a7f49fba468c 100644
--- a/.github/workflows/reusable-phpunit-tests-v3.yml
+++ b/.github/workflows/reusable-phpunit-tests-v3.yml
@@ -129,7 +129,7 @@ jobs:
# - Submit the test results to the WordPress.org host test results.
phpunit-tests:
name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }}
- runs-on: ${{ vars.RUNNER_GROUP || inputs.os }}
+ runs-on: ${{ vars.RUNNERS_NAME || inputs.os }}
timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }}
permissions:
contents: read
From 8c3c976c251d4fd2225b6eaa788b0678c1927206 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Tue, 4 Aug 2026 02:50:15 +0000
Subject: [PATCH 121/151] Build/Test Tools: Expand suggested extensions in
`composer.json`.
The `suggest` section had accumulated only four extensions, added ad hoc as individual changes happened to need them. It now lists every extension that the Hosting handbook's server environment page [https://make.wordpress.org/hosting/handbook/server-environment/#required-extensions identifies] as required, highly recommended, or suggested. This lets development environments be provisioned to match what core actually expects, and gives IDEs an accurate picture of which functions are available.
The `require` section is deliberately left unchanged: the `mysqli` extension stays a suggestion rather than a requirement since a `db.php` drop-in can supply the database layer without it.
Developed in https://github.com/WordPress/wordpress-develop/pull/12384.
Follow-up to r56687, r62529, r62637.
Fixes #65571.
git-svn-id: https://develop.svn.wordpress.org/trunk@63004 602fd350-edb4-49c9-b593-d223f7449a82
---
composer.json | 27 ++++++++++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/composer.json b/composer.json
index 5505c9136c263..1bff1b4d62dd7 100644
--- a/composer.json
+++ b/composer.json
@@ -16,10 +16,35 @@
"php": ">=7.4"
},
"suggest": {
+ "ext-apcu": "*",
+ "ext-bc": "*",
+ "ext-curl": "*",
"ext-dom": "*",
+ "ext-exif": "*",
+ "ext-fileinfo": "*",
+ "ext-filter": "*",
"ext-ftp": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-igbinary": "*",
+ "ext-imagick": "*",
+ "ext-intl": "*",
+ "ext-mbstring": "*",
+ "ext-memcached": "*",
"ext-mysqli": "*",
- "ext-ssh2": "*"
+ "ext-opcache": "*",
+ "ext-openssl": "*",
+ "ext-redis": "*",
+ "ext-shmop": "*",
+ "ext-simplexml": "*",
+ "ext-sockets": "*",
+ "ext-sodium": "*",
+ "ext-ssh2": "*",
+ "ext-timezonedb": "*",
+ "ext-xml": "*",
+ "ext-xmlreader": "*",
+ "ext-zip": "*",
+ "ext-zlib": "*"
},
"require-dev": {
"composer/ca-bundle": "1.5.13",
From 0040ded7216de5597637f81b3117d119b736160b Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Tue, 4 Aug 2026 05:13:18 +0000
Subject: [PATCH 122/151] Build/Test Tools: Add `@phpstan-assert` on
`assertIXRError` and `assertNotIXRError`.
See #64898.
git-svn-id: https://develop.svn.wordpress.org/trunk@63005 602fd350-edb4-49c9-b593-d223f7449a82
---
tests/phpunit/includes/abstract-testcase.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php
index 55a9924fb23c3..98b3456935716 100644
--- a/tests/phpunit/includes/abstract-testcase.php
+++ b/tests/phpunit/includes/abstract-testcase.php
@@ -898,6 +898,8 @@ public function assertNotWPError( $actual, $message = '' ) {
*
* @param mixed $actual The value to check.
* @param string $message Optional. Message to display when the assertion fails.
+ *
+ * @phpstan-assert IXR_Error $actual
*/
public function assertIXRError( $actual, $message = '' ) {
$this->assertInstanceOf( 'IXR_Error', $actual, $message );
@@ -908,6 +910,8 @@ public function assertIXRError( $actual, $message = '' ) {
*
* @param mixed $actual The value to check.
* @param string $message Optional. Message to display when the assertion fails.
+ *
+ * @phpstan-assert !IXR_Error $actual
*/
public function assertNotIXRError( $actual, $message = '' ) {
if ( $actual instanceof IXR_Error ) {
From 1ef9d70aea32f272b3680408d7c4761c8ca32445 Mon Sep 17 00:00:00 2001
From: Weston Ruter
Date: Tue, 4 Aug 2026 07:25:22 +0000
Subject: [PATCH 123/151] XML-RPC: Validate the attachment data in
`mw_newMediaObject()`.
Passing anything other than a struct as the fourth argument caused a fatal error, and because the struct was read before the login was attempted, an unauthenticated request was enough to trigger it.
Read and validate the struct only once the request is authenticated and the `upload_files` capability is confirmed, as every other method on the server does, and reject a call with too few arguments using `minimum_args()`. The `name`, `type` and `bits` members must all be strings: a struct sent for `bits` reached `fwrite()` by way of `wp_upload_bits()` and threw a `TypeError`, while one sent for `type` survived `sanitize_mime_type()` to reach the database as the attachment's post MIME type. A `name` left empty by `sanitize_file_name()` is now reported as a malformed request too, rather than as the server failure `wp_upload_bits()` produced for it.
The fourth argument is expanded into a nested hash in the documentation, covering the previously undocumented `post_id` member. Tests cover each rejected shape, the optional members that remain tolerated when absent, and the ordering of the login and capability checks ahead of the validation.
Developed in https://github.com/WordPress/wordpress-develop/pull/12482.
Follow-up to r32579, r53881.
Props josephscott, westonruter, mukesh27.
See #65600.
Fixes #65611.
git-svn-id: https://develop.svn.wordpress.org/trunk@63006 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-xmlrpc-server.php | 40 ++-
tests/phpunit/tests/xmlrpc/wp/uploadFile.php | 306 +++++++++++++++++++
2 files changed, 340 insertions(+), 6 deletions(-)
diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php
index 7d64d3f46c019..1061dbd1831d2 100644
--- a/src/wp-includes/class-wp-xmlrpc-server.php
+++ b/src/wp-includes/class-wp-xmlrpc-server.php
@@ -6440,24 +6440,33 @@ public function mw_getCategories( $args ) {
* @since 1.5.0
*
* @param array $args {
- * Method arguments. Note: arguments must be ordered as documented.
+ * Method arguments. Note: top-level arguments must be ordered as documented.
*
* @type int $0 Blog ID (unused).
* @type string $1 Username.
* @type string $2 Password.
- * @type array $3 Data.
+ * @type array $3 {
+ * Data for the file to upload.
+ *
+ * @type string $name File name. Sanitized with sanitize_file_name().
+ * @type string $type Optional. File MIME type, stored as the attachment's
+ * post MIME type. Default empty string.
+ * @type string $bits Optional. File contents. Default empty string.
+ * @type int $post_id Optional. ID of the post to attach the file to.
+ * Default 0.
+ * }
* }
* @return array|IXR_Error
*/
public function mw_newMediaObject( $args ) {
+ if ( ! $this->minimum_args( $args, 4 ) ) {
+ return $this->error;
+ }
+
$username = $this->escape( $args[1] );
$password = $this->escape( $args[2] );
$data = $args[3];
- $name = sanitize_file_name( $data['name'] );
- $type = $data['type'];
- $bits = $data['bits'];
-
$user = $this->login( $username, $password );
if ( ! $user ) {
return $this->error;
@@ -6471,6 +6480,25 @@ public function mw_newMediaObject( $args ) {
return $this->error;
}
+ if (
+ ! is_array( $data ) ||
+ ! is_string( $data['name'] ?? null ) ||
+ ! is_string( $data['type'] ?? '' ) ||
+ ! is_string( $data['bits'] ?? '' )
+ ) {
+ return new IXR_Error( 400, __( 'Invalid attachment data.' ) );
+ }
+
+ $name = sanitize_file_name( $data['name'] );
+
+ // A name consisting only of characters the sanitizer strips leaves nothing to write to.
+ if ( '' === $name ) {
+ return new IXR_Error( 400, __( 'Invalid attachment data.' ) );
+ }
+
+ $type = $data['type'] ?? '';
+ $bits = $data['bits'] ?? '';
+
if ( is_multisite() && upload_is_user_over_quota( false ) ) {
$this->error = new IXR_Error(
401,
diff --git a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php
index 00cb601b28f3d..4dab3333fd8a0 100644
--- a/tests/phpunit/tests/xmlrpc/wp/uploadFile.php
+++ b/tests/phpunit/tests/xmlrpc/wp/uploadFile.php
@@ -34,4 +34,310 @@ public function test_valid_attachment() {
$this->assertIsString( $result['url'] );
$this->assertIsString( $result['type'] );
}
+
+ /**
+ * Tests that a non-array data argument returns an error instead of
+ * triggering a fatal error.
+ *
+ * The data argument (the fourth parameter) is expected to be a struct,
+ * which is passed to the method as an array. When it is any other type,
+ * the method must return an IXR_Error rather than attempting to access
+ * array offsets on a non-array value.
+ *
+ * @ticket 65611
+ *
+ * @covers wp_xmlrpc_server::mw_newMediaObject
+ */
+ public function test_invalid_attachment_data_should_return_error() {
+ $this->make_user_by_role( 'editor' );
+
+ $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', 'not-a-struct' ) );
+ $this->assertIXRError( $result, 'A non-array data argument should return an IXR_Error.' );
+ $this->assertSame( 400, $result->code, 'The error code should be 400.' );
+ }
+
+ /**
+ * Tests that an anonymous request with a non-array data argument returns
+ * the login error rather than triggering a fatal error.
+ *
+ * The reported fatal error was reached without credentials because the
+ * data struct was read before the login was attempted. The struct must
+ * only be read once the request is authenticated.
+ *
+ * @ticket 65611
+ *
+ * @covers wp_xmlrpc_server::mw_newMediaObject
+ */
+ public function test_anonymous_request_with_invalid_attachment_data_should_return_login_error() {
+ $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'not-a-user', 'not-a-password', 'not-a-struct' ) );
+ $this->assertIXRError( $result, 'An anonymous request should return an IXR_Error.' );
+ $this->assertSame( 403, $result->code, 'The error code should be the 403 returned for a failed login.' );
+ }
+
+ /**
+ * Tests that a user who cannot upload files is rejected before the data is
+ * read.
+ *
+ * The capability is checked ahead of the attachment data, so a user who is
+ * not allowed to upload is told that rather than being told the data is
+ * malformed. Sending unusable data must not change which error comes back.
+ *
+ * @ticket 65611
+ *
+ * @covers wp_xmlrpc_server::mw_newMediaObject
+ */
+ public function test_incapable_user() {
+ $this->make_user_by_role( 'subscriber' );
+
+ $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'subscriber', 'subscriber', 'not-a-struct' ) );
+ $this->assertIXRError( $result, 'A user who cannot upload files should return an IXR_Error.' );
+ $this->assertSame( 401, $result->code, 'The error code should be the 401 returned for a missing capability.' );
+ }
+
+ /**
+ * Tests that too few arguments return an error instead of emitting a PHP
+ * notice for the undefined arguments.
+ *
+ * @ticket 65611
+ *
+ * @covers wp_xmlrpc_server::mw_newMediaObject
+ *
+ * @dataProvider data_insufficient_arguments
+ *
+ * @param list $args The arguments to pass to the method.
+ */
+ public function test_insufficient_arguments_should_return_error( array $args ) {
+ $this->make_user_by_role( 'editor' );
+
+ $result = $this->myxmlrpcserver->mw_newMediaObject( $args );
+ $this->assertIXRError( $result, 'Insufficient arguments should return an IXR_Error.' );
+ $this->assertSame( 400, $result->code, 'The error code should be 400.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array