From 23ad3cac1bc9e7f52cd7655c856b5067b35562e7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 21:50:08 -0500 Subject: [PATCH 1/5] Notes: Store the blocks a note was added to with the note. A note's link to its block lives in the post content and only persists when the post is saved, so a note added to a post that isn't saved afterwards is orphaned. Register `_wp_note_anchor` comment meta so the editor can store a snapshot of the note's blocks and re-attach the note on load, and delete the snapshot once a saved post links to the note. Claude-Session: https://claude.ai/code/session_018vwYjS8MTZ2CfXecy2yRiU --- src/wp-includes/comment.php | 108 +++++++++++- src/wp-includes/default-filters.php | 3 + .../comment/wpDeletePersistedNoteAnchors.php | 156 ++++++++++++++++++ .../rest-api/rest-comments-controller.php | 69 ++++++++ 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b6847266eb162..82ab7c5e02f9a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4628,9 +4628,10 @@ function _wp_check_for_scheduled_update_comment_type() { } /** - * Register initial note status meta. + * Registers initial note meta. * * @since 6.9.0 + * @since 7.3.0 Added the `_wp_note_anchor` meta. */ function wp_create_initial_comment_meta() { register_meta( @@ -4651,6 +4652,111 @@ function wp_create_initial_comment_meta() { }, ) ); + + /* + * A note's link to its block, `metadata.noteId` and the inline note marker, + * is part of the post content and only persists when the post is saved. The + * editor stores the blocks as they stood when the note was added, so a note + * added to a post that isn't saved afterwards can be re-attached on load. + */ + register_meta( + 'comment', + '_wp_note_anchor', + array( + 'type' => 'array', + 'description' => __( 'The blocks the note was added to, as they stood at the time.' ), + 'single' => true, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'path' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + ), + 'content' => array( 'type' => 'string' ), + ), + 'additionalProperties' => false, + ), + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); +} + +/** + * Collects the IDs of the notes that blocks link to through `metadata.noteId`. + * + * @since 7.3.0 + * @access private + * + * @param array $blocks Parsed blocks, as returned by parse_blocks(). + * @return int[] Note IDs. + */ +function _wp_get_note_ids_from_blocks( array $blocks ): array { + $note_ids = array(); + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + continue; + } + $metadata = is_array( $block['attrs'] ?? null ) ? ( $block['attrs']['metadata'] ?? null ) : null; + $ids = is_array( $metadata ) ? ( $metadata['noteId'] ?? null ) : null; + foreach ( (array) $ids as $id ) { + if ( is_numeric( $id ) ) { + $note_ids[] = (int) $id; + } + } + if ( is_array( $block['innerBlocks'] ?? null ) ) { + $note_ids = array_merge( $note_ids, _wp_get_note_ids_from_blocks( $block['innerBlocks'] ) ); + } + } + return $note_ids; +} + +/** + * Deletes the block snapshots of the notes a saved post now links to. + * + * Once the post content links to a note, the snapshot stored in the note's + * `_wp_note_anchor` meta is no longer needed. Notes the saved content doesn't + * link to keep theirs, since another user may have saved content from before + * the note was added. + * + * @since 7.3.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool $update Whether this is an existing post being updated. + */ +function wp_delete_persisted_note_anchors( $post_id, $post, $update ): void { + if ( ! $update || 'revision' === $post->post_type ) { + return; + } + + $note_ids = get_comments( + array( + 'post_id' => $post_id, + 'type' => 'note', + 'parent' => 0, + 'meta_key' => '_wp_note_anchor', + 'fields' => 'ids', + ) + ); + if ( ! is_array( $note_ids ) || ! $note_ids ) { + return; + } + + $linked = _wp_get_note_ids_from_blocks( parse_blocks( $post->post_content ) ); + foreach ( $note_ids as $note_id ) { + $note_id = (int) $note_id; + if ( $note_id > 0 && in_array( $note_id, $linked, true ) ) { + delete_comment_meta( $note_id, '_wp_note_anchor' ); + } + } } /** diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3c1234f9cfa8e..1fd856fc975a0 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -809,6 +809,9 @@ // Inline note markers. add_filter( 'render_block', 'wp_strip_inline_note_markers' ); +// Note anchor snapshots. +add_action( 'wp_after_insert_post', 'wp_delete_persisted_note_anchors', 10, 3 ); + // User preferences. add_action( 'init', 'wp_register_persisted_preferences_meta' ); diff --git a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php new file mode 100644 index 0000000000000..e135b2f77979b --- /dev/null +++ b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php @@ -0,0 +1,156 @@ +, content: string}> + */ + private const ANCHOR = array( + array( + 'path' => array( 0 ), + 'content' => "\n

Hello

\n", + ), + ); + + /** + * Creates a top-level note with a snapshot on a new post. + * + * @return array{post_id: int, note_id: int} + */ + private function create_note_with_anchor(): array { + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_content' => 'A note.', + ) + ); + add_comment_meta( $note_id, '_wp_note_anchor', self::ANCHOR ); + + return array( + 'post_id' => $post_id, + 'note_id' => $note_id, + ); + } + + /** + * Returns paragraph block markup linked to the given note IDs. + * + * @param int[] $note_ids Note IDs. + * @return string Block markup. + */ + private function paragraph_with_notes( array $note_ids ): string { + $attrs = wp_json_encode( array( 'metadata' => array( 'noteId' => $note_ids ) ) ); + return "\n

Hello

\n"; + } + + public function test_registers_note_anchor_meta(): void { + $this->assertTrue( registered_meta_key_exists( 'comment', '_wp_note_anchor' ) ); + } + + public function test_deletes_anchor_when_saved_content_links_to_note(): void { + $ids = $this->create_note_with_anchor(); + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => $this->paragraph_with_notes( array( $ids['note_id'] ) ), + ) + ); + + $this->assertSame( '', get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + public function test_deletes_anchor_when_note_is_linked_from_an_inner_block(): void { + $ids = $this->create_note_with_anchor(); + $content = "\n
" . $this->paragraph_with_notes( array( $ids['note_id'] ) ) . "
\n"; + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => $content, + ) + ); + + $this->assertSame( '', get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + public function test_keeps_anchor_when_saved_content_does_not_link_to_note(): void { + $ids = $this->create_note_with_anchor(); + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => "\n

Hello

\n", + ) + ); + + $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + public function test_keeps_anchor_of_a_note_on_another_post(): void { + $ids = $this->create_note_with_anchor(); + $other = self::factory()->post->create(); + + wp_update_post( + array( + 'ID' => $other, + 'post_content' => $this->paragraph_with_notes( array( $ids['note_id'] ) ), + ) + ); + + $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + public function test_ignores_malformed_note_ids(): void { + $ids = $this->create_note_with_anchor(); + $content = '' . "\n

Hello

\n"; + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => $content, + ) + ); + + $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + /** + * @dataProvider data_get_note_ids_from_blocks + * + * @param string $content Block markup. + * @param int[] $expected Expected note IDs. + */ + public function test_get_note_ids_from_blocks( string $content, array $expected ): void { + $this->assertSame( $expected, _wp_get_note_ids_from_blocks( parse_blocks( $content ) ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_get_note_ids_from_blocks(): array { + return array( + 'no notes' => array( "\n

Hi

\n", array() ), + 'single id' => array( '

Hi

', array( 5 ) ), + 'list of ids' => array( '

Hi

', array( 5, 7 ) ), + 'nested' => array( '

Hi

', array( 1, 2 ) ), + 'malformed id' => array( '

Hi

', array() ), + 'metadata a string' => array( '

Hi

', array() ), + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 898b344842f9a..dd2360f7fd5f4 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4142,6 +4142,75 @@ public function test_cannot_create_empty_note_with_invalid_resolution_meta() { $this->assertErrorResponse( 'rest_comment_content_invalid', $response, 400 ); } + /** + * Dispatches a request creating a note with the given anchor meta. + * + * @param mixed $anchor Value for the `_wp_note_anchor` meta. + * @return WP_REST_Response Response. + */ + private function create_note_with_anchor_meta( $anchor ): WP_REST_Response { + wp_set_current_user( self::$editor_id ); + $params = array( + 'post' => self::factory()->post->create(), + 'author' => self::$editor_id, + 'type' => 'note', + 'content' => 'A note.', + 'meta' => array( + '_wp_note_anchor' => $anchor, + ), + ); + $body = wp_json_encode( $params ); + $this->assertIsString( $body ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( $body ); + + return rest_get_server()->dispatch( $request ); + } + + public function test_create_note_with_anchor_meta(): void { + $anchor = array( + array( + 'path' => array( 1, 0 ), + 'content' => "\n

Hello

\n", + ), + ); + $response = $this->create_note_with_anchor_meta( $anchor ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertIsArray( $data['meta'] ); + $this->assertIsInt( $data['id'] ); + $this->assertSame( $anchor, $data['meta']['_wp_note_anchor'] ); + $this->assertSame( $anchor, get_comment_meta( $data['id'], '_wp_note_anchor', true ) ); + } + + public function test_cannot_create_note_with_invalid_anchor_meta(): void { + $response = $this->create_note_with_anchor_meta( + array( + array( + 'path' => array( 'first' ), + 'content' => 'x', + ), + ) + ); + $this->assertErrorResponse( 'rest_invalid_type', $response, 400 ); + } + + public function test_cannot_create_note_with_unknown_anchor_property(): void { + $response = $this->create_note_with_anchor_meta( + array( + array( + 'path' => array( 0 ), + 'content' => 'x', + 'extra' => 'x', + ), + ) + ); + $this->assertErrorResponse( 'rest_additional_properties_forbidden', $response, 400 ); + } + /** * @ticket 64096 */ From ace52cbb6c64d71a4ec4d873b095d0b9ef2f1b49 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 21:53:49 -0500 Subject: [PATCH 2/5] Notes: Filter the note anchor snapshot like post content. The snapshot is block markup stored by any user who can edit the note, and editors read it back over REST. Run it through KSES for users without `unfiltered_html`, the same as post content, and drop malformed entries. Claude-Session: https://claude.ai/code/session_018vwYjS8MTZ2CfXecy2yRiU --- src/wp-includes/comment.php | 54 +++++++++++++++++-- .../comment/wpDeletePersistedNoteAnchors.php | 46 ++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 82ab7c5e02f9a..e042aa0c7f7a8 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4663,10 +4663,10 @@ function wp_create_initial_comment_meta() { 'comment', '_wp_note_anchor', array( - 'type' => 'array', - 'description' => __( 'The blocks the note was added to, as they stood at the time.' ), - 'single' => true, - 'show_in_rest' => array( + 'type' => 'array', + 'description' => __( 'The blocks the note was added to, as they stood at the time.' ), + 'single' => true, + 'show_in_rest' => array( 'schema' => array( 'type' => 'array', 'items' => array( @@ -4682,13 +4682,57 @@ function wp_create_initial_comment_meta() { ), ), ), - 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + 'sanitize_callback' => '_wp_sanitize_note_anchor', + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { return current_user_can( 'edit_comment', $object_id ); }, ) ); } +/** + * Sanitizes the `_wp_note_anchor` meta value. + * + * The snapshot is block markup, so it gets the same KSES filtering as post + * content for users who can't post unfiltered HTML. + * + * @since 7.3.0 + * @access private + * + * @param mixed $value Meta value. + * @return list, content: string}> Sanitized snapshot. + */ +function _wp_sanitize_note_anchor( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $anchors = array(); + foreach ( $value as $anchor ) { + if ( ! is_array( $anchor ) || ! is_string( $anchor['content'] ?? null ) || ! is_array( $anchor['path'] ?? null ) ) { + continue; + } + + $path = array(); + foreach ( $anchor['path'] as $index ) { + if ( is_numeric( $index ) ) { + $path[] = absint( $index ); + } + } + + $content = $anchor['content']; + if ( ! current_user_can( 'unfiltered_html' ) ) { + $content = wp_kses_post( $content ); + } + + $anchors[] = array( + 'path' => $path, + 'content' => $content, + ); + } + return $anchors; +} + /** * Collects the IDs of the notes that blocks link to through `metadata.noteId`. * diff --git a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php index e135b2f77979b..c14c537311bbd 100644 --- a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php +++ b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php @@ -8,6 +8,7 @@ * * @covers ::wp_delete_persisted_note_anchors * @covers ::_wp_get_note_ids_from_blocks + * @covers ::_wp_sanitize_note_anchor */ class Tests_Comment_WpDeletePersistedNoteAnchors extends WP_UnitTestCase { @@ -60,6 +61,51 @@ public function test_registers_note_anchor_meta(): void { $this->assertTrue( registered_meta_key_exists( 'comment', '_wp_note_anchor' ) ); } + public function test_strips_disallowed_html_without_unfiltered_html(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'author' ) ) ); + $this->assertFalse( current_user_can( 'unfiltered_html' ) ); + + $value = _wp_sanitize_note_anchor( + array( + array( + 'path' => array( 0, '2' ), + 'content' => "\n

Hi

\n", + ), + 'not an anchor', + ) + ); + + $this->assertSame( + array( + array( + 'path' => array( 0, 2 ), + 'content' => "\n

Hi

\n", + ), + ), + $value + ); + } + + public function test_keeps_html_with_unfiltered_html(): void { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + grant_super_admin( $user_id ); + } + wp_set_current_user( $user_id ); + $content = ''; + + $value = _wp_sanitize_note_anchor( + array( + array( + 'path' => array( 0 ), + 'content' => $content, + ), + ) + ); + + $this->assertSame( $content, $value[0]['content'] ); + } + public function test_deletes_anchor_when_saved_content_links_to_note(): void { $ids = $this->create_note_with_anchor(); From 466451d2294cc73811c3a59537bb5f15a45efb0d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Nov 2026 09:43:14 -0800 Subject: [PATCH 3/5] Tests: Align assignments in the note anchor REST test helper. Fixes the MultipleStatementAlignment warnings reported by the coding standards check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjVYSP9Ax4HH8LkGiuT --- tests/phpunit/tests/rest-api/rest-comments-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index dd2360f7fd5f4..7cef8ee8af029 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4150,7 +4150,7 @@ public function test_cannot_create_empty_note_with_invalid_resolution_meta() { */ private function create_note_with_anchor_meta( $anchor ): WP_REST_Response { wp_set_current_user( self::$editor_id ); - $params = array( + $params = array( 'post' => self::factory()->post->create(), 'author' => self::$editor_id, 'type' => 'note', @@ -4159,7 +4159,7 @@ private function create_note_with_anchor_meta( $anchor ): WP_REST_Response { '_wp_note_anchor' => $anchor, ), ); - $body = wp_json_encode( $params ); + $body = wp_json_encode( $params ); $this->assertIsString( $body ); $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); $request->add_header( 'Content-Type', 'application/json' ); From e109972b218cc7e192f283f473a907cabd2a0ec1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Nov 2026 14:41:33 -0800 Subject: [PATCH 4/5] Notes: Simplify the note anchor meta callbacks. Match the Gutenberg PR: the sanitize callback only filters the markup, since REST validates the meta against its schema, and the cleanup queries only the notes the saved content links to, which replaces the recursive helper with `_flatten_blocks()`. Claude-Session: https://claude.ai/code/session_018vwYjT7bMAk8u5qb6GbP5k --- src/wp-includes/comment.php | 103 +++++------------- src/wp-includes/default-filters.php | 2 +- .../comment/wpDeletePersistedNoteAnchors.php | 56 +++++----- 3 files changed, 57 insertions(+), 104 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index e042aa0c7f7a8..04d27ea8ba396 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4700,66 +4700,18 @@ function wp_create_initial_comment_meta() { * @access private * * @param mixed $value Meta value. - * @return list, content: string}> Sanitized snapshot. + * @return mixed Sanitized meta value. */ -function _wp_sanitize_note_anchor( $value ): array { - if ( ! is_array( $value ) ) { - return array(); +function _wp_sanitize_note_anchor( $value ) { + if ( ! is_array( $value ) || current_user_can( 'unfiltered_html' ) ) { + return $value; } - - $anchors = array(); - foreach ( $value as $anchor ) { - if ( ! is_array( $anchor ) || ! is_string( $anchor['content'] ?? null ) || ! is_array( $anchor['path'] ?? null ) ) { - continue; - } - - $path = array(); - foreach ( $anchor['path'] as $index ) { - if ( is_numeric( $index ) ) { - $path[] = absint( $index ); - } - } - - $content = $anchor['content']; - if ( ! current_user_can( 'unfiltered_html' ) ) { - $content = wp_kses_post( $content ); + foreach ( $value as &$anchor ) { + if ( is_array( $anchor ) && is_string( $anchor['content'] ?? null ) ) { + $anchor['content'] = wp_kses_post( $anchor['content'] ); } - - $anchors[] = array( - 'path' => $path, - 'content' => $content, - ); } - return $anchors; -} - -/** - * Collects the IDs of the notes that blocks link to through `metadata.noteId`. - * - * @since 7.3.0 - * @access private - * - * @param array $blocks Parsed blocks, as returned by parse_blocks(). - * @return int[] Note IDs. - */ -function _wp_get_note_ids_from_blocks( array $blocks ): array { - $note_ids = array(); - foreach ( $blocks as $block ) { - if ( ! is_array( $block ) ) { - continue; - } - $metadata = is_array( $block['attrs'] ?? null ) ? ( $block['attrs']['metadata'] ?? null ) : null; - $ids = is_array( $metadata ) ? ( $metadata['noteId'] ?? null ) : null; - foreach ( (array) $ids as $id ) { - if ( is_numeric( $id ) ) { - $note_ids[] = (int) $id; - } - } - if ( is_array( $block['innerBlocks'] ?? null ) ) { - $note_ids = array_merge( $note_ids, _wp_get_note_ids_from_blocks( $block['innerBlocks'] ) ); - } - } - return $note_ids; + return $value; } /** @@ -4774,32 +4726,33 @@ function _wp_get_note_ids_from_blocks( array $blocks ): array { * * @param int $post_id Post ID. * @param WP_Post $post Post object. - * @param bool $update Whether this is an existing post being updated. */ -function wp_delete_persisted_note_anchors( $post_id, $post, $update ): void { - if ( ! $update || 'revision' === $post->post_type ) { +function wp_delete_persisted_note_anchors( $post_id, $post ): void { + $blocks = parse_blocks( $post->post_content ); + $note_ids = array(); + foreach ( _flatten_blocks( $blocks ) as $block ) { + $ids = is_array( $block ) ? _wp_array_get( $block, array( 'attrs', 'metadata', 'noteId' ), array() ) : array(); + foreach ( (array) $ids as $id ) { + if ( is_numeric( $id ) ) { + $note_ids[] = (int) $id; + } + } + } + if ( ! $note_ids ) { return; } - $note_ids = get_comments( + $snapshot_ids = get_comments( array( - 'post_id' => $post_id, - 'type' => 'note', - 'parent' => 0, - 'meta_key' => '_wp_note_anchor', - 'fields' => 'ids', + 'post_id' => $post_id, + 'type' => 'note', + 'comment__in' => $note_ids, + 'meta_key' => '_wp_note_anchor', + 'fields' => 'ids', ) ); - if ( ! is_array( $note_ids ) || ! $note_ids ) { - return; - } - - $linked = _wp_get_note_ids_from_blocks( parse_blocks( $post->post_content ) ); - foreach ( $note_ids as $note_id ) { - $note_id = (int) $note_id; - if ( $note_id > 0 && in_array( $note_id, $linked, true ) ) { - delete_comment_meta( $note_id, '_wp_note_anchor' ); - } + foreach ( array_filter( $snapshot_ids ) as $note_id ) { + delete_comment_meta( $note_id, '_wp_note_anchor' ); } } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 1fd856fc975a0..dea8c67b6bbbb 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -810,7 +810,7 @@ add_filter( 'render_block', 'wp_strip_inline_note_markers' ); // Note anchor snapshots. -add_action( 'wp_after_insert_post', 'wp_delete_persisted_note_anchors', 10, 3 ); +add_action( 'wp_after_insert_post', 'wp_delete_persisted_note_anchors', 10, 2 ); // User preferences. add_action( 'init', 'wp_register_persisted_preferences_meta' ); diff --git a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php index c14c537311bbd..71a5b2b2cad3e 100644 --- a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php +++ b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php @@ -7,7 +7,6 @@ * @group notes * * @covers ::wp_delete_persisted_note_anchors - * @covers ::_wp_get_note_ids_from_blocks * @covers ::_wp_sanitize_note_anchor */ class Tests_Comment_WpDeletePersistedNoteAnchors extends WP_UnitTestCase { @@ -68,10 +67,9 @@ public function test_strips_disallowed_html_without_unfiltered_html(): void { $value = _wp_sanitize_note_anchor( array( array( - 'path' => array( 0, '2' ), + 'path' => array( 0, 2 ), 'content' => "\n

Hi

\n", ), - 'not an anchor', ) ); @@ -92,18 +90,14 @@ public function test_keeps_html_with_unfiltered_html(): void { grant_super_admin( $user_id ); } wp_set_current_user( $user_id ); - $content = ''; - - $value = _wp_sanitize_note_anchor( + $anchor = array( array( - array( - 'path' => array( 0 ), - 'content' => $content, - ), - ) + 'path' => array( 0 ), + 'content' => '', + ), ); - $this->assertSame( $content, $value[0]['content'] ); + $this->assertSame( $anchor, _wp_sanitize_note_anchor( $anchor ) ); } public function test_deletes_anchor_when_saved_content_links_to_note(): void { @@ -160,9 +154,9 @@ public function test_keeps_anchor_of_a_note_on_another_post(): void { $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); } - public function test_ignores_malformed_note_ids(): void { + public function test_deletes_anchor_when_note_id_is_not_in_a_list(): void { $ids = $this->create_note_with_anchor(); - $content = '' . "\n

Hello

\n"; + $content = '' . "\n

Hello

\n"; wp_update_post( array( @@ -171,32 +165,38 @@ public function test_ignores_malformed_note_ids(): void { ) ); - $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + $this->assertSame( '', get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); } /** - * @dataProvider data_get_note_ids_from_blocks + * @dataProvider data_malformed_metadata * - * @param string $content Block markup. - * @param int[] $expected Expected note IDs. + * @param string $attrs Block attributes as JSON. */ - public function test_get_note_ids_from_blocks( string $content, array $expected ): void { - $this->assertSame( $expected, _wp_get_note_ids_from_blocks( parse_blocks( $content ) ) ); + public function test_ignores_malformed_metadata( string $attrs ): void { + $ids = $this->create_note_with_anchor(); + $content = "\n

Hello

\n"; + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => $content, + ) + ); + + $this->assertSame( self::ANCHOR, get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); } /** * Data provider. * - * @return array + * @return array */ - public function data_get_note_ids_from_blocks(): array { + public function data_malformed_metadata(): array { return array( - 'no notes' => array( "\n

Hi

\n", array() ), - 'single id' => array( '

Hi

', array( 5 ) ), - 'list of ids' => array( '

Hi

', array( 5, 7 ) ), - 'nested' => array( '

Hi

', array( 1, 2 ) ), - 'malformed id' => array( '

Hi

', array() ), - 'metadata a string' => array( '

Hi

', array() ), + 'note id not a number' => array( '{"metadata":{"noteId":"not-a-number"}}' ), + 'note id an object' => array( '{"metadata":{"noteId":{"a":"x"}}}' ), + 'metadata a string' => array( '{"metadata":"x"}' ), ); } } From caa037254e5ac9ccc77e5594dca91cfc3c506524 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 11 Nov 2026 15:04:47 -0800 Subject: [PATCH 5/5] Notes: Rebuild the note anchor snapshot when sanitizing. The sanitize callback is the only check for writes that don't go through REST, so drop malformed entries and cast the path again rather than storing the value as given. Claude-Session: https://claude.ai/code/session_018vwYjT7bMAk8u5qb6GbP5k --- src/wp-includes/comment.php | 35 ++++++++++++++----- .../comment/wpDeletePersistedNoteAnchors.php | 3 +- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 04d27ea8ba396..6539db707d1b2 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4700,18 +4700,37 @@ function wp_create_initial_comment_meta() { * @access private * * @param mixed $value Meta value. - * @return mixed Sanitized meta value. + * @return list, content: string}> Sanitized snapshot. */ -function _wp_sanitize_note_anchor( $value ) { - if ( ! is_array( $value ) || current_user_can( 'unfiltered_html' ) ) { - return $value; +function _wp_sanitize_note_anchor( $value ): array { + if ( ! is_array( $value ) ) { + return array(); } - foreach ( $value as &$anchor ) { - if ( is_array( $anchor ) && is_string( $anchor['content'] ?? null ) ) { - $anchor['content'] = wp_kses_post( $anchor['content'] ); + + $anchors = array(); + foreach ( $value as $anchor ) { + if ( ! is_array( $anchor ) || ! is_string( $anchor['content'] ?? null ) || ! is_array( $anchor['path'] ?? null ) ) { + continue; + } + + $path = array(); + foreach ( $anchor['path'] as $index ) { + if ( is_numeric( $index ) ) { + $path[] = absint( $index ); + } + } + + $content = $anchor['content']; + if ( ! current_user_can( 'unfiltered_html' ) ) { + $content = wp_kses_post( $content ); } + + $anchors[] = array( + 'path' => $path, + 'content' => $content, + ); } - return $value; + return $anchors; } /** diff --git a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php index 71a5b2b2cad3e..a0dd6dcaa4aa9 100644 --- a/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php +++ b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php @@ -67,9 +67,10 @@ public function test_strips_disallowed_html_without_unfiltered_html(): void { $value = _wp_sanitize_note_anchor( array( array( - 'path' => array( 0, 2 ), + 'path' => array( 0, '2' ), 'content' => "\n

Hi

\n", ), + 'not an anchor', ) );