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 +1020 -37 12.7.3 → 16.3 View file →
@@ -10,14 +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;
15 +use WC_Product;
14 16 use WP_Error;
17 +use WP_REST_Request;
15 18 use WP_REST_Server;
16 19
17 20 /**
18 21 * Registers the REST routes for Blaze Dashboard.
19 - * It bascially forwards the requests to the WordPress.com REST API.
22 + * It basically forwards the requests to the WordPress.com REST API.
20 23 */
21 24 class Dashboard_REST_Controller {
22 25 /**
23 26 * Namespace for the REST API.
@@ -26,8 +29,37 @@
26 29 */
27 30 public static $namespace = 'jetpack/v4/blaze-app';
28 31
29 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 + /**
30 62 * Registers the REST routes for Blaze Dashboard.
31 63 *
32 64 * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API.
33 65 * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences.
@@ -84,8 +116,41 @@
84 116 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
85 117 )
86 118 );
87 119
120 + // WordAds DSP API media query routes
121 + register_rest_route(
122 + static::$namespace,
123 + sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
124 + array(
125 + 'methods' => WP_REST_Server::READABLE,
126 + 'callback' => array( $this, 'get_dsp_media' ),
127 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
128 + )
129 + );
130 +
131 + // WordAds DSP API upload to WP Media Library routes
132 + register_rest_route(
133 + static::$namespace,
134 + sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media', $site_id ),
135 + array(
136 + 'methods' => WP_REST_Server::CREATABLE,
137 + 'callback' => array( $this, 'upload_image_to_current_website' ),
138 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
139 + )
140 + );
141 +
142 + // WordAds DSP API media openverse query routes
143 + register_rest_route(
144 + static::$namespace,
145 + sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/media(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
146 + array(
147 + 'methods' => WP_REST_Server::READABLE,
148 + 'callback' => array( $this, 'get_dsp_openverse' ),
149 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
150 + )
151 + );
152 +
88 153 // WordAds DSP API Experiment route
89 154 register_rest_route(
90 155 static::$namespace,
91 156 sprintf( '/sites/%d/wordads/dsp/api/v1/experiments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -98,9 +163,9 @@
98 163
99 164 // WordAds DSP API Campaigns routes
100 165 register_rest_route(
101 166 static::$namespace,
102 - 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 ),
103 168 array(
104 169 'methods' => WP_REST_Server::READABLE,
105 170 'callback' => array( $this, 'get_dsp_campaigns' ),
106 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -105,12 +170,23 @@
105 170 'callback' => array( $this, 'get_dsp_campaigns' ),
106 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
107 172 )
108 173 );
174 +
109 175 register_rest_route(
110 176 static::$namespace,
111 - sprintf( '/sites/%d/wordads/dsp/api/v1/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
177 + sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ),
112 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,
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 ),
188 + array(
113 189 'methods' => WP_REST_Server::EDITABLE,
114 190 'callback' => array( $this, 'edit_dsp_campaigns' ),
115 191 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
116 192 )
@@ -126,8 +202,29 @@
126 202 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
127 203 )
128 204 );
129 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 +
130 227 // WordAds DSP API Search routes
131 228 register_rest_route(
132 229 static::$namespace,
133 230 sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -151,8 +248,27 @@
151 248
152 249 // WordAds DSP API Templates routes
153 250 register_rest_route(
154 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,
155 271 sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
156 272 array(
157 273 'methods' => WP_REST_Server::READABLE,
158 274 'callback' => array( $this, 'get_dsp_templates' ),
@@ -159,8 +275,28 @@
159 275 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
160 276 )
161 277 );
162 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 +
163 299 // WordAds DSP API Subscriptions routes
164 300 register_rest_route(
165 301 static::$namespace,
166 302 sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -179,8 +315,28 @@
179 315 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
180 316 )
181 317 );
182 318
319 + // WordAds DSP API Payments routes
320 + register_rest_route(
321 + static::$namespace,
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 ),
323 + array(
324 + 'methods' => WP_REST_Server::READABLE,
325 + 'callback' => array( $this, 'get_dsp_payments' ),
326 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
327 + )
328 + );
329 + register_rest_route(
330 + static::$namespace,
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 ),
332 + array(
333 + 'methods' => WP_REST_Server::EDITABLE,
334 + 'callback' => array( $this, 'edit_dsp_payments' ),
335 + 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
336 + )
337 + );
338 +
183 339 // WordAds DSP API Smart routes
184 340 register_rest_route(
185 341 static::$namespace,
186 342 sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -261,10 +417,15 @@
261 417 return $this->get_forbidden_error();
262 418 }
263 419
264 420 /**
265 - * Redirect GET requests to WordAds DSP for the site.
421 + * Get a list of posts that are eligible for Blaze campaigns.
266 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 + *
267 428 * @param WP_REST_Request $req The request object.
268 429 * @return array|WP_Error
269 430 */
270 431 public function get_blaze_posts( $req ) {
@@ -272,21 +433,269 @@
272 433 if ( is_wp_error( $site_id ) ) {
273 434 return array();
274 435 }
275 436
276 - // We don't use sub_path in the blaze posts, only query strings
277 - if ( isset( $params['sub_path'] ) ) {
278 - unset( $req->get_params()['sub_path'] );
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 );
279 443 }
280 444
281 - return $this->request_as_user(
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.
469 + if ( isset( $req['sub_path'] ) ) {
470 + unset( $req['sub_path'] );
471 + }
472 +
473 + $response = $this->request_as_user(
282 474 sprintf( '/sites/%d/blaze/posts%s', $site_id, $this->build_subpath_with_query_strings( $req->get_params() ) ),
283 475 'v2',
284 476 array( 'method' => 'GET' )
285 477 );
478 +
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 ) {
481 + return $response;
482 + }
483 +
484 + if ( isset( $response['posts'] ) && count( $response['posts'] ) > 0 ) {
485 + $response['posts'] = $this->add_prices_in_posts( $response['posts'] );
486 + }
487 +
488 + return $response;
286 489 }
287 490
288 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 + /**
289 698 * Builds the subpath including the query string to be used in the DSP call
290 699 *
291 700 * @param array $params The request object parameters.
292 701 * @return string
@@ -309,28 +718,135 @@
309 718 return $sub_path;
310 719 }
311 720
312 721 /**
313 - * Redirect GET requests to WordAds DSP Blaze Posts endpoint for the site.
722 + * Get Blaze posts for DSP
314 723 *
724 + * Maps DSP parameters to blaze/posts format and reuses get_blaze_posts
725 + * for consistent local/WPCOM routing logic.
726 + *
315 727 * @param WP_REST_Request $req The request object.
316 728 * @return array|WP_Error
317 729 */
318 730 public function get_dsp_blaze_posts( $req ) {
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 + );
740 +
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 + }
747 + }
748 +
749 + // Reuse get_blaze_posts (handles local/WPCOM routing).
750 + $response = $this->get_blaze_posts( $blaze_req );
751 +
752 + // Bail if we get an error.
753 + if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
754 + return $response;
755 + }
756 +
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 + );
763 + }
764 +
765 + /**
766 + * Redirect GET requests to WordAds DSP Blaze media endpoint for the site.
767 + *
768 + * @param WP_REST_Request $req The request object.
769 + * @return array|WP_Error
770 + */
771 + public function get_dsp_media( $req ) {
319 772 $site_id = $this->get_site_id();
320 773 if ( is_wp_error( $site_id ) ) {
321 774 return array();
322 775 }
776 + return $this->get_dsp_generic( sprintf( 'v1/wpcom/sites/%d/media', $site_id ), $req );
777 + }
323 778
324 - // We don't use sub_path in the blaze posts, only query strings
325 - if ( isset( $params['sub_path'] ) ) {
326 - unset( $req->get_params()['sub_path'] );
779 + /**
780 + * Redirect POST requests to WordAds DSP Blaze media endpoint for the site.
781 + *
782 + * @return array|WP_Error
783 + */
784 + public function upload_image_to_current_website() {
785 + $site_id = $this->get_site_id();
786 + if ( is_wp_error( $site_id ) ) {
787 + return array( 'error' => $site_id->get_error_message() );
327 788 }
328 789
329 - return $this->get_dsp_generic( sprintf( 'v1/wpcom/sites/%d/blaze/posts', $site_id ), $req );
790 + if ( empty( $_FILES['image'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
791 + return array( 'error' => 'File is missed' );
792 + }
793 + $file = $_FILES['image']; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
794 + $temp_name = $file['tmp_name'] ?? '';
795 + if ( ! $temp_name || ! is_uploaded_file( $temp_name ) ) {
796 + return array( 'error' => 'Specified file was not uploaded' );
797 + }
798 +
799 + // Getting the original file name.
800 + $filename = sanitize_file_name( basename( $file['name'] ) );
801 + // Upload contents to the Upload folder locally.
802 + $upload = wp_upload_bits(
803 + $filename,
804 + null,
805 + file_get_contents( $temp_name ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
806 + );
807 +
808 + if ( ! empty( $upload['error'] ) ) {
809 + return array( 'error' => $upload['error'] );
810 + }
811 +
812 + // Check the type of file. We'll use this as the 'post_mime_type'.
813 + $filetype = wp_check_filetype( $filename, null );
814 +
815 + // Prepare an array of post data for the attachment.
816 + $attachment = array(
817 + 'guid' => wp_upload_dir()['url'] . '/' . $filename,
818 + 'post_mime_type' => $filetype['type'],
819 + 'post_title' => preg_replace( '/\.[^.]+$/', '', $filename ),
820 + 'post_content' => '',
821 + 'post_status' => 'inherit',
822 + );
823 +
824 + // Insert the attachment.
825 + $attach_id = wp_insert_attachment( $attachment, $upload['file'] );
826 +
827 + // Make sure wp_generate_attachment_metadata() has all requirement dependencies.
828 + require_once ABSPATH . 'wp-admin/includes/image.php';
829 +
830 + // Generate the metadata for the attachment, and update the database record.
831 + $attach_data = wp_generate_attachment_metadata( $attach_id, $upload['file'] );
832 + // Store metadata in the local DB.
833 + wp_update_attachment_metadata( $attach_id, $attach_data );
834 +
835 + return array( 'url' => $upload['url'] );
330 836 }
331 837
332 838 /**
839 + * Redirect GET requests to WordAds DSP Blaze openverse endpoint.
840 + *
841 + * @param WP_REST_Request $req The request object.
842 + * @return array|WP_Error
843 + */
844 + public function get_dsp_openverse( $req ) {
845 + return $this->get_dsp_generic( 'v1/wpcom/media', $req );
846 + }
847 +
848 + /**
333 849 * Redirect GET requests to WordAds DSP Credits endpoint for the site.
334 850 *
335 851 * @param WP_REST_Request $req The request object.
336 852 * @return array|WP_Error
@@ -355,9 +871,10 @@
355 871 * @param WP_REST_Request $req The request object.
356 872 * @return array|WP_Error
357 873 */
358 874 public function get_dsp_campaigns( $req ) {
359 - 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 );
360 877 }
361 878
362 879 /**
363 880 * Redirect GET requests to WordAds DSP Site Campaigns endpoint for the site.
@@ -374,8 +891,32 @@
374 891 return $this->get_dsp_generic( sprintf( 'v1/sites/%d/campaigns', $site_id ), $req );
375 892 }
376 893
377 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 + /**
378 919 * Redirect GET requests to WordAds DSP Search endpoint for the site.
379 920 *
380 921 * @param WP_REST_Request $req The request object.
381 922 * @return array|WP_Error
@@ -394,18 +935,209 @@
394 935 return $this->get_dsp_generic( 'v1/user', $req );
395 936 }
396 937
397 938 /**
398 - * 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.
399 940 *
400 941 * @param WP_REST_Request $req The request object.
401 942 * @return array|WP_Error
402 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 + */
403 1103 public function get_dsp_templates( $req ) {
404 1104 return $this->get_dsp_generic( 'v1/templates', $req );
405 1105 }
406 1106
407 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 + /**
408 1140 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
409 1141 *
410 1142 * @param WP_REST_Request $req The request object.
411 1143 * @return array|WP_Error
@@ -414,8 +1146,19 @@
414 1146 return $this->get_dsp_generic( 'v1/subscriptions', $req );
415 1147 }
416 1148
417 1149 /**
1150 + * Redirect GET requests to WordAds DSP Payments endpoint for the site.
1151 + *
1152 + * @param WP_REST_Request $req The request object.
1153 + * @return array|WP_Error
1154 + */
1155 + public function get_dsp_payments( $req ) {
1156 + $version = $req->get_param( 'api_version' ) ?? 'v1';
1157 + return $this->get_dsp_generic( "{$version}/payments", $req );
1158 + }
1159 +
1160 + /**
418 1161 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
419 1162 *
420 1163 * @param WP_REST_Request $req The request object.
421 1164 * @return array|WP_Error
@@ -484,12 +1227,96 @@
484 1227 * @param WP_REST_Request $req The request object.
485 1228 * @return array|WP_Error
486 1229 */
487 1230 public function edit_wpcom_checkout( $req ) {
488 - 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 ) );
489 1232 }
490 1233
491 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 + /**
492 1319 * Redirect POST/PUT/PATCH requests to WordAds DSP Campaigns endpoint for the site.
493 1320 *
494 1321 * @param WP_REST_Request $req The request object.
495 1322 * @return array|WP_Error
@@ -494,9 +1321,10 @@
494 1321 * @param WP_REST_Request $req The request object.
495 1322 * @return array|WP_Error
496 1323 */
497 1324 public function edit_dsp_campaigns( $req ) {
498 - return $this->edit_dsp_generic( 'v1/campaigns', $req, array( 'timeout' => 20 ) );
1325 + $version = $req->get_param( 'api_version' ) ?? 'v1';
1326 + return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 60 ) );
499 1327 }
500 1328
501 1329 /**
502 1330 * Redirect POST/PUT/PATCH requests to WordAds DSP Subscriptions endpoint for the site.
@@ -508,8 +1336,19 @@
508 1336 return $this->edit_dsp_generic( 'v1/subscriptions', $req, array( 'timeout' => 20 ) );
509 1337 }
510 1338
511 1339 /**
1340 + * Redirect POST/PUT/PATCH requests to WordAds DSP Payments endpoint for the site.
1341 + *
1342 + * @param WP_REST_Request $req The request object.
1343 + * @return array|WP_Error
1344 + */
1345 + public function edit_dsp_payments( $req ) {
1346 + $version = $req->get_param( 'api_version' ) ?? 'v1';
1347 + return $this->edit_dsp_generic( "{$version}/payments", $req, array( 'timeout' => 20 ) );
1348 + }
1349 +
1350 + /**
512 1351 * Redirect POST/PUT/PATCH requests to WordAds DSP Logs endpoint for the site.
513 1352 *
514 1353 * @param WP_REST_Request $req The request object.
515 1354 * @return array|WP_Error
@@ -553,21 +1392,68 @@
553 1392 );
554 1393 }
555 1394
556 1395 /**
1396 + * Will check the posts for prices and add them to the posts array
1397 + *
1398 + * @param array $posts The posts object.
1399 + * @return array The list posts with the price on them (if they are woo products).
1400 + */
1401 + protected function add_prices_in_posts( $posts ) {
1402 +
1403 + if ( ! function_exists( 'wc_get_product' ) ||
1404 + ! function_exists( 'wc_get_price_decimal_separator' ) ||
1405 + ! function_exists( 'wc_get_price_thousand_separator' ) ||
1406 + ! function_exists( 'wc_get_price_decimals' ) ||
1407 + ! function_exists( 'get_woocommerce_price_format' ) ||
1408 + ! function_exists( 'get_woocommerce_currency_symbol' )
1409 + ) {
1410 + return $posts;
1411 + }
1412 +
1413 + foreach ( $posts as $key => $item ) {
1414 + if ( ! isset( $item['ID'] ) ) {
1415 + $posts[ $key ]['price'] = '';
1416 + continue;
1417 + }
1418 + $product = wc_get_product( $item['ID'] );
1419 + if ( ! $product || ! $product instanceof WC_Product ) {
1420 + $posts[ $key ]['price'] = '';
1421 + } else {
1422 + $price = $product->get_price();
1423 + $decimal_separator = wc_get_price_decimal_separator();
1424 + $thousand_separator = wc_get_price_thousand_separator();
1425 + $decimals = wc_get_price_decimals();
1426 + $price_format = get_woocommerce_price_format();
1427 + $currency_symbol = get_woocommerce_currency_symbol();
1428 +
1429 + // Convert to float to avoid issues on PHP 8.
1430 + $price = (float) $price;
1431 + $negative = $price < 0;
1432 + $price = $negative ? $price * -1 : $price;
1433 + $price = number_format( $price, $decimals, $decimal_separator, $thousand_separator );
1434 + $formatted_price = sprintf( $price_format, $currency_symbol, $price );
1435 +
1436 + $posts[ $key ]['price'] = html_entity_decode( $formatted_price, ENT_COMPAT );
1437 + }
1438 + }
1439 + return $posts;
1440 + }
1441 +
1442 + /**
557 1443 * Queries the WordPress.com REST API with a user token.
558 1444 *
559 - * @param String $path The API endpoint relative path.
560 - * @param String $version The API version.
561 - * @param array $args Request arguments.
562 - * @param String $body Request body.
563 - * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
564 - * @param bool $use_cache (optional) default to true.
565 - * @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.
566 1452 */
567 1453 protected function request_as_user( $path, $version = '2', $args = array(), $body = null, $base_api_path = 'wpcom', $use_cache = false ) {
568 1454 // Arrays are serialized without considering the order of objects, but it's okay atm.
569 - $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 ) ) );
570 1456
571 1457 if ( $use_cache ) {
572 1458 $response_body_content = get_transient( $cache_key );
573 1459 if ( false !== $response_body_content ) {
@@ -588,12 +1474,18 @@
588 1474 }
589 1475
590 1476 $response_code = wp_remote_retrieve_response_code( $response );
591 1477 $response_body_content = wp_remote_retrieve_body( $response );
592 - $response_body = json_decode( $response_body_content, true );
1478 + $content_type = $response['headers']['content-type'] ?? '';
593 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 +
594 1486 if ( 200 !== $response_code ) {
595 - return $this->get_wp_error( $response_body, $response_code );
1487 + return $this->get_blaze_error( $response_body, $response_code );
596 1488 }
597 1489
598 1490 // Cache the successful JSON response for 5 minutes.
599 1491 set_transient( $cache_key, $response_body_content, 5 * MINUTE_IN_SECONDS );
@@ -616,11 +1508,17 @@
616 1508 * Build error object from remote response body and status code.
617 1509 *
618 1510 * @param array $response_body Remote response body.
619 1511 * @param int $response_code Http response code.
620 - * @return WP_Error
1512 + * @return \WP_REST_Response
621 1513 */
622 - 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 +
623 1521 $error_code = 'remote-error';
624 1522 foreach ( array( 'code', 'error' ) as $error_code_key ) {
625 1523 if ( isset( $response_body[ $error_code_key ] ) ) {
626 1524 $error_code = $response_body[ $error_code_key ];
@@ -627,15 +1525,13 @@
627 1525 break;
628 1526 }
629 1527 }
630 1528
631 - $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';
632 1532
633 - return new WP_Error(
634 - $error_code,
635 - $error_message,
636 - array( 'status' => $response_code )
637 - );
1533 + return new \WP_REST_Response( $response_body, $response_code );
638 1534 }
639 1535
640 1536 /**
641 1537 * Check if the current user is connected.
@@ -647,10 +1543,9 @@
647 1543 if ( ( new Host() )->is_wpcom_simple() ) {
648 1544 return true;
649 1545 }
650 1546
651 - $connection = new Connection_Manager();
652 - return $connection->is_connected() && $connection->is_user_connected();
1547 + return $this->connection->is_connected() && $this->connection->is_user_connected();
653 1548 }
654 1549
655 1550 /**
656 1551 * Get the site ID.
@@ -658,6 +1553,94 @@
658 1553 * @return int|WP_Error
659 1554 */
660 1555 private function get_site_id() {
661 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 + );
662 1645 }
663 1646 }