diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b6847266eb162..6539db707d1b2 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,127 @@ 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, + ), + ), + ), + '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; +} + +/** + * 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. + */ +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; + } + + $snapshot_ids = get_comments( + array( + 'post_id' => $post_id, + 'type' => 'note', + 'comment__in' => $note_ids, + 'meta_key' => '_wp_note_anchor', + 'fields' => 'ids', + ) + ); + 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 3c1234f9cfa8e..dea8c67b6bbbb 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, 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 new file mode 100644 index 0000000000000..a0dd6dcaa4aa9 --- /dev/null +++ b/tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php @@ -0,0 +1,203 @@ +, 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_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 ); + $anchor = array( + array( + 'path' => array( 0 ), + 'content' => '', + ), + ); + + $this->assertSame( $anchor, _wp_sanitize_note_anchor( $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_deletes_anchor_when_note_id_is_not_in_a_list(): void { + $ids = $this->create_note_with_anchor(); + $content = '' . "\n

Hello

\n"; + + wp_update_post( + array( + 'ID' => $ids['post_id'], + 'post_content' => $content, + ) + ); + + $this->assertSame( '', get_comment_meta( $ids['note_id'], '_wp_note_anchor', true ) ); + } + + /** + * @dataProvider data_malformed_metadata + * + * @param string $attrs Block attributes as JSON. + */ + 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 + */ + public function data_malformed_metadata(): array { + return 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"}' ), + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 898b344842f9a..7cef8ee8af029 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 */