• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / duplicate-post / 23666031844

27 Mar 2026 08:24PM UTC coverage: 59.598%. First build
23666031844

push

github

enricobattocchi
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>

8 of 14 new or added lines in 1 file covered. (57.14%)

1630 of 2735 relevant lines covered (59.6%)

7.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.04
/src/post-duplicator.php
1
<?php
2

3
namespace Yoast\WP\Duplicate_Post;
4

5
use WP_Error;
6
use WP_Post;
7

8
/**
9
 * Duplicate Post class to create copies.
10
 *
11
 * @since 4.0
12
 */
13
class Post_Duplicator {
14

15
        /**
16
         * Returns an array with the default option values.
17
         *
18
         * @return array<string, bool|int|string|array|null> The default options values.
19
         */
20
        public function get_default_options() {
6✔
21
                return [
6✔
22
                        'copy_title'             => true,
6✔
23
                        'copy_date'              => false,
6✔
24
                        'copy_status'            => false,
6✔
25
                        'copy_name'              => false,
6✔
26
                        'copy_excerpt'           => true,
6✔
27
                        'copy_content'           => true,
6✔
28
                        'copy_thumbnail'         => true,
6✔
29
                        'copy_template'          => true,
6✔
30
                        'copy_format'            => true,
6✔
31
                        'copy_author'            => false,
6✔
32
                        'copy_password'          => false,
6✔
33
                        'copy_attachments'       => false,
6✔
34
                        'copy_children'          => false,
6✔
35
                        'copy_comments'          => false,
6✔
36
                        'copy_menu_order'        => true,
6✔
37
                        'title_prefix'           => '',
6✔
38
                        'title_suffix'           => '',
6✔
39
                        'increase_menu_order_by' => null,
6✔
40
                        'parent_id'              => null,
6✔
41
                        'meta_excludelist'       => [],
6✔
42
                        'taxonomies_excludelist' => [],
6✔
43
                        'use_filters'            => true,
6✔
44
                ];
6✔
45
        }
46

47
        /**
48
         * Creates a copy of a post object, accordingly to an options array.
49
         *
50
         * @param WP_Post $post    The original post object.
51
         * @param array   $options The options overriding the default ones.
52
         *
53
         * @return int|WP_Error The copy ID, or a WP_Error object on failure.
54
         */
55
        public function create_duplicate( WP_Post $post, array $options = [] ) {
20✔
56
                $defaults = $this->get_default_options();
20✔
57
                $options  = \wp_parse_args( $options, $defaults );
20✔
58

59
                $title           = '';
20✔
60
                $new_post_status = $post->post_status;
20✔
61
                if ( $post->post_type !== 'attachment' ) {
20✔
62
                        $title           = $this->generate_copy_title( $post, $options );
20✔
63
                        $new_post_status = $this->generate_copy_status( $post, $options );
20✔
64
                }
65

66
                $new_post_author_id = $this->generate_copy_author( $post, $options );
20✔
67

68
                $menu_order = 0;
20✔
69
                if ( $options['copy_menu_order'] ) {
20✔
70
                        $menu_order = $post->menu_order;
20✔
71
                }
72

73
                if ( ! empty( $options['increase_menu_order_by'] ) && \is_numeric( $options['increase_menu_order_by'] ) ) {
20✔
74
                        $menu_order += (int) $options['increase_menu_order_by'];
×
75
                }
76

77
                $new_post = [
20✔
78
                        'post_author'           => $new_post_author_id,
20✔
79
                        'post_content'          => ( $options['copy_content'] ) ? $post->post_content : '',
20✔
80
                        'post_content_filtered' => ( $options['copy_content'] ) ? $post->post_content_filtered : '',
20✔
81
                        'post_title'            => $title,
20✔
82
                        'post_excerpt'          => ( $options['copy_excerpt'] ) ? $post->post_excerpt : '',
20✔
83
                        'post_status'           => $new_post_status,
20✔
84
                        'post_type'             => $post->post_type,
20✔
85
                        'comment_status'        => $post->comment_status,
20✔
86
                        'ping_status'           => $post->ping_status,
20✔
87
                        'post_password'         => ( $options['copy_password'] ) ? $post->post_password : '',
20✔
88
                        'post_name'             => ( $options['copy_name'] ) ? $post->post_name : '',
20✔
89
                        'post_parent'           => empty( $options['parent_id'] ) ? $post->post_parent : $options['parent_id'],
20✔
90
                        'menu_order'            => $menu_order,
20✔
91
                        'post_mime_type'        => $post->post_mime_type,
20✔
92
                ];
20✔
93

94
                if ( $options['copy_date'] ) {
20✔
95
                        $new_post_date             = $post->post_date;
20✔
96
                        $new_post['post_date']     = $new_post_date;
20✔
97
                        $new_post['post_date_gmt'] = \get_gmt_from_date( $new_post_date );
20✔
98
                        \add_filter( 'wp_insert_post_data', [ $this, 'set_modified' ], 1, 1 );
20✔
99
                }
100

101
                if ( $options['use_filters'] ) {
20✔
102
                        /**
103
                         * Filter new post values.
104
                         *
105
                         * @param array   $new_post New post values.
106
                         * @param WP_Post $post     Original post object.
107
                         *
108
                         * @return array
109
                         */
110
                        $new_post = \apply_filters( 'duplicate_post_new_post', $new_post, $post );
4✔
111
                }
112

113
                $new_post_id = \wp_insert_post( \wp_slash( $new_post ), true );
20✔
114

115
                if ( $options['copy_date'] ) {
20✔
116
                        \remove_filter( 'wp_insert_post_data', [ $this, 'set_modified' ], 1 );
20✔
117
                }
118

119
                if ( ! \is_wp_error( $new_post_id ) ) {
20✔
120
                        \delete_post_meta( $new_post_id, '_dp_original' );
20✔
121
                        \add_post_meta( $new_post_id, '_dp_original', $post->ID );
20✔
122
                }
123

124
                return $new_post_id;
20✔
125
        }
126

127
        /**
128
         * Modifies the post data to set the modified date to now.
129
         *
130
         * This is needed for the Block editor when a post is copied with its date,
131
         * so that the current publish date is shown instead of "Immediately".
132
         *
133
         * @param array $data The array of post data.
134
         *
135
         * @return array The updated array of post data.
136
         */
137
        public function set_modified( $data ) {
4✔
138
                $data['post_modified']     = \current_time( 'mysql' );
4✔
139
                $data['post_modified_gmt'] = \current_time( 'mysql', 1 );
4✔
140

141
                return $data;
4✔
142
        }
143

144
        /**
145
         * Wraps the function to create a copy for the Rewrite & Republish feature.
146
         *
147
         * @param WP_Post $post The original post object.
148
         *
149
         * @return int|WP_Error The copy ID, or a WP_Error object on failure.
150
         */
151
        public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
24✔
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 );
24✔
156
                if ( ! $claimed ) {
24✔
NEW
157
                        return new \WP_Error(
×
NEW
158
                                'duplicate_post_already_has_copy',
×
NEW
159
                                \__( 'A Rewrite & Republish copy already exists for this post.', 'duplicate-post' ),
×
NEW
160
                        );
×
161
                }
162

163
                $options  = [
24✔
164
                        'copy_title'      => true,
24✔
165
                        'copy_date'       => true,
24✔
166
                        'copy_name'       => false,
24✔
167
                        'copy_content'    => true,
24✔
168
                        'copy_excerpt'    => true,
24✔
169
                        'copy_author'     => true,
24✔
170
                        'copy_menu_order' => true,
24✔
171
                        'use_filters'     => false,
24✔
172
                ];
24✔
173
                $defaults = $this->get_default_options();
24✔
174
                $options  = \wp_parse_args( $options, $defaults );
24✔
175

176
                $new_post_id = $this->create_duplicate( $post, $options );
24✔
177

178
                if ( \is_wp_error( $new_post_id ) ) {
24✔
179
                        // Roll back the claim if copy creation failed.
NEW
180
                        \delete_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
×
NEW
181
                        return $new_post_id;
×
182
                }
183

184
                $this->copy_post_taxonomies( $new_post_id, $post, $options );
24✔
185
                $this->copy_post_meta_info( $new_post_id, $post, $options );
24✔
186

187
                \update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
24✔
188
                \update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
24✔
189
                \update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
24✔
190

191
                return $new_post_id;
24✔
192
        }
193

194
        /**
195
         * Copies the taxonomies of a post to another post.
196
         *
197
         * @param int     $new_id  New post ID.
198
         * @param WP_Post $post    The original post object.
199
         * @param array   $options The options array.
200
         *
201
         * @return void
202
         */
203
        public function copy_post_taxonomies( $new_id, $post, $options ) {
8✔
204
                // Clear default category (added by wp_insert_post).
205
                \wp_set_object_terms( $new_id, [], 'category' );
8✔
206

207
                $post_taxonomies = \get_object_taxonomies( $post->post_type );
8✔
208
                // Several plugins just add support to post-formats but don't register post_format taxonomy.
209
                if ( \post_type_supports( $post->post_type, 'post-formats' ) && ! \in_array( 'post_format', $post_taxonomies, true ) ) {
8✔
210
                        $post_taxonomies[] = 'post_format';
×
211
                }
212

213
                $taxonomies_excludelist = $options['taxonomies_excludelist'];
8✔
214
                if ( ! \is_array( $taxonomies_excludelist ) ) {
8✔
215
                        $taxonomies_excludelist = [];
×
216
                }
217

218
                if ( ! $options['copy_format'] ) {
8✔
219
                        $taxonomies_excludelist[] = 'post_format';
×
220
                }
221

222
                if ( $options['use_filters'] ) {
8✔
223
                        /**
224
                         * Filters the taxonomy excludelist when copying a post.
225
                         *
226
                         * @param array $taxonomies_excludelist The taxonomy excludelist from the options.
227
                         *
228
                         * @return array
229
                         */
230
                        $taxonomies_excludelist = \apply_filters( 'duplicate_post_taxonomies_excludelist_filter', $taxonomies_excludelist );
×
231
                }
232

233
                $post_taxonomies = \array_diff( $post_taxonomies, $taxonomies_excludelist );
8✔
234

235
                foreach ( $post_taxonomies as $taxonomy ) {
8✔
236
                        $post_terms = \wp_get_object_terms( $post->ID, $taxonomy, [ 'orderby' => 'term_order' ] );
8✔
237
                        $terms      = [];
8✔
238
                        $num_terms  = \count( $post_terms );
8✔
239
                        for ( $i = 0; $i < $num_terms; $i++ ) {
8✔
240
                                $terms[] = $post_terms[ $i ]->slug;
8✔
241
                        }
242
                        \wp_set_object_terms( $new_id, $terms, $taxonomy );
8✔
243
                }
244
        }
245

246
        /**
247
         * Copies the meta information of a post to another post.
248
         *
249
         * @param int     $new_id  The new post ID.
250
         * @param WP_Post $post    The original post object.
251
         * @param array   $options The options array.
252
         *
253
         * @return void
254
         */
255
        public function copy_post_meta_info( $new_id, $post, $options ) {
4✔
256
                $post_meta_keys = \get_post_custom_keys( $post->ID );
4✔
257
                if ( empty( $post_meta_keys ) ) {
4✔
258
                        return;
×
259
                }
260
                $meta_excludelist = $options['meta_excludelist'];
4✔
261
                if ( ! \is_array( $meta_excludelist ) ) {
4✔
262
                        $meta_excludelist = [];
×
263
                }
264
                $meta_excludelist = \array_merge( $meta_excludelist, Utils::get_default_filtered_meta_names() );
4✔
265
                if ( ! $options['copy_template'] ) {
4✔
266
                        $meta_excludelist[] = '_wp_page_template';
×
267
                }
268
                if ( ! $options['copy_thumbnail'] ) {
4✔
269
                        $meta_excludelist[] = '_thumbnail_id';
×
270
                }
271

272
                if ( $options['use_filters'] ) {
4✔
273
                        /**
274
                         * Filters the meta fields excludelist when copying a post.
275
                         *
276
                         * @param array $meta_excludelist The meta fields excludelist from the options.
277
                         *
278
                         * @return array
279
                         */
280
                        $meta_excludelist = \apply_filters( 'duplicate_post_excludelist_filter', $meta_excludelist );
×
281
                }
282

283
                $meta_excludelist_string = '(' . \implode( ')|(', $meta_excludelist ) . ')';
4✔
284
                if ( \strpos( $meta_excludelist_string, '*' ) !== false ) {
4✔
285
                        $meta_excludelist_string = \str_replace( [ '*' ], [ '[a-zA-Z0-9_]*' ], $meta_excludelist_string );
×
286

287
                        $meta_keys = [];
×
288
                        foreach ( $post_meta_keys as $meta_key ) {
×
289
                                if ( ! \preg_match( '#^' . $meta_excludelist_string . '$#', $meta_key ) ) {
×
290
                                        $meta_keys[] = $meta_key;
×
291
                                }
292
                        }
293
                }
294
                else {
295
                        $meta_keys = \array_diff( $post_meta_keys, $meta_excludelist );
4✔
296
                }
297

298
                if ( $options['use_filters'] ) {
4✔
299
                        /**
300
                         * Filters the list of meta fields names when copying a post.
301
                         *
302
                         * @param array $meta_keys The list of meta fields name, with the ones in the excludelist already removed.
303
                         *
304
                         * @return array
305
                         */
306
                        $meta_keys = \apply_filters( 'duplicate_post_meta_keys_filter', $meta_keys );
×
307
                }
308

309
                foreach ( $meta_keys as $meta_key ) {
4✔
310
                        $meta_values = \get_post_custom_values( $meta_key, $post->ID );
4✔
311

312
                        // Clear existing meta data so that add_post_meta() works properly with non-unique keys.
313
                        \delete_post_meta( $new_id, $meta_key );
4✔
314

315
                        foreach ( $meta_values as $meta_value ) {
4✔
316
                                $meta_value = \maybe_unserialize( $meta_value );
4✔
317
                                \add_post_meta( $new_id, $meta_key, Utils::recursively_slash_strings( $meta_value ) );
4✔
318
                        }
319
                }
320
        }
321

322
        /**
323
         * Generates and returns the title for the copy.
324
         *
325
         * @param WP_Post $post    The original post object.
326
         * @param array   $options The options array.
327
         *
328
         * @return string The calculated title for the copy.
329
         */
330
        public function generate_copy_title( WP_Post $post, array $options ) {
20✔
331
                $prefix = \sanitize_text_field( $options['title_prefix'] );
20✔
332
                $suffix = \sanitize_text_field( $options['title_suffix'] );
20✔
333
                if ( $options['copy_title'] ) {
20✔
334
                        $title = $post->post_title;
16✔
335
                        if ( ! empty( $prefix ) ) {
16✔
336
                                $prefix .= ' ';
4✔
337
                        }
338
                        if ( ! empty( $suffix ) ) {
16✔
339
                                $suffix = ' ' . $suffix;
10✔
340
                        }
341
                }
342
                else {
343
                        $title = '';
4✔
344
                }
345
                return \trim( $prefix . $title . $suffix );
20✔
346
        }
347

348
        /**
349
         * Generates and returns the status for the copy.
350
         *
351
         * @param WP_Post $post    The original post object.
352
         * @param array   $options The options array.
353
         *
354
         * @return string The calculated status for the copy.
355
         */
356
        public function generate_copy_status( WP_Post $post, array $options ) {
18✔
357
                $new_post_status = 'draft';
18✔
358

359
                if ( $options['copy_status'] ) {
18✔
360
                        $new_post_status = $post->post_status;
8✔
361
                        if ( $new_post_status === 'publish' || $new_post_status === 'future' ) {
8✔
362
                                // Check if the user has the right capability.
363
                                if ( \is_post_type_hierarchical( $post->post_type ) ) {
6✔
364
                                        if ( ! \current_user_can( 'publish_pages' ) ) {
2✔
365
                                                $new_post_status = 'pending';
2✔
366
                                        }
367
                                }
368
                                elseif ( ! \current_user_can( 'publish_posts' ) ) {
4✔
369
                                        $new_post_status = 'pending';
2✔
370
                                }
371
                        }
372
                }
373

374
                return $new_post_status;
18✔
375
        }
376

377
        /**
378
         * Generates and returns the author ID for the copy.
379
         *
380
         * @param WP_Post $post    The original post object.
381
         * @param array   $options The options array.
382
         *
383
         * @return int|string The calculated author ID for the copy.
384
         */
385
        public function generate_copy_author( WP_Post $post, array $options ) {
18✔
386
                $new_post_author    = \wp_get_current_user();
18✔
387
                $new_post_author_id = $new_post_author->ID;
18✔
388
                if ( $options['copy_author'] ) {
18✔
389
                        // Check if the user has the right capability.
390
                        if ( \is_post_type_hierarchical( $post->post_type ) ) {
12✔
391
                                if ( \current_user_can( 'edit_others_pages' ) ) {
4✔
392
                                        $new_post_author_id = $post->post_author;
3✔
393
                                }
394
                        }
395
                        elseif ( \current_user_can( 'edit_others_posts' ) ) {
8✔
396
                                $new_post_author_id = $post->post_author;
2✔
397
                        }
398
                }
399

400
                return $new_post_author_id;
18✔
401
        }
402
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc