PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
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.3, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-ai-feature-settings.php

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