Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Post
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 11
1122
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_additional_fields_schema
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 create_item
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 extract_terms_ids
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 process_post_meta
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 unserialize_no_objects
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 strip_objects
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 filter_post_meta_keys
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 get_term_ids_from_slugs
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 create_missing_terms
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 slug_to_readable_name
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Posts REST route
4 *
5 * @package automattic/jetpack-import
6 */
7
8namespace Automattic\Jetpack\Import\Endpoints;
9
10use Automattic\Jetpack\Sync\Settings;
11use WP_Error;
12use WP_REST_Request;
13use WP_REST_Response;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19if ( ! function_exists( 'post_exists' ) ) {
20    require_once ABSPATH . 'wp-admin/includes/post.php';
21}
22
23/**
24 * Class Post
25 */
26class Post extends \WP_REST_Posts_Controller {
27
28    /**
29     * Base class
30     */
31    use Import;
32
33    /**
34     * The Import ID add a new item to the schema.
35     */
36    use Import_ID;
37
38    /**
39     * Whether the controller supports batching.
40     *
41     * @var array
42     */
43    protected $allow_batch = array( 'v1' => true );
44
45    /**
46     * Constructor.
47     *
48     * @param string $post_type Post type.
49     */
50    public function __construct( $post_type = 'post' ) {
51        parent::__construct( $post_type );
52
53        // @see add_post_meta
54        $this->import_id_meta_type = $post_type;
55    }
56
57    /**
58     * Adds the schema from additional fields to a schema array.
59     *
60     * The type of object is inferred from the passed schema.
61     *
62     * @param array $schema Schema array.
63     * @return array Modified Schema array.
64     */
65    public function add_additional_fields_schema( $schema ) {
66        // WXR saves terms as slugs, so we need to overwrite the schema.
67        $schema['properties']['categories']['items']['type'] = 'string';
68        $schema['properties']['tags']['items']['type']       = 'string';
69
70        // Add the import unique ID to the schema.
71        return $this->add_unique_identifier_to_schema( $schema );
72    }
73
74    /**
75     * Creates a single post.
76     *
77     * @param WP_REST_Request $request Full details about the request.
78     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
79     */
80    public function create_item( $request ) {
81        // Set the WP_IMPORTING constant to prevent sync notifications
82        $this->set_importing();
83
84        // Skip if the post already exists.
85        $post_id = \post_exists(
86            $request['title'],
87            '',
88            $request['date'],
89            $this->post_type,
90            $request['status']
91        );
92
93        if ( $post_id ) {
94            return new WP_Error(
95                'post_exists',
96                __( 'Cannot create existing post.', 'jetpack-import' ),
97                array(
98                    'status'  => 409,
99                    'post_id' => $post_id,
100                )
101            );
102        }
103
104        // WXR saves terms as slugs, so we need to convert them to IDs before send the data to the legacy endpoint.
105        foreach ( array( 'categories', 'tags' ) as $taxonomy ) {
106            $request[ $taxonomy ] = $this->extract_terms_ids( $request, $taxonomy );
107        }
108
109        $response = parent::create_item( $request );
110
111        // Process post metadata.
112        if ( ! is_wp_error( $response ) && isset( $response->data ) && isset( $response->data['id'] ) ) {
113            $this->process_post_meta( $response->data['id'], $request );
114        }
115
116        return $this->add_import_id_metadata( $request, $response );
117    }
118
119    /**
120     * Extract terms IDs from slugs.
121     *
122     * @param WP_REST_Request $request Full details about the request.
123     * @param string          $taxonomy Taxonomy name.
124     * @return array List of terms IDs.
125     */
126    protected function extract_terms_ids( $request, $taxonomy ) {
127        $ret = is_array( $request[ $taxonomy ] ) ? $request[ $taxonomy ] : array();
128
129        if ( ! count( $ret ) ) {
130            return $ret;
131        }
132
133        $taxonomy_name = $taxonomy === 'tags' ? 'post_tag' : 'category';
134
135        // Extract the terms by ID.
136        $ids = $this->get_term_ids_from_slugs( $ret, $taxonomy_name );
137
138        // Create missing terms and add their IDs to the $ids array.
139        $ids = $this->create_missing_terms( $ret, $ids, $taxonomy_name );
140
141        if ( is_array( $ids ) ) {
142            return $ids;
143        } else {
144            // Flush away any invalid terms.
145            return array();
146        }
147    }
148
149    /**
150     * Processes the metadata of a WordPress post when creating it.
151     *
152     * @param int   $post_id The post ID.
153     * @param mixed $request An object containing the metadata being added to the post.
154     * @return void
155     */
156    public function process_post_meta( $post_id, $request ) {
157        $metas = $request->get_param( 'meta' );
158
159        if ( empty( $metas ) ) {
160            return;
161        }
162
163        $meta_keys_array = $this->filter_post_meta_keys( $metas );
164        // Adding it to the whitelist
165        Settings::update_settings( array( 'post_meta_whitelist' => $meta_keys_array ) );
166
167        if ( is_array( $metas ) ) {
168            foreach ( $metas as $meta_key => $meta_value ) {
169
170                $meta_value = self::unserialize_no_objects( $meta_value );
171                if ( $meta_key === '_edit_last' ) {
172                    update_post_meta( $post_id, $meta_key, $meta_value );
173                } else {
174                    // Add the meta data to the post
175                    add_post_meta( $post_id, $meta_key, $meta_value );
176                }
177
178                do_action( 'import_post_meta', $post_id, $meta_key, $meta_value );
179            }
180        }
181    }
182
183    /**
184     * Restore an imported meta value as plain data.
185     *
186     * Imported meta is plain data (scalars and arrays), so serialized values are decoded without
187     * class instantiation and any residual objects are dropped. This matches maybe_unserialize()
188     * for non-serialized and object-free serialized values.
189     *
190     * @param mixed $value The raw meta value.
191     * @return mixed A plain (object-free) value.
192     */
193    private static function unserialize_no_objects( $value ) {
194        if ( ! is_string( $value ) || ! is_serialized( $value ) ) {
195            return $value;
196        }
197
198        // maybe_unserialize() decodes with no `allowed_classes`; call unserialize() directly with classes
199        // disallowed so meta is only ever restored as plain data. The is_serialized() guard above and the
200        // @ (warnings on malformed input) mirror maybe_unserialize()'s own behavior.
201        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes => false makes this decode safe.
202        $decoded = @unserialize( trim( $value ), array( 'allowed_classes' => false ) );
203
204        return self::strip_objects( $decoded );
205    }
206
207    /**
208     * Recursively replace any objects in a decoded value with null.
209     *
210     * @param mixed $value Decoded value.
211     * @return mixed The value with any objects removed.
212     */
213    private static function strip_objects( $value ) {
214        if ( is_object( $value ) ) {
215            return null;
216        }
217
218        if ( is_array( $value ) ) {
219            foreach ( $value as $key => $item ) {
220                $value[ $key ] = self::strip_objects( $item );
221            }
222        }
223
224        return $value;
225    }
226
227    /**
228     * Filters an array of post meta keys.
229     *
230     * @param array $metas An array of metas to filter.
231     * @return array The filtered array of meta keys.
232     */
233    private function filter_post_meta_keys( $metas ) {
234        // Convert array of keys to a plain array of key strings
235        $meta_keys = array_unique( array_keys( $metas ) );
236        // // Filter the array by removing the excluded keys and any keys that include '_oembed'
237        $filtered_keys = array_filter(
238            $meta_keys,
239            function ( $key ) {
240                // We also don't want to include any oembed post meta because it gets created after a post created
241                return ! str_contains( $key, '_oembed' );
242            }
243        );
244        // Return the filtered array
245        return $filtered_keys;
246    }
247
248    /**
249     * Get term IDs from slugs.
250     *
251     * @param array  $term_slugs      Array of term slugs.
252     * @param string $taxonomy_name   Taxonomy name.
253     *
254     * @return array                  Array of term IDs.
255     */
256    protected function get_term_ids_from_slugs( $term_slugs, $taxonomy_name ) {
257        return get_terms(
258            array(
259                'fields'     => 'ids',
260                'hide_empty' => false,
261                'slug'       => $term_slugs,
262                'taxonomy'   => $taxonomy_name,
263            )
264        );
265    }
266
267    /**
268     * Create any missing terms in the given taxonomy.
269     *
270     * @param array  $term_slugs   The slugs of the terms to check for.
271     * @param array  $existing_ids The IDs of any terms that already exist.
272     * @param string $taxonomy_name The name of the taxonomy.
273     *
274     * @return array The IDs of any terms that are now in the taxonomy.
275     */
276    protected function create_missing_terms( $term_slugs, $existing_ids, $taxonomy_name ) {
277        $ids = $existing_ids;
278
279        foreach ( $term_slugs as $term_slug ) {
280            if ( ! term_exists( $term_slug, $taxonomy_name ) ) {
281                $term_name = $this->slug_to_readable_name( $term_slug );
282                $new_term  = wp_insert_term( $term_name, $taxonomy_name, array( 'slug' => $term_slug ) );
283                if ( ! is_wp_error( $new_term ) && isset( $new_term['term_id'] ) ) {
284                    $ids[] = $new_term['term_id'];
285                }
286            }
287        }
288
289        return $ids;
290    }
291
292    /**
293     * Convert a slug to a readable name.
294     *
295     * @param string $slug Slug to convert.
296     * @return string Converted name.
297     */
298    protected function slug_to_readable_name( $slug ) {
299        $name = str_replace( array( '-', '_' ), ' ', $slug );
300        $name = ucwords( $name );
301        return $name;
302    }
303}