PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 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 All 508 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-ai-feature-settings.php +342 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,342 @@
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' );