Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.91% covered (warning)
89.91%
695 / 773
57.89% covered (warning)
57.89%
33 / 57
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_REST_Controller
89.91% covered (warning)
89.91%
695 / 773
57.89% covered (warning)
57.89%
33 / 57
183.65
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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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 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'] = $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}