PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
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 +799 -46 13.5.2 → 16.3-beta View file →
@@ -10,8 +10,9 @@
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;
16 17 use WP_REST_Request;
17 18 use WP_REST_Server;
@@ -17,9 +18,9 @@
17 18 use WP_REST_Server;
18 19
19 20 /**
20 21 * Registers the REST routes for Blaze Dashboard.
21 - * It bascially forwards the requests to the WordPress.com REST API.
22 + * It basically forwards the requests to the WordPress.com REST API.
22 23 */
23 24 class Dashboard_REST_Controller {
24 25 /**
25 26 * Namespace for the REST API.
@@ -28,8 +29,37 @@
28 29 */
29 30 public static $namespace = 'jetpack/v4/blaze-app';
30 31
31 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 + /**
32 62 * Registers the REST routes for Blaze Dashboard.
33 63 *
34 64 * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API.
35 65 * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences.
@@ -133,9 +163,9 @@
133 163
134 164 // WordAds DSP API Campaigns routes
135 165 register_rest_route(
136 166 static::$namespace,
137 - 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 ),
138 168 array(
139 169 'methods' => WP_REST_Server::READABLE,
140 170 'callback' => array( $this, 'get_dsp_campaigns' ),
141 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -140,10 +170,21 @@
140 170 'callback' => array( $this, 'get_dsp_campaigns' ),
141 171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
142 172 )
143 173 );
174 +
144 175 register_rest_route(
145 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,
146 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 ),
147 188 array(
148 189 'methods' => WP_REST_Server::EDITABLE,
149 190 'callback' => array( $this, 'edit_dsp_campaigns' ),
@@ -161,8 +202,29 @@
161 202 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
162 203 )
163 204 );
164 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 +
165 227 // WordAds DSP API Search routes
166 228 register_rest_route(
167 229 static::$namespace,
168 230 sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -186,8 +248,27 @@
186 248
187 249 // WordAds DSP API Templates routes
188 250 register_rest_route(
189 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,
190 271 sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
191 272 array(
192 273 'methods' => WP_REST_Server::READABLE,
193 274 'callback' => array( $this, 'get_dsp_templates' ),
@@ -194,8 +275,28 @@
194 275 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
195 276 )
196 277 );
197 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 +
198 299 // WordAds DSP API Subscriptions routes
199 300 register_rest_route(
200 301 static::$namespace,
201 302 sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
@@ -217,9 +318,9 @@
217 318
218 319 // WordAds DSP API Payments routes
219 320 register_rest_route(
220 321 static::$namespace,
221 - 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 ),
222 323 array(
223 324 'methods' => WP_REST_Server::READABLE,
224 325 'callback' => array( $this, 'get_dsp_payments' ),
225 326 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -226,9 +327,9 @@
226 327 )
227 328 );
228 329 register_rest_route(
229 330 static::$namespace,
230 - 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 ),
231 332 array(
232 333 'methods' => WP_REST_Server::EDITABLE,
233 334 'callback' => array( $this, 'edit_dsp_payments' ),
234 335 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
@@ -316,10 +417,15 @@
316 417 return $this->get_forbidden_error();
317 418 }
318 419
319 420 /**
320 - * Redirect GET requests to WordAds DSP for the site.
421 + * Get a list of posts that are eligible for Blaze campaigns.
321 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 + *
322 428 * @param WP_REST_Request $req The request object.
323 429 * @return array|WP_Error
324 430 */
325 431 public function get_blaze_posts( $req ) {
@@ -327,9 +433,40 @@
327 433 if ( is_wp_error( $site_id ) ) {
328 434 return array();
329 435 }
330 436
331 - // 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.
332 469 if ( isset( $req['sub_path'] ) ) {
333 470 unset( $req['sub_path'] );
334 471 }
335 472
@@ -338,9 +475,10 @@
338 475 'v2',
339 476 array( 'method' => 'GET' )
340 477 );
341 478
342 - 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 ) {
343 481 return $response;
344 482 }
345 483
346 484 if ( isset( $response['posts'] ) && count( $response['posts'] ) > 0 ) {
@@ -350,8 +488,214 @@
350 488 return $response;
351 489 }
352 490
353 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 + /**
354 698 * Builds the subpath including the query string to be used in the DSP call
355 699 *
356 700 * @param array $params The request object parameters.
357 701 * @return string
@@ -374,35 +718,49 @@
374 718 return $sub_path;
375 719 }
376 720
377 721 /**
378 - * Redirect GET requests to WordAds DSP Blaze Posts endpoint for the site.
722 + * Get Blaze posts for DSP
379 723 *
724 + * Maps DSP parameters to blaze/posts format and reuses get_blaze_posts
725 + * for consistent local/WPCOM routing logic.
726 + *
380 727 * @param WP_REST_Request $req The request object.
381 728 * @return array|WP_Error
382 729 */
383 730 public function get_dsp_blaze_posts( $req ) {
384 - $site_id = $this->get_site_id();
385 - if ( is_wp_error( $site_id ) ) {
386 - return array();
387 - }
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 + );
388 740
389 - // We don't use sub_path in the blaze posts, only query strings
390 - if ( isset( $req['sub_path'] ) ) {
391 - 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 + }
392 747 }
393 748
394 - $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 );
395 751
396 - if ( is_wp_error( $response ) ) {
752 + // Bail if we get an error.
753 + if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
397 754 return $response;
398 755 }
399 756
400 - if ( isset( $response['results'] ) && count( $response['results'] ) > 0 ) {
401 - $response['results'] = $this->add_prices_in_posts( $response['results'] );
402 - }
403 -
404 - 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 + );
405 763 }
406 764
407 765 /**
408 766 * Redirect GET requests to WordAds DSP Blaze media endpoint for the site.
@@ -432,9 +790,9 @@
432 790 if ( empty( $_FILES['image'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
433 791 return array( 'error' => 'File is missed' );
434 792 }
435 793 $file = $_FILES['image']; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
436 - $temp_name = $file['tmp_name'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
794 + $temp_name = $file['tmp_name'] ?? '';
437 795 if ( ! $temp_name || ! is_uploaded_file( $temp_name ) ) {
438 796 return array( 'error' => 'Specified file was not uploaded' );
439 797 }
440 798
@@ -513,9 +871,10 @@
513 871 * @param WP_REST_Request $req The request object.
514 872 * @return array|WP_Error
515 873 */
516 874 public function get_dsp_campaigns( $req ) {
517 - 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 );
518 877 }
519 878
520 879 /**
521 880 * Redirect GET requests to WordAds DSP Site Campaigns endpoint for the site.
@@ -532,8 +891,32 @@
532 891 return $this->get_dsp_generic( sprintf( 'v1/sites/%d/campaigns', $site_id ), $req );
533 892 }
534 893
535 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 + /**
536 919 * Redirect GET requests to WordAds DSP Search endpoint for the site.
537 920 *
538 921 * @param WP_REST_Request $req The request object.
539 922 * @return array|WP_Error
@@ -552,18 +935,209 @@
552 935 return $this->get_dsp_generic( 'v1/user', $req );
553 936 }
554 937
555 938 /**
556 - * 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.
557 940 *
558 941 * @param WP_REST_Request $req The request object.
559 942 * @return array|WP_Error
560 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 + */
561 1103 public function get_dsp_templates( $req ) {
562 1104 return $this->get_dsp_generic( 'v1/templates', $req );
563 1105 }
564 1106
565 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 + /**
566 1140 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
567 1141 *
568 1142 * @param WP_REST_Request $req The request object.
569 1143 * @return array|WP_Error
@@ -578,9 +1152,10 @@
578 1152 * @param WP_REST_Request $req The request object.
579 1153 * @return array|WP_Error
580 1154 */
581 1155 public function get_dsp_payments( $req ) {
582 - 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 );
583 1158 }
584 1159
585 1160 /**
586 1161 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
@@ -652,12 +1227,96 @@
652 1227 * @param WP_REST_Request $req The request object.
653 1228 * @return array|WP_Error
654 1229 */
655 1230 public function edit_wpcom_checkout( $req ) {
656 - 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 ) );
657 1232 }
658 1233
659 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 + /**
660 1319 * Redirect POST/PUT/PATCH requests to WordAds DSP Campaigns endpoint for the site.
661 1320 *
662 1321 * @param WP_REST_Request $req The request object.
663 1322 * @return array|WP_Error
@@ -663,9 +1322,9 @@
663 1322 * @return array|WP_Error
664 1323 */
665 1324 public function edit_dsp_campaigns( $req ) {
666 1325 $version = $req->get_param( 'api_version' ) ?? 'v1';
667 - return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 20 ) );
1326 + return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 60 ) );
668 1327 }
669 1328
670 1329 /**
671 1330 * Redirect POST/PUT/PATCH requests to WordAds DSP Subscriptions endpoint for the site.
@@ -683,9 +1342,10 @@
683 1342 * @param WP_REST_Request $req The request object.
684 1343 * @return array|WP_Error
685 1344 */
686 1345 public function edit_dsp_payments( $req ) {
687 - 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 ) );
688 1348 }
689 1349
690 1350 /**
691 1351 * Redirect POST/PUT/PATCH requests to WordAds DSP Logs endpoint for the site.
@@ -734,10 +1394,10 @@
734 1394
735 1395 /**
736 1396 * Will check the posts for prices and add them to the posts array
737 1397 *
738 - * @param WP_REST_Request $posts The posts object.
739 - * @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).
740 1400 */
741 1401 protected function add_prices_in_posts( $posts ) {
742 1402
743 1403 if ( ! function_exists( 'wc_get_product' ) ||
@@ -781,19 +1441,19 @@
781 1441
782 1442 /**
783 1443 * Queries the WordPress.com REST API with a user token.
784 1444 *
785 - * @param String $path The API endpoint relative path.
786 - * @param String $version The API version.
787 - * @param array $args Request arguments.
788 - * @param String $body Request body.
789 - * @param String $base_api_path (optional) the API base path override, defaults to 'rest'.
790 - * @param bool $use_cache (optional) default to true.
791 - * @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.
792 1452 */
793 1453 protected function request_as_user( $path, $version = '2', $args = array(), $body = null, $base_api_path = 'wpcom', $use_cache = false ) {
794 1454 // Arrays are serialized without considering the order of objects, but it's okay atm.
795 - $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 ) ) );
796 1456
797 1457 if ( $use_cache ) {
798 1458 $response_body_content = get_transient( $cache_key );
799 1459 if ( false !== $response_body_content ) {
@@ -814,10 +1474,16 @@
814 1474 }
815 1475
816 1476 $response_code = wp_remote_retrieve_response_code( $response );
817 1477 $response_body_content = wp_remote_retrieve_body( $response );
818 - $response_body = json_decode( $response_body_content, true );
1478 + $content_type = $response['headers']['content-type'] ?? '';
819 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 +
820 1486 if ( 200 !== $response_code ) {
821 1487 return $this->get_blaze_error( $response_body, $response_code );
822 1488 }
823 1489
@@ -842,9 +1508,9 @@
842 1508 * Build error object from remote response body and status code.
843 1509 *
844 1510 * @param array $response_body Remote response body.
845 1511 * @param int $response_code Http response code.
846 - * @return WP_Error
1512 + * @return \WP_REST_Response
847 1513 */
848 1514 protected function get_blaze_error( $response_body, $response_code = 500 ) {
849 1515 if ( ! is_array( $response_body ) ) {
850 1516 $response_body = array(
@@ -859,11 +1525,11 @@
859 1525 break;
860 1526 }
861 1527 }
862 1528
863 - $response_body['code'] = $error_code;
864 - $response_body['status'] = $response_code;
865 - $response_body['errorMessage'] = $response_body['errorMessage'] ?? 'Unknown remote error';
1529 + $response_body['code'] = $error_code;
1530 + $response_body['status'] = $response_code;
1531 + $response_body['errorMessage'] ??= 'Unknown remote error';
866 1532
867 1533 return new \WP_REST_Response( $response_body, $response_code );
868 1534 }
869 1535
@@ -877,10 +1543,9 @@
877 1543 if ( ( new Host() )->is_wpcom_simple() ) {
878 1544 return true;
879 1545 }
880 1546
881 - $connection = new Connection_Manager();
882 - return $connection->is_connected() && $connection->is_user_connected();
1547 + return $this->connection->is_connected() && $this->connection->is_user_connected();
883 1548 }
884 1549
885 1550 /**
886 1551 * Get the site ID.
@@ -888,6 +1553,94 @@
888 1553 * @return int|WP_Error
889 1554 */
890 1555 private function get_site_id() {
891 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 + );
892 1645 }
893 1646 }