Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.03% covered (success)
90.03%
695 / 772
58.93% covered (warning)
58.93%
33 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_REST_Controller
90.03% covered (success)
90.03%
695 / 772
58.93% covered (warning)
58.93%
33 / 56
181.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_routes
99.65% covered (success)
99.65%
281 / 282
0.00% covered (danger)
0.00%
0 / 1
2
 can_user_view_dsp_callback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_blaze_posts
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_blaze_posts_from_wpcom
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_blaze_posts_local
81.82% covered (warning)
81.82%
45 / 55
0.00% covered (danger)
0.00%
0 / 1
11.73
 format_post_for_blaze
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 get_blazable_post_types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_post_type
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 is_jetpack_module_active
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 count_tsp_eligible_posts
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 build_subpath_with_query_strings
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 get_dsp_blaze_posts
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 get_dsp_media
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 upload_image_to_current_website
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
42
 get_dsp_openverse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_credits
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_experiments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_campaigns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_site_campaigns
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_dsp_stats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_stats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_templates_article
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_dsp_templates_article_local
90.48% covered (success)
90.48%
38 / 42
0.00% covered (danger)
0.00%
0 / 1
10.09
 get_dsp_templates_advise_campaign
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_dsp_advise_campaign_local
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
5.04
 get_dsp_templates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_advise_campaign
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_dsp_advise
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_subscriptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_payments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_smart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_locations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_woo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_image
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_dsp_generic
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 edit_wpcom_checkout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create_dsp_campaigns
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 create_dsp_campaigns_local
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
7.10
 edit_dsp_campaigns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_subscriptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_payments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_logs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_smart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 edit_dsp_generic
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 add_prices_in_posts
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 request_as_user
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
6.33
 get_forbidden_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_blaze_error
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
 is_user_connected
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_site_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 are_posts_ready
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_post_featured_image
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 get_data_from_urn
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
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
9namespace Automattic\Jetpack\Blaze;
10
11use Automattic\Jetpack\Connection\Client;
12use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13use Automattic\Jetpack\Status\Host;
14use Automattic\Jetpack\Sync\Health;
15use WC_Product;
16use WP_Error;
17use WP_REST_Request;
18use 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 */
24class 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 for Blaze Dashboard.
50     *
51     * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API.
52     * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences.
53     *
54     * @access public
55     * @static
56     */
57    public function register_rest_routes() {
58        $site_id = $this->get_site_id();
59        if ( is_wp_error( $site_id ) ) {
60            return;
61        }
62
63        // WPCOM API routes
64        register_rest_route(
65            static::$namespace,
66            sprintf( '/sites/%d/blaze/posts(\?.*)?', $site_id ),
67            array(
68                'methods'             => WP_REST_Server::READABLE,
69                'callback'            => array( $this, 'get_blaze_posts' ),
70                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
71            )
72        );
73
74        // WordAds DSP API Posts routes
75        register_rest_route(
76            static::$namespace,
77            sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/blaze/posts(\?.*)?', $site_id ),
78            array(
79                'methods'             => WP_REST_Server::READABLE,
80                'callback'            => array( $this, 'get_dsp_blaze_posts' ),
81                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
82            )
83        );
84
85        // WordAds DSP API Checkout route
86        register_rest_route(
87            static::$namespace,
88            sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/checkout', $site_id ),
89            array(
90                'methods'             => WP_REST_Server::EDITABLE,
91                'callback'            => array( $this, 'edit_wpcom_checkout' ),
92                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
93            )
94        );
95
96        // WordAds DSP API Credits routes
97        register_rest_route(
98            static::$namespace,
99            sprintf( '/sites/%d/wordads/dsp/api/v1/credits(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
100            array(
101                'methods'             => WP_REST_Server::READABLE,
102                'callback'            => array( $this, 'get_dsp_credits' ),
103                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
104            )
105        );
106
107        // WordAds DSP API media query routes
108        register_rest_route(
109            static::$namespace,
110            sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
111            array(
112                'methods'             => WP_REST_Server::READABLE,
113                'callback'            => array( $this, 'get_dsp_media' ),
114                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
115            )
116        );
117
118        // WordAds DSP API upload to WP Media Library routes
119        register_rest_route(
120            static::$namespace,
121            sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media', $site_id ),
122            array(
123                'methods'             => WP_REST_Server::CREATABLE,
124                'callback'            => array( $this, 'upload_image_to_current_website' ),
125                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
126            )
127        );
128
129        // WordAds DSP API media openverse query routes
130        register_rest_route(
131            static::$namespace,
132            sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/media(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
133            array(
134                'methods'             => WP_REST_Server::READABLE,
135                'callback'            => array( $this, 'get_dsp_openverse' ),
136                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
137            )
138        );
139
140        // WordAds DSP API Experiment route
141        register_rest_route(
142            static::$namespace,
143            sprintf( '/sites/%d/wordads/dsp/api/v1/experiments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
144            array(
145                'methods'             => WP_REST_Server::READABLE,
146                'callback'            => array( $this, 'get_dsp_experiments' ),
147                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
148            )
149        );
150
151        // WordAds DSP API Campaigns routes
152        register_rest_route(
153            static::$namespace,
154            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
155            array(
156                'methods'             => WP_REST_Server::READABLE,
157                'callback'            => array( $this, 'get_dsp_campaigns' ),
158                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
159            )
160        );
161
162        register_rest_route(
163            static::$namespace,
164            sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ),
165            array(
166                'methods'             => WP_REST_Server::CREATABLE,
167                'callback'            => array( $this, 'create_dsp_campaigns' ),
168                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
169            )
170        );
171
172        register_rest_route(
173            static::$namespace,
174            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
175            array(
176                'methods'             => WP_REST_Server::EDITABLE,
177                'callback'            => array( $this, 'edit_dsp_campaigns' ),
178                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
179            )
180        );
181
182        // WordAds DSP API Site Campaigns routes
183        register_rest_route(
184            static::$namespace,
185            sprintf( '/sites/%1$d/wordads/dsp/api/v1/sites/%1$d/campaigns(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
186            array(
187                'methods'             => WP_REST_Server::READABLE,
188                'callback'            => array( $this, 'get_dsp_site_campaigns' ),
189                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
190            )
191        );
192
193        // WordAds DSP API Site Stats routes
194        register_rest_route(
195            static::$namespace,
196            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/stats(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
197            array(
198                'methods'             => WP_REST_Server::READABLE,
199                'callback'            => array( $this, 'get_dsp_stats' ),
200                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
201            )
202        );
203
204        register_rest_route(
205            static::$namespace,
206            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/stats(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
207            array(
208                'methods'             => WP_REST_Server::EDITABLE,
209                'callback'            => array( $this, 'edit_dsp_stats' ),
210                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
211            )
212        );
213
214        // WordAds DSP API Search routes
215        register_rest_route(
216            static::$namespace,
217            sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
218            array(
219                'methods'             => WP_REST_Server::READABLE,
220                'callback'            => array( $this, 'get_dsp_search' ),
221                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
222            )
223        );
224
225        // WordAds DSP API Users routes
226        register_rest_route(
227            static::$namespace,
228            sprintf( '/sites/%d/wordads/dsp/api/v1/user(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
229            array(
230                'methods'             => WP_REST_Server::READABLE,
231                'callback'            => array( $this, 'get_dsp_user' ),
232                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
233            )
234        );
235
236        // WordAds DSP API Templates routes
237        register_rest_route(
238            static::$namespace,
239            sprintf( '/sites/%d/wordads/dsp/api/v1/templates/article/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
240            array(
241                'methods'             => WP_REST_Server::READABLE,
242                'callback'            => array( $this, 'get_dsp_templates_article' ),
243                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
244            )
245        );
246        register_rest_route(
247            static::$namespace,
248            sprintf( '/sites/%d/wordads/dsp/api/v1/templates/advise/campaign/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
249            array(
250                'methods'             => WP_REST_Server::READABLE,
251                'callback'            => array( $this, 'get_dsp_templates_advise_campaign' ),
252                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
253            )
254        );
255
256        register_rest_route(
257            static::$namespace,
258            sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
259            array(
260                'methods'             => WP_REST_Server::READABLE,
261                'callback'            => array( $this, 'get_dsp_templates' ),
262                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
263            )
264        );
265
266        // WordAds DSP API Advise routes
267        register_rest_route(
268            static::$namespace,
269            sprintf( '/sites/%d/wordads/dsp/api/v1/advise/campaign/(?P<urn>[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ),
270            array(
271                'methods'             => WP_REST_Server::READABLE,
272                'callback'            => array( $this, 'get_dsp_advise_campaign' ),
273                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
274            )
275        );
276        register_rest_route(
277            static::$namespace,
278            sprintf( '/sites/%d/wordads/dsp/api/v1/advise(?P<sub_path>[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ),
279            array(
280                'methods'             => WP_REST_Server::READABLE,
281                'callback'            => array( $this, 'get_dsp_advise' ),
282                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
283            )
284        );
285
286        // WordAds DSP API Subscriptions routes
287        register_rest_route(
288            static::$namespace,
289            sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
290            array(
291                'methods'             => WP_REST_Server::READABLE,
292                'callback'            => array( $this, 'get_dsp_subscriptions' ),
293                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
294            )
295        );
296        register_rest_route(
297            static::$namespace,
298            sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
299            array(
300                'methods'             => WP_REST_Server::EDITABLE,
301                'callback'            => array( $this, 'edit_dsp_subscriptions' ),
302                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
303            )
304        );
305
306        // WordAds DSP API Payments routes
307        register_rest_route(
308            static::$namespace,
309            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
310            array(
311                'methods'             => WP_REST_Server::READABLE,
312                'callback'            => array( $this, 'get_dsp_payments' ),
313                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
314            )
315        );
316        register_rest_route(
317            static::$namespace,
318            sprintf( '/sites/%d/wordads/dsp/api/(?P<api_version>v[0-9]+\.?[0-9]*)/payments(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
319            array(
320                'methods'             => WP_REST_Server::EDITABLE,
321                'callback'            => array( $this, 'edit_dsp_payments' ),
322                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
323            )
324        );
325
326        // WordAds DSP API Smart routes
327        register_rest_route(
328            static::$namespace,
329            sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
330            array(
331                'methods'             => WP_REST_Server::READABLE,
332                'callback'            => array( $this, 'get_dsp_smart' ),
333                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
334            )
335        );
336        register_rest_route(
337            static::$namespace,
338            sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P<sub_path>[a-zA-Z0-9-_\/]*)', $site_id ),
339            array(
340                'methods'             => WP_REST_Server::EDITABLE,
341                'callback'            => array( $this, 'edit_dsp_smart' ),
342                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
343            )
344        );
345
346        // WordAds DSP API Locations routes
347        register_rest_route(
348            static::$namespace,
349            sprintf( '/sites/%d/wordads/dsp/api/v1/locations(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
350            array(
351                'methods'             => WP_REST_Server::READABLE,
352                'callback'            => array( $this, 'get_dsp_locations' ),
353                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
354            )
355        );
356
357        // WordAds DSP API Woo routes
358        register_rest_route(
359            static::$namespace,
360            sprintf( '/sites/%d/wordads/dsp/api/v1/woo(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
361            array(
362                'methods'             => WP_REST_Server::READABLE,
363                'callback'            => array( $this, 'get_dsp_woo' ),
364                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
365            )
366        );
367
368        // WordAds DSP API Image routes
369        register_rest_route(
370            static::$namespace,
371            sprintf( '/sites/%d/wordads/dsp/api/v1/image(?P<sub_path>[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ),
372            array(
373                'methods'             => WP_REST_Server::READABLE,
374                'callback'            => array( $this, 'get_dsp_image' ),
375                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
376            )
377        );
378
379        // WordAds DSP API Logs routes
380        register_rest_route(
381            static::$namespace,
382            sprintf( '/sites/%d/wordads/dsp/api/v1/logs', $site_id ),
383            array(
384                'methods'             => WP_REST_Server::EDITABLE,
385                'callback'            => array( $this, 'edit_dsp_logs' ),
386                'permission_callback' => array( $this, 'can_user_view_dsp_callback' ),
387            )
388        );
389    }
390
391    /**
392     * Only administrators can access the API.
393     *
394     * @return bool|WP_Error True if a blog token was used to sign the request, WP_Error otherwise.
395     */
396    public function can_user_view_dsp_callback() {
397        if (
398            $this->is_user_connected()
399            && current_user_can( 'manage_options' )
400        ) {
401            return true;
402        }
403
404        return $this->get_forbidden_error();
405    }
406
407    /**
408     * Get a list of posts that are eligible for Blaze campaigns.
409     *
410     * Routes to WPCOM API or local database based on Jetpack Sync status:
411     * - If sync is ready: Uses WPCOM API (has stats data like like_count, monthly_view_count).
412     * - If sync is not ready: Uses local database query (stats show as -1, stats-based
413     *   sorting falls back to date).
414     *
415     * @param WP_REST_Request $req The request object.
416     * @return array|WP_Error
417     */
418    public function get_blaze_posts( $req ) {
419        $site_id = $this->get_site_id();
420        if ( is_wp_error( $site_id ) ) {
421            return array();
422        }
423
424        $sync_ready = $this->are_posts_ready();
425
426        if ( $sync_ready ) {
427            $response = $this->get_blaze_posts_from_wpcom( $req, $site_id );
428        } else {
429            $response = $this->get_blaze_posts_local( $req );
430        }
431
432        if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
433            return $response;
434        }
435
436        if ( is_array( $response ) ) {
437            $response['sync_ready'] = $sync_ready;
438        }
439
440        return $response;
441    }
442
443    /**
444     * Get Blaze posts from the WPCOM API.
445     *
446     * Used when Jetpack Sync is ready and posts are available on WPCOM.
447     * Provides full functionality including stats data (like_count, monthly_view_count)
448     * and stats-based sorting.
449     *
450     * @param WP_REST_Request $req The request object.
451     * @param int             $site_id The site ID.
452     * @return array|WP_Error
453     */
454    private function get_blaze_posts_from_wpcom( $req, $site_id ) {
455        // We don't use sub_path in the blaze posts, only query strings.
456        if ( isset( $req['sub_path'] ) ) {
457            unset( $req['sub_path'] );
458        }
459
460        $response = $this->request_as_user(
461            sprintf( '/sites/%d/blaze/posts%s', $site_id, $this->build_subpath_with_query_strings( $req->get_params() ) ),
462            'v2',
463            array( 'method' => 'GET' )
464        );
465
466        // Bail if we get an error (WP_ERROR or an already formatted WP_REST_Response error).
467        if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
468            return $response;
469        }
470
471        if ( isset( $response['posts'] ) && count( $response['posts'] ) > 0 ) {
472            $response['posts'] = $this->add_prices_in_posts( $response['posts'] );
473        }
474
475        return $response;
476    }
477
478    /**
479     * Get Blaze posts from the local WordPress database.
480     *
481     * Used as fallback when Jetpack Sync is not ready. Stats fields (like_count,
482     * monthly_view_count) are returned as -1 since they are only available on WPCOM.
483     * If user requests sorting by stats fields, falls back to sorting by date.
484     *
485     * @param WP_REST_Request $req The request object.
486     * @return array
487     */
488    private function get_blaze_posts_local( $req ) {
489        // Default and maximum posts per page for this function.
490        $default_posts_per_page = 20;
491
492        // Parse request parameters.
493        $page           = absint( $req->get_param( 'page' ) ?? 1 );
494        $posts_per_page = absint( $req->get_param( 'posts_per_page' ) ?? $default_posts_per_page );
495        $order          = $req->get_param( 'order' ) ?? 'DESC';
496        $order_by       = $req->get_param( 'order_by' ) ?? 'date';
497        $post_types     = $req->get_param( 'filter_post_type' ) ?? implode( ',', $this->get_blazable_post_types() );
498        $title          = strtolower( sanitize_text_field( $req->get_param( 'title' ) ?? '' ) );
499
500        // Sanitize and validate post types.
501        $post_type_list = $this->sanitize_post_type( $post_types );
502
503        // Validate page parameter.
504        if ( $page < 1 ) {
505            $page = 1;
506        }
507
508        // Validate post per page parameter (use default value if invalid)
509        if ( $posts_per_page <= 0 || $posts_per_page > $default_posts_per_page ) {
510            $posts_per_page = $default_posts_per_page;
511        }
512
513        // Validate order.
514        $order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $order ) : 'DESC';
515
516        // Validate order_by - stats-related fields fall back to date (handled by WPCOM).
517        $valid_order_by = array( 'post_title', 'type', 'date', 'modified', 'comment_count' );
518        if ( ! in_array( $order_by, $valid_order_by, true ) ) {
519            $order_by = 'date';
520        }
521
522        $args = array(
523            'post_type'           => $post_type_list,
524            'post_status'         => 'publish',
525            'post_password'       => '',
526            'posts_per_page'      => $posts_per_page,
527            'paged'               => $page,
528            'ignore_sticky_posts' => 1,
529            'orderby'             => $order_by,
530            'order'               => $order,
531        );
532
533        // Add title search filter if provided.
534        $title_filter = null;
535        if ( ! empty( $title ) ) {
536            $title_filter = function ( $where ) use ( $title ) {
537                global $wpdb;
538                $title_like = '%' . $wpdb->esc_like( $title ) . '%';
539                $where     .= $wpdb->prepare( " AND {$wpdb->posts}.post_title LIKE %s", $title_like );
540                return $where;
541            };
542            add_filter( 'posts_where', $title_filter );
543        }
544
545        $query       = new \WP_Query( $args );
546        $posts       = $query->get_posts();
547        $total_pages = $query->max_num_pages;
548
549        // Remove the title filter after query.
550        if ( $title_filter !== null ) {
551            remove_filter( 'posts_where', $title_filter );
552        }
553
554        // Format posts for the response.
555        $formatted_posts = array();
556        if ( $page <= $total_pages ) {
557            foreach ( $posts as $post ) {
558                $formatted_posts[] = $this->format_post_for_blaze( $post );
559            }
560        }
561
562        // Add prices for WooCommerce products.
563        if ( count( $formatted_posts ) > 0 ) {
564            $formatted_posts = $this->add_prices_in_posts( $formatted_posts );
565        }
566
567        return array(
568            'posts'         => $formatted_posts,
569            'total_items'   => $query->found_posts,
570            'post_title'    => $title,
571            'page'          => $page,
572            'total_pages'   => $total_pages,
573            'stats_enabled' => $this->is_jetpack_module_active( 'stats' ),
574            'likes_enabled' => $this->is_jetpack_module_active( 'likes' ),
575            'tsp_eligible'  => $this->count_tsp_eligible_posts(),
576        );
577    }
578
579    /**
580     * Format a post object for the Blaze API response.
581     *
582     * @param \WP_Post $post The post object.
583     * @return array Formatted post data.
584     */
585    protected function format_post_for_blaze( $post ) {
586        $featured_image_data = $this->get_post_featured_image( $post->ID );
587        $featured_image      = $featured_image_data['URL'] ?? null;
588
589        // Get SKU for WooCommerce products.
590        $sku = get_post_meta( $post->ID, '_sku', true );
591
592        return array(
593            'ID'                 => $post->ID,
594            'title'              => $post->post_title,
595            'type'               => $post->post_type,
596            'date'               => gmdate( 'c', strtotime( $post->post_date_gmt ) ),
597            'modified'           => gmdate( 'c', strtotime( $post->post_modified_gmt ) ),
598            'comment_count'      => (int) $post->comment_count,
599            'like_count'         => -1, // Stats not available locally.
600            'featured_image'     => $featured_image,
601            'author'             => $post->post_author,
602            'sku'                => $sku,
603            'post_url'           => get_permalink( $post->ID ),
604            'monthly_view_count' => -1, // Stats not available locally.
605        );
606    }
607
608    /**
609     * Get the post types that are eligible for Blaze campaigns.
610     *
611     * @return array List of post type slugs.
612     */
613    private function get_blazable_post_types() {
614        return array( 'post', 'page', 'product' );
615    }
616
617    /**
618     * Sanitize and validate post types for Blaze.
619     *
620     * @param string $post_types Comma-separated list of post types.
621     * @return array Valid post types, or all blazable types if none valid.
622     */
623    private function sanitize_post_type( $post_types ) {
624        $blazable_post_types = $this->get_blazable_post_types();
625        if ( ! is_string( $post_types ) ) {
626            return $blazable_post_types;
627        }
628        $post_types     = sanitize_text_field( $post_types );
629        $post_type_list = explode( ',', $post_types );
630
631        $allowed_types = array();
632
633        foreach ( $post_type_list as $post_type ) {
634            if ( in_array( $post_type, $blazable_post_types, true ) ) {
635                $allowed_types[] = $post_type;
636            }
637        }
638
639        return count( $allowed_types )
640            ? $allowed_types
641            : $blazable_post_types;
642    }
643
644    /**
645     * Check if a Jetpack module is active.
646     * Uses jetpack-status Modules class which handles WPCOM and self-hosted sites.
647     *
648     * @param string $module_name The module name (e.g., 'stats', 'likes').
649     * @return bool Whether the module is active.
650     */
651    private function is_jetpack_module_active( $module_name ) {
652        // Default to true if Modules class is unavailable (matches WPCOM behavior).
653        if ( ! class_exists( '\Automattic\Jetpack\Modules' ) ) {
654            return true;
655        }
656        $modules = new \Automattic\Jetpack\Modules();
657        return $modules->is_active( $module_name );
658    }
659
660    /**
661     * Count posts eligible for TSP (has Gutenberg blocks).
662     * Matches WPCOM's count_tsp_eligible_posts implementation.
663     *
664     * @return bool Whether there are TSP eligible posts.
665     */
666    private function count_tsp_eligible_posts() {
667        $query = array(
668            'posts_per_page'      => 1,
669            'order'               => 'DESC',
670            'orderby'             => 'date',
671            'post_type'           => 'post',
672            'post_status'         => array( 'publish' ),
673            's'                   => '<!-- wp:',
674            'fields'              => 'ids',
675            'ignore_sticky_posts' => 1,
676            'offset'              => 0,
677        );
678
679        $wp_query = new \WP_Query( $query );
680
681        return (int) $wp_query->found_posts > 0;
682    }
683
684    /**
685     * Builds the subpath including the query string to be used in the DSP call
686     *
687     * @param array $params The request object parameters.
688     * @return string
689     */
690    private function build_subpath_with_query_strings( $params ) {
691        $sub_path = '';
692        if ( isset( $params['sub_path'] ) ) {
693            $sub_path = $params['sub_path'];
694            unset( $params['sub_path'] );
695        }
696
697        if ( isset( $params['rest_route'] ) ) {
698            unset( $params['rest_route'] );
699        }
700
701        if ( ! empty( $params ) ) {
702            $sub_path = $sub_path . '?' . http_build_query( stripslashes_deep( $params ) );
703        }
704
705        return $sub_path;
706    }
707
708    /**
709     * Get Blaze posts for DSP
710     *
711     * Maps DSP parameters to blaze/posts format and reuses get_blaze_posts
712     * for consistent local/WPCOM routing logic.
713     *
714     * @param WP_REST_Request $req The request object.
715     * @return array|WP_Error
716     */
717    public function get_dsp_blaze_posts( $req ) {
718        // Map DSP params â†’ blaze params.
719        $param_map = array(
720            'title'            => $req->get_param( 'search' ),
721            'filter_post_type' => $req->get_param( 'post_type' ),
722            'posts_per_page'   => $req->get_param( 'limit' ),
723            'page'             => $req->get_param( 'page' ),
724            'order'            => $req->get_param( 'order' ),
725            'order_by'         => $req->get_param( 'order_by' ),
726        );
727
728        // Create new request with transformed params (only non-null values).
729        $blaze_req = new \WP_REST_Request( 'GET' );
730        foreach ( $param_map as $key => $value ) {
731            if ( $value !== null ) {
732                $blaze_req->set_param( $key, $value );
733            }
734        }
735
736        // Reuse get_blaze_posts (handles local/WPCOM routing).
737        $response = $this->get_blaze_posts( $blaze_req );
738
739        // Bail if we get an error.
740        if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) {
741            return $response;
742        }
743
744        // Transform response to DSP format.
745        return array(
746            'results'    => $response['posts'] ?? array(),
747            'total'      => $response['total_items'] ?? 0,
748            'sync_ready' => $response['sync_ready'] ?? false,
749        );
750    }
751
752    /**
753     * Redirect GET requests to WordAds DSP Blaze media endpoint for the site.
754     *
755     * @param WP_REST_Request $req The request object.
756     * @return array|WP_Error
757     */
758    public function get_dsp_media( $req ) {
759        $site_id = $this->get_site_id();
760        if ( is_wp_error( $site_id ) ) {
761            return array();
762        }
763        return $this->get_dsp_generic( sprintf( 'v1/wpcom/sites/%d/media', $site_id ), $req );
764    }
765
766    /**
767     * Redirect POST requests to WordAds DSP Blaze media endpoint for the site.
768     *
769     * @return array|WP_Error
770     */
771    public function upload_image_to_current_website() {
772        $site_id = $this->get_site_id();
773        if ( is_wp_error( $site_id ) ) {
774            return array( 'error' => $site_id->get_error_message() );
775        }
776
777        if ( empty( $_FILES['image'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
778            return array( 'error' => 'File is missed' );
779        }
780        $file      = $_FILES['image']; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
781        $temp_name = $file['tmp_name'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
782        if ( ! $temp_name || ! is_uploaded_file( $temp_name ) ) {
783            return array( 'error' => 'Specified file was not uploaded' );
784        }
785
786        // Getting the original file name.
787        $filename = sanitize_file_name( basename( $file['name'] ) );
788        // Upload contents to the Upload folder locally.
789        $upload = wp_upload_bits(
790            $filename,
791            null,
792            file_get_contents( $temp_name ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
793        );
794
795        if ( ! empty( $upload['error'] ) ) {
796            return array( 'error' => $upload['error'] );
797        }
798
799        // Check the type of file. We'll use this as the 'post_mime_type'.
800        $filetype = wp_check_filetype( $filename, null );
801
802        // Prepare an array of post data for the attachment.
803        $attachment = array(
804            'guid'           => wp_upload_dir()['url'] . '/' . $filename,
805            'post_mime_type' => $filetype['type'],
806            'post_title'     => preg_replace( '/\.[^.]+$/', '', $filename ),
807            'post_content'   => '',
808            'post_status'    => 'inherit',
809        );
810
811        // Insert the attachment.
812        $attach_id = wp_insert_attachment( $attachment, $upload['file'] );
813
814        // Make sure wp_generate_attachment_metadata() has all requirement dependencies.
815        require_once ABSPATH . 'wp-admin/includes/image.php';
816
817        // Generate the metadata for the attachment, and update the database record.
818        $attach_data = wp_generate_attachment_metadata( $attach_id, $upload['file'] );
819        // Store metadata in the local DB.
820        wp_update_attachment_metadata( $attach_id, $attach_data );
821
822        return array( 'url' => $upload['url'] );
823    }
824
825    /**
826     * Redirect GET requests to WordAds DSP Blaze openverse endpoint.
827     *
828     * @param WP_REST_Request $req The request object.
829     * @return array|WP_Error
830     */
831    public function get_dsp_openverse( $req ) {
832        return $this->get_dsp_generic( 'v1/wpcom/media', $req );
833    }
834
835    /**
836     * Redirect GET requests to WordAds DSP Credits endpoint for the site.
837     *
838     * @param WP_REST_Request $req The request object.
839     * @return array|WP_Error
840     */
841    public function get_dsp_credits( $req ) {
842        return $this->get_dsp_generic( 'v1/credits', $req );
843    }
844
845    /**
846     * Redirect GET requests to WordAds DSP Experiments endpoint for the site.
847     *
848     * @param WP_REST_Request $req The request object.
849     * @return array|WP_Error
850     */
851    public function get_dsp_experiments( $req ) {
852        return $this->get_dsp_generic( 'v1/experiments', $req );
853    }
854
855    /**
856     * Redirect GET requests to WordAds DSP Campaigns endpoint for the site.
857     *
858     * @param WP_REST_Request $req The request object.
859     * @return array|WP_Error
860     */
861    public function get_dsp_campaigns( $req ) {
862        $version = $req->get_param( 'api_version' ) ?? 'v1';
863        return $this->get_dsp_generic( "{$version}/campaigns", $req );
864    }
865
866    /**
867     * Redirect GET requests to WordAds DSP Site Campaigns endpoint for the site.
868     *
869     * @param WP_REST_Request $req The request object.
870     * @return array|WP_Error
871     */
872    public function get_dsp_site_campaigns( $req ) {
873        $site_id = $this->get_site_id();
874        if ( is_wp_error( $site_id ) ) {
875            return array();
876        }
877
878        return $this->get_dsp_generic( sprintf( 'v1/sites/%d/campaigns', $site_id ), $req );
879    }
880
881    /**
882     * Redirect GET requests to WordAds DSP Stats endpoint for the site.
883     *
884     * @param WP_REST_Request $req The request object.
885     *
886     * @return array|WP_Error
887     */
888    public function get_dsp_stats( $req ) {
889        $version = $req->get_param( 'api_version' ) ?? 'v1';
890        return $this->get_dsp_generic( "{$version}/stats", $req );
891    }
892
893    /**
894     * Redirect POST requests to WordAds DSP Stats endpoint for the site.
895     *
896     * @param WP_REST_Request $req The request object.
897     *
898     * @return array|WP_Error
899     */
900    public function edit_dsp_stats( $req ) {
901        $version = $req->get_param( 'api_version' ) ?? 'v1';
902        return $this->edit_dsp_generic( "{$version}/stats", $req );
903    }
904
905    /**
906     * Redirect GET requests to WordAds DSP Search endpoint for the site.
907     *
908     * @param WP_REST_Request $req The request object.
909     * @return array|WP_Error
910     */
911    public function get_dsp_search( $req ) {
912        return $this->get_dsp_generic( 'v1/search', $req );
913    }
914
915    /**
916     * Redirect GET requests to WordAds DSP User endpoint for the site.
917     *
918     * @param WP_REST_Request $req The request object.
919     * @return array|WP_Error
920     */
921    public function get_dsp_user( $req ) {
922        return $this->get_dsp_generic( 'v1/user', $req );
923    }
924
925    /**
926     * Redirect GET requests to the WordAds DSP Templates Article endpoint for the site.
927     *
928     * @param WP_REST_Request $req The request object.
929     * @return array|WP_Error
930     */
931    public function get_dsp_templates_article( $req ) {
932        $urn = $req->get_param( 'urn' ) ?? '';
933
934        $sync_ready = $this->are_posts_ready();
935
936        $response = $sync_ready ?
937            $this->get_dsp_generic( 'v1/templates/article/' . $urn, $req ) :
938            $this->get_dsp_templates_article_local( $urn, $req );
939
940        if ( ! is_wp_error( $response ) && is_array( $response ) ) {
941            $response['sync_ready'] = $sync_ready;
942        }
943
944        return $response;
945    }
946
947    /**
948     * Get the article information to be used in the Blaze create campaign flow.
949     *
950     * 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.
951     *
952     * @param string          $urn The request urn.
953     * @param WP_REST_Request $req The request object.
954     * @return array|WP_Error
955     */
956    public function get_dsp_templates_article_local( $urn, $req ) {
957        $parsed_urn = $this->get_data_from_urn( $urn );
958        $site_id    = $this->get_site_id();
959
960        if ( is_wp_error( $site_id ) ) {
961            return array();
962        }
963
964        if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
965            return $this->get_forbidden_error();
966        }
967
968        $post = get_post( $parsed_urn['post_id'] );
969        if ( ! $post ) {
970            return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
971        }
972
973        // Generates the attachments object
974        $post_attachments = get_attached_media( 'image', $post->ID );
975        $attachments      = array();
976
977        foreach ( $post_attachments as $attachment ) {
978            $attachment_url = wp_get_attachment_url( $attachment->ID );
979            $metadata       = wp_get_attachment_metadata( $attachment->ID );
980
981            // Skip attachment if some of the required data is missing
982            if ( ! $attachment_url || ! $metadata || ! isset( $metadata['width'] ) || ! isset( $metadata['height'] ) ) {
983                continue;
984            }
985
986            $attachments[ $attachment->ID ] = array(
987                'ID'        => $attachment->ID,
988                'URL'       => $attachment_url,
989                'mime_type' => $attachment->post_mime_type,
990                'width'     => $metadata['width'],
991                'height'    => $metadata['height'],
992            );
993        }
994
995        $body = array(
996            'widget_origin' => $req->get_param( 'widget_origin' ),
997            'wp_post'       => array(
998                'ID'             => $post->ID,
999                'title'          => $post->post_title,
1000                'excerpt'        => $post->post_excerpt,
1001                'URL'            => get_permalink( $post ),
1002                'type'           => $post->post_type,
1003                'content'        => $post->post_content,
1004                'post_thumbnail' => $this->get_post_featured_image( $post->ID ),
1005                'attachments'    => (object) $attachments,
1006            ),
1007        );
1008
1009        return $this->request_as_user(
1010            sprintf( '/sites/%d/wordads/dsp/api/v1/templates/article/%s', $site_id, $urn ),
1011            'v2',
1012            array( 'method' => 'POST' ),
1013            $body
1014        );
1015    }
1016
1017    /**
1018     * Redirect GET requests to the WordAds DSP Templates Advise Campaign endpoint for the site.
1019     *
1020     * @param WP_REST_Request $req The request object.
1021     * @return array|WP_Error
1022     */
1023    public function get_dsp_templates_advise_campaign( $req ) {
1024        $urn = $req->get_param( 'urn' ) ?? '';
1025
1026        $sync_ready = $this->are_posts_ready();
1027
1028        $response = $sync_ready ?
1029            $this->get_dsp_generic( 'v1/templates/advise/campaign/' . $urn, $req ) :
1030            $this->get_dsp_advise_campaign_local( $urn );
1031
1032        if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1033            $response['sync_ready'] = $sync_ready;
1034        }
1035
1036        return $response;
1037    }
1038
1039    /**
1040     * Get the advise campaign information to be used in the Blaze create campaign flow.
1041     *
1042     * If Jetpack Sync still is running, this endpoint will read local DB data and provide additional information to the WPCOM endpoint.
1043     *
1044     * @param string $urn The request urn.
1045     * @return array|WP_Error
1046     */
1047    public function get_dsp_advise_campaign_local( $urn ) {
1048        $parsed_urn = $this->get_data_from_urn( $urn );
1049        $site_id    = $this->get_site_id();
1050
1051        if ( is_wp_error( $site_id ) ) {
1052            return array();
1053        }
1054
1055        if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
1056            return $this->get_forbidden_error();
1057        }
1058
1059        $post = get_post( $parsed_urn['post_id'] );
1060        if ( ! $post ) {
1061            return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
1062        }
1063
1064        $rendered_content = apply_filters( 'the_content', $post->post_content );
1065
1066        $body = array(
1067            'wp_post' => array(
1068                'ID'      => $post->ID,
1069                'title'   => $post->post_title,
1070                'URL'     => get_permalink( $post ),
1071                'type'    => $post->post_type,
1072                'content' => $rendered_content,
1073            ),
1074        );
1075
1076        return $this->request_as_user(
1077            sprintf( '/sites/%d/wordads/dsp/api/v1/advise/campaign/%s', $site_id, $urn ),
1078            'v2',
1079            array( 'method' => 'POST' ),
1080            $body
1081        );
1082    }
1083
1084    /**
1085     * Redirect GET requests to the WordAds DSP Templates endpoint for the site.
1086     *
1087     * @param WP_REST_Request $req The request object.
1088     * @return array|WP_Error
1089     */
1090    public function get_dsp_templates( $req ) {
1091        return $this->get_dsp_generic( 'v1/templates', $req );
1092    }
1093
1094    /**
1095     * Redirect GET requests to the WordAds DSP Advise Campaign endpoint for the site.
1096     *
1097     * @param WP_REST_Request $req The request object.
1098     * @return array|WP_Error
1099     */
1100    public function get_dsp_advise_campaign( $req ) {
1101        $urn = $req->get_param( 'urn' ) ?? '';
1102
1103        $sync_ready = $this->are_posts_ready();
1104
1105        $response = $sync_ready ?
1106            $this->get_dsp_generic( 'v1/advise/campaign/' . $urn, $req ) :
1107            $this->get_dsp_advise_campaign_local( $urn );
1108
1109        if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1110            $response['sync_ready'] = $sync_ready;
1111        }
1112
1113        return $response;
1114    }
1115
1116    /**
1117     * Redirect GET requests to the WordAds DSP Advise endpoint for the site.
1118     *
1119     * @param WP_REST_Request $req The request object.
1120     * @return array|WP_Error
1121     */
1122    public function get_dsp_advise( $req ) {
1123        return $this->get_dsp_generic( 'v1/advise', $req );
1124    }
1125
1126    /**
1127     * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
1128     *
1129     * @param WP_REST_Request $req The request object.
1130     * @return array|WP_Error
1131     */
1132    public function get_dsp_subscriptions( $req ) {
1133        return $this->get_dsp_generic( 'v1/subscriptions', $req );
1134    }
1135
1136    /**
1137     * Redirect GET requests to WordAds DSP Payments endpoint for the site.
1138     *
1139     * @param WP_REST_Request $req The request object.
1140     * @return array|WP_Error
1141     */
1142    public function get_dsp_payments( $req ) {
1143        $version = $req->get_param( 'api_version' ) ?? 'v1';
1144        return $this->get_dsp_generic( "{$version}/payments", $req );
1145    }
1146
1147    /**
1148     * Redirect GET requests to WordAds DSP Subscriptions endpoint for the site.
1149     *
1150     * @param WP_REST_Request $req The request object.
1151     * @return array|WP_Error
1152     */
1153    public function get_dsp_smart( $req ) {
1154        return $this->get_dsp_generic( 'v1/smart', $req );
1155    }
1156
1157    /**
1158     * Redirect GET requests to WordAds DSP Locations endpoint for the site.
1159     *
1160     * @param WP_REST_Request $req The request object.
1161     * @return array|WP_Error
1162     */
1163    public function get_dsp_locations( $req ) {
1164        return $this->get_dsp_generic( 'v1/locations', $req );
1165    }
1166
1167    /**
1168     * Redirect GET requests to WordAds DSP Woo endpoint for the site.
1169     *
1170     * @param WP_REST_Request $req The request object.
1171     * @return array|WP_Error
1172     */
1173    public function get_dsp_woo( $req ) {
1174        return $this->get_dsp_generic( 'v1/woo', $req );
1175    }
1176
1177    /**
1178     * Redirect GET requests to WordAds DSP Countries endpoint for the site.
1179     *
1180     * @param WP_REST_Request $req The request object.
1181     * @return array|WP_Error
1182     */
1183    public function get_dsp_image( $req ) {
1184        return $this->get_dsp_generic( 'v1/image', $req );
1185    }
1186
1187    /**
1188     * Redirect GET requests to WordAds DSP for the site.
1189     *
1190     * @param String          $path The Root API endpoint.
1191     * @param WP_REST_Request $req The request object.
1192     * @param array           $args Request arguments.
1193     * @return array|WP_Error
1194     */
1195    public function get_dsp_generic( $path, $req, $args = array() ) {
1196        $site_id = $this->get_site_id();
1197        if ( is_wp_error( $site_id ) ) {
1198            return array();
1199        }
1200
1201        return $this->request_as_user(
1202            sprintf( '/sites/%d/wordads/dsp/api/%s%s', $site_id, $path, $this->build_subpath_with_query_strings( $req->get_params() ) ),
1203            'v2',
1204            array_merge(
1205                $args,
1206                array( 'method' => 'GET' )
1207            )
1208        );
1209    }
1210
1211    /**
1212     * Redirect POST/PUT/PATCH requests to WordAds DSP WPCOM Checkout endpoint for the site.
1213     *
1214     * @param WP_REST_Request $req The request object.
1215     * @return array|WP_Error
1216     */
1217    public function edit_wpcom_checkout( $req ) {
1218        return $this->edit_dsp_generic( 'v1/wpcom/checkout', $req, array( 'timeout' => 60 ) );
1219    }
1220
1221    /**
1222     * Redirect POST request to WordAds DSP Create Campaign endpoint for the site.
1223     *
1224     * 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.
1225     *
1226     * @param WP_REST_Request $req The request object.
1227     * @return array|WP_Error
1228     */
1229    public function create_dsp_campaigns( $req ) {
1230        $sync_ready = $this->are_posts_ready();
1231
1232        $response = $sync_ready ?
1233            $this->edit_dsp_generic( 'v1.1/campaigns', $req, array( 'timeout' => 60 ) ) :
1234            $this->create_dsp_campaigns_local( $req );
1235
1236        if ( ! is_wp_error( $response ) && is_array( $response ) ) {
1237            $response['sync_ready'] = $sync_ready;
1238        }
1239
1240        return $response;
1241    }
1242
1243    /**
1244     * Sends a create campaign request to the WordAds DSP Create Campaign endpoint.
1245     * Includes additional Post information to the original request.
1246     *
1247     * @param WP_REST_Request $req The request object.
1248     * @return array|WP_Error
1249     */
1250    public function create_dsp_campaigns_local( $req ) {
1251        $site_id = $this->get_site_id();
1252        if ( is_wp_error( $site_id ) ) {
1253            return array();
1254        }
1255
1256        $request_body = $req->get_json_params();
1257        if ( ! is_array( $request_body ) ) {
1258            return new WP_Error( 'invalid_json', esc_html__( 'Invalid JSON Body', 'jetpack-blaze' ), array( 'status' => 400 ) );
1259        }
1260
1261        if ( ! isset( $request_body['target_urn'] ) ) {
1262            return new WP_Error( 'missing_target_urn', esc_html__( 'Missing target_urn in request body', 'jetpack-blaze' ), array( 'status' => 400 ) );
1263        }
1264
1265        $urn        = $request_body['target_urn'];
1266        $parsed_urn = $this->get_data_from_urn( $urn );
1267
1268        if ( ! $parsed_urn['site_id'] || $parsed_urn['site_id'] !== $site_id ) {
1269            return $this->get_forbidden_error();
1270        }
1271
1272        $post = get_post( $parsed_urn['post_id'] );
1273        if ( ! $post ) {
1274            return new WP_Error( 'post_not_found', esc_html__( 'Post not found', 'jetpack-blaze' ), array( 'status' => 404 ) );
1275        }
1276
1277        $featured_image = $this->get_post_featured_image( $post->ID );
1278
1279        $body = array_merge(
1280            $request_body,
1281            array(
1282                'wp_post' => array(
1283                    'ID'             => $post->ID,
1284                    'title'          => $post->post_title,
1285                    'URL'            => get_permalink( $post ),
1286                    'type'           => $post->post_type,
1287                    'content'        => $post->post_content,
1288                    'featured_image' => $featured_image['URL'] ?? '',
1289                    'modified'       => $post->post_modified,
1290                ),
1291            )
1292        );
1293
1294        return $this->request_as_user(
1295            sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ),
1296            'v2',
1297            array(
1298                'method'  => 'POST',
1299                'timeout' => 60,
1300            ),
1301            $body
1302        );
1303    }
1304
1305    /**
1306     * Redirect POST/PUT/PATCH requests to WordAds DSP Campaigns endpoint for the site.
1307     *
1308     * @param WP_REST_Request $req The request object.
1309     * @return array|WP_Error
1310     */
1311    public function edit_dsp_campaigns( $req ) {
1312        $version = $req->get_param( 'api_version' ) ?? 'v1';
1313        return $this->edit_dsp_generic( "{$version}/campaigns", $req, array( 'timeout' => 60 ) );
1314    }
1315
1316    /**
1317     * Redirect POST/PUT/PATCH requests to WordAds DSP Subscriptions endpoint for the site.
1318     *
1319     * @param WP_REST_Request $req The request object.
1320     * @return array|WP_Error
1321     */
1322    public function edit_dsp_subscriptions( $req ) {
1323        return $this->edit_dsp_generic( 'v1/subscriptions', $req, array( 'timeout' => 20 ) );
1324    }
1325
1326    /**
1327     * Redirect POST/PUT/PATCH requests to WordAds DSP Payments endpoint for the site.
1328     *
1329     * @param WP_REST_Request $req The request object.
1330     * @return array|WP_Error
1331     */
1332    public function edit_dsp_payments( $req ) {
1333        $version = $req->get_param( 'api_version' ) ?? 'v1';
1334        return $this->edit_dsp_generic( "{$version}/payments", $req, array( 'timeout' => 20 ) );
1335    }
1336
1337    /**
1338     * Redirect POST/PUT/PATCH requests to WordAds DSP Logs endpoint for the site.
1339     *
1340     * @param WP_REST_Request $req The request object.
1341     * @return array|WP_Error
1342     */
1343    public function edit_dsp_logs( $req ) {
1344        return $this->edit_dsp_generic( 'v1/logs', $req );
1345    }
1346
1347    /**
1348     * Redirect POST/PUT/PATCH requests to WordAds DSP Smart endpoint for the site.
1349     *
1350     * @param WP_REST_Request $req The request object.
1351     * @return array|WP_Error
1352     */
1353    public function edit_dsp_smart( $req ) {
1354        return $this->edit_dsp_generic( 'v1/smart', $req );
1355    }
1356
1357    /**
1358     * Redirect POST/PUT/PATCH requests to WordAds DSP for the site.
1359     *
1360     * @param String          $path The Root API endpoint.
1361     * @param WP_REST_Request $req The request object.
1362     * @param array           $args Request arguments.
1363     * @return array|WP_Error
1364     */
1365    public function edit_dsp_generic( $path, $req, $args = array() ) {
1366        $site_id = $this->get_site_id();
1367        if ( is_wp_error( $site_id ) ) {
1368            return array();
1369        }
1370
1371        return $this->request_as_user(
1372            sprintf( '/sites/%d/wordads/dsp/api/%s%s', $site_id, $path, $req->get_param( 'sub_path' ) ),
1373            'v2',
1374            array_merge(
1375                $args,
1376                array( 'method' => $req->get_method() )
1377            ),
1378            $req->get_body()
1379        );
1380    }
1381
1382    /**
1383     * Will check the posts for prices and add them to the posts array
1384     *
1385     * @param array $posts The posts object.
1386     * @return array The list posts with the price on them (if they are woo products).
1387     */
1388    protected function add_prices_in_posts( $posts ) {
1389
1390        if ( ! function_exists( 'wc_get_product' ) ||
1391            ! function_exists( 'wc_get_price_decimal_separator' ) ||
1392            ! function_exists( 'wc_get_price_thousand_separator' ) ||
1393            ! function_exists( 'wc_get_price_decimals' ) ||
1394            ! function_exists( 'get_woocommerce_price_format' ) ||
1395            ! function_exists( 'get_woocommerce_currency_symbol' )
1396        ) {
1397            return $posts;
1398        }
1399
1400        foreach ( $posts as $key => $item ) {
1401            if ( ! isset( $item['ID'] ) ) {
1402                $posts[ $key ]['price'] = '';
1403                continue;
1404            }
1405            $product = wc_get_product( $item['ID'] );
1406            if ( ! $product || ! $product instanceof WC_Product ) {
1407                $posts[ $key ]['price'] = '';
1408            } else {
1409                $price              = $product->get_price();
1410                $decimal_separator  = wc_get_price_decimal_separator();
1411                $thousand_separator = wc_get_price_thousand_separator();
1412                $decimals           = wc_get_price_decimals();
1413                $price_format       = get_woocommerce_price_format();
1414                $currency_symbol    = get_woocommerce_currency_symbol();
1415
1416                // Convert to float to avoid issues on PHP 8.
1417                $price           = (float) $price;
1418                $negative        = $price < 0;
1419                $price           = $negative ? $price * -1 : $price;
1420                $price           = number_format( $price, $decimals, $decimal_separator, $thousand_separator );
1421                $formatted_price = sprintf( $price_format, $currency_symbol, $price );
1422
1423                $posts[ $key ]['price'] = html_entity_decode( $formatted_price, ENT_COMPAT );
1424            }
1425        }
1426        return $posts;
1427    }
1428
1429    /**
1430     * Queries the WordPress.com REST API with a user token.
1431     *
1432     * @param String            $path The API endpoint relative path.
1433     * @param String            $version The API version.
1434     * @param array             $args Request arguments.
1435     * @param null|String|array $body Request body.
1436     * @param String            $base_api_path (optional) the API base path override, defaults to 'rest'.
1437     * @param bool              $use_cache (optional) default to true.
1438     * @return array|string|WP_Error|\WP_REST_Response $response Data.
1439     */
1440    protected function request_as_user( $path, $version = '2', $args = array(), $body = null, $base_api_path = 'wpcom', $use_cache = false ) {
1441        // Arrays are serialized without considering the order of objects, but it's okay atm.
1442        $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 ) ) );
1443
1444        if ( $use_cache ) {
1445            $response_body_content = get_transient( $cache_key );
1446            if ( false !== $response_body_content ) {
1447                return json_decode( $response_body_content, true );
1448            }
1449        }
1450
1451        $response = Client::wpcom_json_api_request_as_user(
1452            $path,
1453            $version,
1454            $args,
1455            $body,
1456            $base_api_path
1457        );
1458
1459        if ( is_wp_error( $response ) ) {
1460            return $response;
1461        }
1462
1463        $response_code         = wp_remote_retrieve_response_code( $response );
1464        $response_body_content = wp_remote_retrieve_body( $response );
1465        $content_type          = $response['headers']['content-type'] ?? '';
1466
1467        if ( str_starts_with( $content_type, 'text/csv' ) ) {
1468            return $response_body_content;
1469        }
1470
1471        $response_body = json_decode( $response_body_content, true );
1472
1473        if ( 200 !== $response_code ) {
1474            return $this->get_blaze_error( $response_body, $response_code );
1475        }
1476
1477        // Cache the successful JSON response for 5 minutes.
1478        set_transient( $cache_key, $response_body_content, 5 * MINUTE_IN_SECONDS );
1479        return $response_body;
1480    }
1481
1482    /**
1483     * Return a WP_Error object with a forbidden error.
1484     */
1485    protected function get_forbidden_error() {
1486        $error_msg = esc_html__(
1487            'You are not allowed to perform this action.',
1488            'jetpack-blaze'
1489        );
1490
1491        return new WP_Error( 'rest_forbidden', $error_msg, array( 'status' => rest_authorization_required_code() ) );
1492    }
1493
1494    /**
1495     * Build error object from remote response body and status code.
1496     *
1497     * @param array $response_body Remote response body.
1498     * @param int   $response_code Http response code.
1499     * @return \WP_REST_Response
1500     */
1501    protected function get_blaze_error( $response_body, $response_code = 500 ) {
1502        if ( ! is_array( $response_body ) ) {
1503            $response_body = array(
1504                'errorMessage' => $response_body,
1505            );
1506        }
1507
1508        $error_code = 'remote-error';
1509        foreach ( array( 'code', 'error' ) as $error_code_key ) {
1510            if ( isset( $response_body[ $error_code_key ] ) ) {
1511                $error_code = $response_body[ $error_code_key ];
1512                break;
1513            }
1514        }
1515
1516        $response_body['code']         = $error_code;
1517        $response_body['status']       = $response_code;
1518        $response_body['errorMessage'] = $response_body['errorMessage'] ?? 'Unknown remote error';
1519
1520        return new \WP_REST_Response( $response_body, $response_code );
1521    }
1522
1523    /**
1524     * Check if the current user is connected.
1525     * On WordPress.com Simple, it is always connected.
1526     *
1527     * @return true
1528     */
1529    private function is_user_connected() {
1530        if ( ( new Host() )->is_wpcom_simple() ) {
1531            return true;
1532        }
1533
1534        return $this->connection->is_connected() && $this->connection->is_user_connected();
1535    }
1536
1537    /**
1538     * Get the site ID.
1539     *
1540     * @return int|WP_Error
1541     */
1542    private function get_site_id() {
1543        return Connection_Manager::get_site_id();
1544    }
1545
1546    /**
1547     * Check if the Health status code is sync.
1548     *
1549     * @return bool True if is sync, false otherwise.
1550     */
1551    private function are_posts_ready(): bool {
1552        // On WordPress.com Simple, Sync is not present, so we consider always ready.
1553        if ( ( new Host() )->is_wpcom_simple() ) {
1554            return true;
1555        }
1556
1557        return Health::STATUS_IN_SYNC === Health::get_status();
1558    }
1559
1560    /**
1561     * Get the featured image data for a post.
1562     *
1563     * @param int $post_id The post ID.
1564     *
1565     * @return null|array {
1566     *     Featured image data, or null if no featured image exists.
1567     *
1568     *     @type int    $ID        The attachment ID.
1569     *     @type string $URL       The image URL.
1570     *     @type int    $width     The image width in pixels.
1571     *     @type int    $height    The image height in pixels.
1572     *     @type string $mime_type The image mime type (e.g., 'image/jpeg').
1573     * }
1574     */
1575    private function get_post_featured_image( $post_id ) {
1576        $thumbnail_id = get_post_thumbnail_id( $post_id );
1577        if ( ! $thumbnail_id ) {
1578            return null;
1579        }
1580
1581        $image_src = wp_get_attachment_image_src( $thumbnail_id, 'full' );
1582        if ( ! $image_src ) {
1583            return null;
1584        }
1585
1586        return array(
1587            'ID'        => $thumbnail_id,
1588            'URL'       => $image_src[0],
1589            'width'     => $image_src[1],
1590            'height'    => $image_src[2],
1591            'mime_type' => get_post_mime_type( $thumbnail_id ),
1592        );
1593    }
1594
1595    /**
1596     * Extract site ID and post ID from a WordPress.com URN.
1597     *
1598     * Parses a URN in the format "urn:wpcom:post:SITE_ID:POST_ID" and returns
1599     * an associative array containing the site ID and post ID components.
1600     *
1601     * @param string $urn The URN string to parse (e.g., "urn:wpcom:post:12345:67890").
1602     * @return array {
1603     *     Associative array containing the parsed URN components.
1604     *
1605     *     @type int $site_id The WordPress.com site ID.
1606     *     @type int $post_id The post ID.
1607     * }
1608     */
1609    private function get_data_from_urn( $urn ) {
1610        $default = array(
1611            'site_id' => 0,
1612            'post_id' => 0,
1613        );
1614
1615        if ( empty( $urn ) ) {
1616            return $default;
1617        }
1618
1619        $urn_parts = explode( ':', $urn );
1620
1621        if ( count( $urn_parts ) < 5 ) {
1622            return $default;
1623        }
1624
1625        $site_id = (int) $urn_parts[3];
1626        $post_id = (int) $urn_parts[4];
1627
1628        return array(
1629            'site_id' => $site_id,
1630            'post_id' => $post_id,
1631        );
1632    }
1633}