connection = $connection ?? new Connection_Manager(); } /** * Registers the REST routes on the `rest_api_init` hook. * * Instantiated here, rather than eagerly, so the controller class only loads * on requests that reach `rest_api_init`. Static so the callback can be * unregistered. * * @access public */ public static function register() { ( new self() )->register_rest_routes(); } /** * Registers the REST routes for Blaze Dashboard. * * Blaze Dashboard is built from `wp-calypso`, which leverages the `public-api.wordpress.com` API. * The current Site ID is added as part of the route, so that the front end doesn't have to handle the differences. * * @access public * @static */ public function register_rest_routes() { $site_id = $this->get_site_id(); if ( is_wp_error( $site_id ) ) { return; } // WPCOM API routes register_rest_route( static::$namespace, sprintf( '/sites/%d/blaze/posts(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_blaze_posts' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Posts routes register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/blaze/posts(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_blaze_posts' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Checkout route register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/checkout', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_wpcom_checkout' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Credits routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/credits(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_credits' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API media query routes register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_media' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API upload to WP Media Library routes register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/sites/%1$d/media', $site_id ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'upload_image_to_current_website' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API media openverse query routes register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/wpcom/media(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_openverse' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Experiment route register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/experiments(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_experiments' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Campaigns routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/campaigns(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_campaigns' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1.1/campaigns', $site_id ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_dsp_campaigns' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/campaigns(?P[a-zA-Z0-9-_\/]*)', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_campaigns' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Site Campaigns routes register_rest_route( static::$namespace, sprintf( '/sites/%1$d/wordads/dsp/api/v1/sites/%1$d/campaigns(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_site_campaigns' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Site Stats routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/stats(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_stats' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/stats(?P[a-zA-Z0-9-_\/]*)', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_stats' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Search routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/search(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_search' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Users routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/user(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_user' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Templates routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/templates/article/(?P[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_templates_article' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/templates/advise/campaign/(?P[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_templates_advise_campaign' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/templates(?P[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_templates' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Advise routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/advise/campaign/(?P[a-zA-Z0-9-_:]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_advise_campaign' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/advise(?P[a-zA-Z0-9-_\/:]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_advise' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Subscriptions routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_subscriptions' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/subscriptions(?P[a-zA-Z0-9-_\/]*)', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_subscriptions' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Payments routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/payments(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_payments' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/(?Pv[0-9]+\.?[0-9]*)/payments(?P[a-zA-Z0-9-_\/]*)', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_payments' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Smart routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_smart' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/smart(?P[a-zA-Z0-9-_\/]*)', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_smart' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Locations routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/locations(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_locations' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Woo routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/woo(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_woo' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Image routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/image(?P[a-zA-Z0-9-_\/]*)(\?.*)?', $site_id ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_dsp_image' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); // WordAds DSP API Logs routes register_rest_route( static::$namespace, sprintf( '/sites/%d/wordads/dsp/api/v1/logs', $site_id ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'edit_dsp_logs' ), 'permission_callback' => array( $this, 'can_user_view_dsp_callback' ), ) ); } /** * Only administrators can access the API. * * @return bool|WP_Error True if a blog token was used to sign the request, WP_Error otherwise. */ public function can_user_view_dsp_callback() { if ( $this->is_user_connected() && current_user_can( 'manage_options' ) ) { return true; } return $this->get_forbidden_error(); } /** * Get a list of posts that are eligible for Blaze campaigns. * * Routes to WPCOM API or local database based on Jetpack Sync status: * - If sync is ready: Uses WPCOM API (has stats data like like_count, monthly_view_count). * - If sync is not ready: Uses local database query (stats show as -1, stats-based * sorting falls back to date). * * @param WP_REST_Request $req The request object. * @return array|WP_Error */ public function get_blaze_posts( $req ) { $site_id = $this->get_site_id(); if ( is_wp_error( $site_id ) ) { return array(); } $sync_ready = $this->are_posts_ready(); if ( $sync_ready ) { $response = $this->get_blaze_posts_from_wpcom( $req, $site_id ); } else { $response = $this->get_blaze_posts_local( $req ); } if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) { return $response; } if ( is_array( $response ) ) { $response['sync_ready'] = $sync_ready; } return $response; } /** * Get Blaze posts from the WPCOM API. * * Used when Jetpack Sync is ready and posts are available on WPCOM. * Provides full functionality including stats data (like_count, monthly_view_count) * and stats-based sorting. * * @param WP_REST_Request $req The request object. * @param int $site_id The site ID. * @return array|WP_Error */ private function get_blaze_posts_from_wpcom( $req, $site_id ) { // We don't use sub_path in the blaze posts, only query strings. if ( isset( $req['sub_path'] ) ) { unset( $req['sub_path'] ); } $response = $this->request_as_user( sprintf( '/sites/%d/blaze/posts%s', $site_id, $this->build_subpath_with_query_strings( $req->get_params() ) ), 'v2', array( 'method' => 'GET' ) ); // Bail if we get an error (WP_ERROR or an already formatted WP_REST_Response error). if ( is_wp_error( $response ) || $response instanceof \WP_REST_Response ) { return $response; } if ( isset( $response['posts'] ) && count( $response['posts'] ) > 0 ) { $response['posts'] = $this->add_prices_in_posts( $response['posts'] ); } return $response; } /** * Get Blaze posts from the local WordPress database. * * Used as fallback when Jetpack Sync is not ready. Stats fields (like_count, * monthly_view_count) are returned as -1 since they are only available on WPCOM. * If user requests sorting by stats fields, falls back to sorting by date. * * @param WP_REST_Request $req The request object. * @return array */ private function get_blaze_posts_local( $req ) { // Default and maximum posts per page for this function. $default_posts_per_page = 20; // Parse request parameters. $page = absint( $req->get_param( 'page' ) ?? 1 ); $posts_per_page = absint( $req->get_param( 'posts_per_page' ) ?? $default_posts_per_page ); $order = $req->get_param( 'order' ) ?? 'DESC'; $order_by = $req->get_param( 'order_by' ) ?? 'date'; $post_types = $req->get_param( 'filter_post_type' ) ?? implode( ',', $this->get_blazable_post_types() ); $title = strtolower( sanitize_text_field( $req->get_param( 'title' ) ?? '' ) ); // Sanitize and validate post types. $post_type_list = $this->sanitize_post_type( $post_types ); // Validate page parameter. if ( $page < 1 ) { $page = 1; } // Validate post per page parameter (use default value if invalid) if ( $posts_per_page <= 0 || $posts_per_page > $default_posts_per_page ) { $posts_per_page = $default_posts_per_page; } // Validate order. $order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $order ) : 'DESC'; // Validate order_by - stats-related fields fall back to date (handled by WPCOM). $valid_order_by = array( 'post_title', 'type', 'date', 'modified', 'comment_count' ); if ( ! in_array( $order_by, $valid_order_by, true ) ) { $order_by = 'date'; } $args = array( 'post_type' => $post_type_list, 'post_status' => 'publish', 'post_password' => '', 'posts_per_page' => $posts_per_page, 'paged' => $page, 'ignore_sticky_posts' => 1, 'orderby' => $order_by, 'order' => $order, ); // Add title search filter if provided. $title_filter = null; if ( ! empty( $title ) ) { $title_filter = function ( $where ) use ( $title ) { global $wpdb; $title_like = '%' . $wpdb->esc_like( $title ) . '%'; $where .= $wpdb->prepare( " AND {$wpdb->posts}.post_title LIKE %s", $title_like ); return $where; }; add_filter( 'posts_where', $title_filter ); } $query = new \WP_Query( $args ); $posts = $query->get_posts(); $total_pages = $query->max_num_pages; // Remove the title filter after query. if ( $title_filter !== null ) { remove_filter( 'posts_where', $title_filter ); } // Format posts for the response. $formatted_posts = array(); if ( $page <= $total_pages ) { foreach ( $posts as $post ) { $formatted_posts[] = $this->format_post_for_blaze( $post ); } } // Add prices for WooCommerce products. if ( count( $formatted_posts ) > 0 ) { $formatted_posts = $this->add_prices_in_posts( $formatted_posts ); } return array( 'posts' => $formatted_posts, 'total_items' => $query->found_posts, 'post_title' => $title, 'page' => $page, 'total_pages' => $total_pages, 'stats_enabled' => $this->is_jetpack_module_active( 'stats' ), 'likes_enabled' => $this->is_jetpack_module_active( 'likes' ), 'tsp_eligible' => $this->count_tsp_eligible_posts(), ); } /** * Format a post object for the Blaze API response. * * @param \WP_Post $post The post object. * @return array Formatted post data. */ protected function format_post_for_blaze( $post ) { $featured_image_data = $this->get_post_featured_image( $post->ID ); $featured_image = $featured_image_data['URL'] ?? null; // Get SKU for WooCommerce products. $sku = get_post_meta( $post->ID, '_sku', true ); return array( 'ID' => $post->ID, 'title' => $post->post_title, 'type' => $post->post_type, 'date' => gmdate( 'c', strtotime( $post->post_date_gmt ) ), 'modified' => gmdate( 'c', strtotime( $post->post_modified_gmt ) ), 'comment_count' => (int) $post->comment_count, 'like_count' => -1, // Stats not available locally. 'featured_image' => $featured_image, 'author' => $post->post_author, 'sku' => $sku, 'post_url' => get_permalink( $post->ID ), 'monthly_view_count' => -1, // Stats not available locally. ); } /** * Get the post types that are eligible for Blaze campaigns. * * @return array List of post type slugs. */ private function get_blazable_post_types() { return array( 'post', 'page', 'product' ); } /** * Sanitize and validate post types for Blaze. * * @param string $post_types Comma-separated list of post types. * @return array Valid post types, or all blazable types if none valid. */ private function sanitize_post_type( $post_types ) { $blazable_post_types = $this->get_blazable_post_types(); if ( ! is_string( $post_types ) ) { return $blazable_post_types; } $post_types = sanitize_text_field( $post_types ); $post_type_list = explode( ',', $post_types ); $allowed_types = array(); foreach ( $post_type_list as $post_type ) { if ( in_array( $post_type, $blazable_post_types, true ) ) { $allowed_types[] = $post_type; } } return count( $allowed_types ) ? $allowed_types : $blazable_post_types; } /** * Check if a Jetpack module is active. * Uses jetpack-status Modules class which handles WPCOM and self-hosted sites. * * @param string $module_name The module name (e.g., 'stats', 'likes'). * @return bool Whether the module is active. */ private function is_jetpack_module_active( $module_name ) { // Default to true if Modules class is unavailable (matches WPCOM behavior). if ( ! class_exists( '\Automattic\Jetpack\Modules' ) ) { return true; } $modules = new \Automattic\Jetpack\Modules(); return $modules->is_active( $module_name ); } /** * Count posts eligible for TSP (has Gutenberg blocks). * Matches WPCOM's count_tsp_eligible_posts implementation. * * @return bool Whether there are TSP eligible posts. */ private function count_tsp_eligible_posts() { $query = array( 'posts_per_page' => 1, 'order' => 'DESC', 'orderby' => 'date', 'post_type' => 'post', 'post_status' => array( 'publish' ), 's' => '