PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-blaze / src / class-dashboard-rest-controller.php

class-dashboard-rest-controller.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-blaze/src/class-dashboard-rest-controller.php

1,647 lines 50.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The Blaze Rest Controller class.
4 * Registers the REST routes for Blaze Dashboard.
5 *
6 * @package automattic/jetpack-blaze
7 */
8
9 namespace Automattic\Jetpack\Blaze;
10
11 use Automattic\Jetpack\Connection\Client;
12 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13 use Automattic\Jetpack\Status\Host;
14 use Automattic\Jetpack\Sync\Health;
15 use WC_Product;
16 use WP_Error;
17 use WP_REST_Request;
18 use WP_REST_Server;
19
20 /**
21 * Registers the REST routes for Blaze Dashboard.
22 * It basically forwards the requests to the WordPress.com REST API.
23 */
24 class Dashboard_REST_Controller {
25 /**
26 * Namespace for the REST API.
27 *
28 * @var string
29 */
30 public static $namespace = 'jetpack/v4/blaze-app';
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 /**
62 * Registers the REST routes for Blaze Dashboard.
63 *
64 * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API.
65 * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences.
66 *
67 * @access public
68 * @static
69 */
70 public function register_rest_routes() {
71 $site_id = $this->get_site_id();
72 if ( is_wp_error( $site_id ) ) {
73 return;
74 }
75
76 // WPCOM API routes
77 register_rest_route(
78 static::$namespace,
79 sprintf( '/sites/%d/blaze/posts(\?.*)?', $site_id ),
80 array(
81 'methods' => WP_REST_Server::READABLE,
82 'callback' => array( $this, 'get_blaze_posts' ),
83 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
84 )
85 );
86
87 // WordAds DSP API Posts routes
88 register_rest_route(
89 static::$namespace,
90 sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/blaze/posts(\?.*)?', $site_id ),
91 array(
92 'methods' => WP_REST_Server::READABLE,
93 'callback' => array( $this, 'get_dsp_blaze_posts' ),
94 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
95 )
96 );
97
98 // WordAds DSP API Checkout route
99 register_rest_route(
100 static::$namespace,
101 sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/checkout', $site_id ),
102 array(
103 'methods' => WP_REST_Server::EDITABLE,
104 'callback' => array( $this, 'edit_wpcom_checkout' ),
105 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
106 )
107 );
108
109 // WordAds DSP API Credits routes
110 register_rest_route(
111 static::$namespace,
112 sprintf( '/sites/%d/wordads/dsp/api/v1/credits(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
113 array(
114 'methods' => WP_REST_Server::READABLE,
115 'callback' => array( $this, 'get_dsp_credits' ),
116 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
117 )
118 );
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
153 // WordAds DSP API Experiment route
154 register_rest_route(
155 static::$namespace,
156 sprintf( '/sites/%d/wordads/dsp/api/v1/experiments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
157 array(
158 'methods' => WP_REST_Server::READABLE,
159 'callback' => array( $this, 'get_dsp_experiments' ),
160 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
161 )
162 );
163
164 // WordAds DSP API Campaigns routes
165 register_rest_route(
166 static::$namespace,
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 ),
168 array(
169 'methods' => WP_REST_Server::READABLE,
170 'callback' => array( $this, 'get_dsp_campaigns' ),
171 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
172 )
173 );
174
175 register_rest_route(
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,
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(
189 'methods' => WP_REST_Server::EDITABLE,
190 'callback' => array( $this, 'edit_dsp_campaigns' ),
191 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
192 )
193 );
194
195 // WordAds DSP API Site Campaigns routes
196 register_rest_route(
197 static::$namespace,
198 sprintf( '/sites/%1$d/wordads/dsp/api/v1/sites/%1$d/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
199 array(
200 'methods' => WP_REST_Server::READABLE,
201 'callback' => array( $this, 'get_dsp_site_campaigns' ),
202 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
203 )
204 );
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
227 // WordAds DSP API Search routes
228 register_rest_route(
229 static::$namespace,
230 sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
231 array(
232 'methods' => WP_REST_Server::READABLE,
233 'callback' => array( $this, 'get_dsp_search' ),
234 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
235 )
236 );
237
238 // WordAds DSP API Users routes
239 register_rest_route(
240 static::$namespace,
241 sprintf( '/sites/%d/wordads/dsp/api/v1/user(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
242 array(
243 'methods' => WP_REST_Server::READABLE,
244 'callback' => array( $this, 'get_dsp_user' ),
245 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
246 )
247 );
248
249 // WordAds DSP API Templates routes
250 register_rest_route(
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,
271 sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
272 array(
273 'methods' => WP_REST_Server::READABLE,
274 'callback' => array( $this, 'get_dsp_templates' ),
275 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
276 )
277 );
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
299 // WordAds DSP API Subscriptions routes
300 register_rest_route(
301 static::$namespace,
302 sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
303 array(
304 'methods' => WP_REST_Server::READABLE,
305 'callback' => array( $this, 'get_dsp_subscriptions' ),
306 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
307 )
308 );
309 register_rest_route(
310 static::$namespace,
311 sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
312 array(
313 'methods' => WP_REST_Server::EDITABLE,
314 'callback' => array( $this, 'edit_dsp_subscriptions' ),
315 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
316 )
317 );
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
339 // WordAds DSP API Smart routes
340 register_rest_route(
341 static::$namespace,
342 sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
343 array(
344 'methods' => WP_REST_Server::READABLE,
345 'callback' => array( $this, 'get_dsp_smart' ),
346 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
347 )
348 );
349 register_rest_route(
350 static::$namespace,
351 sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
352 array(
353 'methods' => WP_REST_Server::EDITABLE,
354 'callback' => array( $this, 'edit_dsp_smart' ),
355 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
356 )
357 );
358
359 // WordAds DSP API Locations routes
360 register_rest_route(
361 static::$namespace,
362 sprintf( '/sites/%d/wordads/dsp/api/v1/locations(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
363 array(
364 'methods' => WP_REST_Server::READABLE,
365 'callback' => array( $this, 'get_dsp_locations' ),
366 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
367 )
368 );
369
370 // WordAds DSP API Woo routes
371 register_rest_route(
372 static::$namespace,
373 sprintf( '/sites/%d/wordads/dsp/api/v1/woo(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
374 array(
375 'methods' => WP_REST_Server::READABLE,
376 'callback' => array( $this, 'get_dsp_woo' ),
377 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
378 )
379 );
380
381 // WordAds DSP API Image routes
382 register_rest_route(
383 static::$namespace,
384 sprintf( '/sites/%d/wordads/dsp/api/v1/image(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
385 array(
386 'methods' => WP_REST_Server::READABLE,
387 'callback' => array( $this, 'get_dsp_image' ),
388 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
389 )
390 );
391
392 // WordAds DSP API Logs routes
393 register_rest_route(
394 static::$namespace,
395 sprintf( '/sites/%d/wordads/dsp/api/v1/logs', $site_id ),
396 array(
397 'methods' => WP_REST_Server::EDITABLE,
398 'callback' => array( $this, 'edit_dsp_logs' ),
399 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
400 )
401 );
402 }
403
404 /**
405 * Only administrators can access the API.
406 *
407 * @return bool|WP_Error True if a blog token was used to sign the request, WP_Error otherwise.
408 */
409 public function can_user_view_dsp_callback() {
410 if (
411 $this->is_user_connected()
412 && current_user_can( 'manage_options' )
413 ) {
414 return true;
415 }
416
417 return $this->get_forbidden_error();
418 }
419
420 /**
421 * Get a list of posts that are eligible for Blaze campaigns.
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 *
428 * @param WP_REST_Request $req The request object.
429 * @return array|WP_Error
430 */
431 public function get_blaze_posts( $req ) {
432 $site_id = $this->get_site_id();
433 if ( is_wp_error( $site_id ) ) {
434 return array();
435 }
436
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.
469 if ( isset( $req['sub_path'] ) ) {
470 unset( $req['sub_path'] );
471 }
472
473 $response = $this->request_as_user(
474 sprintf( '/sites/%d/blaze/posts%s', $site_id, $this->build_subpath_with_query_strings( $req->get_params() ) ),
475 'v2',
476 array( 'method' => 'GET' )
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;
489 }
490
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 /**
698 * Builds the subpath including the query string to be used in the DSP call
699 *
700 * @param array $params The request object parameters.
701 * @return string
702 */
703 private function build_subpath_with_query_strings( $params ) {
704 $sub_path = '';
705 if ( isset( $params['sub_path'] ) ) {
706 $sub_path = $params['sub_path'];
707 unset( $params['sub_path'] );
708 }
709
710 if ( isset( $params['rest_route'] ) ) {
711 unset( $params['rest_route'] );
712 }
713
714 if ( ! empty( $params ) ) {
715 $sub_path = $sub_path . '?' . http_build_query( stripslashes_deep( $params ) );
716 }
717
718 return $sub_path;
719 }
720
721 /**
722 * Get Blaze posts for DSP
723 *
724 * Maps DSP parameters to blaze/posts format and reuses get_blaze_posts
725 * for consistent local/WPCOM routing logic.
726 *
727 * @param WP_REST_Request $req The request object.
728 * @return array|WP_Error
729 */
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 ) {
772 $site_id = $this->get_site_id();
773 if ( is_wp_error( $site_id ) ) {
774 return array();
775 }
776 return $this->get_dsp_generic( sprintf( 'v1/wpcom/sites/%d/media', $site_id ), $req );
777 }
778
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() );
788 }
789
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'] );
836 }
837
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 /**
849 * Redirect GET requests to WordAds DSP Credits endpoint for the site.
850 *
851 * @param WP_REST_Request $req The request object.
852 * @return array|WP_Error
853 */
854 public function get_dsp_credits( $req ) {
855 return $this->get_dsp_generic( 'v1/credits', $req );
856 }
857
858 /**
859 * Redirect GET requests to WordAds DSP Experiments endpoint for the site.
860 *
861 * @param WP_REST_Request $req The request object.
862 * @return array|WP_Error
863 */
864 public function get_dsp_experiments( $req ) {
865 return $this->get_dsp_generic( 'v1/experiments', $req );
866 }
867
868 /**
869 * Redirect GET requests to WordAds DSP Campaigns endpoint for the site.
870 *
871 * @param WP_REST_Request $req The request object.
872 * @return array|WP_Error
873 */
874 public function get_dsp_campaigns( $req ) {
875 $version = $req->get_param( 'api_version' ) ?? 'v1';
876 return $this->get_dsp_generic( "{$version}/campaigns", $req );
877 }
878
879 /**
880 * Redirect GET requests to WordAds DSP Site Campaigns endpoint for the site.
881 *
882 * @param WP_REST_Request $req The request object.
883 * @return array|WP_Error
884 */
885 public function get_dsp_site_campaigns( $req ) {
886 $site_id = $this->get_site_id();
887 if ( is_wp_error( $site_id ) ) {
888 return array();
889 }
890
891 return $this->get_dsp_generic( sprintf( 'v1/sites/%d/campaigns', $site_id ), $req );
892 }
893
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 /**
919 * Redirect GET requests to WordAds DSP Search endpoint for the site.
920 *
921 * @param WP_REST_Request $req The request object.
922 * @return array|WP_Error
923 */
924 public function get_dsp_search( $req ) {
925 return $this->get_dsp_generic( 'v1/search', $req );
926 }
927
928 /**
929 * Redirect GET requests to WordAds DSP User endpoint for the site.
930 *
931 * @param WP_REST_Request $req The request object.
932 * @return array|WP_Error
933 */
934 public function get_dsp_user( $req ) {
935 return $this->get_dsp_generic( 'v1/user', $req );
936 }
937
938 /**
939 * Redirect GET requests to the WordAds DSP Templates Article endpoint for the site.
940 *
941 * @param WP_REST_Request $req The request object.
942 * @return array|WP_Error
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 */
1103 public function get_dsp_templates( $req ) {
1104 return $this->get_dsp_generic( 'v1/templates', $req );
1105 }
1106
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 /**
1140 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
1141 *
1142 * @param WP_REST_Request $req The request object.
1143 * @return array|WP_Error
1144 */
1145 public function get_dsp_subscriptions( $req ) {
1146 return $this->get_dsp_generic( 'v1/subscriptions', $req );
1147 }
1148
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 /**
1161 * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
1162 *
1163 * @param WP_REST_Request $req The request object.
1164 * @return array|WP_Error
1165 */
1166 public function get_dsp_smart( $req ) {
1167 return $this->get_dsp_generic( 'v1/smart', $req );
1168 }
1169
1170 /**
1171 * Redirect GET requests to WordAds DSP Locations endpoint for the site.
1172 *
1173 * @param WP_REST_Request $req The request object.
1174 * @return array|WP_Error
1175 */
1176 public function get_dsp_locations( $req ) {
1177 return $this->get_dsp_generic( 'v1/locations', $req );
1178 }
1179
1180 /**
1181 * Redirect GET requests to WordAds DSP Woo endpoint for the site.
1182 *
1183 * @param WP_REST_Request $req The request object.
1184 * @return array|WP_Error
1185 */
1186 public function get_dsp_woo( $req ) {
1187 return $this->get_dsp_generic( 'v1/woo', $req );
1188 }
1189
1190 /**
1191 * Redirect GET requests to WordAds DSP Countries endpoint for the site.
1192 *
1193 * @param WP_REST_Request $req The request object.
1194 * @return array|WP_Error
1195 */
1196 public function get_dsp_image( $req ) {
1197 return $this->get_dsp_generic( 'v1/image', $req );
1198 }
1199
1200 /**
1201 * Redirect GET requests to WordAds DSP for the site.
1202 *
1203 * @param String $path The Root API endpoint.
1204 * @param WP_REST_Request $req The request object.
1205 * @param array $args Request arguments.
1206 * @return array|WP_Error
1207 */
1208 public function get_dsp_generic( $path, $req, $args = array() ) {
1209 $site_id = $this->get_site_id();
1210 if ( is_wp_error( $site_id ) ) {
1211 return array();
1212 }
1213
1214 return $this->request_as_user(
1215 sprintf( '/sites/%d/wordads/dsp/api/%s%s', $site_id, $path, $this->build_subpath_with_query_strings( $req->get_params() ) ),
1216 'v2',
1217 array_merge(
1218 $args,
1219 array( 'method' => 'GET' )
1220 )
1221 );
1222 }
1223
1224 /**
1225 * Redirect POST/PUT/PATCH requests to WordAds DSP WPCOM Checkout endpoint for the site.
1226 *
1227 * @param WP_REST_Request $req The request object.
1228 * @return array|WP_Error
1229 */
1230 public function edit_wpcom_checkout( $req ) {
1231 return $this->edit_dsp_generic( 'v1/wpcom/checkout', $req, array( 'timeout' => 60 ) );
1232 }
1233
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 /**
1319 * Redirect POST/PUT/PATCH requests to WordAds DSP Campaigns endpoint for the site.
1320 *
1321 * @param WP_REST_Request $req The request object.
1322 * @return array|WP_Error
1323 */
1324 public function edit_dsp_campaigns( $req ) {
1325 $version = $req->get_param( 'api_version' ) ?? 'v1';
1326 return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 60 ) );
1327 }
1328
1329 /**
1330 * Redirect POST/PUT/PATCH requests to WordAds DSP Subscriptions endpoint for the site.
1331 *
1332 * @param WP_REST_Request $req The request object.
1333 * @return array|WP_Error
1334 */
1335 public function edit_dsp_subscriptions( $req ) {
1336 return $this->edit_dsp_generic( 'v1/subscriptions', $req, array( 'timeout' => 20 ) );
1337 }
1338
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 /**
1351 * Redirect POST/PUT/PATCH requests to WordAds DSP Logs endpoint for the site.
1352 *
1353 * @param WP_REST_Request $req The request object.
1354 * @return array|WP_Error
1355 */
1356 public function edit_dsp_logs( $req ) {
1357 return $this->edit_dsp_generic( 'v1/logs', $req );
1358 }
1359
1360 /**
1361 * Redirect POST/PUT/PATCH requests to WordAds DSP Smart endpoint for the site.
1362 *
1363 * @param WP_REST_Request $req The request object.
1364 * @return array|WP_Error
1365 */
1366 public function edit_dsp_smart( $req ) {
1367 return $this->edit_dsp_generic( 'v1/smart', $req );
1368 }
1369
1370 /**
1371 * Redirect POST/PUT/PATCH requests to WordAds DSP for the site.
1372 *
1373 * @param String $path The Root API endpoint.
1374 * @param WP_REST_Request $req The request object.
1375 * @param array $args Request arguments.
1376 * @return array|WP_Error
1377 */
1378 public function edit_dsp_generic( $path, $req, $args = array() ) {
1379 $site_id = $this->get_site_id();
1380 if ( is_wp_error( $site_id ) ) {
1381 return array();
1382 }
1383
1384 return $this->request_as_user(
1385 sprintf( '/sites/%d/wordads/dsp/api/%s%s', $site_id, $path, $req->get_param( 'sub_path' ) ),
1386 'v2',
1387 array_merge(
1388 $args,
1389 array( 'method' => $req->get_method() )
1390 ),
1391 $req->get_body()
1392 );
1393 }
1394
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 /**
1443 * Queries the WordPress.com REST API with a user token.
1444 *
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.
1452 */
1453 protected function request_as_user( $path, $version = '2', $args = array(), $body = null, $base_api_path = 'wpcom', $use_cache = false ) {
1454 // Arrays are serialized without considering the order of objects, but it's okay atm.
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 ) ) );
1456
1457 if ( $use_cache ) {
1458 $response_body_content = get_transient( $cache_key );
1459 if ( false !== $response_body_content ) {
1460 return json_decode( $response_body_content, true );
1461 }
1462 }
1463
1464 $response = Client::wpcom_json_api_request_as_user(
1465 $path,
1466 $version,
1467 $args,
1468 $body,
1469 $base_api_path
1470 );
1471
1472 if ( is_wp_error( $response ) ) {
1473 return $response;
1474 }
1475
1476 $response_code = wp_remote_retrieve_response_code( $response );
1477 $response_body_content = wp_remote_retrieve_body( $response );
1478 $content_type = $response['headers']['content-type'] ?? '';
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
1486 if ( 200 !== $response_code ) {
1487 return $this->get_blaze_error( $response_body, $response_code );
1488 }
1489
1490 // Cache the successful JSON response for 5 minutes.
1491 set_transient( $cache_key, $response_body_content, 5 * MINUTE_IN_SECONDS );
1492 return $response_body;
1493 }
1494
1495 /**
1496 * Return a WP_Error object with a forbidden error.
1497 */
1498 protected function get_forbidden_error() {
1499 $error_msg = esc_html__(
1500 'You are not allowed to perform this action.',
1501 'jetpack-blaze'
1502 );
1503
1504 return new WP_Error( 'rest_forbidden', $error_msg, array( 'status' => rest_authorization_required_code() ) );
1505 }
1506
1507 /**
1508 * Build error object from remote response body and status code.
1509 *
1510 * @param array $response_body Remote response body.
1511 * @param int $response_code Http response code.
1512 * @return \WP_REST_Response
1513 */
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
1521 $error_code = 'remote-error';
1522 foreach ( array( 'code', 'error' ) as $error_code_key ) {
1523 if ( isset( $response_body[ $error_code_key ] ) ) {
1524 $error_code = $response_body[ $error_code_key ];
1525 break;
1526 }
1527 }
1528
1529 $response_body['code'] = $error_code;
1530 $response_body['status'] = $response_code;
1531 $response_body['errorMessage'] ??= 'Unknown remote error';
1532
1533 return new \WP_REST_Response( $response_body, $response_code );
1534 }
1535
1536 /**
1537 * Check if the current user is connected.
1538 * On WordPress.com Simple, it is always connected.
1539 *
1540 * @return true
1541 */
1542 private function is_user_connected() {
1543 if ( ( new Host() )->is_wpcom_simple() ) {
1544 return true;
1545 }
1546
1547 return $this->connection->is_connected() && $this->connection->is_user_connected();
1548 }
1549
1550 /**
1551 * Get the site ID.
1552 *
1553 * @return int|WP_Error
1554 */
1555 private function get_site_id() {
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 );
1645 }
1646 }
1647