PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-blaze/src/class-dashboard-rest-controller.php +809 -51 13.2.4 → 16.3 View file →
@@ -10,15 +10,17 @@
10 10
11 11 use Automattic\Jetpack\Connection\Client;
12 12 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13 13 use Automattic\Jetpack\Status\Host;
14 +use Automattic\Jetpack\Sync\Health;
14 15 use WC_Product;
15 16 use WP_Error;
17 +use WP_REST_Request;
16 18 use WP_REST_Server;
17 19
18 20 /**
19 21 * Registers the REST routes for Blaze Dashboard.
20 - * It bascially forwards the requests to the WordPress.com REST API.
22 + * It basically forwards the requests to the WordPress.com REST API.
21 23 */
22 24 class Dashboard_REST_Controller {
23 25 /**
24 26 * Namespace for the REST API.
@@ -27,8 +29,37 @@
27 29 */
28 30 public static $namespace = 'jetpack/v4/blaze-app';
29 31
30 32 /**
33 + * Connection manager object.
34 + *
35 + * @var \Automattic\Jetpack\Connection\Manager
36 + */
37 + private $connection;
38 +
39 + /**
40 + * Creates the Dashboard_REST_Controller object.
41 + *
42 + * @param \Automattic\Jetpack\Connection\Manager $connection The connection manager object.
43 + */
44 + public function __construct( $connection = null ) {
45 + $this->connection = $connection ?? new Connection_Manager();
46 + }
47 +
48 + /**
49 + * Registers the REST routes on the `rest_api_init` hook.
50 + *
51 + * Instantiated here, rather than eagerly, so the controller class only loads
52 + * on requests that reach `rest_api_init`. Static so the callback can be
53 + * unregistered.
54 + *
55 + * @access public
56 + */
57 + public static function register() {
58 + ( new self() )->register_rest_routes();
59 + }
60 +
61 + /**
31 62 * Registers the REST routes for Blaze Dashboard.
32 63 *
33 64 * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API.
34 65 * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences.
@@ -132,9 +163,9 @@
132 163
133 164 // WordAds DSP API Campaigns routes
134 165 register_rest_route(
135 166 static::$namespace,
136 - sprintf( '/sites/%d/wordads/dsp/api/v1/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
167 + sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
137 168 array(
138 169 'methods' => WP_REST_Server::READABLE,
139 170 'callback' => array( $this, 'get_dsp_campaigns' ),
140 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -139,10 +170,21 @@
139 170 'callback' => array( $this, 'get_dsp_campaigns' ),
140 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
141 172 )
142 173 );
174 +
143 175 register_rest_route(
144 176 static::$namespace,
177 + sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ),
178 + array(
179 + 'methods' => WP_REST_Server::CREATABLE,
180 + 'callback' => array( $this, 'create_dsp_campaigns' ),
181 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
182 + )
183 + );
184 +
185 + register_rest_route(
186 + static::$namespace,
145 187 sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
146 188 array(
147 189 'methods' => WP_REST_Server::EDITABLE,
148 190 'callback' => array( $this, 'edit_dsp_campaigns' ),
@@ -160,8 +202,29 @@
160 202 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
161 203 )
162 204 );
163 205
206 + // WordAds DSP API Site Stats routes
207 + register_rest_route(
208 + static::$namespace,
209 + sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/stats(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
210 + array(
211 + 'methods' => WP_REST_Server::READABLE,
212 + 'callback' => array( $this, 'get_dsp_stats' ),
213 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
214 + )
215 + );
216 +
217 + register_rest_route(
218 + static::$namespace,
219 + sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/stats(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
220 + array(
221 + 'methods' => WP_REST_Server::EDITABLE,
222 + 'callback' => array( $this, 'edit_dsp_stats' ),
223 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
224 + )
225 + );
226 +
164 227 // WordAds DSP API Search routes
165 228 register_rest_route(
166 229 static::$namespace,
167 230 sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -185,8 +248,27 @@
185 248
186 249 // WordAds DSP API Templates routes
187 250 register_rest_route(
188 251 static::$namespace,
252 + sprintf( '/sites/%d/wordads/dsp/api/v1/templates/article/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
253 + array(
254 + 'methods' => WP_REST_Server::READABLE,
255 + 'callback' => array( $this, 'get_dsp_templates_article' ),
256 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
257 + )
258 + );
259 + register_rest_route(
260 + static::$namespace,
261 + sprintf( '/sites/%d/wordads/dsp/api/v1/templates/advise/campaign/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
262 + array(
263 + 'methods' => WP_REST_Server::READABLE,
264 + 'callback' => array( $this, 'get_dsp_templates_advise_campaign' ),
265 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
266 + )
267 + );
268 +
269 + register_rest_route(
270 + static::$namespace,
189 271 sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
190 272 array(
191 273 'methods' => WP_REST_Server::READABLE,
192 274 'callback' => array( $this, 'get_dsp_templates' ),
@@ -193,8 +275,28 @@
193 275 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
194 276 )
195 277 );
196 278
279 + // WordAds DSP API Advise routes
280 + register_rest_route(
281 + static::$namespace,
282 + sprintf( '/sites/%d/wordads/dsp/api/v1/advise/campaign/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
283 + array(
284 + 'methods' => WP_REST_Server::READABLE,
285 + 'callback' => array( $this, 'get_dsp_advise_campaign' ),
286 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
287 + )
288 + );
289 + register_rest_route(
290 + static::$namespace,
291 + sprintf( '/sites/%d/wordads/dsp/api/v1/advise(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
292 + array(
293 + 'methods' => WP_REST_Server::READABLE,
294 + 'callback' => array( $this, 'get_dsp_advise' ),
295 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
296 + )
297 + );
298 +
197 299 // WordAds DSP API Subscriptions routes
198 300 register_rest_route(
199 301 static::$namespace,
200 302 sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -216,9 +318,9 @@
216 318
217 319 // WordAds DSP API Payments routes
218 320 register_rest_route(
219 321 static::$namespace,
220 - sprintf( '/sites/%d/wordads/dsp/api/v1/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
322 + sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
221 323 array(
222 324 'methods' => WP_REST_Server::READABLE,
223 325 'callback' => array( $this, 'get_dsp_payments' ),
224 326 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -225,9 +327,9 @@
225 327 )
226 328 );
227 329 register_rest_route(
228 330 static::$namespace,
229 - sprintf( '/sites/%d/wordads/dsp/api/v1/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
331 + sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
230 332 array(
231 333 'methods' => WP_REST_Server::EDITABLE,
232 334 'callback' => array( $this, 'edit_dsp_payments' ),
233 335 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -315,10 +417,15 @@
315 417 return $this->get_forbidden_error();
316 418 }
317 419
318 420 /**
319 - * Redirect GET requests to WordAds DSP for the site.
421 + * Get a list of posts that are eligible for Blaze campaigns.
320 422 *
423 + * Routes to WPCOM API or local database based on Jetpack Sync status:
424 + * - If sync is ready: Uses WPCOM API (has stats data like like_count, monthly_view_count).
425 + * - If sync is not ready: Uses local database query (stats show as -1, stats-based
426 + * sorting falls back to date).
427 + *
321 428 * @param WP_REST_Request $req The request object.
322 429 * @return array|WP_Error
323 430 */
324 431 public function get_blaze_posts( $req ) {
@@ -326,9 +433,40 @@
326 433 if ( is_wp_error( $site_id ) ) {
327 434 return array();
328 435 }
329 436
330 - // We don't use sub_path in the blaze posts, only query strings
437 + $sync_ready = $this->are_posts_ready();
438 +
439 + if ( $sync_ready ) {
440 + $response = $this->get_blaze_posts_from_wpcom( $req, $site_id );
441 + } else {
442 + $response = $this->get_blaze_posts_local( $req );
443 + }
444 +
445 + if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
446 + return $response;
447 + }
448 +
449 + if ( is_array( $response ) ) {
450 + $response['sync_ready'] = $sync_ready;
451 + }
452 +
453 + return $response;
454 + }
455 +
456 + /**
457 + * Get Blaze posts from the WPCOM API.
458 + *
459 + * Used when Jetpack Sync is ready and posts are available on WPCOM.
460 + * Provides full functionality including stats data (like_count, monthly_view_count)
461 + * and stats-based sorting.
462 + *
463 + * @param WP_REST_Request $req The request object.
464 + * @param int $site_id The site ID.
465 + * @return array|WP_Error
466 + */
467 + private function get_blaze_posts_from_wpcom( $req, $site_id ) {
468 + // We don't use sub_path in the blaze posts, only query strings.
331 469 if ( isset( $req['sub_path'] ) ) {
332 470 unset( $req['sub_path'] );
333 471 }
334 472
@@ -337,9 +475,10 @@
337 475 'v2',
338 476 array( 'method' => 'GET' )
339 477 );
340 478
341 - if ( is_wp_error( $response ) ) {
479 + // Bail if we get an error (WP_ERROR or an already formatted WP_REST_Response error).
480 + if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
342 481 return $response;
343 482 }
344 483
345 484 if ( isset( $response['posts'] ) && count( $response['posts'] ) > 0 ) {
@@ -349,8 +488,214 @@
349 488 return $response;
350 489 }
351 490
352 491 /**
492 + * Get Blaze posts from the local WordPress database.
493 + *
494 + * Used as fallback when Jetpack Sync is not ready. Stats fields (like_count,
495 + * monthly_view_count) are returned as -1 since they are only available on WPCOM.
496 + * If user requests sorting by stats fields, falls back to sorting by date.
497 + *
498 + * @param WP_REST_Request $req The request object.
499 + * @return array
500 + */
501 + private function get_blaze_posts_local( $req ) {
502 + // Default and maximum posts per page for this function.
503 + $default_posts_per_page = 20;
504 +
505 + // Parse request parameters.
506 + $page = absint( $req->get_param( 'page' ) ?? 1 );
507 + $posts_per_page = absint( $req->get_param( 'posts_per_page' ) ?? $default_posts_per_page );
508 + $order = $req->get_param( 'order' ) ?? 'DESC';
509 + $order_by = $req->get_param( 'order_by' ) ?? 'date';
510 + $post_types = $req->get_param( 'filter_post_type' ) ?? implode( ',', $this->get_blazable_post_types() );
511 + $title = strtolower( sanitize_text_field( $req->get_param( 'title' ) ?? '' ) );
512 +
513 + // Sanitize and validate post types.
514 + $post_type_list = $this->sanitize_post_type( $post_types );
515 +
516 + // Validate page parameter.
517 + if ( $page < 1 ) {
518 + $page = 1;
519 + }
520 +
521 + // Validate post per page parameter (use default value if invalid)
522 + if ( $posts_per_page <= 0 || $posts_per_page > $default_posts_per_page ) {
523 + $posts_per_page = $default_posts_per_page;
524 + }
525 +
526 + // Validate order.
527 + $order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $order ) : 'DESC';
528 +
529 + // Validate order_by - stats-related fields fall back to date (handled by WPCOM).
530 + $valid_order_by = array( 'post_title', 'type', 'date', 'modified', 'comment_count' );
531 + if ( ! in_array( $order_by, $valid_order_by, true ) ) {
532 + $order_by = 'date';
533 + }
534 +
535 + $args = array(
536 + 'post_type' => $post_type_list,
537 + 'post_status' => 'publish',
538 + 'post_password' => '',
539 + 'posts_per_page' => $posts_per_page,
540 + 'paged' => $page,
541 + 'ignore_sticky_posts' => 1,
542 + 'orderby' => $order_by,
543 + 'order' => $order,
544 + );
545 +
546 + // Add title search filter if provided.
547 + $title_filter = null;
548 + if ( ! empty( $title ) ) {
549 + $title_filter = function ( $where ) use ( $title ) {
550 + global $wpdb;
551 + $title_like = '%' . $wpdb->esc_like( $title ) . '%';
552 + $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_title LIKE %s", $title_like );
553 + return $where;
554 + };
555 + add_filter( 'posts_where', $title_filter );
556 + }
557 +
558 + $query = new \WP_Query( $args );
559 + $posts = $query->get_posts();
560 + $total_pages = $query->max_num_pages;
561 +
562 + // Remove the title filter after query.
563 + if ( $title_filter !== null ) {
564 + remove_filter( 'posts_where', $title_filter );
565 + }
566 +
567 + // Format posts for the response.
568 + $formatted_posts = array();
569 + if ( $page <= $total_pages ) {
570 + foreach ( $posts as $post ) {
571 + $formatted_posts[] = $this->format_post_for_blaze( $post );
572 + }
573 + }
574 +
575 + // Add prices for WooCommerce products.
576 + if ( count( $formatted_posts ) > 0 ) {
577 + $formatted_posts = $this->add_prices_in_posts( $formatted_posts );
578 + }
579 +
580 + return array(
581 + 'posts' => $formatted_posts,
582 + 'total_items' => $query->found_posts,
583 + 'post_title' => $title,
584 + 'page' => $page,
585 + 'total_pages' => $total_pages,
586 + 'stats_enabled' => $this->is_jetpack_module_active( 'stats' ),
587 + 'likes_enabled' => $this->is_jetpack_module_active( 'likes' ),
588 + 'tsp_eligible' => $this->count_tsp_eligible_posts(),
589 + );
590 + }
591 +
592 + /**
593 + * Format a post object for the Blaze API response.
594 + *
595 + * @param \WP_Post $post The post object.
596 + * @return array Formatted post data.
597 + */
598 + protected function format_post_for_blaze( $post ) {
599 + $featured_image_data = $this->get_post_featured_image( $post->ID );
600 + $featured_image = $featured_image_data['URL'] ?? null;
601 +
602 + // Get SKU for WooCommerce products.
603 + $sku = get_post_meta( $post->ID, '_sku', true );
604 +
605 + return array(
606 + 'ID' => $post->ID,
607 + 'title' => $post->post_title,
608 + 'type' => $post->post_type,
609 + 'date' => gmdate( 'c', strtotime( $post->post_date_gmt ) ),
610 + 'modified' => gmdate( 'c', strtotime( $post->post_modified_gmt ) ),
611 + 'comment_count' => (int) $post->comment_count,
612 + 'like_count' => -1, // Stats not available locally.
613 + 'featured_image' => $featured_image,
614 + 'author' => $post->post_author,
615 + 'sku' => $sku,
616 + 'post_url' => get_permalink( $post->ID ),
617 + 'monthly_view_count' => -1, // Stats not available locally.
618 + );
619 + }
620 +
621 + /**
622 + * Get the post types that are eligible for Blaze campaigns.
623 + *
624 + * @return array List of post type slugs.
625 + */
626 + private function get_blazable_post_types() {
627 + return array( 'post', 'page', 'product' );
628 + }
629 +
630 + /**
631 + * Sanitize and validate post types for Blaze.
632 + *
633 + * @param string $post_types Comma-separated list of post types.
634 + * @return array Valid post types, or all blazable types if none valid.
635 + */
636 + private function sanitize_post_type( $post_types ) {
637 + $blazable_post_types = $this->get_blazable_post_types();
638 + if ( ! is_string( $post_types ) ) {
639 + return $blazable_post_types;
640 + }
641 + $post_types = sanitize_text_field( $post_types );
642 + $post_type_list = explode( ',', $post_types );
643 +
644 + $allowed_types = array();
645 +
646 + foreach ( $post_type_list as $post_type ) {
647 + if ( in_array( $post_type, $blazable_post_types, true ) ) {
648 + $allowed_types[] = $post_type;
649 + }
650 + }
651 +
652 + return count( $allowed_types )
653 + ? $allowed_types
654 + : $blazable_post_types;
655 + }
656 +
657 + /**
658 + * Check if a Jetpack module is active.
659 + * Uses jetpack-status Modules class which handles WPCOM and self-hosted sites.
660 + *
661 + * @param string $module_name The module name (e.g., 'stats', 'likes').
662 + * @return bool Whether the module is active.
663 + */
664 + private function is_jetpack_module_active( $module_name ) {
665 + // Default to true if Modules class is unavailable (matches WPCOM behavior).
666 + if ( ! class_exists( '\Automattic\Jetpack\Modules' ) ) {
667 + return true;
668 + }
669 + $modules = new \Automattic\Jetpack\Modules();
670 + return $modules->is_active( $module_name );
671 + }
672 +
673 + /**
674 + * Count posts eligible for TSP (has Gutenberg blocks).
675 + * Matches WPCOM's count_tsp_eligible_posts implementation.
676 + *
677 + * @return bool Whether there are TSP eligible posts.
678 + */
679 + private function count_tsp_eligible_posts() {
680 + $query = array(
681 + 'posts_per_page' => 1,
682 + 'order' => 'DESC',
683 + 'orderby' => 'date',
684 + 'post_type' => 'post',
685 + 'post_status' => array( 'publish' ),
686 + 's' => '<!-- wp:',
687 + 'fields' => 'ids',
688 + 'ignore_sticky_posts' => 1,
689 + 'offset' => 0,
690 + );
691 +
692 + $wp_query = new \WP_Query( $query );
693 +
694 + return (int) $wp_query->found_posts > 0;
695 + }
696 +
697 + /**
353 698 * Builds the subpath including the query string to be used in the DSP call
354 699 *
355 700 * @param array $params The request object parameters.
356 701 * @return string
@@ -373,35 +718,49 @@
373 718 return $sub_path;
374 719 }
375 720
376 721 /**
377 - * Redirect GET requests to WordAds DSP Blaze Posts endpoint for the site.
722 + * Get Blaze posts for DSP
378 723 *
724 + * Maps DSP parameters to blaze/posts format and reuses get_blaze_posts
725 + * for consistent local/WPCOM routing logic.
726 + *
379 727 * @param WP_REST_Request $req The request object.
380 728 * @return array|WP_Error
381 729 */
382 730 public function get_dsp_blaze_posts( $req ) {
383 - $site_id = $this->get_site_id();
384 - if ( is_wp_error( $site_id ) ) {
385 - return array();
386 - }
731 + // Map DSP params → blaze params.
732 + $param_map = array(
733 + 'title' => $req->get_param( 'search' ),
734 + 'filter_post_type' => $req->get_param( 'post_type' ),
735 + 'posts_per_page' => $req->get_param( 'limit' ),
736 + 'page' => $req->get_param( 'page' ),
737 + 'order' => $req->get_param( 'order' ),
738 + 'order_by' => $req->get_param( 'order_by' ),
739 + );
387 740
388 - // We don't use sub_path in the blaze posts, only query strings
389 - if ( isset( $req['sub_path'] ) ) {
390 - unset( $req['sub_path'] );
741 + // Create new request with transformed params (only non-null values).
742 + $blaze_req = new \WP_REST_Request( 'GET' );
743 + foreach ( $param_map as $key => $value ) {
744 + if ( $value !== null ) {
745 + $blaze_req->set_param( $key, $value );
746 + }
391 747 }
392 748
393 - $response = $this->get_dsp_generic( sprintf( 'v1/wpcom/sites/%d/blaze/posts', $site_id ), $req );
749 + // Reuse get_blaze_posts (handles local/WPCOM routing).
750 + $response = $this->get_blaze_posts( $blaze_req );
394 751
395 - if ( is_wp_error( $response ) ) {
752 + // Bail if we get an error.
753 + if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
396 754 return $response;
397 755 }
398 756
399 - if ( isset( $response['results'] ) && count( $response['results'] ) > 0 ) {
400 - $response['results'] = $this->add_prices_in_posts( $response['results'] );
401 - }
402 -
403 - return $response;
757 + // Transform response to DSP format.
758 + return array(
759 + 'results' => $response['posts'] ?? array(),
760 + 'total' => $response['total_items'] ?? 0,
761 + 'sync_ready' => $response['sync_ready'] ?? false,
762 + );
404 763 }
405 764
406 765 /**
407 766 * Redirect GET requests to WordAds DSP Blaze media endpoint for the site.
@@ -431,9 +790,9 @@
431 790 if ( empty( $_FILES['image'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
432 791 return array( 'error' => 'File is missed' );
433 792 }
434 793 $file = $_FILES['image']; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
435 - $temp_name = $file['tmp_name'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
794 + $temp_name = $file['tmp_name'] ?? '';
436 795 if ( ! $temp_name || ! is_uploaded_file( $temp_name ) ) {
437 796 return array( 'error' => 'Specified file was not uploaded' );
438 797 }
439 798
@@ -512,9 +871,10 @@
512 871 * @param WP_REST_Request $req The request object.
513 872 * @return array|WP_Error
514 873 */
515 874 public function get_dsp_campaigns( $req ) {
516 - return $this->get_dsp_generic( 'v1/campaigns', $req );
875 + $version = $req->get_param( 'api_version' ) ?? 'v1';
876 + return $this->get_dsp_generic( "{$version}/campaigns", $req );
517 877 }
518 878
519 879 /**
520 880 * Redirect GET requests to WordAds DSP Site Campaigns endpoint for the site.
@@ -531,8 +891,32 @@
531 891 return $this->get_dsp_generic( sprintf( 'v1/sites/%d/campaigns', $site_id ), $req );
532 892 }
533 893
534 894 /**
895 + * Redirect GET requests to WordAds DSP Stats endpoint for the site.
896 + *
897 + * @param WP_REST_Request $req The request object.
898 + *
899 + * @return array|WP_Error
900 + */
901 + public function get_dsp_stats( $req ) {
902 + $version = $req->get_param( 'api_version' ) ?? 'v1';
903 + return $this->get_dsp_generic( "{$version}/stats", $req );
904 + }
905 +
906 + /**
907 + * Redirect POST requests to WordAds DSP Stats endpoint for the site.
908 + *
909 + * @param WP_REST_Request $req The request object.
910 + *
911 + * @return array|WP_Error
912 + */
913 + public function edit_dsp_stats( $req ) {
914 + $version = $req->get_param( 'api_version' ) ?? 'v1';
915 + return $this->edit_dsp_generic( "{$version}/stats", $req );
916 + }
917 +
918 + /**
535 919 * Redirect GET requests to WordAds DSP Search endpoint for the site.
536 920 *
537 921 * @param WP_REST_Request $req The request object.
538 922 * @return array|WP_Error
@@ -551,18 +935,209 @@
551 935 return $this->get_dsp_generic( 'v1/user', $req );
552 936 }
553 937
554 938 /**
555 - * Redirect GET requests to WordAds DSP Search endpoint for the site.
939 + * Redirect GET requests to the WordAds DSP Templates Article endpoint for the site.
556 940 *
557 941 * @param WP_REST_Request $req The request object.
558 942 * @return array|WP_Error
559 943 */
944 + public function get_dsp_templates_article( $req ) {
945 + $urn = $req->get_param( 'urn' ) ?? '';
946 +
947 + $sync_ready = $this->are_posts_ready();
948 +
949 + $response = $sync_ready ?
950 + $this->get_dsp_generic( 'v1/templates/article/' . $urn, $req ) :
951 + $this->get_dsp_templates_article_local( $urn, $req );
952 +
953 + if ( ! is_wp_error( $response ) && is_array( $response ) ) {
954 + $response['sync_ready'] = $sync_ready;
955 + }
956 +
957 + return $response;
958 + }
959 +
960 + /**
961 + * Get the article information to be used in the Blaze create campaign flow.
962 + *
963 + * If Jetpack Sync is not yet complete and posts are not fully synced, this endpoint will read local DB data and provide additional information to the WPCOM endpoint.
964 + *
965 + * @param string $urn The request urn.
966 + * @param WP_REST_Request $req The request object.
967 + * @return array|WP_Error
968 + */
969 + public function get_dsp_templates_article_local( $urn, $req ) {
970 + $parsed_urn = $this->get_data_from_urn( $urn );
971 + $site_id = $this->get_site_id();
972 +
973 + if ( is_wp_error( $site_id ) ) {
974 + return array();
975 + }
976 +
977 + if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
978 + return $this->get_forbidden_error();
979 + }
980 +
981 + $post = get_post( $parsed_urn['post_id'] );
982 + if ( ! $post ) {
983 + return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
984 + }
985 +
986 + // Generates the attachments object
987 + $post_attachments = get_attached_media( 'image', $post->ID );
988 + $attachments = array();
989 +
990 + foreach ( $post_attachments as $attachment ) {
991 + $attachment_url = wp_get_attachment_url( $attachment->ID );
992 + $metadata = wp_get_attachment_metadata( $attachment->ID );
993 +
994 + // Skip attachment if some of the required data is missing
995 + if ( ! $attachment_url || ! $metadata || ! isset( $metadata['width'] ) || ! isset( $metadata['height'] ) ) {
996 + continue;
997 + }
998 +
999 + $attachments[ $attachment->ID ] = array(
1000 + 'ID' => $attachment->ID,
1001 + 'URL' => $attachment_url,
1002 + 'mime_type' => $attachment->post_mime_type,
1003 + 'width' => $metadata['width'],
1004 + 'height' => $metadata['height'],
1005 + );
1006 + }
1007 +
1008 + $body = array(
1009 + 'widget_origin' => $req->get_param( 'widget_origin' ),
1010 + 'wp_post' => array(
1011 + 'ID' => $post->ID,
1012 + 'title' => $post->post_title,
1013 + 'excerpt' => $post->post_excerpt,
1014 + 'URL' => get_permalink( $post ),
1015 + 'type' => $post->post_type,
1016 + 'content' => $post->post_content,
1017 + 'post_thumbnail' => $this->get_post_featured_image( $post->ID ),
1018 + 'attachments' => (object) $attachments,
1019 + ),
1020 + );
1021 +
1022 + return $this->request_as_user(
1023 + sprintf( '/sites/%d/wordads/dsp/api/v1/templates/article/%s', $site_id, $urn ),
1024 + 'v2',
1025 + array( 'method' => 'POST' ),
1026 + $body
1027 + );
1028 + }
1029 +
1030 + /**
1031 + * Redirect GET requests to the WordAds DSP Templates Advise Campaign endpoint for the site.
1032 + *
1033 + * @param WP_REST_Request $req The request object.
1034 + * @return array|WP_Error
1035 + */
1036 + public function get_dsp_templates_advise_campaign( $req ) {
1037 + $urn = $req->get_param( 'urn' ) ?? '';
1038 +
1039 + $sync_ready = $this->are_posts_ready();
1040 +
1041 + $response = $sync_ready ?
1042 + $this->get_dsp_generic( 'v1/templates/advise/campaign/' . $urn, $req ) :
1043 + $this->get_dsp_advise_campaign_local( $urn );
1044 +
1045 + if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1046 + $response['sync_ready'] = $sync_ready;
1047 + }
1048 +
1049 + return $response;
1050 + }
1051 +
1052 + /**
1053 + * Get the advise campaign information to be used in the Blaze create campaign flow.
1054 + *
1055 + * If Jetpack Sync still is running, this endpoint will read local DB data and provide additional information to the WPCOM endpoint.
1056 + *
1057 + * @param string $urn The request urn.
1058 + * @return array|WP_Error
1059 + */
1060 + public function get_dsp_advise_campaign_local( $urn ) {
1061 + $parsed_urn = $this->get_data_from_urn( $urn );
1062 + $site_id = $this->get_site_id();
1063 +
1064 + if ( is_wp_error( $site_id ) ) {
1065 + return array();
1066 + }
1067 +
1068 + if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
1069 + return $this->get_forbidden_error();
1070 + }
1071 +
1072 + $post = get_post( $parsed_urn['post_id'] );
1073 + if ( ! $post ) {
1074 + return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
1075 + }
1076 +
1077 + $rendered_content = apply_filters( 'the_content', $post->post_content );
1078 +
1079 + $body = array(
1080 + 'wp_post' => array(
1081 + 'ID' => $post->ID,
1082 + 'title' => $post->post_title,
1083 + 'URL' => get_permalink( $post ),
1084 + 'type' => $post->post_type,
1085 + 'content' => $rendered_content,
1086 + ),
1087 + );
1088 +
1089 + return $this->request_as_user(
1090 + sprintf( '/sites/%d/wordads/dsp/api/v1/advise/campaign/%s', $site_id, $urn ),
1091 + 'v2',
1092 + array( 'method' => 'POST' ),
1093 + $body
1094 + );
1095 + }
1096 +
1097 + /**
1098 + * Redirect GET requests to the WordAds DSP Templates endpoint for the site.
1099 + *
1100 + * @param WP_REST_Request $req The request object.
1101 + * @return array|WP_Error
1102 + */
560 1103 public function get_dsp_templates( $req ) {
561 1104 return $this->get_dsp_generic( 'v1/templates', $req );
562 1105 }
563 1106
564 1107 /**
1108 + * Redirect GET requests to the WordAds DSP Advise Campaign endpoint for the site.
1109 + *
1110 + * @param WP_REST_Request $req The request object.
1111 + * @return array|WP_Error
1112 + */
1113 + public function get_dsp_advise_campaign( $req ) {
1114 + $urn = $req->get_param( 'urn' ) ?? '';
1115 +
1116 + $sync_ready = $this->are_posts_ready();
1117 +
1118 + $response = $sync_ready ?
1119 + $this->get_dsp_generic( 'v1/advise/campaign/' . $urn, $req ) :
1120 + $this->get_dsp_advise_campaign_local( $urn );
1121 +
1122 + if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1123 + $response['sync_ready'] = $sync_ready;
1124 + }
1125 +
1126 + return $response;
1127 + }
1128 +
1129 + /**
1130 + * Redirect GET requests to the WordAds DSP Advise endpoint for the site.
1131 + *
1132 + * @param WP_REST_Request $req The request object.
1133 + * @return array|WP_Error
1134 + */
1135 + public function get_dsp_advise( $req ) {
1136 + return $this->get_dsp_generic( 'v1/advise', $req );
1137 + }
1138 +
1139 + /**
565 1140 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
566 1141 *
567 1142 * @param WP_REST_Request $req The request object.
568 1143 * @return array|WP_Error
@@ -577,9 +1152,10 @@
577 1152 * @param WP_REST_Request $req The request object.
578 1153 * @return array|WP_Error
579 1154 */
580 1155 public function get_dsp_payments( $req ) {
581 - return $this->get_dsp_generic( 'v1/payments', $req );
1156 + $version = $req->get_param( 'api_version' ) ?? 'v1';
1157 + return $this->get_dsp_generic( "{$version}/payments", $req );
582 1158 }
583 1159
584 1160 /**
585 1161 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
@@ -651,12 +1227,96 @@
651 1227 * @param WP_REST_Request $req The request object.
652 1228 * @return array|WP_Error
653 1229 */
654 1230 public function edit_wpcom_checkout( $req ) {
655 - return $this->edit_dsp_generic( 'v1/wpcom/checkout', $req, array( 'timeout' => 20 ) );
1231 + return $this->edit_dsp_generic( 'v1/wpcom/checkout', $req, array( 'timeout' => 60 ) );
656 1232 }
657 1233
658 1234 /**
1235 + * Redirect POST request to WordAds DSP Create Campaign endpoint for the site.
1236 + *
1237 + * If Jetpack Sync is not yet complete and posts are not fully synced, this endpoint will read local DB data and provide additional information to the WPCOM endpoint.
1238 + *
1239 + * @param WP_REST_Request $req The request object.
1240 + * @return array|WP_Error
1241 + */
1242 + public function create_dsp_campaigns( $req ) {
1243 + $sync_ready = $this->are_posts_ready();
1244 +
1245 + $response = $sync_ready ?
1246 + $this->edit_dsp_generic( 'v1.1/campaigns', $req, array( 'timeout' => 60 ) ) :
1247 + $this->create_dsp_campaigns_local( $req );
1248 +
1249 + if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1250 + $response['sync_ready'] = $sync_ready;
1251 + }
1252 +
1253 + return $response;
1254 + }
1255 +
1256 + /**
1257 + * Sends a create campaign request to the WordAds DSP Create Campaign endpoint.
1258 + * Includes additional Post information to the original request.
1259 + *
1260 + * @param WP_REST_Request $req The request object.
1261 + * @return array|WP_Error
1262 + */
1263 + public function create_dsp_campaigns_local( $req ) {
1264 + $site_id = $this->get_site_id();
1265 + if ( is_wp_error( $site_id ) ) {
1266 + return array();
1267 + }
1268 +
1269 + $request_body = $req->get_json_params();
1270 + if ( ! is_array( $request_body ) ) {
1271 + return new WP_Error( 'invalid_json', esc_html__( 'Invalid JSON Body', 'jetpack-blaze' ), array( 'status' => 400 ) );
1272 + }
1273 +
1274 + if ( ! isset( $request_body['target_urn'] ) ) {
1275 + return new WP_Error( 'missing_target_urn', esc_html__( 'Missing target_urn in request body', 'jetpack-blaze' ), array( 'status' => 400 ) );
1276 + }
1277 +
1278 + $urn = $request_body['target_urn'];
1279 + $parsed_urn = $this->get_data_from_urn( $urn );
1280 +
1281 + if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
1282 + return $this->get_forbidden_error();
1283 + }
1284 +
1285 + $post = get_post( $parsed_urn['post_id'] );
1286 + if ( ! $post ) {
1287 + return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
1288 + }
1289 +
1290 + $featured_image = $this->get_post_featured_image( $post->ID );
1291 +
1292 + $body = array_merge(
1293 + $request_body,
1294 + array(
1295 + 'wp_post' => array(
1296 + 'ID' => $post->ID,
1297 + 'title' => $post->post_title,
1298 + 'URL' => get_permalink( $post ),
1299 + 'type' => $post->post_type,
1300 + 'content' => $post->post_content,
1301 + 'featured_image' => $featured_image['URL'] ?? '',
1302 + 'modified' => $post->post_modified,
1303 + ),
1304 + )
1305 + );
1306 +
1307 + return $this->request_as_user(
1308 + sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ),
1309 + 'v2',
1310 + array(
1311 + 'method' => 'POST',
1312 + 'timeout' => 60,
1313 + ),
1314 + $body
1315 + );
1316 + }
1317 +
1318 + /**
659 1319 * Redirect POST/PUT/PATCH requests to WordAds DSP Campaigns endpoint for the site.
660 1320 *
661 1321 * @param WP_REST_Request $req The request object.
662 1322 * @return array|WP_Error
@@ -662,9 +1322,9 @@
662 1322 * @return array|WP_Error
663 1323 */
664 1324 public function edit_dsp_campaigns( $req ) {
665 1325 $version = $req->get_param( 'api_version' ) ?? 'v1';
666 - return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 20 ) );
1326 + return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 60 ) );
667 1327 }
668 1328
669 1329 /**
670 1330 * Redirect POST/PUT/PATCH requests to WordAds DSP Subscriptions endpoint for the site.
@@ -682,9 +1342,10 @@
682 1342 * @param WP_REST_Request $req The request object.
683 1343 * @return array|WP_Error
684 1344 */
685 1345 public function edit_dsp_payments( $req ) {
686 - return $this->edit_dsp_generic( 'v1/payments', $req, array( 'timeout' => 20 ) );
1346 + $version = $req->get_param( 'api_version' ) ?? 'v1';
1347 + return $this->edit_dsp_generic( "{$version}/payments", $req, array( 'timeout' => 20 ) );
687 1348 }
688 1349
689 1350 /**
690 1351 * Redirect POST/PUT/PATCH requests to WordAds DSP Logs endpoint for the site.
@@ -733,10 +1394,10 @@
733 1394
734 1395 /**
735 1396 * Will check the posts for prices and add them to the posts array
736 1397 *
737 - * @param WP_REST_Request $posts The posts object.
738 - * @return array|WP_Error
1398 + * @param array $posts The posts object.
1399 + * @return array The list posts with the price on them (if they are woo products).
739 1400 */
740 1401 protected function add_prices_in_posts( $posts ) {
741 1402
742 1403 if ( ! function_exists( 'wc_get_product' ) ||
@@ -780,19 +1441,19 @@
780 1441
781 1442 /**
782 1443 * Queries the WordPress.com REST API with a user token.
783 1444 *
784 - * @param String $path The API endpoint relative path.
785 - * @param String $version The API version.
786 - * @param array $args Request arguments.
787 - * @param String $body Request body.
788 - * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
789 - * @param bool $use_cache (optional) default to true.
790 - * @return array|WP_Error $response Data.
1445 + * @param String $path The API endpoint relative path.
1446 + * @param String $version The API version.
1447 + * @param array $args Request arguments.
1448 + * @param null|String|array $body Request body.
1449 + * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
1450 + * @param bool $use_cache (optional) default to true.
1451 + * @return array|string|WP_Error|\WP_REST_Response $response Data.
791 1452 */
792 1453 protected function request_as_user( $path, $version = '2', $args = array(), $body = null, $base_api_path = 'wpcom', $use_cache = false ) {
793 1454 // Arrays are serialized without considering the order of objects, but it's okay atm.
794 - $cache_key = 'BLAZE_REST_RESP_' . md5( implode( '|', array( $path, $version, wp_json_encode( $args ), wp_json_encode( $body ), $base_api_path ) ) );
1455 + $cache_key = 'BLAZE_REST_RESP_' . md5( implode( '|', array( $path, $version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ), wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), $base_api_path ) ) );
795 1456
796 1457 if ( $use_cache ) {
797 1458 $response_body_content = get_transient( $cache_key );
798 1459 if ( false !== $response_body_content ) {
@@ -813,12 +1474,18 @@
813 1474 }
814 1475
815 1476 $response_code = wp_remote_retrieve_response_code( $response );
816 1477 $response_body_content = wp_remote_retrieve_body( $response );
817 - $response_body = json_decode( $response_body_content, true );
1478 + $content_type = $response['headers']['content-type'] ?? '';
818 1479
1480 + if ( str_starts_with( $content_type, 'text/csv' ) ) {
1481 + return $response_body_content;
1482 + }
1483 +
1484 + $response_body = json_decode( $response_body_content, true );
1485 +
819 1486 if ( 200 !== $response_code ) {
820 - return $this->get_wp_error( $response_body, $response_code );
1487 + return $this->get_blaze_error( $response_body, $response_code );
821 1488 }
822 1489
823 1490 // Cache the successful JSON response for 5 minutes.
824 1491 set_transient( $cache_key, $response_body_content, 5 * MINUTE_IN_SECONDS );
@@ -841,11 +1508,17 @@
841 1508 * Build error object from remote response body and status code.
842 1509 *
843 1510 * @param array $response_body Remote response body.
844 1511 * @param int $response_code Http response code.
845 - * @return WP_Error
1512 + * @return \WP_REST_Response
846 1513 */
847 - protected function get_wp_error( $response_body, $response_code = 500 ) {
1514 + protected function get_blaze_error( $response_body, $response_code = 500 ) {
1515 + if ( ! is_array( $response_body ) ) {
1516 + $response_body = array(
1517 + 'errorMessage' => $response_body,
1518 + );
1519 + }
1520 +
848 1521 $error_code = 'remote-error';
849 1522 foreach ( array( 'code', 'error' ) as $error_code_key ) {
850 1523 if ( isset( $response_body[ $error_code_key ] ) ) {
851 1524 $error_code = $response_body[ $error_code_key ];
@@ -852,15 +1525,13 @@
852 1525 break;
853 1526 }
854 1527 }
855 1528
856 - $error_message = isset( $response_body['message'] ) ? $response_body['message'] : 'unknown remote error';
1529 + $response_body['code'] = $error_code;
1530 + $response_body['status'] = $response_code;
1531 + $response_body['errorMessage'] ??= 'Unknown remote error';
857 1532
858 - return new WP_Error(
859 - $error_code,
860 - $error_message,
861 - array( 'status' => $response_code )
862 - );
1533 + return new \WP_REST_Response( $response_body, $response_code );
863 1534 }
864 1535
865 1536 /**
866 1537 * Check if the current user is connected.
@@ -872,10 +1543,9 @@
872 1543 if ( ( new Host() )->is_wpcom_simple() ) {
873 1544 return true;
874 1545 }
875 1546
876 - $connection = new Connection_Manager();
877 - return $connection->is_connected() && $connection->is_user_connected();
1547 + return $this->connection->is_connected() && $this->connection->is_user_connected();
878 1548 }
879 1549
880 1550 /**
881 1551 * Get the site ID.
@@ -883,6 +1553,94 @@
883 1553 * @return int|WP_Error
884 1554 */
885 1555 private function get_site_id() {
886 1556 return Connection_Manager::get_site_id();
1557 + }
1558 +
1559 + /**
1560 + * Check if the Health status code is sync.
1561 + *
1562 + * @return bool True if is sync, false otherwise.
1563 + */
1564 + private function are_posts_ready(): bool {
1565 + // On WordPress.com Simple, Sync is not present, so we consider always ready.
1566 + if ( ( new Host() )->is_wpcom_simple() ) {
1567 + return true;
1568 + }
1569 +
1570 + return Health::STATUS_IN_SYNC === Health::get_status();
1571 + }
1572 +
1573 + /**
1574 + * Get the featured image data for a post.
1575 + *
1576 + * @param int $post_id The post ID.
1577 + *
1578 + * @return null|array {
1579 + * Featured image data, or null if no featured image exists.
1580 + *
1581 + * @type int $ID The attachment ID.
1582 + * @type string $URL The image URL.
1583 + * @type int $width The image width in pixels.
1584 + * @type int $height The image height in pixels.
1585 + * @type string $mime_type The image mime type (e.g., 'image/jpeg').
1586 + * }
1587 + */
1588 + private function get_post_featured_image( $post_id ) {
1589 + $thumbnail_id = get_post_thumbnail_id( $post_id );
1590 + if ( ! $thumbnail_id ) {
1591 + return null;
1592 + }
1593 +
1594 + $image_src = wp_get_attachment_image_src( $thumbnail_id, 'full' );
1595 + if ( ! $image_src ) {
1596 + return null;
1597 + }
1598 +
1599 + return array(
1600 + 'ID' => $thumbnail_id,
1601 + 'URL' => $image_src[0],
1602 + 'width' => $image_src[1],
1603 + 'height' => $image_src[2],
1604 + 'mime_type' => get_post_mime_type( $thumbnail_id ),
1605 + );
1606 + }
1607 +
1608 + /**
1609 + * Extract site ID and post ID from a WordPress.com URN.
1610 + *
1611 + * Parses a URN in the format "urn:wpcom:post:SITE_ID:POST_ID" and returns
1612 + * an associative array containing the site ID and post ID components.
1613 + *
1614 + * @param string $urn The URN string to parse (e.g., "urn:wpcom:post:12345:67890").
1615 + * @return array {
1616 + * Associative array containing the parsed URN components.
1617 + *
1618 + * @type int $site_id The WordPress.com site ID.
1619 + * @type int $post_id The post ID.
1620 + * }
1621 + */
1622 + private function get_data_from_urn( $urn ) {
1623 + $default = array(
1624 + 'site_id' => 0,
1625 + 'post_id' => 0,
1626 + );
1627 +
1628 + if ( empty( $urn ) ) {
1629 + return $default;
1630 + }
1631 +
1632 + $urn_parts = explode( ':', $urn );
1633 +
1634 + if ( count( $urn_parts ) < 5 ) {
1635 + return $default;
1636 + }
1637 +
1638 + $site_id = (int) $urn_parts[3];
1639 + $post_id = (int) $urn_parts[4];
1640 +
1641 + return array(
1642 + 'site_id' => $site_id,
1643 + 'post_id' => $post_id,
1644 + );
887 1645 }
888 1646 }