Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 551
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API_Update_Post_Endpoint
0.00% covered (danger)
0.00%
0 / 381
0.00% covered (danger)
0.00%
0 / 7
56882
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 callback
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 write_post
0.00% covered (danger)
0.00%
0 / 317
0.00% covered (danger)
0.00%
0 / 1
42230
 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`). All unprotected meta keys are available by default for read requests. Both unprotected and protected meta keys are avaiable for authenticated requests with proper capabilities. Protected meta keys can be made available with the <code>rest_api_allowed_public_metadata</code> filter.',
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`). All unprotected meta keys are available by default for read requests. Both unprotected and protected meta keys are available for authenticated requests with proper capabilities. Protected meta keys can be made available with the <code>rest_api_allowed_public_metadata</code> filter.',
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  = isset( $input['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 = isset( $input['publicize'] ) ? $input['publicize'] : null;
486        unset( $input['publicize'] );
487
488        $publicize_custom_message = isset( $input['publicize_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 = isset( $input['metadata'] ) ? $input['metadata'] : null;
498        unset( $input['metadata'] );
499
500        $likes = isset( $input['likes_enabled'] ) ? $input['likes_enabled'] : null;
501        unset( $input['likes_enabled'] );
502
503        $sharing = isset( $input['sharing_enabled'] ) ? $input['sharing_enabled'] : null;
504        unset( $input['sharing_enabled'] );
505
506        $sticky = isset( $input['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        if ( $new ) {
525
526            if ( isset( $input['content'] ) && ! has_shortcode( $input['content'], 'gallery' ) && ( $has_media || $has_media_by_url ) ) {
527                switch ( ( $has_media + $has_media_by_url ) ) {
528                    case 0:
529                        // No images - do nothing.
530                        break;
531                    case 1:
532                        // 1 image - make it big.
533                        $input['content']       = "[gallery size=full columns=1]\n\n" . $input['content'];
534                        $insert['post_content'] = $input['content'];
535                        break;
536                    default:
537                        // Several images - 3 column gallery.
538                        $input['content']       = "[gallery]\n\n" . $input['content'];
539                        $insert['post_content'] = $input['content'];
540                        break;
541                }
542            }
543
544            $post_id = wp_insert_post( add_magic_quotes( $insert ), true );
545        } else {
546            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $post is set and validated several blocks earlier if $new (only set once) is falsy.
547            $insert['ID'] = $post->ID;
548
549            // wp_update_post ignores date unless edit_date is set
550            // See: https://codex.wordpress.org/Function_Reference/wp_update_post#Scheduling_posts
551            // See: https://core.trac.wordpress.org/browser/tags/3.9.2/src/wp-includes/post.php#L3302 .
552            if ( isset( $input['date_gmt'] ) || isset( $input['date'] ) ) {
553                $insert['edit_date'] = true;
554            }
555
556            // this two-step process ensures any changes submitted along with status=trash get saved before trashing.
557            if ( isset( $input['status'] ) && 'trash' === $input['status'] ) {
558                // if we insert it with status='trash', it will get double-trashed, so insert it as a draft first.
559                unset( $insert['status'] );
560                $post_id = wp_update_post( (object) $insert );
561                // now call wp_trash_post so post_meta gets set and any filters get called.
562                wp_trash_post( $post_id );
563            } else {
564                $post_id = wp_update_post( (object) $insert );
565            }
566        }
567
568        if ( ! $post_id || is_wp_error( $post_id ) ) {
569            return $post_id;
570        }
571
572        // make sure this post actually exists and is not an error of some kind (ie, trying to load media in the posts endpoint).
573        $post_check = $this->get_post_by( 'ID', $post_id, $args['context'] );
574        if ( is_wp_error( $post_check ) ) {
575            return $post_check;
576        }
577
578        if ( $has_media ) {
579            $this->api->trap_wp_die( 'upload_error' );
580            foreach ( $input['media'] as $media_item ) {
581                $_FILES['.api.media.item.'] = $media_item;
582                // check for WP_Error if we ever actually need $media_id .
583                $media_id = media_handle_upload( '.api.media.item.', $post_id ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
584            }
585            $this->api->trap_wp_die( null );
586
587            unset( $_FILES['.api.media.item.'] );
588        }
589
590        if ( $has_media_by_url ) {
591            foreach ( $input['media_urls'] as $url ) {
592                $this->handle_media_sideload( $url, $post_id );
593            }
594        }
595
596        // Set like status for the post.
597        /** This filter is documented in modules/likes.php */
598        $sitewide_likes_enabled = (bool) apply_filters( 'wpl_is_enabled_sitewide', ! get_option( 'disabled_likes' ) );
599        if ( $new ) {
600            if ( $sitewide_likes_enabled ) {
601                if ( false === $likes ) {
602                    update_post_meta( $post_id, 'switch_like_status', 0 );
603                } else {
604                    delete_post_meta( $post_id, 'switch_like_status' );
605                }
606            } elseif ( $likes ) {
607                update_post_meta( $post_id, 'switch_like_status', 1 );
608            } else {
609                delete_post_meta( $post_id, 'switch_like_status' );
610            }
611        } elseif ( isset( $likes ) ) {
612            if ( $sitewide_likes_enabled ) {
613                if ( false === $likes ) {
614                    update_post_meta( $post_id, 'switch_like_status', 0 );
615                } else {
616                    delete_post_meta( $post_id, 'switch_like_status' );
617                }
618            } elseif ( true === $likes ) {
619                update_post_meta( $post_id, 'switch_like_status', 1 );
620            } else {
621                delete_post_meta( $post_id, 'switch_like_status' );
622            }
623        }
624
625        // Set sharing status of the post.
626        if ( $new ) {
627            $sharing_enabled = isset( $sharing ) ? (bool) $sharing : true;
628            if ( false === $sharing_enabled ) {
629                update_post_meta( $post_id, 'sharing_disabled', 1 );
630            }
631        } elseif ( isset( $sharing ) && true === $sharing ) {
632            delete_post_meta( $post_id, 'sharing_disabled' );
633        } elseif ( isset( $sharing ) && false == $sharing ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
634            update_post_meta( $post_id, 'sharing_disabled', 1 );
635        }
636
637        if ( isset( $sticky ) ) {
638            if ( true === $sticky ) {
639                stick_post( $post_id );
640            } else {
641                unstick_post( $post_id );
642            }
643        }
644
645        // WPCOM Specific (Jetpack's will get bumped elsewhere
646        // Tracks how many posts are published and sets meta
647        // so we can track some other cool stats (like likes & comments on posts published).
648        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
649            if (
650                ( $new && 'publish' === $input['status'] )
651                || (
652                    ! $new && isset( $last_status )
653                    && 'publish' !== $last_status
654                    && isset( $new_status )
655                    && 'publish' === $new_status
656                )
657            ) {
658                /** This action is documented in modules/widgets/social-media-icons.php */
659                do_action( 'jetpack_bump_stats_extras', 'api-insights-posts', $this->api->token_details['client_id'] );
660                update_post_meta( $post_id, '_rest_api_published', 1 );
661                update_post_meta( $post_id, '_rest_api_client_id', $this->api->token_details['client_id'] );
662            }
663        }
664
665        // We ask the user/dev to pass Publicize services he/she wants activated for the post, but Publicize expects us
666        // to instead flag the ones we don't want to be skipped. proceed with said logic.
667        // any posts coming from Path (client ID 25952) should also not publicize.
668        if ( false === $publicize || ( isset( $this->api->token_details['client_id'] ) && 25952 === (int) $this->api->token_details['client_id'] ) ) {
669            // No publicize at all, skip all by ID.
670            foreach ( $GLOBALS['publicize_ui']->publicize->get_services( 'all' ) as $name => $service ) {
671                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $name );
672                $service_connections = $GLOBALS['publicize_ui']->publicize->get_connections( $name );
673                if ( ! $service_connections ) {
674                    continue;
675                }
676                foreach ( $service_connections as $service_connection ) {
677                    update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $service_connection->unique_id, 1 );
678                }
679            }
680        } elseif ( is_array( $publicize ) && ( $publicize !== array() ) ) {
681            foreach ( $GLOBALS['publicize_ui']->publicize->get_services( 'all' ) as $name => $service ) {
682                /*
683                * We support both indexed and associative arrays:
684                * * indexed are to pass entire services
685                * * associative are to pass specific connections per service
686                *
687                * We do support mixed arrays: mixed integer and string keys (see 3rd example below).
688                *
689                * EG: array( 'linkedin', 'facebook') will only publicize to those, ignoring the other available services
690                *      Form data: publicize[]=linkedin&publicize[]=facebook
691                * 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.
692                *      Form data: publicize[linkedin]=$pub_conn_id_0,$pub_conn_id_3&publicize[facebook]=$pub_conn_id_7
693                * 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
694                *      Form data: publicize[]=linkedin&publicize[facebook]=$pub_conn_id_0,$pub_conn_id_3
695                */
696
697                // Delete any stale SKIP value for the service by name. We'll add it back by ID.
698                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $name );
699
700                // Get the user's connections.
701                $service_connections = $GLOBALS['publicize_ui']->publicize->get_connections( $name );
702
703                // if the user doesn't have any connections for this service, move on.
704                if ( ! $service_connections ) {
705                    continue;
706                }
707
708                if ( ! in_array( $name, $publicize, true ) && ! array_key_exists( $name, $publicize ) ) {
709                    // Skip the whole service by adding each connection ID.
710                    foreach ( $service_connections as $service_connection ) {
711                        update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $service_connection->unique_id, 1 );
712                    }
713                } elseif ( ! empty( $publicize[ $name ] ) ) {
714                    // Seems we're being asked to only push to [a] specific connection[s].
715                    // Explode the list on commas, which will also support a single passed ID.
716                    $requested_connections = explode( ',', ( preg_replace( '/[\s]*/', '', $publicize[ $name ] ) ) );
717                    // Flag the connections we can't match with the requested list to be skipped.
718                    foreach ( $service_connections as $service_connection ) {
719                        if ( ! in_array( $service_connection->meta['connection_data']->id, $requested_connections, true ) ) {
720                            update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $service_connection->unique_id, 1 );
721                        } else {
722                            delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $service_connection->unique_id );
723                        }
724                    }
725                } else {
726                    // delete all SKIP values; it's okay to publish to all connected IDs for this service.
727                    foreach ( $service_connections as $service_connection ) {
728                        delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_SKIP . $service_connection->unique_id );
729                    }
730                }
731            }
732        }
733
734        if ( $publicize_custom_message !== null ) {
735            if ( empty( $publicize_custom_message ) ) {
736                delete_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_MESS );
737            } else {
738                update_post_meta( $post_id, $GLOBALS['publicize_ui']->publicize->POST_MESS, trim( $publicize_custom_message ) );
739            }
740        }
741
742        if ( ! empty( $insert['post_format'] ) ) {
743            if ( 'default' !== strtolower( $insert['post_format'] ) ) {
744                set_post_format( $post_id, $insert['post_format'] );
745            } else {
746                set_post_format( $post_id, get_option( 'default_post_format' ) );
747            }
748        }
749
750        if ( isset( $featured_image ) ) {
751            $this->parse_and_set_featured_image( $post_id, $delete_featured_image, $featured_image );
752        }
753
754        if ( ! empty( $metadata ) ) {
755            foreach ( (array) $metadata as $meta ) {
756
757                $meta = (object) $meta;
758
759                if (
760                    in_array( isset( $meta->key ) ? $meta->key : null, Jetpack_SEO_Posts::POST_META_KEYS_ARRAY, true ) &&
761                    ! Jetpack_SEO_Utils::is_enabled_jetpack_seo()
762                ) {
763                    return new WP_Error( 'unauthorized', __( 'SEO tools are not enabled for this site.', 'jetpack' ), 403 );
764                }
765
766                $existing_meta_item = new stdClass();
767
768                if ( empty( $meta->operation ) ) {
769                    $meta->operation = 'update';
770                }
771
772                if ( ! empty( $meta->value ) ) {
773                    if ( 'true' == $meta->value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
774                        $meta->value = true;
775                    }
776                    if ( 'false' == $meta->value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
777                        $meta->value = false;
778                    }
779                }
780
781                if ( ! empty( $meta->id ) ) {
782                    $meta->id           = absint( $meta->id );
783                    $existing_meta_item = get_metadata_by_mid( 'post', $meta->id );
784                    if ( $post_id !== (int) $existing_meta_item->post_id ) {
785                        // Only allow updates for metadata on this post.
786                        continue;
787                    }
788                }
789
790                $unslashed_meta_key           = isset( $meta->key ) ? wp_unslash( $meta->key ) : null; // should match what the final key will be.
791                $meta->key                    = isset( $meta->key ) ? wp_slash( $meta->key ) : null;
792                $unslashed_existing_meta_key  = isset( $existing_meta_item->meta_key ) ? wp_unslash( $existing_meta_item->meta_key ) : null;
793                $existing_meta_item->meta_key = isset( $existing_meta_item->meta_key ) ? wp_slash( $existing_meta_item->meta_key ) : null;
794
795                // make sure that the meta id passed matches the existing meta key.
796                if ( ! empty( $meta->id ) && ! empty( $meta->key ) ) {
797                    $meta_by_id = get_metadata_by_mid( 'post', $meta->id );
798                    if ( $meta_by_id->meta_key !== $meta->key ) {
799                        continue; // skip this meta.
800                    }
801                }
802
803                switch ( $meta->operation ) {
804                    case 'delete':
805                        if ( ! empty( $meta->id ) && ! empty( $existing_meta_item->meta_key ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_existing_meta_key ) ) {
806                            delete_metadata_by_mid( 'post', $meta->id );
807                        } elseif ( ! empty( $meta->key ) && ! empty( $meta->previous_value ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_meta_key ) ) {
808                            delete_post_meta( $post_id, $meta->key, $meta->previous_value );
809                        } elseif ( ! empty( $meta->key ) && current_user_can( 'delete_post_meta', $post_id, $unslashed_meta_key ) ) {
810                            delete_post_meta( $post_id, $meta->key );
811                        }
812
813                        break;
814                    case 'add':
815                        if ( ! empty( $meta->id ) || ! empty( $meta->previous_value ) ) {
816                            break;
817                        } 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 ) ) {
818                            add_post_meta( $post_id, $meta->key, $meta->value );
819                        }
820
821                        break;
822                    case 'update':
823                        if ( ! isset( $meta->value ) ) {
824                            break;
825                        } 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 ) ) ) {
826                            update_metadata_by_mid( 'post', $meta->id, $meta->value );
827                        } 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 ) ) ) {
828                            update_post_meta( $post_id, $meta->key, $meta->value, $meta->previous_value );
829                        } elseif ( ! empty( $meta->key ) && ( current_user_can( 'edit_post_meta', $post_id, $unslashed_meta_key ) || WPCOM_JSON_API_Metadata::is_public( $meta->key ) ) ) {
830                            update_post_meta( $post_id, $meta->key, $meta->value );
831                        }
832
833                        break;
834                }
835            }
836        }
837
838        /**
839         * Fires when a post is created via the REST API.
840         *
841         * @module json-api
842         *
843         * @since 2.3.0
844         *
845         * @param int $post_id Post ID.
846         * @param array $insert Data used to build the post.
847         * @param string $new New post URL suffix.
848         */
849        do_action( 'rest_api_inserted_post', $post_id, $insert, $new );
850
851        $return = $this->get_post_by( 'ID', $post_id, $args['context'] );
852        if ( ! $return || is_wp_error( $return ) ) {
853            return $return;
854        }
855
856        if ( isset( $input['type'] ) && 'revision' === $input['type'] ) {
857            $return['preview_nonce'] = wp_create_nonce( 'post_preview_' . $input['parent'] );
858        }
859
860        if ( isset( $sticky ) ) {
861            // workaround for sticky test occasionally failing, maybe a race condition with stick_post() above.
862            $return['sticky'] = ( true === $sticky );
863        }
864
865        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
866        do_action( 'wpcom_json_api_objects', 'posts' );
867
868        return $return;
869    }
870
871    /**
872     * Delete a post.
873     *
874     * /sites/%s/posts/%d/delete -> $blog_id, $post_id
875     *
876     * @param string $path API path.
877     * @param array  $blog_id Blog ID.
878     * @param array  $post_id Post ID.
879     *
880     * @return array|WP_Error
881     */
882    public function delete_post( $path, $blog_id, $post_id ) {
883        $post = get_post( $post_id );
884        if ( ! $post || is_wp_error( $post ) ) {
885            return new WP_Error( 'unknown_post', 'Unknown post', 404 );
886        }
887
888        if ( ! $this->is_post_type_allowed( $post->post_type ) ) {
889            return new WP_Error( 'unknown_post_type', 'Unknown post type', 404 );
890        }
891
892        if ( ! current_user_can( 'delete_post', $post->ID ) ) {
893            return new WP_Error( 'unauthorized', 'User cannot delete posts', 403 );
894        }
895
896        $args   = $this->query_args();
897        $return = $this->get_post_by( 'ID', $post->ID, $args['context'] );
898        if ( ! $return || is_wp_error( $return ) ) {
899            return $return;
900        }
901
902        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
903        do_action( 'wpcom_json_api_objects', 'posts' );
904
905        // we need to call wp_trash_post so that untrash will work correctly for all post types.
906        if ( 'trash' === $post->post_status ) {
907            wp_delete_post( $post->ID );
908        } else {
909            wp_trash_post( $post->ID );
910        }
911
912        $status = get_post_status( $post->ID );
913        if ( false === $status ) {
914            $return['status'] = 'deleted';
915            return $return;
916        }
917
918        return $this->get_post_by( 'ID', $post->ID, $args['context'] );
919    }
920
921    /**
922     * Restore a post.
923     *
924     * /sites/%s/posts/%d/restore -> $blog_id, $post_id
925     *
926     * @param string $path API path.
927     * @param int    $blog_id Blog ID.
928     * @param int    $post_id Post ID.
929     *
930     * @return array|WP_Error
931     */
932    public function restore_post( $path, $blog_id, $post_id ) {
933        $args = $this->query_args();
934        $post = get_post( $post_id );
935
936        if ( ! $post || is_wp_error( $post ) ) {
937            return new WP_Error( 'unknown_post', 'Unknown post', 404 );
938        }
939
940        if ( ! current_user_can( 'delete_post', $post->ID ) ) {
941            return new WP_Error( 'unauthorized', 'User cannot restore trashed posts', 403 );
942        }
943
944        /** This action is documented in json-endpoints/class.wpcom-json-api-site-settings-endpoint.php */
945        do_action( 'wpcom_json_api_objects', 'posts' );
946
947        wp_untrash_post( $post->ID );
948
949        return $this->get_post_by( 'ID', $post->ID, $args['context'] );
950    }
951
952    /**
953     * Set or delete a post's featured image.
954     *
955     * @param int  $post_id Post ID.
956     * @param bool $delete_featured_image Whether to delete the featured image.
957     * @param int  $featured_image Thumbnail ID to attach.
958     *
959     * @return null|int|bool
960     */
961    private function parse_and_set_featured_image( $post_id, $delete_featured_image, $featured_image ) {
962        if ( $delete_featured_image ) {
963            delete_post_thumbnail( $post_id );
964            return;
965        }
966
967        $featured_image = (string) $featured_image;
968
969        // if we got a post ID, we can just set it as the thumbnail.
970        if ( ctype_digit( $featured_image ) && 'attachment' === get_post_type( $featured_image ) ) {
971            set_post_thumbnail( $post_id, $featured_image );
972            return $featured_image;
973        }
974
975        $featured_image_id = $this->handle_media_sideload( $featured_image, $post_id, 'image' );
976
977        if ( empty( $featured_image_id ) || ! is_int( $featured_image_id ) ) {
978            return false;
979        }
980
981        set_post_thumbnail( $post_id, $featured_image_id );
982        return $featured_image_id;
983    }
984
985    /**
986     * Get the Author ID for a post.
987     *
988     * @param int|string $author Author ID.
989     * @param string     $post_type Post type.
990     *
991     * @return int|WP_Error
992     */
993    private function parse_and_set_author( $author = null, $post_type = 'post' ) {
994        if ( empty( $author ) || ! post_type_supports( $post_type, 'author' ) ) {
995            return get_current_user_id();
996        }
997
998        $author = (string) $author;
999        if ( ctype_digit( $author ) ) {
1000            $_user = get_user_by( 'id', $author );
1001            if ( ! $_user || is_wp_error( $_user ) ) {
1002                return new WP_Error( 'invalid_author', 'Invalid author provided' );
1003            }
1004
1005            return $_user->ID;
1006        }
1007
1008        $_user = get_user_by( 'login', $author );
1009        if ( ! $_user || is_wp_error( $_user ) ) {
1010            return new WP_Error( 'invalid_author', 'Invalid author provided' );
1011        }
1012
1013        return $_user->ID;
1014    }
1015}