PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / REST / Settings_IO_Controller.php

Settings_IO_Controller.php in AI trunk, at includes/REST/Settings_IO_Controller.php

386 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST controller for AI settings import and export.
4 *
5 * @package WordPress\AI\REST
6 *
7 * @since 1.3.0
8 */
9
10 declare( strict_types=1 );
11
12 namespace WordPress\AI\REST;
13
14 use WordPress\AI\Settings\Settings_Registration;
15
16 // Exit if accessed directly.
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Handles the settings export (GET) and import (POST) REST endpoints.
21 *
22 * @since 1.3.0
23 */
24 final class Settings_IO_Controller {
25
26 /**
27 * The REST API namespace.
28 *
29 * @since 1.3.0
30 *
31 * @var string
32 */
33 private const API_NAMESPACE = 'ai/v1';
34
35 /**
36 * The export route path.
37 *
38 * @since 1.3.0
39 *
40 * @var string
41 */
42 private const EXPORT_ROUTE = '/settings/export';
43
44 /**
45 * The import route path.
46 *
47 * @since 1.3.0
48 *
49 * @var string
50 */
51 private const IMPORT_ROUTE = '/settings/import';
52
53 /**
54 * The current export/import schema version.
55 *
56 * Increment this constant when the export format changes in a
57 * backward-incompatible way so that older files are rejected.
58 *
59 * @since 1.3.0
60 *
61 * @var int
62 */
63 public const SCHEMA_VERSION = 1;
64
65 /**
66 * Option name segments that indicate a sensitive option.
67 *
68 * Option names in this plugin are snake_case (e.g. `wpai_openai_api_key`),
69 * so each pattern is matched as a whole underscore-delimited segment
70 * rather than as a raw substring. This avoids false positives such as
71 * `auth` matching an unrelated option like `wpai_default_author`, while
72 * still catching multi-segment names like `wpai_openai_api_key` because
73 * `key` matches its trailing `_key` segment on its own.
74 *
75 * @since 1.3.0
76 *
77 * @var list<string>
78 */
79 private const SENSITIVE_PATTERNS = array( 'key', 'token', 'secret', 'credential', 'password', 'auth' ); // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition
80
81 /**
82 * Initializes the REST routes.
83 *
84 * @since 1.3.0
85 */
86 public function init(): void {
87 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
88 }
89
90 /**
91 * Registers the export and import REST routes.
92 *
93 * @since 1.3.0
94 */
95 public function register_routes(): void {
96 register_rest_route(
97 self::API_NAMESPACE,
98 self::EXPORT_ROUTE,
99 array(
100 'methods' => 'GET',
101 'callback' => array( $this, 'export_settings' ),
102 'permission_callback' => array( $this, 'check_permission' ),
103 )
104 );
105
106 register_rest_route(
107 self::API_NAMESPACE,
108 self::IMPORT_ROUTE,
109 array(
110 'methods' => 'POST',
111 'callback' => array( $this, 'import_settings' ),
112 'permission_callback' => array( $this, 'check_permission' ),
113 'args' => array(
114 'version' => array(
115 'type' => 'integer',
116 'required' => true,
117 'sanitize_callback' => 'absint',
118 ),
119 'exported_at' => array(
120 'type' => 'string',
121 'required' => false,
122 'default' => '',
123 ),
124 'plugin_version' => array(
125 'type' => 'string',
126 'required' => false,
127 'default' => '',
128 ),
129 'providers' => array(
130 'type' => 'object',
131 'required' => false,
132 'default' => array(),
133 ),
134 'settings' => array(
135 'type' => 'object',
136 'required' => false,
137 'default' => array(),
138 ),
139 ),
140 )
141 );
142 }
143
144 /**
145 * Checks whether the current user may access these endpoints.
146 *
147 * @since 1.3.0
148 *
149 * @return bool True if the user has the required capability.
150 */
151 public function check_permission(): bool {
152 return current_user_can( 'manage_options' );
153 }
154
155 /**
156 * Returns the non-sensitive AI configuration as a portable JSON structure.
157 *
158 * The response body matches the schema accepted by the import endpoint.
159 *
160 * @since 1.3.0
161 *
162 * @return \WP_REST_Response The export payload.
163 */
164 public function export_settings(): \WP_REST_Response {
165 $registered = get_registered_settings();
166 $exportable = $this->get_exportable_option_names();
167 $settings = array();
168 $providers = array();
169
170 foreach ( $exportable as $option_name ) {
171 $default = $registered[ $option_name ]['default'] ?? false;
172 $value = get_option( $option_name, $default );
173
174 if ( $this->is_developer_config_option( $option_name ) ) {
175 $providers[ $option_name ] = $value;
176 } else {
177 $settings[ $option_name ] = $value;
178 }
179 }
180
181 $payload = array(
182 'version' => self::SCHEMA_VERSION,
183 'exported_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
184 'plugin_version' => WPAI_VERSION,
185 'providers' => $providers,
186 'settings' => $settings,
187 );
188
189 return new \WP_REST_Response( $payload, 200 );
190 }
191
192 /**
193 * Imports AI settings from a previously exported payload.
194 *
195 * Only settings that are currently registered in the plugin's option group
196 * and are not flagged as sensitive will be written. Every value is validated
197 * and sanitized against the option's registered schema before being saved;
198 * values that fail validation are rejected rather than written verbatim.
199 * All other keys in the payload are silently ignored.
200 *
201 * @since 1.3.0
202 *
203 * @param \WP_REST_Request $request The REST request.
204 * @return \WP_REST_Response|\WP_Error The response or an error.
205 */
206 public function import_settings( \WP_REST_Request $request ) {
207 $version = (int) $request->get_param( 'version' );
208
209 if ( self::SCHEMA_VERSION !== $version ) {
210 return new \WP_Error(
211 'unsupported_schema_version',
212 sprintf(
213 /* translators: %d: schema version number. */
214 __( 'Unsupported schema version: %d.', 'ai' ),
215 $version
216 ),
217 array( 'status' => 422 )
218 );
219 }
220
221 $registered = get_registered_settings();
222 $exportable = array_flip( $this->get_exportable_option_names() );
223 $providers = $request->get_param( 'providers' );
224 $settings = $request->get_param( 'settings' );
225
226 if ( ! is_array( $providers ) ) {
227 $providers = array();
228 }
229
230 if ( ! is_array( $settings ) ) {
231 $settings = array();
232 }
233
234 $all_values = array_merge( $settings, $providers );
235 $imported = 0;
236 $rejected = 0;
237
238 foreach ( $all_values as $option_name => $value ) {
239 if ( ! is_string( $option_name ) ) {
240 continue;
241 }
242
243 // Only import options that are registered and allowed.
244 if ( ! isset( $exportable[ $option_name ] ) ) {
245 continue;
246 }
247
248 $schema = $this->get_option_schema( $registered[ $option_name ] ?? array() );
249
250 // Reject values that don't match the option's registered type/shape
251 // (e.g. a string passed where the setting expects a boolean or object)
252 // rather than writing them to the database unsanitized.
253 if ( is_wp_error( rest_validate_value_from_schema( $value, $schema, $option_name ) ) ) {
254 ++$rejected;
255 continue;
256 }
257
258 $sanitized = rest_sanitize_value_from_schema( $value, $schema, $option_name );
259
260 if ( is_wp_error( $sanitized ) ) {
261 ++$rejected;
262 continue;
263 }
264
265 update_option( $option_name, $sanitized );
266 ++$imported;
267 }
268
269 $message = $rejected > 0
270 ? sprintf(
271 /* translators: 1: number of imported settings, 2: number of rejected settings. */
272 __( 'Settings imported successfully. %1$d setting(s) imported, %2$d rejected due to invalid values.', 'ai' ),
273 $imported,
274 $rejected
275 )
276 : __( 'Settings imported successfully.', 'ai' );
277
278 return new \WP_REST_Response(
279 array(
280 'imported' => $imported,
281 'rejected' => $rejected,
282 'message' => $message,
283 ),
284 200
285 );
286 }
287
288 /**
289 * Builds a REST schema array describing an option's expected shape.
290 *
291 * Uses the schema declared via `show_in_rest.schema` when available (as is
292 * the case for the developer model configuration objects), falling back to
293 * a minimal schema derived from the option's registered `type` otherwise.
294 *
295 * @since 1.3.0
296 *
297 * @param array<string, mixed> $args The option's registered arguments from
298 * {@see get_registered_settings()}.
299 * @return array<string, mixed> A REST-compatible schema array.
300 */
301 private function get_option_schema( array $args ): array {
302 $show_in_rest = $args['show_in_rest'] ?? false;
303
304 if ( is_array( $show_in_rest ) && isset( $show_in_rest['schema'] ) && is_array( $show_in_rest['schema'] ) ) {
305 return $show_in_rest['schema'];
306 }
307
308 return array( 'type' => $args['type'] ?? 'string' );
309 }
310
311 /**
312 * Returns the option names that are safe to export and import.
313 *
314 * Returns every option that belongs to the plugin's settings group
315 * and whose name does not match any sensitive pattern.
316 *
317 * @since 1.3.0
318 *
319 * @return list<string> Exportable option names.
320 */
321 public function get_exportable_option_names(): array {
322 $registered = get_registered_settings();
323 $exportable = array();
324
325 foreach ( $registered as $option_name => $args ) {
326 // Ensure $option_name is a string
327 $option_name = (string) $option_name;
328
329 if ( ( $args['group'] ?? '' ) !== Settings_Registration::OPTION_GROUP ) {
330 continue;
331 }
332
333 if ( $this->is_sensitive_option( $option_name ) ) {
334 continue;
335 }
336
337 $exportable[] = $option_name;
338 }
339
340 return $exportable;
341 }
342
343 /**
344 * Checks whether an option name contains a sensitive keyword.
345 *
346 * Each pattern in {@see self::SENSITIVE_PATTERNS} is matched as a whole
347 * underscore-delimited segment (or sequence of segments), not as a raw
348 * substring, so names like `wpai_default_author` are not mistakenly
349 * flagged just because they contain the letters "auth".
350 *
351 * @since 1.3.0
352 *
353 * @param string $option_name The option name to inspect.
354 * @return bool True if the option should be excluded.
355 */
356 private function is_sensitive_option( string $option_name ): bool {
357 $lower = strtolower( $option_name );
358
359 foreach ( self::SENSITIVE_PATTERNS as $pattern ) {
360 // Allow an optional trailing "s" so plural segments (e.g. "credentials")
361 // are also caught, without resorting to a raw substring match.
362 if ( 1 === preg_match( '/(?:^|_)' . preg_quote( $pattern, '/' ) . 's?(?:_|$)/', $lower ) ) {
363 return true;
364 }
365 }
366
367 return false;
368 }
369
370 /**
371 * Checks whether an option stores a developer model configuration.
372 *
373 * Developer config options hold provider and model identifier strings
374 * (e.g. `{"provider":"openai","model":"gpt-4.1-mini"}`) and are placed
375 * in the `providers` section of the export payload.
376 *
377 * @since 1.3.0
378 *
379 * @param string $option_name The option name to inspect.
380 * @return bool True if the option is a developer model config.
381 */
382 private function is_developer_config_option( string $option_name ): bool {
383 return false !== strpos( $option_name, '_field_developer' );
384 }
385 }
386