Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
25.97% covered (danger)
25.97%
20 / 77
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI
27.03% covered (danger)
27.03%
20 / 74
20.00% covered (danger)
20.00%
1 / 5
143.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_register_routes
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 register_routes
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 permission_callback
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 suggest_guidelines
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * REST API proxy endpoint for AI-powered content guidelines suggestions.
4 *
5 * Proxies requests to the wpcom endpoint that generates guidelines
6 * using site content analysis.
7 *
8 * @package automattic/jetpack
9 */
10
11use Automattic\Jetpack\Connection\Client;
12use Automattic\Jetpack\Connection\Manager;
13use Automattic\Jetpack\Status\Host;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * Class WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI
21 */
22class WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI extends WP_REST_Controller {
23    /**
24     * Namespace prefix.
25     *
26     * @var string
27     */
28    public $namespace = 'wpcom/v2';
29
30    /**
31     * Endpoint base route.
32     *
33     * @var string
34     */
35    public $rest_base = 'jetpack-ai/suggest-guidelines';
36
37    /**
38     * Constructor.
39     */
40    public function __construct() {
41        $this->is_wpcom                     = true;
42        $this->wpcom_is_wpcom_only_endpoint = true;
43
44        add_action( 'rest_api_init', array( $this, 'maybe_register_routes' ) );
45    }
46
47    /**
48     * Register routes on `rest_api_init`, gating on the AI feature state.
49     *
50     * The Jetpack_AI_Helper check (which loads the helper and instantiates
51     * Status/Host classes) runs here rather than in the constructor so that code
52     * is only loaded when the REST API is actually in use, not on every
53     * front-end, cron, or login request.
54     */
55    public function maybe_register_routes() {
56        if ( ! class_exists( 'Jetpack_AI_Helper' ) ) {
57            require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php';
58        }
59
60        // Intentionally gated to Simple, Atomic, and WordPress VIP sites only.
61        // Broader self-hosted Jetpack support is deferred — we want to roll out
62        // on these platforms first before opening to all connected sites.
63        if ( ! \Jetpack_AI_Helper::is_enabled_for_content_guidelines() ) {
64            return;
65        }
66
67        $this->register_routes();
68    }
69
70    /**
71     * Register routes.
72     */
73    public function register_routes() {
74        register_rest_route(
75            $this->namespace,
76            '/' . $this->rest_base,
77            array(
78                'methods'             => WP_REST_Server::CREATABLE,
79                'callback'            => array( $this, 'suggest_guidelines' ),
80                'permission_callback' => array( $this, 'permission_callback' ),
81                'args'                => array(
82                    'categories' => array(
83                        'description' => __( 'Categories to generate guidelines for.', 'jetpack' ),
84                        'type'        => 'object',
85                        'required'    => true,
86                    ),
87                ),
88            )
89        );
90    }
91
92    /**
93     * Permission check — require manage_options, plus a connected user account
94     * on sites that proxy the request to WordPress.com over the connection.
95     *
96     * @return bool|WP_Error
97     */
98    public function permission_callback() {
99        if ( ! current_user_can( 'manage_options' ) ) {
100            return false;
101        }
102
103        // suggest_guidelines() signs with the user token off Simple, so without a
104        // user connection there is no identity to present and WordPress.com rejects
105        // the request on private sites.
106        if ( ! ( new Host() )->is_wpcom_simple() && ! ( new Manager() )->is_user_connected() ) {
107            return new WP_Error(
108                'rest_cannot_suggest_guidelines',
109                __( 'Please connect your user account to WordPress.com', 'jetpack' ),
110                array( 'status' => rest_authorization_required_code() )
111            );
112        }
113
114        return true;
115    }
116
117    /**
118     * Proxy the suggest-guidelines request to wpcom.
119     *
120     * @param WP_REST_Request $request The request object.
121     * @return mixed|WP_Error
122     */
123    public function suggest_guidelines( $request ) {
124        $blog_id = \Jetpack_Options::get_option( 'id' );
125
126        $body = array(
127            'categories' => $request->get_param( 'categories' ),
128        );
129
130        $path = sprintf( '/sites/%d/jetpack-ai/suggest-guidelines', $blog_id ) . '?force=wpcom';
131        $args = array(
132            'method'  => 'POST',
133            'headers' => array( 'content-type' => 'application/json' ),
134            'timeout' => 90,
135        );
136
137        // On a private site WordPress.com resolves access from the requesting user,
138        // and a blog token carries no user_id — so the request is rejected before
139        // the endpoint runs, and again on the internal AI proxy dispatch. Signing
140        // as the user clears both, because the identity holds for the whole request.
141        // permission_callback() has already established that a user token exists.
142        //
143        // Simple sites keep the blog-token call: it short-circuits to an in-process
144        // WPCOM_API_Direct dispatch that never leaves the request, so the logged-in
145        // user is already present and there is no connection to authenticate against.
146        if ( ( new Host() )->is_wpcom_simple() ) {
147            $response = Client::wpcom_json_api_request_as_blog(
148                $path,
149                '2',
150                $args,
151                wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
152                'wpcom'
153            );
154        } else {
155            $response = Client::wpcom_json_api_request_as_user(
156                $path,
157                '2',
158                $args,
159                wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
160                'wpcom'
161            );
162        }
163
164        if ( is_wp_error( $response ) ) {
165            return $response;
166        }
167
168        $status_code = wp_remote_retrieve_response_code( $response );
169        $body_str    = wp_remote_retrieve_body( $response );
170        $data        = json_decode( $body_str, true );
171
172        if ( $status_code !== 200 ) {
173            $message = is_array( $data ) && isset( $data['message'] ) ? $data['message'] : __( 'Failed to generate guidelines.', 'jetpack' );
174            $code    = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'upstream_error';
175            return new WP_Error( $code, $message, array( 'status' => $status_code ) );
176        }
177
178        if ( JSON_ERROR_NONE !== json_last_error() ) {
179            return new WP_Error(
180                'invalid_response',
181                __( 'The guidelines service returned a malformed response.', 'jetpack' ),
182                array( 'status' => 502 )
183            );
184        }
185
186        return $data;
187    }
188}
189
190wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Agent_Guidelines_AI' );