Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion src/wp-includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<array{path: list<int>, 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' );
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand Down
203 changes: 203 additions & 0 deletions tests/phpunit/tests/comment/wpDeletePersistedNoteAnchors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

/**
* Tests for deleting note anchor snapshots once the post links to the note.
*
* @group comment
* @group notes
*
* @covers ::wp_delete_persisted_note_anchors
* @covers ::_wp_sanitize_note_anchor
*/
class Tests_Comment_WpDeletePersistedNoteAnchors extends WP_UnitTestCase {

/**
* A snapshot as the editor stores it.
*
* @var list<array{path: list<int>, content: string}>
*/
private const ANCHOR = array(
array(
'path' => array( 0 ),
'content' => "<!-- wp:paragraph -->\n<p>Hello</p>\n<!-- /wp:paragraph -->",
),
);

/**
* 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 "<!-- wp:paragraph $attrs -->\n<p>Hello</p>\n<!-- /wp:paragraph -->";
}

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' => "<!-- wp:paragraph {\"metadata\":{\"noteId\":[3]}} -->\n<p><mark class=\"wp-note\" data-id=\"new\">Hi</mark><script>alert(1)</script></p>\n<!-- /wp:paragraph -->",
),
'not an anchor',
)
);

$this->assertSame(
array(
array(
'path' => array( 0, 2 ),
'content' => "<!-- wp:paragraph {\"metadata\":{\"noteId\":[3]}} -->\n<p><mark class=\"wp-note\" data-id=\"new\">Hi</mark></p>\n<!-- /wp:paragraph -->",
),
),
$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' => '<!-- wp:html --><script>alert(1)</script><!-- /wp:html -->',
),
);

$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 = "<!-- wp:group -->\n<div class=\"wp-block-group\">" . $this->paragraph_with_notes( array( $ids['note_id'] ) ) . "</div>\n<!-- /wp:group -->";

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' => "<!-- wp:paragraph -->\n<p>Hello</p>\n<!-- /wp:paragraph -->",
)
);

$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 = '<!-- wp:paragraph {"metadata":{"noteId":' . $ids['note_id'] . '}} -->' . "\n<p>Hello</p>\n<!-- /wp:paragraph -->";

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 = "<!-- wp:paragraph $attrs -->\n<p>Hello</p>\n<!-- /wp:paragraph -->";

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<string, array{0: string}>
*/
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"}' ),
);
}
}
Loading
Loading