PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / _inc / lib / core-api / wpcom-endpoints / class-wpcom-rest-api-v2-endpoint-ai-feature-settings.php

class-wpcom-rest-api-v2-endpoint-ai-feature-settings.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-ai-feature-settings.php

343 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API endpoint for the Jetpack AI feature settings page.
4 *
5 * GET — returns the AI gate state (host support, connection, plan) together
6 * with the master switch and per-feature toggle values, in one round
7 * trip, so the settings page can render every state without extra
8 * requests.
9 * POST — accepts a partial update ({ master_enabled, features }) and writes
10 * the site-local options backing the toggles. Returns the fresh GET
11 * shape.
12 *
13 * Toggles use site-local options; SEO settings access uses the cached WordPress.com site record.
14 * WordPress.com Simple keeps its existing settings endpoint.
15 *
16 * @package automattic/jetpack
17 */
18
19 use Automattic\Jetpack\Connection\Manager;
20 use Automattic\Jetpack\Current_Plan;
21 use Automattic\Jetpack\Search\Plan as Search_Plan;
22 use Automattic\Jetpack\SEO\Ai_Seo;
23 use Automattic\Jetpack\Status\Host;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit( 0 );
27 }
28
29 // On WordPress.com the endpoint files load from the synced jetpack-endpoints
30 // directory, outside the plugin tree, so pull the settings class in via the
31 // plugin dir constant (same pattern as the jetpack-ai endpoint's AI helper).
32 require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-settings.php';
33
34 /**
35 * Class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
36 */
37 class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings extends WP_REST_Controller {
38 /**
39 * Namespace prefix.
40 *
41 * @var string
42 */
43 public $namespace = 'wpcom/v2';
44
45 /**
46 * Endpoint base route.
47 *
48 * @var string
49 */
50 public $rest_base = 'jetpack-ai/feature-settings';
51
52 /**
53 * Constructor.
54 */
55 public function __construct() {
56 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
57 }
58
59 /**
60 * Register routes.
61 *
62 * Not on WordPress.com Simple: the per-feature toggles and their write
63 * endpoint apply to Atomic and self-hosted sites only, while Simple keeps
64 * the existing wp.com settings contract.
65 */
66 public function register_routes() {
67 if ( ( new Host() )->is_wpcom_simple() ) {
68 return;
69 }
70
71 register_rest_route(
72 $this->namespace,
73 '/' . $this->rest_base,
74 array(
75 array(
76 'methods' => WP_REST_Server::READABLE,
77 'callback' => array( $this, 'get_settings' ),
78 'permission_callback' => array( $this, 'permissions_check' ),
79 ),
80 array(
81 'methods' => WP_REST_Server::EDITABLE,
82 'callback' => array( $this, 'update_settings' ),
83 'permission_callback' => array( $this, 'permissions_check' ),
84 'args' => array(
85 'master_enabled' => array(
86 'type' => 'boolean',
87 'required' => false,
88 ),
89 'features' => array(
90 'type' => 'object',
91 'required' => false,
92 ),
93 ),
94 ),
95 )
96 );
97 }
98
99 /**
100 * Check permissions.
101 *
102 * @return bool|WP_Error
103 */
104 public function permissions_check() {
105 if ( ! current_user_can( 'manage_options' ) ) {
106 return new WP_Error(
107 'rest_forbidden',
108 __( 'You do not have permission to manage Jetpack AI settings.', 'jetpack' ),
109 array( 'status' => rest_authorization_required_code() )
110 );
111 }
112
113 return true;
114 }
115
116 /**
117 * GET handler.
118 *
119 * @return WP_REST_Response
120 */
121 public function get_settings() {
122 return rest_ensure_response( $this->build_settings_response() );
123 }
124
125 /**
126 * POST handler. Accepts a partial payload and writes only the keys present.
127 *
128 * @param WP_REST_Request $request The request.
129 * @return WP_REST_Response|WP_Error
130 */
131 public function update_settings( $request ) {
132 // The host gate is a server-owner decision: while it is off there is
133 // nothing to configure, so refuse writes outright.
134 if ( ! Jetpack_AI_Settings::host_allows_ai() ) {
135 return new WP_Error(
136 'ai_disabled_by_host',
137 __( 'Jetpack AI is not available for this site.', 'jetpack' ),
138 array( 'status' => 403 )
139 );
140 }
141
142 $features = $request->get_param( 'features' );
143
144 // AI Answers requires a paid Search plan. Checked up front, before any
145 // option changes, so a payload combining `ai_search` with other
146 // features doesn't partially apply.
147 if ( is_array( $features ) ) {
148 $ai_search_value = self::extract_feature_value( $features, 'ai_search' );
149 if ( $ai_search_value && $this->ai_search_requires_upgrade() ) {
150 return new WP_Error(
151 'ai_search_requires_upgrade',
152 __( 'AI-generated search answers require a paid Jetpack Search plan.', 'jetpack' ),
153 array( 'status' => 403 )
154 );
155 }
156 }
157
158 if ( $request->has_param( 'master_enabled' ) ) {
159 // Routes through the setter so the write lands on whichever store backs
160 // the master on this platform: the option on Simple, the `ai` module
161 // off-Simple.
162 Jetpack_AI_Settings::set_master_enabled( (bool) $request->get_param( 'master_enabled' ) );
163 }
164
165 if ( is_array( $features ) ) {
166 foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
167 $value = self::extract_feature_value( $features, $key );
168 if ( null === $value ) {
169 continue;
170 }
171
172 update_option( $option, $value );
173 }
174 }
175
176 return rest_ensure_response( $this->build_settings_response() );
177 }
178
179 /**
180 * Pull one feature's value out of the `features` request param, sanitized
181 * to a bool. A feature value may be a bare boolean or an object carrying
182 * an `enabled` key. Returns null only when the key (or `enabled` sub-key)
183 * is absent — a present-but-null value still sanitizes to false, it
184 * isn't treated as absent.
185 *
186 * @param array $features The `features` request param.
187 * @param string $key Feature key.
188 * @return bool|null Sanitized value, or null if absent.
189 */
190 private static function extract_feature_value( array $features, string $key ) {
191 if ( ! array_key_exists( $key, $features ) ) {
192 return null;
193 }
194
195 $value = $features[ $key ];
196 if ( is_array( $value ) ) {
197 if ( ! array_key_exists( 'enabled', $value ) ) {
198 return null;
199 }
200 $value = $value['enabled'];
201 }
202
203 return rest_sanitize_boolean( $value );
204 }
205
206 /**
207 * Whether enabling AI-generated search answers requires a plan upgrade.
208 * Computed fresh from `Search_Plan`, deliberately not via the shared,
209 * memoized `Search_Blocks::supports_paid_search()` — this endpoint's own
210 * tests change plan fixtures across dispatches within one PHPUnit
211 * process, and that memo doesn't reset, which breaks them.
212 *
213 * @param Search_Plan|null $search_plan Plan instance to reuse, or null to create one.
214 * @return bool
215 */
216 private function ai_search_requires_upgrade( ?Search_Plan $search_plan = null ) {
217 $search_plan ??= ( class_exists( Search_Plan::class ) ? new Search_Plan() : null );
218 return ! ( $search_plan && $search_plan->supports_instant_search() && ! $search_plan->is_free_plan() );
219 }
220
221 /**
222 * Assemble the full settings + gate-state payload.
223 *
224 * @return array
225 */
226 private function build_settings_response() {
227 $search_plan = class_exists( Search_Plan::class ) ? new Search_Plan() : null;
228 $is_connected = Jetpack_AI_Settings::site_is_connected();
229
230 // Entitlement: the plan includes some Search product (Classic or Instant).
231 $supports_search = $search_plan && $search_plan->supports_search();
232
233 // AI Answers only runs with the paid Search product provisioned. Mirror
234 // the gate the Search dashboard's AI Answers tab uses for its upsell:
235 // gated when the plan is free or lacks Instant Search.
236 $ai_search_requires_upgrade = $this->ai_search_requires_upgrade( $search_plan );
237
238 $stored = array();
239 foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
240 $stored[ $key ] = (bool) get_option(
241 $option,
242 Jetpack_AI_Settings::FEATURE_DEFAULTS[ $key ]
243 );
244 }
245
246 return array(
247 'host_allows_ai' => Jetpack_AI_Settings::host_allows_ai(),
248 'is_connected' => $is_connected,
249 'is_user_connected' => Jetpack_AI_Settings::user_is_connected(),
250 'plan' => array(
251 'supports_ai' => class_exists( Current_Plan::class ) && Current_Plan::supports( 'ai-assistant' ),
252 'supports_search' => $supports_search,
253 // The free Search tier reports supports_search too, but its
254 // remedy for the gated AI Search row is still an upgrade — the
255 // settings page needs this flag to pick the right badge copy.
256 'is_free_search_plan' => $supports_search && $search_plan->is_free_plan(),
257 ),
258 'master_enabled' => Jetpack_AI_Settings::is_master_enabled(),
259 'features' => array(
260 'writing_assistant' => array( 'enabled' => $stored['writing_assistant'] ),
261 'image_editor' => array( 'enabled' => $stored['image_editor'] ),
262 'feature_clip' => array(
263 'enabled' => $stored['feature_clip'],
264 'available' => $this->is_feature_clip_available(),
265 ),
266 'ai_seo' => array(
267 'enabled' => $stored['ai_seo'],
268 'available' => $this->is_ai_seo_available(),
269 'can_manage' => $is_connected && $this->can_manage_seo(),
270 ),
271 'ai_search' => array(
272 'enabled' => $stored['ai_search'],
273 'requires_upgrade' => $ai_search_requires_upgrade,
274 ),
275 ),
276 );
277 }
278
279 /**
280 * Whether the site's active features include SEO settings.
281 *
282 * The is_ai_seo_available() check accepts plan defaults, which can report SEO
283 * support even when site-specific restrictions, such as VIP's, block settings.
284 *
285 * @return bool
286 */
287 private function can_manage_seo() {
288 $site_data = ( new Manager( 'jetpack' ) )->get_connected_site_data();
289 if ( is_wp_error( $site_data ) ) {
290 return false;
291 }
292
293 $active_features = $site_data->plan->features->active ?? null;
294 return is_array( $active_features ) && in_array( 'advanced-seo', $active_features, true );
295 }
296
297 /**
298 * Whether the AI SEO row is available, so the settings page can hide it. The
299 * row is offered only where a surface it governs can run: the sidebar's
300 * suggestions or the editor's generation.
301 *
302 * Guarded with is_callable: the autoloader can pick an older jetpack-seo copy
303 * from another plugin, predating this gate. Without its verdict the row is
304 * hidden rather than offered.
305 *
306 * @return bool
307 */
308 private function is_ai_seo_available() {
309 if ( ! is_callable( array( Ai_Seo::class, 'has_reachable_surface' ) ) ) {
310 return false;
311 }
312
313 return Ai_Seo::has_reachable_surface();
314 }
315
316 /**
317 * Whether Feature Clip can operate on this site, so the settings page can
318 * grey out its nested row where the feature can't run.
319 *
320 * Feature Clip is nested under the image editor: it reports available only
321 * when Image Studio is enabled — the shared environment (host and master
322 * gates plus platform checks) AND the `image_editor` toggle. With the image
323 * editor off the clip row greys out rather than hides, so the settings page
324 * keys that greyed state off this field.
325 *
326 * The extension file that defines the predicate isn't loaded in every
327 * context this endpoint is (on WordPress.com the endpoint loads from the
328 * synced jetpack-endpoints directory), so a partial load defaults to
329 * available rather than greying a row that works.
330 *
331 * @return bool
332 */
333 private function is_feature_clip_available() {
334 if ( ! function_exists( '\Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled' ) ) {
335 return true;
336 }
337
338 return (bool) \Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled();
339 }
340 }
341
342 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings' );
343