Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.93% covered (danger)
18.93%
106 / 560
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API_Update_Post_Endpoint
27.18% covered (danger)
27.18%
106 / 390
0.00% covered (danger)
0.00%
0 / 7
21196.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 callback
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 write_post
30.37% covered (danger)
30.37%
99 / 326
0.00% covered (danger)
0.00%
0 / 1
13704.69
 delete_post
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 restore_post
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 parse_and_set_featured_image
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 parse_and_set_author
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Update post endpoint.
4 *
5 * Endpoints:
6 * Create a post:  /sites/%s/posts/new
7 * Update a post:  /sites/%s/posts/%d
8 * Delete a post:  /sites/%s/posts/%d/delete
9 * Restore a post: /sites/%s/posts/%d/restore
10 */
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16new WPCOM_JSON_API_Update_Post_Endpoint(
17    array(
18        'description'          => 'Create a post.',
19        'group'                => 'posts',
20        'stat'                 => 'posts:new',
21        'new_version'          => '1.2',
22        'max_version'          => '1',
23        'method'               => 'POST',
24        'path'                 => '/sites/%s/posts/new',
25        'path_labels'          => array(
26            '$site' => '(int|string) Site ID or domain',
27        ),
28
29        'request_format'       => array(
30            // explicitly document all input.
31            'date'              => "(ISO 8601 datetime) The post's creation time.",
32            'title'             => '(HTML) The post title.',
33            'content'           => '(HTML) The post content.',
34            'excerpt'           => '(HTML) An optional post excerpt.',
35            'slug'              => '(string) The name (slug) for the post, used in URLs.',
36            'author'            => '(string) The username or ID for the user to assign the post to.',
37            'publicize'         => '(array|bool) True or false if the post be shared to external services. An array of services if we only want to share to a select few. Defaults to true.',
38            'publicize_message' => '(string) Custom message to be shared to external services.',
39            'status'            => array(
40                'publish'    => 'Publish the post.',
41                'private'    => 'Privately publish the post.',
42                'draft'      => 'Save the post as a draft.',
43                'pending'    => 'Mark the post as pending editorial approval.',
44                'auto-draft' => 'Save a placeholder for a newly created post, with no content.',
45            ),
46            'sticky'            => array(
47                'false' => 'Post is not marked as sticky.',
48                'true'  => 'Stick the post to the front page.',
49            ),
50            'password'          => '(string) The plaintext password protecting the post, or, more likely, the empty string if the post is not password protected.',
51            'parent'            => "(int) The post ID of the new post's parent.",
52            'type'              => "(string) The post type. Defaults to 'post'. Post types besides post and page need to be whitelisted using the <code>rest_api_allowed_post_types</code> filter.",
53            'categories'        => '(array|string) Comma-separated list or array of categories (name or id)',
54            'tags'              => '(array|string) Comma-separated list or array of tags (name or id)',
55            'format'            => array_merge( array( 'default' => 'Use default post format' ), get_post_format_strings() ),
56            'featured_image'    => '(string) The post ID of an existing attachment to set as the featured image. Pass an empty string to delete the existing image.',
57            'media'             => '(media) An array of files to attach to the post. To upload media, the entire request should be multipart/form-data encoded. Multiple media items will be displayed in a gallery. Accepts  jpg, jpeg, png, gif, pdf, doc, ppt, odt, pptx, docx, pps, ppsx, xls, xlsx, key. Audio and Video may also be available. See <code>allowed_file_types</code> in the options response of the site endpoint. <br /><br /><strong>Example</strong>:<br />' .
58                            "<code>curl \<br />--form 'title=Image' \<br />--form 'media[]=@/path/to/file.jpg' \<br />-H 'Authorization: BEARER your-token' \<br />'https://public-api.wordpress.com/rest/v1/sites/123/posts/new'</code>",
59            'media_urls'        => '(array) An array of URLs for images to attach to a post. Sideloads the media in for a post.',
60            'metadata'          => '(array) Array of metadata objects containing the following properties: `key` (metadata key), `id` (meta ID), `previous_value` (if set, the action will only occur for the provided previous value), `value` (the new value to set the meta to), `operation` (the operation to perform: `update` or `add`; defaults to `update`). Unprotected meta keys can be changed by users who can edit the post. Protected meta keys (those starting with an underscore) can be changed only when the user can edit that key, which requires an <code>auth_callback</code> registered with <code>register_meta()</code> or an <code>auth_post_meta_{$meta_key}</code> filter. Keys allowed by the <code>rest_api_allowed_public_metadata</code> filter can also be added or updated.',
61            'comments_open'     => "(bool) Should the post be open to comments? Defaults to the blog's preference.",
62            'pings_open'        => "(bool) Should the post be open to comments? Defaults to the blog's preference.",
63            'likes_enabled'     => "(bool) Should the post be open to likes? Defaults to the blog's preference.",
64            'sharing_enabled'   => '(bool) Should sharing buttons show on this post? Defaults to true.',
65            'menu_order'        => '(int) (Pages Only) the order pages should appear in. Use 0 to maintain alphabetical order.',
66        ),
67
68        'example_request'      => 'https://public-api.wordpress.com/rest/v1/sites/82974409/posts/new/',
69
70        'example_request_data' => array(
71            'headers' => array(
72                'authorization' => 'Bearer YOUR_API_TOKEN',
73            ),
74
75            'body'    => array(
76                'title'      => 'Hello World',
77                'content'    => 'Hello. I am a test post. I was created by the API',
78                'tags'       => 'tests',
79                'categories' => 'API',
80            ),
81        ),
82    )
83);
84
85new WPCOM_JSON_API_Update_Post_Endpoint(
86    array(
87        'description'          => 'Edit a post.',
88        'group'                => 'posts',
89        'stat'                 => 'posts:1:POST',
90        'new_version'          => '1.2',
91        'max_version'          => '1',
92        'method'               => 'POST',
93        'path'                 => '/sites/%s/posts/%d',
94        'path_labels'          => array(
95            '$site'    => '(int|string) Site ID or domain',
96            '$post_ID' => '(int) The post ID',
97        ),
98
99        'request_format'       => array(
100            'date'              => "(ISO 8601 datetime) The post's creation time.",
101            'title'             => '(HTML) The post title.',
102            'content'           => '(HTML) The post content.',
103            'excerpt'           => '(HTML) An optional post excerpt.',
104            'slug'              => '(string) The name (slug) for the post, used in URLs.',
105            'author'            => '(string) The username or ID for the user to assign the post to.',
106            'publicize'         => '(array|bool) True or false if the post be shared to external services. An array of services if we only want to share to a select few. Defaults to true.',
107            'publicize_message' => '(string) Custom message to be shared to external services.',
108            'status'            => array(
109                'publish' => 'Publish the post.',
110                'private' => 'Privately publish the post.',
111                'draft'   => 'Save the post as a draft.',
112                'pending' => 'Mark the post as pending editorial approval.',
113                'trash'   => 'Set the post as trashed.',
114            ),
115            'sticky'            => array(
116                'false' => 'Post is not marked as sticky.',
117                'true'  => 'Stick the post to the front page.',
118            ),
119            'password'          => '(string) The plaintext password protecting the post, or, more likely, the empty string if the post is not password protected.',
120            'parent'            => "(int) The post ID of the new post's parent.",
121            'categories'        => '(array|string) Comma-separated list or array of categories (name or id)',
122            'tags'              => '(array|string) Comma-separated list or array of tags (name or id)',
123            'format'            => array_merge( array( 'default' => 'Use default post format' ), get_post_format_strings() ),
124            'comments_open'     => '(bool) Should the post be open to comments?',
125            'pings_open'        => '(bool) Should the post be open to comments?',
126            'likes_enabled'     => '(bool) Should the post be open to likes?',
127            'menu_order'        => '(int) (Pages Only) the order pages should appear in. Use 0 to maintain alphabetical order.',
128            'sharing_enabled'   => '(bool) Should sharing buttons show on this post?',
129            'featured_image'    => '(string) The post ID of an existing attachment to set as the featured image. Pass an empty string to delete the existing image.',
130            'media'             => '(media) An array of files to attach to the post. To upload media, the entire request should be multipart/form-data encoded. Multiple media items will be displayed in a gallery. Accepts  jpg, jpeg, png, gif, pdf, doc, ppt, odt, pptx, docx, pps, ppsx, xls, xlsx, key. Audio and Video may also be available. See <code>allowed_file_types</code> in the options resposne of the site endpoint. <br /><br /><strong>Example</strong>:<br />' .
131                            "<code>curl \<br />--form 'title=Image' \<br />--form 'media[]=@/path/to/file.jpg' \<br />-H 'Authorization: BEARER your-token' \<br />'https://public-api.wordpress.com/rest/v1/sites/123/posts/new'</code>",
132            'media_urls'        => '(array) An array of URLs for images to attach to a post. Sideloads the media in for a post.',
133            'metadata'          => '(array) Array of metadata objects containing the following properties: `key` (metadata key), `id` (meta ID), `previous_value` (if set, the action will only occur for the provided previous value), `value` (the new value to set the meta to), `operation` (the operation to perform: `update` or `add`; defaults to `update`). Users who can edit the post get its unprotected meta keys. Protected meta keys (those starting with an underscore) are returned only when the user can edit that key, which requires an <code>auth_callback</code> registered with <code>register_meta()</code> or an <code>auth_post_meta_{$meta_key}</code> filter. Keys allowed by the <code>rest_api_allowed_public_metadata</code> filter, plus <code>_thumbnail_id</code> and keys starting with <code>geo_</code> or <code>_wpas_</code>, are returned to anyone who can read the post.',
134        ),
135
136        'example_request'      => 'https://public-api.wordpress.com/rest/v1/sites/82974409/posts/881',
137
138        'example_request_data' => array(
139            'headers' => array(
140                'authorization' => 'Bearer YOUR_API_TOKEN',
141            ),
142
143            'body'    => array(
144                'title'      => 'Hello World (Again)',
145                'content'    => 'Hello. I am an edited post. I was edited by the API',
146                'tags'       => 'tests',
147                'categories' => 'API',
148            ),
149        ),
150    )
151);
152
153new WPCOM_JSON_API_Update_Post_Endpoint(
154    array(
155        'description'          => 'Delete a post. Note: If the trash is enabled, this request will send the post to the trash. A second request will permanently delete the post.',
156        'group'                => 'posts',
157        'stat'                 => 'posts:1:delete',
158        'new_version'          => '1.1',
159        'max_version'          => '1',
160        'method'               => 'POST',
161        'path'                 => '/sites/%s/posts/%d/delete',
162        'path_labels'          => array(
163            '$site'    => '(int|string) Site ID or domain',
164            '$post_ID' => '(int) The post ID',
165        ),
166
167        'example_request'      => 'https://public-api.wordpress.com/rest/v1/sites/82974409/posts/$post_ID/delete/',
168
169        'example_request_data' => array(
170            'headers' => array(
171                'authorization' => 'Bearer YOUR_API_TOKEN',
172            ),
173        ),
174    )
175);
176
177new WPCOM_JSON_API_Update_Post_Endpoint(
178    array(
179        'description'          => 'Restore a post or page from the trash to its previous status.',
180        'group'                => 'posts',
181        'stat'                 => 'posts:1:restore',
182
183        'method'               => 'POST',
184        'new_version'          => '1.1',
185        'max_version'          => '1',
186        'path'                 => '/sites/%s/posts/%d/restore',
187        'path_labels'          => array(
188            '$site'    => '(int|string) Site ID or domain',
189            '$post_ID' => '(int) The post ID',
190        ),
191
192        'example_request'      => 'https://public-api.wordpress.com/rest/v1/sites/82974409/posts/$post_ID/restore/',
193
194        'example_request_data' => array(
195            'headers' => array(
196                'authorization' => 'Bearer YOUR_API_TOKEN',
197            ),
198        ),
199    )
200);
201
202/**
203 * Update post endpoint class.
204 *
205 * @phan-constructor-used-for-side-effects
206 */
207class WPCOM_JSON_API_Update_Post_Endpoint extends WPCOM_JSON_API_Post_Endpoint {
208    /**
209     * WPCOM_JSON_API_Update_Post_Endpoint constructor.
210     *
211     * @param array $args Args.
212     */
213    public function __construct( $args ) {
214        parent::__construct( $args );
215        if ( $this->api->ends_with( $this->path, '/delete' ) ) {
216            $this->post_object_format['status']['deleted'] = 'The post has been deleted permanently.';
217        }
218    }
219
220    /**
221     * Update post API callback.
222     *
223     * /sites/%s/posts/new        -> $blog_id
224     * /sites/%s/posts/%d         -> $blog_id, $post_id
225     * /sites/%s/posts/%d/delete  -> $blog_id, $post_id
226     * /sites/%s/posts/%d/restore -> $blog_id, $post_id
227     *
228     * @param string $path API path.
229     * @param int    $blog_id Blog ID.
230     * @param int    $post_id Post ID.
231     *
232     * @return array|bool|WP_Error
233     */
234    public function callback( $path = '', $blog_id = 0, $post_id = 0 ) {
235        $blog_id = $this->api->switch_to_blog_and_validate_user( $this->api->get_blog_id( $blog_id ) );
236        if ( is_wp_error( $blog_id ) ) {
237            return $blog_id;
238        }
239
240        if ( $this->api->ends_with( $path, '/delete' ) ) {
241            return $this->delete_post( $path, $blog_id, $post_id );
242        } elseif ( $this->api->ends_with( $path, '/restore' ) ) {
243            return $this->restore_post( $path, $blog_id, $post_id );
244        } else {
245            return $this->write_post( $path, $blog_id, $post_id );
246        }
247    }
248
249    /**
250     * Create or update a post.
251     *
252     * /sites/%s/posts/new -> $blog_id
253     * /sites/%s/posts/%d  -> $blog_id, $post_id
254     *
255     * @param string $path API path.
256     * @param int    $blog_id Blog ID.
257     * @param int    $post_id Post ID.
258     */
259    public function write_post( $path, $blog_id, $post_id ) {
260        $delete_featured_image = null;
261        $new                   = $this->api->ends_with( $path, '/new' );
262        $args                  = $this->query_args();
263
264        // unhook publicize, it's hooked again later -- without this, skipping services is impossible.
265        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
266            remove_action( 'save_post', array( $GLOBALS['publicize_ui']->publicize, 'async_publicize_post' ), 100 );
267            add_action( 'rest_api_inserted_post', array( $GLOBALS['publicize_ui']->publicize, 'async_publicize_post' ) );
268        }
269
270        if ( $new ) {
271            $input = $this->input( true );
272
273            // default to post.
274            if ( empty( $input['type'] ) ) {
275                $input['type'] = 'post';
276            }
277
278            if ( 'revision' === $input['type'] ) {
279                if ( ! isset( $input['parent'] ) ) {
280                    return new WP_Error( 'invalid_input', 'Invalid request input', 400 );
281                }
282                $input['status'] = 'inherit'; // force inherit for revision type.
283                $input['slug']   = $input['parent'] . '-autosave-v1';
284            } elseif ( ! isset( $input['title'] ) && ! isset( $input['content'] ) && ! isset( $input['excerpt'] ) ) {
285                return new WP_Error( 'invalid_input', 'Invalid request input', 400 );
286            }
287
288            $post_type = get_post_type_object( $input['type'] );
289
290            if ( ! $this->is_post_type_allowed( $input['type'] ) ) {
291                return new WP_Error( 'unknown_post_type', 'Unknown post type', 404 );
292            }
293
294            if ( ! empty( $input['author'] ) ) {
295                $author_id = $this->parse_and_set_author( $input['author'], $input['type'] );
296                unset( $input['author'] );
297                if ( is_wp_error( $author_id ) ) {
298                    return $author_id;
299                }
300            }
301
302            if ( 'publish' === $input['status'] ) {
303                if ( ! current_user_can( $post_type->cap->publish_posts ) ) {
304                    if ( current_user_can( $post_type->cap->edit_posts ) ) {
305                        $input['status'] = 'pending';
306                    } else {
307                        return new WP_Error( 'unauthorized', 'User cannot publish posts', 403 );
308                    }
309                }
310            } elseif ( ! current_user_can( $post_type->cap->edit_posts ) ) {
311                return new WP_Error( 'unauthorized', 'User cannot edit posts', 403 );
312            }
313        } else {
314            $input = $this->input( false );
315
316            if ( ! is_array( $input ) || ! $input ) {
317                return new WP_Error( 'invalid_input', 'Invalid request input', 400 );
318            }
319
320            if ( isset( $input['status'] ) && 'trash' === $input['status'] && ! current_user_can( 'delete_post', $post_id ) ) {
321                return new WP_Error( 'unauthorized', 'User cannot delete post', 403 );
322            }
323
324            $post       = get_post( $post_id );
325            $_post_type = ( ! empty( $input['type'] ) ) ? $input['type'] : $post->post_type;
326            $post_type  = get_post_type_object( $_post_type );
327            if ( ! $post || is_wp_error( $post ) ) {
328                return new WP_Error( 'unknown_post', 'Unknown post', 404 );
329            }
330
331            if ( ! current_user_can( 'edit_post', $post->ID ) ) {
332                return new WP_Error( 'unauthorized', 'User cannot edit post', 403 );
333            }
334
335            if ( ! empty( $input['author'] ) ) {
336                $author_id = $this->parse_and_set_author( $input['author'], $_post_type );
337                unset( $input['author'] );
338                if ( is_wp_error( $author_id ) ) {
339                    return $author_id;
340                }
341            }
342
343            if ( ( isset( $input['status'] ) && 'publish' === $input['status'] ) && 'publish' !== $post->post_status && ! current_user_can( 'publish_post', $post->ID ) ) {
344                $input['status'] = 'pending';
345            }
346            $last_status = $post->post_status;
347            $new_status  = $input['status'] ?? $last_status;
348
349            // Make sure that drafts get the current date when transitioning to publish if not supplied in the post.
350            $date_in_past = ( strtotime( $post->post_date_gmt ) < time() );
351            if ( 'publish' === $new_status && 'draft' === $last_status && ! isset( $input['date_gmt'] ) && $date_in_past ) {
352                $input['date_gmt'] = gmdate( 'Y-m-d H:i:s' );
353            }
354
355            // Untrash a post so that the proper hooks get called as well as the comments get untrashed.
356            if ( 'trash' === $last_status && 'trash' !== $new_status && isset( $post->ID ) ) {
357                wp_untrash_post( $post->ID );
358                $untashed_post = get_post( $post->ID );
359                // Lets make sure that we use the revert the slug.
360                if ( isset( $untashed_post->post_name ) && $untashed_post->post_name . '__trashed' === $input['slug'] ) {
361                    unset( $input['slug'] );
362                }
363            }
364        }
365
366        if ( function_exists( 'wpcom_switch_to_locale' ) ) {
367            // fixes calypso-pre-oss #12476: respect blog locale when creating the post slug.
368            wpcom_switch_to_locale( get_blog_lang_code( $blog_id ) );
369        }
370
371        // If date was set, $this->input will set date_gmt, date still needs to be adjusted for the blog's offset.
372        if ( isset( $input['date_gmt'] ) ) {
373            $gmt_offset       = get_option( 'gmt_offset' );
374            $time_with_offset = strtotime( $input['date_gmt'] ) + $gmt_offset * HOUR_IN_SECONDS;
375            $input['date']    = gmdate( 'Y-m-d H:i:s', $time_with_offset );
376        }
377
378        if ( ! empty( $author_id ) && get_current_user_id() !== $author_id ) {
379            if ( ! current_user_can( $post_type->cap->edit_others_posts ) ) {
380                return new WP_Error( 'unauthorized', "User is not allowed to publish others' posts.", 403 );
381            } elseif ( ! user_can( $author_id, $post_type->cap->edit_posts ) ) {
382                return new WP_Error( 'unauthorized', 'Assigned author cannot publish post.', 403 );
383            }
384        }
385
386        if ( ! is_post_type_hierarchical( $post_type->name ) && 'revision' !== $post_type->name ) {
387            unset( $input['parent'] );
388        }
389
390        $tax_input = array();
391
392        foreach ( array(
393            'categories' => 'category',
394            'tags'       => 'post_tag',
395        ) as $key => $taxonomy ) {
396            if ( ! isset( $input[ $key ] ) ) {
397                continue;
398            }
399
400            $tax_input[ $taxonomy ] = array();
401
402            $is_hierarchical = is_taxonomy_hierarchical( $taxonomy );
403
404            if ( is_array( $input[ $key ] ) ) {
405                $terms = $input[ $key ];
406            } else {
407                $terms = explode( ',', $input[ $key ] );
408            }
409
410            foreach ( $terms as $term ) {
411                /**
412                 * `curl --data 'category[]=123'` should be interpreted as a category ID,
413                 * not a category whose name is '123'.
414                 *
415                 * Consequence: To add a category/tag whose name is '123', the client must
416                 * first look up its ID.
417                 */
418                $term = (string) $term; // ctype_digit compat.
419                if ( ctype_digit( $term ) ) {
420                    $term = (int) $term;
421                }
422
423                $term_info = term_exists( $term, $taxonomy );
424
425                if ( ! $term_info ) {
426                    // A term ID that doesn't already exist. Ignore it: we don't know what name to give it.
427                    if ( is_int( $term ) ) {
428                        continue;
429                    }
430                    // only add a new tag/cat if the user has access to.
431                    $tax = get_taxonomy( $taxonomy );
432
433                    // see https://core.trac.wordpress.org/ticket/26409 .
434                    if ( 'category' === $taxonomy && ! current_user_can( $tax->cap->edit_terms ) ) {
435                        continue;
436                    } elseif ( ! current_user_can( $tax->cap->assign_terms ) ) {
437                        continue;
438                    }
439
440                    $term_info = wp_insert_term( $term, $taxonomy );
441                }
442
443                if ( ! is_wp_error( $term_info ) ) {
444                    if ( $is_hierarchical ) {
445                        // Categories must be added by ID.
446                        $tax_input[ $taxonomy ][] = (int) $term_info['term_id'];
447                    } elseif ( is_int( $term ) ) { // Tags must be added by name.
448                        $term                     = get_term( $term, $taxonomy );
449                        $tax_input[ $taxonomy ][] = $term->name;
450                    } else {
451                        $tax_input[ $taxonomy ][] = $term;
452                    }
453                }
454            }
455        }
456
457        if ( isset( $input['categories'] ) && empty( $tax_input['category'] ) && 'revision' !== $post_type->name ) {
458            $tax_input['category'][] = get_option( 'default_category' );
459        }
460
461        unset( $input['tags'], $input['categories'] );
462
463        $insert = array();
464
465        if ( ! empty( $input['slug'] ) ) {
466            $insert['post_name'] = $input['slug'];
467            unset( $input['slug'] );
468        }
469
470        if ( isset( $input['comments_open'] ) ) {
471            $insert['comment_status'] = ( true === $input['comments_open'] ) ? 'open' : 'closed';
472        }
473
474        if ( isset( $input['pings_open'] ) ) {
475            $insert['ping_status'] = ( true === $input['pings_open'] ) ? 'open' : 'closed';
476        }
477
478        unset( $input['comments_open'], $input['pings_open'] );
479
480        if ( isset( $input['menu_order'] ) ) {
481            $insert['menu_order'] = $input['menu_order'];
482            unset( $input['menu_order'] );
483        }
484
485        $publicize = $input['publicize'] ?? null;
486        unset( $input['publicize'] );
487
488        $publicize_custom_message = $input['publicize_message'] ?? null;
489        unset( $input['publicize_message'] );
490
491        if ( isset( $input['featured_image'] ) ) {
492            $featured_image        = trim( $input['featured_image'] );
493            $delete_featured_image = empty( $featured_image );
494            unset( $input['featured_image'] );
495        }
496
497        $metadata = $input['metadata'] ?? null;
498        unset( $input['metadata'] );
499
500        $likes = $input['likes_enabled'] ?? null;
501        unset( $input['likes_enabled'] );
502
503        $sharing = $input['sharing_enabled'] ?? null;
504        unset( $input['sharing_enabled'] );
505
506        $sticky = $input['sticky'] ?? null;
507        unset( $input['sticky'] );
508
509        foreach ( $input as $key => $value ) {
510            $insert[ "post_$key" ] = $value;
511        }
512
513        if ( ! empty( $author_id ) ) {
514            $insert['post_author'] = absint( $author_id );
515        }
516
517        if ( ! empty( $tax_input ) ) {
518            $insert['tax_input'] = $tax_input;
519        }
520
521        $has_media        = isset( $input['media'] ) && $input['media'] ? count( $input['media'] ) : false;
522        $has_media_by_url = isset( $input['media_urls'] ) && $input['media_urls'] ? count( $input['media_urls'] ) : false;
523
524        // A JSON boolean false is cast to array( false ) by the (array|bool) input type.
525        $skip_publicize = false === $publicize || array( false ) === $publicize;
526
527        $block_publicize_flag = $skip_publicize && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM );
528
529        /**
530         * On Jetpack sites, Sync flags the post for sharing on publish, before the skip meta below exists.
531         * WordPress.com unhooks sharing from save_post above instead.
532         *
533         * @return false
534         */
535        $block_publicize = static function () {
536            return false;
537        };
538        if ( $block_publicize_flag ) {
539            add_filter( 'publicize_should_publicize_published_post', $block_publicize, PHP_INT_MAX );
540        }
541
542        if ( $new ) {
543
544            if ( isset( $input['content'] ) && ! has_shortcode( $input['content'], 'gallery' ) && ( $has_media || $has_media_by_url ) ) {
545                switch ( ( $has_media + $has_media_by_url ) ) {
546                    case 0:
547                        // No images - do nothing.
548                        break;
549                    case 1:
550                        // 1 image - make it big.
551                        $input['content']       = "[gallery size=full columns=1]\n\n" . $input['content'];
552                        $insert['post_content'] = $input['content'];
553                        break;
554                    default:
555                        // Several images - 3 column gallery.
556                        $input['content']       = "[gallery]\n\n" . $input['content'];
557                        $insert['post_content'] = $input['content'];
558                        break;
559                }
560            }
561
562            $post_id = wp_insert_post( add_magic_quotes( $insert ), true );
563        } else {
564            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $post is set and validated several blocks earlier if $new (only set once) is falsy.
565            $insert['ID'] = $post->ID;
566
567            // wp_update_post ignores date unless edit_date is set
568            // See: https://codex.wordpress.org/Function_Reference/wp_update_post#Scheduling_posts
569            // See: https://core.trac.wordpress.org/browser/tags/3.9.2/src/wp-includes/post.php#L3302 .
570            if ( isset( $input['date_gmt'] ) || isset( $input['date'] ) ) {
571                $insert['edit_date'] = true;
572            }
573
574            // this two-step process ensures any changes submitted along with status=trash get saved before trashing.
575            if ( isset( $input['status'] ) && 'trash' === $input['status'] ) {
576                // if we insert it with status='trash', it will get double-trashed, so insert it as a draft first.
577                unset( $insert['status'] );
578                $post_id = wp_update_post( (object) $insert );
579                // now call wp_trash_post so post_meta gets set and any filters get called.
580                wp_trash_post( $post_id );
581            } else {
582                $post_id = wp_update_post( (object) $insert );
583            }
584        }
585
586        if ( $block_publicize_flag ) {
587            remove_filter( 'publicize_should_publicize_published_post', $block_publicize, PHP_INT_MAX );
588        }
589
590        if ( ! $post_id || is_wp_error( $post_id ) ) {
591            return $post_id;
592        }
593
594        // make sure this post actually exists and is not an error of some kind (ie, trying to load media in the posts endpoint).
595        $post_check = $this->get_post_by( 'ID', $post_id, $args['context'] );
596        if ( is_wp_error( $post_check ) ) {
597            return $post_check;
598        }
599
600        if ( $has_media ) {
601            $this->api->trap_wp_die( 'upload_error' );
602            foreach ( $input['media'] as $media_item ) {
603                $_FILES['.api.media.item.'] = $media_item;
604                // check for WP_Error if we ever actually need $media_id .
605                $media_id = media_handle_upload( '.api.media.item.', $post_id ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
606            }
607            $this->api->trap_wp_die( null );
608
609            unset( $_FILES['.api.media.item.'] );
610        }
611
612        if ( $has_media_by_url ) {
613            foreach ( $input['media_urls'] as $url ) {
614                $this->handle_media_sideload( $url, $post_id );
615            }
616        }
617
618        // Set like status for the post.
619        /** This filter is documented in modules/likes.php */
620        $sitewide_likes_enabled = (bool) apply_filters( 'wpl_is_enabled_sitewide', ! get_option( 'disabled_likes' ) );
621        if ( $new ) {
622            if ( $sitewide_likes_enabled ) {
623                if ( false === $likes ) {
624                    update_post_meta( $post_id, 'switch_like_status', 0 );
625                } else {
626                    delete_post_meta( $post_id, 'switch_like_status' );
627                }
628            } elseif ( $likes ) {
629                update_post_meta( $post_id, 'switch_like_status', 1 );
630            } else {
631                delete_post_meta( $post_id, 'switch_like_status' );
632            }
633        } elseif ( isset( $likes ) ) {
634            if ( $sitewide_likes_enabled ) {
635                if ( false === $likes ) {
636                    update_post_meta( $post_id, 'switch_like_status', 0 );
637                } else {
638                    delete_post_meta( $post_id, 'switch_like_status' );
639                }
640            } elseif ( true === $likes ) {
641                update_post_meta( $post_id, 'switch_like_status', 1 );
642            } else {
643                delete_post_meta( $post_id, 'switch_like_status' );
644            }
645        }
646
647        // Set sharing status of the post.
648        if ( $new ) {
649            $sharing_enabled = isset( $sharing ) ? (bool) $sharing : true;
650            if ( ! $sharing_enabled ) {
651                update_post_meta( $post_id, 'sharing_disabled', 1 );
652            }
653        } elseif ( isset( $sharing ) && true === $sharing ) {
654            delete_post_meta( $post_id, 'sharing_disabled' );
655        } elseif ( isset( $sharing ) && false == $sharing ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
656            update_post_meta( $post_id, 'sharing_disabled', 1 );
657        }
658
659        if ( isset( $sticky ) ) {
660            if ( true === $sticky ) {
661                stick_post( $post_id );
662            } else {
663                unstick_post( $post_id );
664            }
665        }
666
667        // WPCOM Specific (Jetpack's will get bumped elsewhere
668        // Tracks how many posts are published and sets meta
669        // so we can track some other cool stats (like likes & comments on posts published).
670        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
671            if (
672                ( $new && 'publish' === $input['status'] )
673                || (
674                    ! $new && isset( $last_status )
675                    && 'publish' !== $last_status
676                    && isset( $new_status )
677                    && 'publish' === $new_status
678                )
679            ) {
680                /** This action is documented in modules/widgets/social-media-icons.php */
681                do_action( 'jetpack_bump_stats_extras', 'api-insights-posts', $this->api->token_details['client_id'] );
682                update_post_meta( $post_id, '_rest_api_published', 1 );
683                update_post_meta( $post_id, '_rest_api_client_id', $this->api->token_details['client_id'] );
684            }
685        }
686
687        // We ask the user/dev to pass Publicize services he/she wants activated for the post, but Publicize expects us
688        // to instead flag the ones we don't want to be skipped. proceed with said logic.
689        if ( $skip_publicize ) {
690            // No publicize at all, skip all by ID.
691            foreach ( $GLOBALS['publicize_ui']->publicize->get_services( 'all' ) as $name => $service ) {
692                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $name );
693                $service_connections = $GLOBALS['publicize_ui']->publicize->get_connections( $name );
694                if ( ! $service_connections ) {
695                    continue;
696                }
697                foreach ( $service_connections as $service_connection ) {
698                    update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $GLOBALS['publicize_ui']->publicize->get_connection_unique_id( $service_connection ), 1 );
699                }
700            }
701        } elseif ( is_array( $publicize ) && ( $publicize !== array() ) ) {
702            foreach ( $GLOBALS['publicize_ui']->publicize->get_services( 'all' ) as $name => $service ) {
703                /*
704                * We support both indexed and associative arrays:
705                * * indexed are to pass entire services
706                * * associative are to pass specific connections per service
707                *
708                * We do support mixed arrays: mixed integer and string keys (see 3rd example below).
709                *
710                * EG: array( 'linkedin', 'facebook') will only publicize to those, ignoring the other available services
711                *      Form data: publicize[]=linkedin&publicize[]=facebook
712                * EG: array( 'linkedin' => '(int) $pub_conn_id_0, (int) $pub_conn_id_3', 'facebook' => (int) $pub_conn_id_7 ) will publicize to two LinkedIn accounts, and one Facebook connection, of potentially many.
713                *      Form data: publicize[linkedin]=$pub_conn_id_0,$pub_conn_id_3&publicize[facebook]=$pub_conn_id_7
714                * EG: array( 'linkedin', 'facebook' => '(int) $pub_conn_id_0, (int) $pub_conn_id_3' ) will publicize to all available LinkedIn accounts, but only 2 of potentially many Facebook connections
715                *      Form data: publicize[]=linkedin&publicize[facebook]=$pub_conn_id_0,$pub_conn_id_3
716                */
717
718                // Delete any stale SKIP value for the service by name. We'll add it back by ID.
719                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $name );
720
721                // Get the user's connections.
722                $service_connections = $GLOBALS['publicize_ui']->publicize->get_connections( $name );
723
724                // if the user doesn't have any connections for this service, move on.
725                if ( ! $service_connections ) {
726                    continue;
727                }
728
729                if ( ! in_array( $name, $publicize, true ) && ! array_key_exists( $name, $publicize ) ) {
730                    // Skip the whole service by adding each connection ID.
731                    foreach ( $service_connections as $service_connection ) {
732                        update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $GLOBALS['publicize_ui']->publicize->get_connection_unique_id( $service_connection ), 1 );
733                    }
734                } elseif ( ! empty( $publicize[ $name ] ) ) {
735                    // Seems we're being asked to only push to [a] specific connection[s].
736                    // Explode the list on commas, which will also support a single passed ID.
737                    $requested_connections = explode( ',', ( preg_replace( '/[\s]*/', '', $publicize[ $name ] ) ) );
738                    // Flag the connections we can't match with the requested list to be skipped.
739                    foreach ( $service_connections as $service_connection ) {
740                        if ( ! in_array( (string) $GLOBALS['publicize_ui']->publicize->get_connection_id( $service_connection ), $requested_connections, true ) ) {
741                            update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $GLOBALS['publicize_ui']->publicize->get_connection_unique_id( $service_connection ), 1 );
742                        } else {
743                            delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $GLOBALS['publicize_ui']->publicize->get_connection_unique_id( $service_connection ) );
744                        }
745                    }
746                } else {
747                    // delete all SKIP values; it's okay to publish to all connected IDs for this service.
748                    foreach ( $service_connections as $service_connection ) {
749                        delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $GLOBALS['publicize_ui']->publicize->get_connection_unique_id( $service_connection ) );
750                    }
751                }
752            }
753        }
754
755        if ( $publicize_custom_message !== null ) {
756            if ( empty( $publicize_custom_message ) ) {
757                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_MESS );
758            } else {
759                update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_MESS, trim( $publicize_custom_message ) );
760            }
761        }
762
763        if ( ! empty( $insert['post_format'] ) ) {
764            if ( 'default' !== strtolower( $insert['post_format'] ) ) {
765                set_post_format( $post_id, $insert['post_format'] );
766            } else {
767                set_post_format( $post_id, get_option( 'default_post_format' ) );
768            }
769        }
770
771        if ( isset( $featured_image ) ) {
772            $this->parse_and_set_featured_image( $post_id, $delete_featured_image, $featured_image );
773        }
774
775        if ( ! empty( $metadata ) ) {
776            foreach ( (array) $metadata as $meta ) {
777
778                $meta = (object) $meta;
779
780                if (
781                    in_array( $meta->key ?? null, Jetpack_SEO_Posts::POST_META_KEYS_ARRAY, true ) &&
782                    ! Jetpack_SEO_Utils::is_enabled_jetpack_seo()
783                ) {
784                    return new WP_Error( 'unauthorized', __( 'SEO tools are not enabled for this site.', 'jetpack' ), 403 );
785                }
786
787                $existing_meta_item = new stdClass();
788
789                if ( empty( $meta->operation ) ) {
790                    $meta->operation = 'update';
791                }
792
793                if ( ! empty( $meta->value ) ) {
794                    if ( 'true' == $meta->value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
795                        $meta->value = true;
796                    }
797                    if ( 'false' == $meta->value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
798                        $meta->value = false;
799                    }
800                }
801
802                if ( ! empty( $meta->id ) ) {
803                    $meta->id           = absint( $meta->id );
804                    $existing_meta_item = get_metadata_by_mid( 'post', $meta->id );
805                    if ( $post_id !== (int) $existing_meta_item->post_id ) {
806                        // Only allow updates for metadata on this post.
807                        continue;
808                    }
809                }
810
811                $unslashed_meta_key           = isset( $meta->key ) ? wp_unslash( $meta->key ) : null; // should match what the final key will be.
812                $meta->key                    = isset( $meta->key ) ? wp_slash( $meta->key ) : null;
813                $unslashed_existing_meta_key  = isset( $existing_meta_item->meta_key ) ? wp_unslash( $existing_meta_item->meta_key ) : null;
814                $existing_meta_item->meta_key = isset( $existing_meta_item->meta_key ) ? wp_slash( $existing_meta_item->meta_key ) : null;
815
816                // make sure that the meta id passed matches the existing meta key.
817                if ( ! empty( $meta->id ) && ! empty( $meta->key ) ) {
818                    $meta_by_id = get_metadata_by_mid( 'post', $meta->id );
819                    if ( $meta_by_id->meta_key !== $meta->key ) {
820                        continue; // skip this meta.
821                    }
822                }
823
824                switch ( $meta->operation ) {
825                    case 'delete':
826                        if ( ! empty( $meta->id ) && ! empty( $existing_meta_item->meta_key ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_existing_meta_key ) ) {
827                            delete_metadata_by_mid( 'post', $meta->id );
828                        } elseif ( ! empty( $meta->key ) && ! empty( $meta->previous_value ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_meta_key ) ) {
829                            delete_post_meta( $post_id, $meta->key, $meta->previous_value );
830                        } elseif ( ! empty( $meta->key ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_meta_key ) ) {
831                            delete_post_meta( $post_id, $meta->key );
832                        }
833
834                        break;
835                    case 'add':
836                        if ( ! empty( $meta->id ) || ! empty( $meta->previous_value ) ) {
837                            break;
838                        } elseif ( ! empty( $meta->key ) && ! empty( $meta->value ) && ( current_user_can( 'add_post_meta', $post_id, $unslashed_meta_key ) ) || WPCOM_JSON_API_Metadata::is_public( $meta->key ) ) {
839                            add_post_meta( $post_id, $meta->key, $meta->value );
840                        }
841
842                        break;
843                    case 'update':
844                        if ( ! isset( $meta->value ) ) {
845                            break;
846                        } elseif ( ! empty( $meta->id ) && ! empty( $existing_meta_item->meta_key ) && ( current_user_can( 'edit_post_meta', $post_id, $unslashed_existing_meta_key ) || WPCOM_JSON_API_Metadata::is_public( $meta->key ) ) ) {
847                            update_metadata_by_mid( 'post', $meta->id, $meta->value );
848                        } elseif ( ! empty( $meta->key ) && ! empty( $meta->previous_value ) && ( current_user_can( 'edit_post_meta', $post_id, $unslashed_meta_key ) || WPCOM_JSON_API_Metadata::is_public( $meta->key ) ) ) {
849                            update_post_meta( $post_id, $meta->key, $meta->value, $meta->previous_value );
850                        } elseif ( ! empty( $meta->key ) && ( current_user_can( 'edit_post_meta', $post_id, $unslashed_meta_key ) || WPCOM_JSON_API_Metadata::is_public( $meta->key ) ) ) {
851                            update_post_meta( $post_id, $meta->key, $meta->value );
852                        }
853
854                        break;
855                }
856            }
857        }
858
859        /**
860         * Fires when a post is created via the REST API.
861         *
862         * @module json-api
863         *
864         * @since 2.3.0
865         *
866         * @param int $post_id Post ID.
867         * @param array $insert Data used to build the post.
868         * @param string $new New post URL suffix.
869         */
870        do_action( 'rest_api_inserted_post', $post_id, $insert, $new );
871
872        $return = $this->get_post_by( 'ID', $post_id, $args['context'] );
873        if ( ! $return || is_wp_error( $return ) ) {
874            return $return;
875        }
876
877        if ( isset( $input['type'] ) && 'revision' === $input['type'] ) {
878            $return['preview_nonce'] = wp_create_nonce( 'post_preview_' . $input['parent'] );
879        }
880
881        if ( isset( $sticky ) ) {
882            // workaround for sticky test occasionally failing, maybe a race condition with stick_post() above.
883            $return['sticky'] = ( true === $sticky );
884        }
885
886        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
887        do_action( 'wpcom_json_api_objects', 'posts' );
888
889        return $return;
890    }
891
892    /**
893     * Delete a post.
894     *
895     * /sites/%s/posts/%d/delete -> $blog_id, $post_id
896     *
897     * @param string $path API path.
898     * @param array  $blog_id Blog ID.
899     * @param array  $post_id Post ID.
900     *
901     * @return array|WP_Error
902     */
903    public function delete_post( $path, $blog_id, $post_id ) {
904        $post = get_post( $post_id );
905        if ( ! $post || is_wp_error( $post ) ) {
906            return new WP_Error( 'unknown_post', 'Unknown post', 404 );
907        }
908
909        if ( ! $this->is_post_type_allowed( $post->post_type ) ) {
910            return new WP_Error( 'unknown_post_type', 'Unknown post type', 404 );
911        }
912
913        if ( ! current_user_can( 'delete_post', $post->ID ) ) {
914            return new WP_Error( 'unauthorized', 'User cannot delete posts', 403 );
915        }
916
917        $args   = $this->query_args();
918        $return = $this->get_post_by( 'ID', $post->ID, $args['context'] );
919        if ( ! $return || is_wp_error( $return ) ) {
920            return $return;
921        }
922
923        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
924        do_action( 'wpcom_json_api_objects', 'posts' );
925
926        // we need to call wp_trash_post so that untrash will work correctly for all post types.
927        if ( 'trash' === $post->post_status ) {
928            wp_delete_post( $post->ID );
929        } else {
930            wp_trash_post( $post->ID );
931        }
932
933        $status = get_post_status( $post->ID );
934        if ( false === $status ) {
935            $return['status'] = 'deleted';
936            return $return;
937        }
938
939        return $this->get_post_by( 'ID', $post->ID, $args['context'] );
940    }
941
942    /**
943     * Restore a post.
944     *
945     * /sites/%s/posts/%d/restore -> $blog_id, $post_id
946     *
947     * @param string $path API path.
948     * @param int    $blog_id Blog ID.
949     * @param int    $post_id Post ID.
950     *
951     * @return array|WP_Error
952     */
953    public function restore_post( $path, $blog_id, $post_id ) {
954        $args = $this->query_args();
955        $post = get_post( $post_id );
956
957        if ( ! $post || is_wp_error( $post ) ) {
958            return new WP_Error( 'unknown_post', 'Unknown post', 404 );
959        }
960
961        if ( ! current_user_can( 'delete_post', $post->ID ) ) {
962            return new WP_Error( 'unauthorized', 'User cannot restore trashed posts', 403 );
963        }
964
965        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
966        do_action( 'wpcom_json_api_objects', 'posts' );
967
968        wp_untrash_post( $post->ID );
969
970        return $this->get_post_by( 'ID', $post->ID, $args['context'] );
971    }
972
973    /**
974     * Set or delete a post's featured image.
975     *
976     * @param int  $post_id Post ID.
977     * @param bool $delete_featured_image Whether to delete the featured image.
978     * @param int  $featured_image Thumbnail ID to attach.
979     *
980     * @return null|int|bool
981     */
982    private function parse_and_set_featured_image( $post_id, $delete_featured_image, $featured_image ) {
983        if ( $delete_featured_image ) {
984            delete_post_thumbnail( $post_id );
985            return;
986        }
987
988        $featured_image = (string) $featured_image;
989
990        // if we got a post ID, we can just set it as the thumbnail.
991        if ( ctype_digit( $featured_image ) && 'attachment' === get_post_type( $featured_image ) ) {
992            set_post_thumbnail( $post_id, $featured_image );
993            return $featured_image;
994        }
995
996        $featured_image_id = $this->handle_media_sideload( $featured_image, $post_id, 'image' );
997
998        if ( empty( $featured_image_id ) || ! is_int( $featured_image_id ) ) {
999            return false;
1000        }
1001
1002        set_post_thumbnail( $post_id, $featured_image_id );
1003        return $featured_image_id;
1004    }
1005
1006    /**
1007     * Get the Author ID for a post.
1008     *
1009     * @param int|string $author Author ID.
1010     * @param string     $post_type Post type.
1011     *
1012     * @return int|WP_Error
1013     */
1014    private function parse_and_set_author( $author = null, $post_type = 'post' ) {
1015        if ( empty( $author ) || ! post_type_supports( $post_type, 'author' ) ) {
1016            return get_current_user_id();
1017        }
1018
1019        $author = (string) $author;
1020        if ( ctype_digit( $author ) ) {
1021            $_user = get_user_by( 'id', $author );
1022            if ( ! $_user || is_wp_error( $_user ) ) {
1023                return new WP_Error( 'invalid_author', 'Invalid author provided' );
1024            }
1025
1026            return $_user->ID;
1027        }
1028
1029        $_user = get_user_by( 'login', $author );
1030        if ( ! $_user || is_wp_error( $_user ) ) {
1031            return new WP_Error( 'invalid_author', 'Invalid author provided' );
1032        }
1033
1034        return $_user->ID;
1035    }
1036}