Skip to content

Commit bfb3622

Browse files
fix(duplicator): prevent race condition in R&R copy creation
Claim the slot on the original post using add_post_meta() with $unique = true before creating the copy. This returns false if the meta key already exists, preventing duplicate copies when two concurrent requests both pass the permission check before either sets the meta. If wp_insert_post() fails, the claim is rolled back by deleting the meta. Also fixes Block_Editor_Test to mock get_post_type_object() for the restBase addition to the localized JS object. Ref: Yoast/reserved-tasks#1127 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3305cdf commit bfb3622

2 files changed

Lines changed: 40 additions & 7 deletions

File tree

src/post-duplicator.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,17 @@ public function set_modified( $data ) {
149149
* @return int|WP_Error The copy ID, or a WP_Error object on failure.
150150
*/
151151
public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
152+
// Claim the slot on the original before creating the copy to prevent
153+
// a race condition where two concurrent requests both pass the
154+
// permission check before either has set the meta.
155+
$claimed = \add_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', 'pending', true );
156+
if ( ! $claimed ) {
157+
return new \WP_Error(
158+
'duplicate_post_already_has_copy',
159+
\__( 'A Rewrite & Republish copy already exists for this post.', 'duplicate-post' ),
160+
);
161+
}
162+
152163
$options = [
153164
'copy_title' => true,
154165
'copy_date' => true,
@@ -164,15 +175,19 @@ public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
164175

165176
$new_post_id = $this->create_duplicate( $post, $options );
166177

167-
if ( ! \is_wp_error( $new_post_id ) ) {
168-
$this->copy_post_taxonomies( $new_post_id, $post, $options );
169-
$this->copy_post_meta_info( $new_post_id, $post, $options );
170-
171-
\update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
172-
\update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
173-
\update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
178+
if ( \is_wp_error( $new_post_id ) ) {
179+
// Roll back the claim if copy creation failed.
180+
\delete_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
181+
return $new_post_id;
174182
}
175183

184+
$this->copy_post_taxonomies( $new_post_id, $post, $options );
185+
$this->copy_post_meta_info( $new_post_id, $post, $options );
186+
187+
\update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
188+
\update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
189+
\update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
190+
176191
return $new_post_id;
177192
}
178193

tests/Unit/UI/Block_Editor_Test.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ public function test_enqueue_block_editor_scripts() {
310310
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
311311
$post = Mockery::mock( WP_Post::class );
312312
$post->ID = 123;
313+
$post->post_type = 'post';
313314
$new_draft_link = 'http://fakeu.rl/new_draft';
314315
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
315316
$rewriting = 0;
@@ -374,8 +375,16 @@ public function test_enqueue_block_editor_scripts() {
374375
->with( $post )
375376
->andReturnNull();
376377

378+
$post_type_object = Mockery::mock( 'WP_Post_Type' );
379+
$post_type_object->rest_base = 'posts';
380+
381+
Monkey\Functions\expect( '\get_post_type_object' )
382+
->with( 'post' )
383+
->andReturn( $post_type_object );
384+
377385
$edit_js_object = [
378386
'postId' => 123,
387+
'restBase' => 'posts',
379388
'newDraftLink' => $new_draft_link,
380389
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
381390
'showLinks' => $show_links,
@@ -414,6 +423,7 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
414423
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
415424
$post = Mockery::mock( WP_Post::class );
416425
$post->ID = 123;
426+
$post->post_type = 'post';
417427
$new_draft_link = 'http://fakeu.rl/new_draft';
418428
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
419429
$rewriting = 1;
@@ -475,8 +485,16 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
475485
->with( $post )
476486
->andReturnNull();
477487

488+
$post_type_object = Mockery::mock( 'WP_Post_Type' );
489+
$post_type_object->rest_base = 'posts';
490+
491+
Monkey\Functions\expect( '\get_post_type_object' )
492+
->with( 'post' )
493+
->andReturn( $post_type_object );
494+
478495
$edit_js_object = [
479496
'postId' => 123,
497+
'restBase' => 'posts',
480498
'newDraftLink' => $new_draft_link,
481499
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
482500
'showLinks' => $show_links,

0 commit comments

Comments
 (0)