PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
thinkrank / includes / api / class-schema-endpoint.php

class-schema-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/api/class-schema-endpoint.php

2,256 lines 84.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema API Endpoints Class
4 *
5 * REST API endpoints for schema markup generation, validation, and management.
6 * Provides comprehensive API access to Schema Management System functionality
7 * with proper authentication, validation, and error handling.
8 *
9 * @package ThinkRank
10 * @subpackage API
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\API;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 use ThinkRank\SEO\Schema_Management_System;
24 use ThinkRank\SEO\Schema_Input_Validator;
25 use ThinkRank\API\Traits\Rate_Limiter;
26 use ThinkRank\API\Traits\Context_Authorization;
27 use ThinkRank\API\Traits\CSRF_Protection;
28 use WP_REST_Controller;
29 use WP_REST_Request;
30 use WP_REST_Response;
31 use WP_Error;
32
33 // Load Rate Limiter trait
34 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-rate-limiter.php';
35 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
36 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
37
38 /**
39 * Schema API Endpoints Class
40 *
41 * Provides REST API endpoints for schema markup operations including
42 * generation, validation, deployment, and performance tracking with
43 * proper authentication and comprehensive error handling.
44 *
45 * @since 1.0.0
46 */
47 class Schema_Endpoint extends WP_REST_Controller {
48
49 use Rate_Limiter;
50 use Context_Authorization;
51 // Shared nonce check — this class used to carry a byte-identical private
52 // copy of verify_request_nonce() (#457).
53 use CSRF_Protection;
54
55 /**
56 * Maximum number of items a single /bulk request may process synchronously.
57 * Larger workloads should be paged or queued rather than run in one request.
58 *
59 * @since 1.20.1
60 * @var int
61 */
62 private const MAX_BULK_ITEMS = 50;
63
64 /**
65 * Schema Management System instance
66 *
67 * @since 1.0.0
68 * @var Schema_Management_System
69 */
70 private Schema_Management_System $schema_manager;
71
72 /**
73 * Schema Input Validator instance
74 *
75 * @since 1.0.0
76 * @var Schema_Input_Validator
77 */
78 private Schema_Input_Validator $input_validator;
79
80 /**
81 * API namespace
82 *
83 * @since 1.0.0
84 * @var string
85 */
86 protected $namespace = 'thinkrank/v1';
87
88 /**
89 * API resource base
90 *
91 * @since 1.0.0
92 * @var string
93 */
94 protected $rest_base = 'schema';
95
96 /**
97 * Constructor
98 *
99 * @since 1.0.0
100 */
101 public function __construct() {
102 $this->schema_manager = new Schema_Management_System();
103 $this->input_validator = new Schema_Input_Validator();
104 }
105
106 /**
107 * Register API routes
108 *
109 * @since 1.0.0
110 */
111 public function register_routes(): void {
112 // Generate schema markup
113 register_rest_route(
114 $this->namespace,
115 '/' . $this->rest_base . '/generate',
116 [
117 [
118 'methods' => 'POST',
119 'callback' => [$this, 'generate_schema'],
120 'permission_callback' => [$this, 'check_generate_permissions'],
121 'args' => $this->get_generate_schema_args()
122 ]
123 ]
124 );
125
126 // Validate schema markup
127 register_rest_route(
128 $this->namespace,
129 '/' . $this->rest_base . '/validate',
130 [
131 [
132 'methods' => 'POST',
133 'callback' => [$this, 'validate_schema'],
134 'permission_callback' => [$this, 'check_validate_permissions'],
135 'args' => $this->get_validate_schema_args()
136 ]
137 ]
138 );
139
140 // Deploy schema markup
141 register_rest_route(
142 $this->namespace,
143 '/' . $this->rest_base . '/deploy',
144 [
145 [
146 'methods' => 'POST',
147 'callback' => [$this, 'deploy_schema'],
148 'permission_callback' => [$this, 'check_deploy_permissions'],
149 'args' => $this->get_deploy_schema_args()
150 ]
151 ]
152 );
153
154 // Get schema types
155 register_rest_route(
156 $this->namespace,
157 '/' . $this->rest_base . '/types',
158 [
159 [
160 'methods' => 'GET',
161 'callback' => [$this, 'get_schema_types'],
162 'permission_callback' => [$this, 'check_read_permissions']
163 ]
164 ]
165 );
166
167 // Get deployed schemas
168 register_rest_route(
169 $this->namespace,
170 '/' . $this->rest_base . '/deployed',
171 [
172 [
173 'methods' => 'GET',
174 'callback' => [$this, 'get_deployed_schemas'],
175 'permission_callback' => [$this, 'check_read_permissions'],
176 'args' => $this->get_context_route_args()
177 ]
178 ]
179 );
180
181 // Get schema for context
182 register_rest_route(
183 $this->namespace,
184 '/' . $this->rest_base . '/(?P<context_type>[a-zA-Z0-9_-]+)/(?P<context_id>\d+)',
185 [
186 [
187 'methods' => 'GET',
188 'callback' => [$this, 'get_context_schema'],
189 'permission_callback' => [$this, 'check_read_permissions'],
190 'args' => [
191 'context_type' => [
192 'required' => true,
193 'type' => 'string',
194 'enum' => ['site', 'post', 'page', 'product']
195 ],
196 'context_id' => [
197 'required' => true,
198 'type' => 'integer',
199 'minimum' => 1
200 ]
201 ]
202 ]
203 ]
204 );
205
206 // Optimize rich snippets
207 register_rest_route(
208 $this->namespace,
209 '/' . $this->rest_base . '/optimize',
210 [
211 [
212 'methods' => 'POST',
213 'callback' => [$this, 'optimize_rich_snippets'],
214 'permission_callback' => [$this, 'check_optimize_permissions'],
215 'args' => $this->get_optimize_schema_args()
216 ]
217 ]
218 );
219
220 // Track schema performance
221 register_rest_route(
222 $this->namespace,
223 '/' . $this->rest_base . '/performance/(?P<context_type>[a-zA-Z0-9_-]+)/(?P<context_id>\d+)',
224 [
225 [
226 'methods' => 'GET',
227 'callback' => [$this, 'get_schema_performance'],
228 'permission_callback' => [$this, 'check_read_permissions'],
229 'args' => [
230 'context_type' => [
231 'required' => true,
232 'type' => 'string',
233 'enum' => ['site', 'post', 'page', 'product']
234 ],
235 'context_id' => [
236 'required' => true,
237 'type' => 'integer',
238 'minimum' => 1
239 ]
240 ]
241 ]
242 ]
243 );
244
245 // Get schema preview
246 register_rest_route(
247 $this->namespace,
248 '/' . $this->rest_base . '/preview',
249 [
250 [
251 'methods' => 'POST',
252 'callback' => [$this, 'get_schema_preview'],
253 'permission_callback' => [$this, 'check_read_permissions'],
254 'args' => $this->get_preview_schema_args()
255 ]
256 ]
257 );
258
259 // Bulk operations
260 register_rest_route(
261 $this->namespace,
262 '/' . $this->rest_base . '/bulk',
263 [
264 [
265 'methods' => 'POST',
266 'callback' => [$this, 'bulk_operations'],
267 'permission_callback' => [$this, 'check_bulk_permissions'],
268 'args' => $this->get_bulk_operations_args()
269 ]
270 ]
271 );
272
273 // Schema settings management
274 register_rest_route(
275 $this->namespace,
276 '/' . $this->rest_base . '/settings',
277 [
278 [
279 'methods' => 'GET',
280 'callback' => [$this, 'get_settings'],
281 'permission_callback' => [$this, 'check_read_permissions'],
282 'args' => $this->get_context_route_args()
283 ],
284 [
285 'methods' => 'POST',
286 'callback' => [$this, 'save_settings'],
287 'permission_callback' => [$this, 'check_manage_permissions'],
288 'args' => $this->get_settings_args()
289 ]
290 ]
291 );
292
293 // Import schema from URL
294 register_rest_route(
295 $this->namespace,
296 '/' . $this->rest_base . '/import',
297 [
298 [
299 'methods' => 'POST',
300 'callback' => [$this, 'import_schema_from_url'],
301 'permission_callback' => [$this, 'check_manage_permissions'],
302 'args' => [
303 'url' => [
304 'required' => true,
305 'type' => 'string',
306 'format' => 'uri'
307 ]
308 ]
309 ]
310 ]
311 );
312 }
313
314 /**
315 * Import schema from URL
316 *
317 * @since 1.0.0
318 *
319 * @param WP_REST_Request $request Request object
320 * @return WP_REST_Response|WP_Error Response object or error
321 */
322 public function import_schema_from_url(WP_REST_Request $request) {
323 try {
324 $url = esc_url_raw($request->get_param('url'));
325
326 if (empty($url)) {
327 return new WP_Error(
328 'invalid_url',
329 'A valid URL is required',
330 ['status' => 400]
331 );
332 }
333
334 // Block SSRF: reject non-http(s)/malformed URLs and any host that
335 // resolves to a private, loopback, link-local, or otherwise reserved
336 // IP range — including the link-local 169.254.0.0/16 (cloud metadata,
337 // e.g. 169.254.169.254) and 100.64.0.0/10 (CGNAT) ranges that
338 // wp_http_validate_url() does NOT block — re-validated on every
339 // redirect hop. See fetch_import_url() / \ThinkRank\Core\Url_Safety.
340 $response = $this->fetch_import_url($url);
341
342 if (is_wp_error($response)) {
343 // Preserve the SSRF/redirect block responses (they already carry a
344 // 4xx status); wrap transport-level failures as a 500.
345 $error_data = $response->get_error_data();
346 if (is_array($error_data) && isset($error_data['status'])) {
347 return $response;
348 }
349 return new WP_Error(
350 'fetch_failed',
351 'Failed to fetch data from URL: ' . $response->get_error_message(),
352 ['status' => 500]
353 );
354 }
355
356 $response_code = wp_remote_retrieve_response_code($response);
357 if ($response_code !== 200) {
358 return new WP_Error(
359 'fetch_error',
360 'Failed to fetch data from URL (HTTP ' . $response_code . ')',
361 ['status' => 400]
362 );
363 }
364
365 $body = wp_remote_retrieve_body($response);
366
367 if (empty($body)) {
368 return new WP_Error(
369 'empty_response',
370 'Returned content is empty',
371 ['status' => 400]
372 );
373 }
374
375 // Suppress DOM errors for malformed HTML
376 libxml_use_internal_errors(true);
377
378 $dom = new \DOMDocument();
379 // Prepend an XML encoding hint so DOMDocument parses UTF-8 correctly.
380 // Avoids the deprecated mb_convert_encoding($body, 'HTML-ENTITIES') call,
381 // which emits deprecation notices on PHP 8.2+.
382 $dom->loadHTML('<?xml encoding="UTF-8">' . $body, LIBXML_NOERROR | LIBXML_NOWARNING);
383
384 libxml_clear_errors();
385
386 $xpath = new \DOMXPath($dom);
387 $scripts = $xpath->query('//script[@type="application/ld+json"]');
388
389 $found_schemas = [];
390
391 if ($scripts->length > 0) {
392 foreach ($scripts as $script) {
393 $json = trim($script->nodeValue);
394 $data = json_decode($json, true);
395
396 if (json_last_error() === JSON_ERROR_NONE && !empty($data)) {
397 // A script block may hold a single entity, a bare list
398 // of entities, or an object wrapping @graph. Treating
399 // every block as one flat object collapsed lists into
400 // numeric keys and never opened @graph — the shape Yoast
401 // and Rank Math emit — so the import produced entries
402 // with no top-level @type that deploy silently dropped
403 // (#467).
404 foreach ($this->extract_schema_entities($data) as $entity) {
405 // Strictly set @context to https://schema.org
406 $entity['@context'] = 'https://schema.org';
407 $found_schemas[] = $entity;
408 }
409 }
410 }
411 }
412
413 if (empty($found_schemas)) {
414 return new WP_Error(
415 'no_schema_found',
416 'No valid JSON-LD schema markup found on this page',
417 ['status' => 404]
418 );
419 }
420
421 return new WP_REST_Response([
422 'success' => true,
423 'data' => $found_schemas[0],
424 'all_found' => $found_schemas,
425 'message' => 'Schema imported successfully'
426 ], 200);
427
428 } catch (\Exception $e) {
429 return new WP_Error(
430 'import_failed',
431 'Schema import failed: ' . $e->getMessage(),
432 ['status' => 500]
433 );
434 }
435 }
436
437 /**
438 * Sanitize schema form data of arbitrary depth.
439 *
440 * The metabox forms post nested structures — `faq_questions` is a list of
441 * `{question, answer}` objects and `howto_steps` a list of `{name, text}`
442 * objects. A flat `array_map('sanitize_text_field', $value)` handed those
443 * inner arrays to a string sanitizer, which returns '', so every question
444 * and step was blanked before the builder saw it and FAQPage generated with
445 * an empty `mainEntity` (failing its own required-property validation).
446 * Recursing keeps the shape and still sanitizes every scalar leaf.
447 *
448 * @since 2.0.2
449 *
450 * @param array $data Raw form data.
451 * @return array Sanitized form data with structure preserved.
452 */
453 private function sanitize_schema_form_data(array $data): array {
454 $sanitized = [];
455
456 foreach ($data as $key => $value) {
457 $clean_key = is_int($key) ? $key : sanitize_key($key);
458
459 if (is_array($value)) {
460 $sanitized[$clean_key] = $this->sanitize_schema_form_data($value);
461 } elseif (is_bool($value)) {
462 $sanitized[$clean_key] = $value;
463 } elseif (is_string($value)) {
464 $sanitized[$clean_key] = sanitize_text_field($value);
465 } elseif (is_numeric($value)) {
466 $sanitized[$clean_key] = floatval($value);
467 }
468 }
469
470 return $sanitized;
471 }
472
473 /**
474 * Flatten one decoded JSON-LD script block into individual entities.
475 *
476 * JSON-LD allows a script tag to carry a single object, an array of objects,
477 * or an object whose `@graph` holds the entities. Mirrors the Pro file
478 * importer's extract_schemas() so both paths agree (#467).
479 *
480 * @since 1.16.0
481 *
482 * @param array $decoded Decoded JSON-LD.
483 * @return array<int,array> One entry per entity.
484 */
485 private function extract_schema_entities(array $decoded): array {
486 // Object wrapping @graph — the shape Yoast and Rank Math emit.
487 if (!empty($decoded['@graph']) && is_array($decoded['@graph'])) {
488 $context = $decoded['@context'] ?? null;
489 $entities = [];
490
491 foreach ($decoded['@graph'] as $entity) {
492 if (!is_array($entity) || empty($entity)) {
493 continue;
494 }
495 // Carry the outer @context onto entities that lack their own.
496 if (null !== $context && !isset($entity['@context'])) {
497 $entity['@context'] = $context;
498 }
499 $entities[] = $entity;
500 }
501
502 return $entities;
503 }
504
505 // Bare list of entities: [{...}, {...}]
506 if (isset($decoded[0]) && is_array($decoded[0])) {
507 return array_values(array_filter($decoded, static function ($entity) {
508 return is_array($entity) && !empty($entity);
509 }));
510 }
511
512 // Single entity.
513 return [$decoded];
514 }
515
516 /**
517 * Fetch a remote URL for schema import.
518 *
519 * Delegates to the shared SSRF guard, which follows redirects manually and
520 * re-validates the resolved host against the block list on every hop —
521 * wp_safe_remote_get()'s own redirect validation goes through
522 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot.
523 *
524 * @param string $url URL to fetch.
525 * @return array|\WP_Error Response array on success, WP_Error otherwise.
526 */
527 private function fetch_import_url(string $url) {
528 return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
529 'timeout' => 15,
530 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)',
531 // Without a cap the whole body is buffered into memory and then
532 // handed to DOMDocument at roughly twice the size, so a hostile or
533 // simply enormous page could exhaust the request (#473).
534 'limit_response_size' => 2 * MB_IN_BYTES,
535 ]);
536 }
537
538 /**
539 * Generate schema markup
540 *
541 * @since 1.0.0
542 *
543 * @param WP_REST_Request $request Request object
544 * @return WP_REST_Response|WP_Error Response object or error
545 */
546 public function generate_schema(WP_REST_Request $request) {
547 try {
548 $user_id = get_current_user_id();
549
550 // SECURITY: Check rate limits first
551 $rate_limit_check = $this->check_rate_limit('generate_schema', $user_id);
552 if (is_wp_error($rate_limit_check)) {
553 return $rate_limit_check;
554 }
555
556 // SECURITY: Validate user permissions and rate limiting
557 $permission_check = $this->input_validator->validate_user_permissions('generate', $user_id);
558 if (!$permission_check['valid']) {
559 return new WP_Error(
560 'permission_denied',
561 implode(', ', $permission_check['errors']),
562 ['status' => 403]
563 );
564 }
565
566 // SECURITY: Validate and sanitize context parameters with ownership checks
567 $context_type = $request->get_param('context_type');
568 $context_id = $request->get_param('context_id');
569 $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id);
570
571 if (!$context_validation['valid']) {
572 return new WP_Error(
573 'invalid_context',
574 implode(', ', $context_validation['errors']),
575 ['status' => 400]
576 );
577 }
578
579 $context_type = $context_validation['sanitized_data']['context_type'];
580 $context_id = $context_validation['sanitized_data']['context_id'];
581
582 // SECURITY: Validate and sanitize schema types
583 $schema_types = $request->get_param('schema_types') ?? [];
584 if (empty($schema_types) || !is_array($schema_types)) {
585 return new WP_Error(
586 'missing_schema_types',
587 'Schema types array is required',
588 ['status' => 400]
589 );
590 }
591
592 // Sanitize schema types
593 $sanitized_schema_types = [];
594 foreach ($schema_types as $type) {
595 $sanitized_type = sanitize_text_field($type);
596 if (!empty($sanitized_type)) {
597 $sanitized_schema_types[] = $sanitized_type;
598 }
599 }
600
601 if (empty($sanitized_schema_types)) {
602 return new WP_Error(
603 'invalid_schema_types',
604 'No valid schema types provided',
605 ['status' => 400]
606 );
607 }
608
609 // SECURITY: Sanitize options
610 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
611
612 // SECURITY: Sanitize content_data if provided
613 $content_data = $request->get_param('content_data');
614 if ($content_data && is_array($content_data)) {
615 $content_data = [
616 'title' => isset($content_data['title']) ? sanitize_text_field($content_data['title']) : '',
617 'description' => isset($content_data['description']) ? sanitize_textarea_field($content_data['description']) : '',
618 'content' => isset($content_data['content']) ? wp_kses_post($content_data['content']) : '',
619 'word_count' => isset($content_data['word_count']) ? (int) $content_data['word_count'] : 0,
620 'focus_keyword' => isset($content_data['focus_keyword']) ? sanitize_text_field($content_data['focus_keyword']) : '',
621 'post_type' => isset($content_data['post_type']) ? sanitize_text_field($content_data['post_type']) : '',
622 'post_url' => isset($content_data['post_url']) ? esc_url_raw($content_data['post_url']) : ''
623 ];
624
625 // Add content_data to options so schema manager can use it
626 $options['content_data'] = $content_data;
627 }
628
629 // SECURITY: Sanitize schema_form_data if provided
630 $schema_form_data = $request->get_param('schema_form_data');
631 if ($schema_form_data && is_array($schema_form_data)) {
632 // Add schema_form_data to options so schema manager can use it
633 $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data);
634 }
635
636 // Generate schema markup with sanitized inputs
637 $generation_results = $this->schema_manager->generate_schema_markup(
638 $context_type,
639 $context_id,
640 $sanitized_schema_types,
641 $options
642 );
643
644 return new WP_REST_Response([
645 'success' => true,
646 'data' => $generation_results,
647 'message' => 'Schema markup generated successfully'
648 ], 200);
649
650 } catch (\Exception $e) {
651 return new WP_Error(
652 'generation_failed',
653 'Schema generation failed: ' . $e->getMessage(),
654 ['status' => 500]
655 );
656 }
657 }
658
659 /**
660 * Validate schema markup
661 *
662 * @since 1.0.0
663 *
664 * @param WP_REST_Request $request Request object
665 * @return WP_REST_Response|WP_Error Response object or error
666 */
667 public function validate_schema(WP_REST_Request $request) {
668 try {
669 $user_id = get_current_user_id();
670
671 // SECURITY: Validate user permissions and rate limiting
672 $permission_check = $this->input_validator->validate_user_permissions('validate', $user_id);
673 if (!$permission_check['valid']) {
674 return new WP_Error(
675 'permission_denied',
676 implode(', ', $permission_check['errors']),
677 ['status' => 403]
678 );
679 }
680
681 $schema_data = $request->get_param('schema_data');
682 $schema_type = $request->get_param('schema_type');
683 $options = $request->get_param('options') ?? [];
684
685 // SECURITY: Validate input parameters
686 if (empty($schema_data) || empty($schema_type)) {
687 return new WP_Error(
688 'missing_parameters',
689 'Schema data and type are required',
690 ['status' => 400]
691 );
692 }
693
694 // SECURITY: Sanitize schema type
695 $schema_type = sanitize_text_field($schema_type);
696
697 // SECURITY: Validate and sanitize schema data using input validator
698 if (!is_array($schema_data)) {
699 return new WP_Error(
700 'invalid_schema_data',
701 'Schema data must be an array/object',
702 ['status' => 400]
703 );
704 }
705
706 $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
707 if (!$input_validation['valid']) {
708 return new WP_Error(
709 'schema_validation_failed',
710 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
711 [
712 'status' => 400,
713 'validation_errors' => $input_validation['errors'],
714 'validation_warnings' => $input_validation['warnings']
715 ]
716 );
717 }
718
719 // Use sanitized data for validation
720 $sanitized_schema_data = $input_validation['sanitized_data'];
721
722 // SECURITY: Sanitize options
723 $options = $this->input_validator->sanitize_options($options);
724
725 // Validate schema markup with sanitized data
726 $validation_results = $this->schema_manager->validate_schema_markup(
727 $sanitized_schema_data,
728 $schema_type,
729 $options
730 );
731
732 return new WP_REST_Response([
733 'success' => true,
734 'data' => $validation_results,
735 'message' => 'Schema validation completed'
736 ], 200);
737
738 } catch (\Exception $e) {
739 return new WP_Error(
740 'validation_failed',
741 'Schema validation failed: ' . $e->getMessage(),
742 ['status' => 500]
743 );
744 }
745 }
746
747 /**
748 * Deploy schema markup
749 *
750 * @since 1.0.0
751 *
752 * @param WP_REST_Request $request Request object
753 * @return WP_REST_Response|WP_Error Response object or error
754 */
755 public function deploy_schema(WP_REST_Request $request) {
756 try {
757 $user_id = get_current_user_id();
758
759 // SECURITY: Validate user permissions and rate limiting
760 $permission_check = $this->input_validator->validate_user_permissions('deploy', $user_id);
761 if (!$permission_check['valid']) {
762 return new WP_Error(
763 'permission_denied',
764 implode(', ', $permission_check['errors']),
765 ['status' => 403]
766 );
767 }
768
769 // SECURITY: Validate and sanitize context parameters with ownership checks
770 $context_type = $request->get_param('context_type');
771 $context_id = $request->get_param('context_id');
772 $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id);
773
774 if (!$context_validation['valid']) {
775 return new WP_Error(
776 'invalid_context',
777 implode(', ', $context_validation['errors']),
778 ['status' => 400]
779 );
780 }
781
782 $context_type = $context_validation['sanitized_data']['context_type'];
783 $context_id = $context_validation['sanitized_data']['context_id'];
784
785 // SECURITY: Validate schema data
786 $schema_data = $request->get_param('schema_data');
787 if (empty($schema_data) || !is_array($schema_data)) {
788 return new WP_Error(
789 'invalid_schema_data',
790 'Valid schema data array is required',
791 ['status' => 400]
792 );
793 }
794
795 // SECURITY: Validate each schema in the data
796 $sanitized_schema_data = [];
797 $skipped_schemas = [];
798 foreach ($schema_data as $schema_key => $schema_content) {
799 $schema_key = sanitize_text_field($schema_key);
800
801 if (!is_array($schema_content)) {
802 return new WP_Error(
803 'invalid_schema_content',
804 "Schema content for {$schema_key} must be an array",
805 ['status' => 400]
806 );
807 }
808
809 // Ensure schema has required structure fields before validation
810 // Use @type from schema content if available, otherwise fall back to key.
811 // `@type` may legitimately be an array ("@type": ["Product","Offer"]);
812 // sanitize_text_field() on an array yields '', which then failed the
813 // whitelist lookup with "Invalid schema type:" (#468). Resolve the
814 // primary type for lookup and leave the original value in the payload.
815 if (isset($schema_content['@type'])) {
816 $raw_type = $schema_content['@type'];
817 $schema_type = is_array($raw_type)
818 ? sanitize_text_field((string) reset($raw_type))
819 : sanitize_text_field((string) $raw_type);
820 } else {
821 $schema_type = $schema_key;
822 }
823
824 if (!isset($schema_content['@type'])) {
825 $schema_content['@type'] = $schema_type;
826 }
827 if (!isset($schema_content['@context'])) {
828 $schema_content['@context'] = 'https://schema.org';
829 }
830
831 // Validate using the actual schema type, not the key.
832 // A failure skips this entry instead of aborting the batch: the
833 // UI sends every schema in one payload, so one unsupported type
834 // used to block the valid entries alongside it (#468).
835 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
836
837 if (!$input_validation['valid']) {
838 $skipped_schemas[] = [
839 'key' => $schema_key,
840 'type' => $schema_type,
841 'errors' => $input_validation['errors'],
842 ];
843 continue;
844 }
845
846 // Store using the key (which may be unique like "Article-1")
847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
848 }
849
850 // Every entry failed — that is a request-level error worth a 400,
851 // since there is nothing to deploy.
852 if (empty($sanitized_schema_data) && !empty($skipped_schemas)) {
853 return new WP_Error(
854 'schema_validation_failed',
855 sprintf(
856 /* translators: %s: comma-separated list of schema types. */
857 __('No schema could be deployed. Failed types: %s', 'thinkrank'),
858 implode(', ', wp_list_pluck($skipped_schemas, 'type'))
859 ),
860 [
861 'status' => 400,
862 'skipped' => $skipped_schemas,
863 ]
864 );
865 }
866
867 // SECURITY: Sanitize options
868 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
869
870 // This route is the user pressing Deploy, so the payload is the full
871 // intended set for the context — types missing from it were removed
872 // deliberately and must come off the page (#464).
873 $options['authoritative'] = true;
874
875 // A skipped entry was sent, so the user still wants it on the page;
876 // it only failed validation. Retiring it as "missing from the
877 // payload" took a live Organization down behind a success toast
878 // (#949) — leave whatever is deployed for it in place.
879 $options['retain_types'] = array_values(array_unique(array_merge(
880 wp_list_pluck($skipped_schemas, 'key'),
881 wp_list_pluck($skipped_schemas, 'type')
882 )));
883
884 // Deploy schema markup with sanitized data
885 $deployment_results = $this->schema_manager->deploy_schema_markup(
886 $context_type,
887 $context_id,
888 $sanitized_schema_data,
889 $options
890 );
891
892 $response = [
893 'success' => true,
894 'data' => $deployment_results,
895 'message' => 'Schema markup deployed successfully'
896 ];
897
898 // Report what was skipped so the UI can say "3 deployed, 1 skipped"
899 // rather than silently dropping entries (#468).
900 if (!empty($skipped_schemas)) {
901 $response['skipped'] = $skipped_schemas;
902 $response['partial'] = true;
903 $response['message'] = sprintf(
904 /* translators: 1: number deployed, 2: number skipped. */
905 __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'),
906 count($sanitized_schema_data),
907 count($skipped_schemas)
908 );
909 }
910
911 return new WP_REST_Response($response, 200);
912
913 } catch (\Exception $e) {
914 return new WP_Error(
915 'deployment_failed',
916 'Schema deployment failed: ' . $e->getMessage(),
917 ['status' => 500]
918 );
919 }
920 }
921
922 /**
923 * Get available schema types
924 *
925 * @since 1.0.0
926 *
927 * @param WP_REST_Request $request Request object
928 * @return WP_REST_Response Response object
929 */
930 public function get_schema_types(WP_REST_Request $request): WP_REST_Response {
931 // Get context parameter to determine which schema types to return
932 $context = $request->get_param('context') ?? 'site';
933
934 // Site-level schema types only (post/page schemas handled by metabox)
935 $site_schema_types = [
936 'Organization' => [
937 'name' => 'Organization',
938 'description' => 'Company or organization information (site-wide)',
939 'context_types' => ['site'],
940 'priority' => 'high'
941 ],
942 'LocalBusiness' => [
943 'name' => 'LocalBusiness',
944 'description' => 'Local businesses and service providers (site-wide)',
945 'context_types' => ['site'],
946 'priority' => 'high'
947 ],
948 'Person' => [
949 'name' => 'Person',
950 'description' => 'Individual person or author information (site-wide)',
951 'context_types' => ['site'],
952 'priority' => 'medium'
953 ],
954 'WebSite' => [
955 'name' => 'WebSite',
956 'description' => 'Website-level information and search functionality',
957 'context_types' => ['site'],
958 'priority' => 'high'
959 ]
960 ];
961
962 // All schema types for metabox context
963 $all_schema_types = [
964 'Article' => [
965 'name' => 'Article',
966 'description' => 'News articles, blog posts, and editorial content',
967 'context_types' => ['post', 'page'],
968 'priority' => 'high'
969 ],
970 'BlogPosting' => [
971 'name' => 'BlogPosting',
972 'description' => 'Blog posts and personal articles',
973 'context_types' => ['post', 'page'],
974 'priority' => 'high'
975 ],
976 'TechnicalArticle' => [
977 'name' => 'TechnicalArticle',
978 'description' => 'Technical documentation and tutorials',
979 'context_types' => ['post', 'page'],
980 'priority' => 'high'
981 ],
982 'NewsArticle' => [
983 'name' => 'NewsArticle',
984 'description' => 'News articles and press releases',
985 'context_types' => ['post', 'page'],
986 'priority' => 'high'
987 ],
988 'ScholarlyArticle' => [
989 'name' => 'ScholarlyArticle',
990 'description' => 'Academic and research articles',
991 'context_types' => ['post', 'page'],
992 'priority' => 'high'
993 ],
994 'Report' => [
995 'name' => 'Report',
996 'description' => 'Reports and analytical content',
997 'context_types' => ['post', 'page'],
998 'priority' => 'medium'
999 ],
1000 'HowTo' => [
1001 'name' => 'HowTo',
1002 'description' => 'Step-by-step instructions and tutorials',
1003 'context_types' => ['post', 'page'],
1004 'priority' => 'medium'
1005 ],
1006 'FAQPage' => [
1007 'name' => 'FAQPage',
1008 'description' => 'Frequently Asked Questions pages',
1009 'context_types' => ['page', 'post'],
1010 'priority' => 'high'
1011 ],
1012 'Event' => [
1013 'name' => 'Event',
1014 'description' => 'Events, conferences, and gatherings',
1015 'context_types' => ['post', 'page'],
1016 'priority' => 'medium'
1017 ],
1018 'Product' => [
1019 'name' => 'Product',
1020 'description' => 'Products for e-commerce and retail',
1021 'context_types' => ['product', 'post', 'page'],
1022 'priority' => 'critical'
1023 ],
1024 'SoftwareApplication' => [
1025 'name' => 'SoftwareApplication',
1026 'description' => 'Software applications and web apps',
1027 'context_types' => ['post', 'page'],
1028 'priority' => 'high'
1029 ]
1030 ] + $site_schema_types;
1031
1032 // Return appropriate schema types based on context
1033 $schema_types = ($context === 'metabox') ? $all_schema_types : $site_schema_types;
1034
1035 return new WP_REST_Response([
1036 'success' => true,
1037 'data' => $schema_types,
1038 'message' => 'Schema types retrieved successfully'
1039 ], 200);
1040 }
1041
1042 /**
1043 * Get deployed schemas
1044 *
1045 * @since 1.0.0
1046 *
1047 * @param WP_REST_Request $request Request object
1048 * @return WP_REST_Response|WP_Error Response object or error
1049 */
1050 public function get_deployed_schemas(WP_REST_Request $request) {
1051 try {
1052 // SECURITY: this route reads the schema deployed against a specific
1053 // object. The thinkrank_schema capability authorises the section, not
1054 // every post on the site, so the object itself has to be authorised
1055 // before the read (#385).
1056 $context = $this->resolve_request_context($request);
1057 if (is_wp_error($context)) {
1058 return $context;
1059 }
1060 [$context_type, $context_id] = $context;
1061
1062 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1063
1064 return new WP_REST_Response([
1065 'success' => true,
1066 'data' => $deployed_schemas,
1067 'message' => 'Deployed schemas retrieved successfully'
1068 ], 200);
1069
1070 } catch (\Exception $e) {
1071 return new WP_Error(
1072 'deployed_schemas_failed',
1073 'Failed to retrieve deployed schemas: ' . $e->getMessage(),
1074 ['status' => 500]
1075 );
1076 }
1077 }
1078
1079 /**
1080 * Get schema for specific context
1081 *
1082 * @since 1.0.0
1083 *
1084 * @param WP_REST_Request $request Request object
1085 * @return WP_REST_Response|WP_Error Response object or error
1086 */
1087 public function get_context_schema(WP_REST_Request $request) {
1088 try {
1089 $context_type = $request->get_param('context_type');
1090 $context_id = (int) $request->get_param('context_id');
1091
1092 // Validate context and the caller's access to it. Returns true or a
1093 // WP_Error carrying the right status (400 shape, 403 authorization).
1094 $context_validation = $this->validate_context($context_type, $context_id);
1095 if (is_wp_error($context_validation)) {
1096 return $context_validation;
1097 }
1098
1099 // Get schema output data
1100 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
1101
1102 return new WP_REST_Response([
1103 'success' => true,
1104 'data' => $schema_data,
1105 'message' => 'Context schema retrieved successfully'
1106 ], 200);
1107
1108 } catch (\Exception $e) {
1109 return new WP_Error(
1110 'retrieval_failed',
1111 'Schema retrieval failed: ' . $e->getMessage(),
1112 ['status' => 500]
1113 );
1114 }
1115 }
1116
1117 /**
1118 * Optimize rich snippets
1119 *
1120 * @since 1.0.0
1121 *
1122 * @param WP_REST_Request $request Request object
1123 * @return WP_REST_Response|WP_Error Response object or error
1124 */
1125 public function optimize_rich_snippets(WP_REST_Request $request) {
1126 try {
1127 $user_id = get_current_user_id();
1128
1129 // SECURITY: Validate user permissions and rate limiting
1130 $permission_check = $this->input_validator->validate_user_permissions('optimize', $user_id);
1131 if (!$permission_check['valid']) {
1132 return new WP_Error(
1133 'permission_denied',
1134 implode(', ', $permission_check['errors']),
1135 ['status' => 403]
1136 );
1137 }
1138
1139 $schema_data = $request->get_param('schema_data');
1140 $schema_type = $request->get_param('schema_type');
1141 $options = $request->get_param('options') ?? [];
1142
1143 // Validate input
1144 if (empty($schema_data) || empty($schema_type)) {
1145 return new WP_Error(
1146 'missing_parameters',
1147 'Schema data and type are required',
1148 ['status' => 400]
1149 );
1150 }
1151
1152 // SECURITY: Validate and sanitize schema data using input validator,
1153 // the same way generate/validate/deploy do — this route must not be
1154 // the one path that hands a raw client blob to the schema manager.
1155 if (!is_array($schema_data)) {
1156 return new WP_Error(
1157 'invalid_schema_data',
1158 'Schema data must be an array/object',
1159 ['status' => 400]
1160 );
1161 }
1162
1163 $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
1164 if (!$input_validation['valid']) {
1165 return new WP_Error(
1166 'schema_validation_failed',
1167 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
1168 [
1169 'status' => 400,
1170 'validation_errors' => $input_validation['errors'],
1171 'validation_warnings' => $input_validation['warnings']
1172 ]
1173 );
1174 }
1175
1176 // SECURITY: Sanitize options
1177 $options = $this->input_validator->sanitize_options($options);
1178
1179 // Optimize rich snippets with the sanitized data
1180 $optimization_results = $this->schema_manager->optimize_rich_snippets(
1181 $input_validation['sanitized_data'],
1182 $schema_type,
1183 $options
1184 );
1185
1186 return new WP_REST_Response([
1187 'success' => true,
1188 'data' => $optimization_results,
1189 'message' => 'Rich snippets optimization completed'
1190 ], 200);
1191
1192 } catch (\Exception $e) {
1193 return new WP_Error(
1194 'optimization_failed',
1195 'Rich snippets optimization failed: ' . $e->getMessage(),
1196 ['status' => 500]
1197 );
1198 }
1199 }
1200
1201 /**
1202 * Get schema performance data
1203 *
1204 * @since 1.0.0
1205 *
1206 * @param WP_REST_Request $request Request object
1207 * @return WP_REST_Response|WP_Error Response object or error
1208 */
1209 public function get_schema_performance(WP_REST_Request $request) {
1210 try {
1211 $context_type = $request->get_param('context_type');
1212 $context_id = (int) $request->get_param('context_id');
1213 $options = $request->get_param('options') ?? [];
1214
1215 // Validate context and the caller's access to it. Returns true or a
1216 // WP_Error carrying the right status (400 shape, 403 authorization).
1217 $context_validation = $this->validate_context($context_type, $context_id);
1218 if (is_wp_error($context_validation)) {
1219 return $context_validation;
1220 }
1221
1222 // Track schema performance
1223 $performance_data = $this->schema_manager->track_schema_performance(
1224 $context_type,
1225 $context_id,
1226 $options
1227 );
1228
1229 return new WP_REST_Response([
1230 'success' => true,
1231 'data' => $performance_data,
1232 'message' => 'Schema performance data retrieved successfully'
1233 ], 200);
1234
1235 } catch (\Exception $e) {
1236 return new WP_Error(
1237 'performance_tracking_failed',
1238 'Schema performance tracking failed: ' . $e->getMessage(),
1239 ['status' => 500]
1240 );
1241 }
1242 }
1243
1244 /**
1245 * Get schema preview
1246 *
1247 * @since 1.0.0
1248 *
1249 * @param WP_REST_Request $request Request object
1250 * @return WP_REST_Response|WP_Error Response object or error
1251 */
1252 public function get_schema_preview(WP_REST_Request $request) {
1253 try {
1254 $schema_data = $request->get_param('schema_data');
1255 $schema_type = $request->get_param('schema_type');
1256
1257 // Validate input
1258 if (empty($schema_data) || empty($schema_type)) {
1259 return new WP_Error(
1260 'missing_parameters',
1261 'Schema data and type are required',
1262 ['status' => 400]
1263 );
1264 }
1265
1266 // Generate preview
1267 $preview_data = $this->generate_preview($schema_data, $schema_type);
1268
1269 return new WP_REST_Response([
1270 'success' => true,
1271 'data' => $preview_data,
1272 'message' => 'Schema preview generated successfully'
1273 ], 200);
1274
1275 } catch (\Exception $e) {
1276 return new WP_Error(
1277 'preview_failed',
1278 'Schema preview generation failed: ' . $e->getMessage(),
1279 ['status' => 500]
1280 );
1281 }
1282 }
1283
1284 /**
1285 * Bulk operations for schema management
1286 *
1287 * @since 1.0.0
1288 *
1289 * @param WP_REST_Request $request Request object
1290 * @return WP_REST_Response|WP_Error Response object or error
1291 *
1292 * @throws \Exception On failure.
1293 */
1294 public function bulk_operations(WP_REST_Request $request) {
1295 try {
1296 $user_id = get_current_user_id();
1297
1298 // SECURITY: Validate user permissions and rate limiting
1299 $permission_check = $this->input_validator->validate_user_permissions('bulk_operations', $user_id);
1300 if (!$permission_check['valid']) {
1301 return new WP_Error(
1302 'permission_denied',
1303 implode(', ', $permission_check['errors']),
1304 ['status' => 403]
1305 );
1306 }
1307
1308 $operation = $request->get_param('operation');
1309 $items = $request->get_param('items') ?? [];
1310 $options = $request->get_param('options') ?? [];
1311
1312 // Validate input
1313 if (empty($operation) || empty($items)) {
1314 return new WP_Error(
1315 'missing_parameters',
1316 'Operation and items are required',
1317 ['status' => 400]
1318 );
1319 }
1320
1321 // Defensive recheck of the item cap (the REST arg maxItems already
1322 // enforces it, but never process an unbounded batch even if that
1323 // schema is bypassed).
1324 if (count($items) > self::MAX_BULK_ITEMS) {
1325 return new WP_Error(
1326 'too_many_items',
1327 sprintf('Bulk operations are limited to %d items per request.', self::MAX_BULK_ITEMS),
1328 ['status' => 400]
1329 );
1330 }
1331
1332 $results = [];
1333 $errors = [];
1334
1335 foreach ($items as $item) {
1336 try {
1337 if (!is_array($item)) {
1338 throw new \Exception('Invalid bulk item');
1339 }
1340
1341 // SECURITY: apply the same per-item context-ownership and
1342 // schema validation the single-item routes enforce, and carry
1343 // the validators' NORMALIZED output forward to dispatch. The
1344 // bulk path previously dispatched raw context_id / schema_data
1345 // with no ownership (IDOR) or size/depth/type checks, and even
1346 // after validating still passed the raw item fields on.
1347 $item_context_type = isset($item['context_type']) ? (string) $item['context_type'] : '';
1348 $item_context_id = isset($item['context_id']) ? (int) $item['context_id'] : null;
1349
1350 // Sanitized values actually dispatched (default to the raw
1351 // context for the validate operation, which has no context).
1352 $context_type = $item_context_type;
1353 $context_id = $item_context_id;
1354
1355 if ($operation === 'generate' || $operation === 'deploy') {
1356 $context_check = $this->input_validator->validate_context_parameters(
1357 $item_context_type,
1358 $item_context_id,
1359 $user_id
1360 );
1361 if (!$context_check['valid']) {
1362 throw new \Exception(implode(', ', $context_check['errors']));
1363 }
1364 // Use the sanitized context, matching the single routes.
1365 $context_type = $context_check['sanitized_data']['context_type'];
1366 $context_id = $context_check['sanitized_data']['context_id'];
1367 }
1368
1369 // Sanitize shared options once per item, as the single routes do.
1370 $item_options = $this->input_validator->sanitize_options($options);
1371
1372 switch ($operation) {
1373 case 'generate':
1374 // Sanitize schema types like the single generate route.
1375 $raw_types = (isset($item['schema_types']) && is_array($item['schema_types']))
1376 ? $item['schema_types']
1377 : [];
1378 $schema_types = [];
1379 foreach ($raw_types as $type) {
1380 $type = sanitize_text_field((string) $type);
1381 if ($type !== '') {
1382 $schema_types[] = $type;
1383 }
1384 }
1385 if (empty($schema_types)) {
1386 throw new \Exception('schema_types is required');
1387 }
1388 $result = $this->schema_manager->generate_schema_markup(
1389 $context_type,
1390 $context_id,
1391 $schema_types,
1392 $item_options
1393 );
1394 break;
1395 case 'validate':
1396 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1397 throw new \Exception('schema_data is required');
1398 }
1399 $schema_type = '';
1400 if (isset($item['schema_type']) && is_string($item['schema_type'])) {
1401 $schema_type = sanitize_text_field($item['schema_type']);
1402 } elseif (isset($item['schema_data']['@type']) && is_string($item['schema_data']['@type'])) {
1403 $schema_type = sanitize_text_field($item['schema_data']['@type']);
1404 }
1405 $data_check = $this->input_validator->validate_schema_data($item['schema_data'], $schema_type);
1406 if (!$data_check['valid']) {
1407 throw new \Exception(implode(', ', $data_check['errors']));
1408 }
1409 // Validate the SANITIZED data, not the raw payload.
1410 $result = $this->schema_manager->validate_schema_markup(
1411 $data_check['sanitized_data'],
1412 $schema_type,
1413 $item_options
1414 );
1415 break;
1416 case 'deploy':
1417 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1418 throw new \Exception('schema_data is required');
1419 }
1420 // Mirror the single deploy route: validate EACH schema
1421 // entry in the collection (type resolution + default
1422 // @type/@context) and build a sanitized collection,
1423 // rather than validating the whole map as one schema.
1424 $sanitized_schema_data = [];
1425 foreach ($item['schema_data'] as $schema_key => $schema_content) {
1426 $schema_key = sanitize_text_field((string) $schema_key);
1427 if (!is_array($schema_content)) {
1428 throw new \Exception("Schema content for {$schema_key} must be an array");
1429 }
1430 $schema_type = isset($schema_content['@type'])
1431 ? sanitize_text_field($schema_content['@type'])
1432 : $schema_key;
1433 if (!isset($schema_content['@type'])) {
1434 $schema_content['@type'] = $schema_type;
1435 }
1436 if (!isset($schema_content['@context'])) {
1437 $schema_content['@context'] = 'https://schema.org';
1438 }
1439 $data_check = $this->input_validator->validate_schema_data($schema_content, $schema_type);
1440 if (!$data_check['valid']) {
1441 throw new \Exception("Schema validation failed for {$schema_type}: " . implode(', ', $data_check['errors']));
1442 }
1443 $sanitized_schema_data[$schema_key] = $data_check['sanitized_data'];
1444 }
1445 $result = $this->schema_manager->deploy_schema_markup(
1446 $context_type,
1447 $context_id,
1448 $sanitized_schema_data,
1449 $item_options
1450 );
1451 break;
1452 default:
1453 throw new \Exception("Unsupported operation: {$operation}");
1454 }
1455
1456 $results[] = [
1457 'item' => $item,
1458 'success' => true,
1459 'data' => $result
1460 ];
1461
1462 } catch (\Exception $e) {
1463 $errors[] = [
1464 'item' => $item,
1465 'error' => $e->getMessage()
1466 ];
1467 }
1468 }
1469
1470 return new WP_REST_Response([
1471 'success' => empty($errors),
1472 'data' => [
1473 'results' => $results,
1474 'errors' => $errors,
1475 'total_processed' => count($items),
1476 'successful' => count($results),
1477 'failed' => count($errors)
1478 ],
1479 'message' => "Bulk {$operation} operation completed"
1480 ], 200);
1481
1482 } catch (\Exception $e) {
1483 return new WP_Error(
1484 'bulk_operation_failed',
1485 'Bulk operation failed: ' . $e->getMessage(),
1486 ['status' => 500]
1487 );
1488 }
1489 }
1490
1491 /**
1492 * Permission callbacks
1493 */
1494
1495 /**
1496 * Check permissions for schema generation with CSRF protection
1497 *
1498 * @since 1.0.0
1499 *
1500 * @param WP_REST_Request $request Request object
1501 * @return bool Permission status
1502 */
1503 public function check_generate_permissions(WP_REST_Request $request): bool {
1504 // Gate on the Role Manager's schema capability, like the read and
1505 // settings routes. Core post caps were both too loose in principle and
1506 // too strict in practice: a role granted schema access but without
1507 // publish_posts could not deploy (#457).
1508 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1509 return false;
1510 }
1511
1512 // SECURITY: Verify nonce for CSRF protection
1513 return $this->verify_request_nonce($request);
1514 }
1515
1516 /**
1517 * Check permissions for schema validation with CSRF protection
1518 *
1519 * @since 1.0.0
1520 *
1521 * @param WP_REST_Request $request Request object
1522 * @return bool Permission status
1523 */
1524 public function check_validate_permissions(WP_REST_Request $request): bool {
1525 // Gate on the Role Manager's schema capability, like the read and
1526 // settings routes. Core post caps were both too loose in principle and
1527 // too strict in practice: a role granted schema access but without
1528 // publish_posts could not deploy (#457).
1529 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1530 return false;
1531 }
1532
1533 // SECURITY: Verify nonce for CSRF protection
1534 return $this->verify_request_nonce($request);
1535 }
1536
1537 /**
1538 * Check permissions for schema deployment with CSRF protection
1539 *
1540 * @since 1.0.0
1541 *
1542 * @param WP_REST_Request $request Request object
1543 * @return bool Permission status
1544 */
1545 public function check_deploy_permissions(WP_REST_Request $request): bool {
1546 // Gate on the Role Manager's schema capability, like the read and
1547 // settings routes. Core post caps were both too loose in principle and
1548 // too strict in practice: a role granted schema access but without
1549 // publish_posts could not deploy (#457).
1550 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1551 return false;
1552 }
1553
1554 // SECURITY: Verify nonce for CSRF protection
1555 return $this->verify_request_nonce($request);
1556 }
1557
1558 /**
1559 * Check permissions for reading schema data
1560 *
1561 * @since 1.0.0
1562 *
1563 * @param WP_REST_Request $request Request object
1564 * @return bool Permission status
1565 */
1566 public function check_read_permissions(WP_REST_Request $request): bool {
1567 // Schema config + deployed JSON-LD are not subscriber-visible — require
1568 // the same Schema management capability as the write routes.
1569 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema');
1570 }
1571
1572 /**
1573 * Check permissions for schema optimization with CSRF protection
1574 *
1575 * @since 1.0.0
1576 *
1577 * @param WP_REST_Request $request Request object
1578 * @return bool Permission status
1579 */
1580 public function check_optimize_permissions(WP_REST_Request $request): bool {
1581 // Gate on the Role Manager's schema capability, like the read and
1582 // settings routes. Core post caps were both too loose in principle and
1583 // too strict in practice: a role granted schema access but without
1584 // publish_posts could not deploy (#457).
1585 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1586 return false;
1587 }
1588
1589 // SECURITY: Verify nonce for CSRF protection
1590 return $this->verify_request_nonce($request);
1591 }
1592
1593 /**
1594 * Check permissions for bulk operations with CSRF protection
1595 *
1596 * @since 1.0.0
1597 *
1598 * @param WP_REST_Request $request Request object
1599 * @return bool Permission status
1600 */
1601 public function check_bulk_permissions(WP_REST_Request $request): bool {
1602 // Check user capability
1603 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1604 return false;
1605 }
1606
1607 // SECURITY: Verify nonce for CSRF protection
1608 return $this->verify_request_nonce($request);
1609 }
1610
1611 /**
1612 * Check permissions for managing schema settings with CSRF protection
1613 *
1614 * @since 1.0.0
1615 *
1616 * @param WP_REST_Request $request Request object
1617 * @return bool Permission status
1618 */
1619 public function check_manage_permissions(WP_REST_Request $request): bool {
1620 // Check user capability
1621 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1622 return false;
1623 }
1624
1625 // SECURITY: Verify nonce for CSRF protection (only for POST requests)
1626 if ($request->get_method() === 'POST') {
1627 return $this->verify_request_nonce($request);
1628 }
1629
1630 return true;
1631 }
1632
1633 /**
1634 * Helper methods
1635 */
1636
1637 // verify_request_nonce() now comes from the shared CSRF_Protection trait
1638 // used by the other endpoints; the local copy was identical (#457).
1639
1640 /**
1641 * Generate schema preview
1642 *
1643 * @since 1.0.0
1644 *
1645 * @param array $schema_data Schema data
1646 * @param string $schema_type Schema type
1647 * @return array Preview data
1648 */
1649 private function generate_preview(array $schema_data, string $schema_type): array {
1650 return [
1651 'rich_snippets' => [
1652 $schema_type => $this->format_rich_snippet_preview($schema_data, $schema_type)
1653 ],
1654 'json_ld' => wp_json_encode($schema_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
1655 'validation_status' => 'pending'
1656 ];
1657 }
1658
1659 /**
1660 * Format rich snippet preview for specific schema type
1661 *
1662 * @since 1.0.0
1663 *
1664 * @param array $schema_data Schema data
1665 * @param string $schema_type Schema type
1666 * @return array Formatted preview data
1667 */
1668 private function format_rich_snippet_preview(array $schema_data, string $schema_type): array {
1669 switch ($schema_type) {
1670 case 'Organization':
1671 return [
1672 'title' => $schema_data['name'] ?? 'Organization Name',
1673 'url' => $schema_data['url'] ?? home_url(),
1674 'description' => $schema_data['description'] ?? 'Organization description',
1675 'additional_info' => $this->format_organization_info($schema_data)
1676 ];
1677
1678 case 'LocalBusiness':
1679 return [
1680 'title' => $schema_data['name'] ?? 'Business Name',
1681 'url' => $schema_data['url'] ?? home_url(),
1682 'description' => $schema_data['description'] ?? 'Business description',
1683 'additional_info' => $this->format_local_business_info($schema_data)
1684 ];
1685
1686 case 'Article':
1687 return [
1688 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Article Title',
1689 'url' => $schema_data['url'] ?? home_url(),
1690 'description' => $schema_data['description'] ?? 'Article description',
1691 'additional_info' => $this->format_article_info($schema_data)
1692 ];
1693
1694 default:
1695 return [
1696 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1697 'url' => $schema_data['url'] ?? home_url(),
1698 'description' => $schema_data['description'] ?? 'Description',
1699 'additional_info' => ''
1700 ];
1701 }
1702 }
1703 private function format_organization_info(array $schema_data): string {
1704 $info = [];
1705
1706 if (!empty($schema_data['contactPoint']['telephone'])) {
1707 $info[] = '📞 ' . $schema_data['contactPoint']['telephone'];
1708 }
1709
1710 if (!empty($schema_data['contactPoint']['email'])) {
1711 $info[] = '✉️ ' . $schema_data['contactPoint']['email'];
1712 }
1713
1714 if (!empty($schema_data['address']['streetAddress'])) {
1715 $info[] = '📍 ' . $schema_data['address']['streetAddress'];
1716 }
1717
1718 return implode(' • ', $info);
1719 }
1720
1721 /**
1722 * Format local business additional info
1723 *
1724 * @since 1.0.0
1725 *
1726 * @param array $schema_data Schema data
1727 * @return string Formatted info
1728 */
1729 private function format_local_business_info(array $schema_data): string {
1730 $info = [];
1731
1732 // Address
1733 if (!empty($schema_data['address'])) {
1734 $address = $schema_data['address'];
1735 $address_parts = [];
1736
1737 if (!empty($address['streetAddress'])) {
1738 $address_parts[] = $address['streetAddress'];
1739 }
1740 if (!empty($address['addressLocality'])) {
1741 $address_parts[] = $address['addressLocality'];
1742 }
1743
1744 if (!empty($address_parts)) {
1745 $info[] = '📍 ' . implode(', ', $address_parts);
1746 }
1747 }
1748
1749 // Phone
1750 if (!empty($schema_data['telephone'])) {
1751 $info[] = '📞 ' . $schema_data['telephone'];
1752 }
1753
1754 // Opening hours
1755 if (!empty($schema_data['openingHours'])) {
1756 $hours = is_array($schema_data['openingHours'])
1757 ? implode(', ', $schema_data['openingHours'])
1758 : $schema_data['openingHours'];
1759 $info[] = '🕒 ' . $hours;
1760 }
1761
1762 return implode(' • ', $info);
1763 }
1764
1765 /**
1766 * Format article additional info
1767 *
1768 * @since 1.0.0
1769 *
1770 * @param array $schema_data Schema data
1771 * @return string Formatted info
1772 */
1773 private function format_article_info(array $schema_data): string {
1774 $info = [];
1775
1776 if (!empty($schema_data['author']['name'])) {
1777 $info[] = '👤 By ' . $schema_data['author']['name'];
1778 }
1779
1780 if (!empty($schema_data['datePublished'])) {
1781 $info[] = '�
1782 ' . gmdate('M j, Y', strtotime($schema_data['datePublished']));
1783 }
1784
1785 if (!empty($schema_data['publisher']['name'])) {
1786 $info[] = '🏢 ' . $schema_data['publisher']['name'];
1787 }
1788
1789 return implode(' • ', $info);
1790 }
1791
1792 /**
1793 * Argument validation methods
1794 */
1795
1796 /**
1797 * Get arguments for schema generation endpoint
1798 *
1799 * @since 1.0.0
1800 *
1801 * @return array Arguments array
1802 */
1803 private function get_generate_schema_args(): array {
1804 return [
1805 'context_type' => [
1806 'required' => true,
1807 'type' => 'string',
1808 'enum' => ['site', 'post', 'page', 'product'],
1809 'description' => 'Context type for schema generation'
1810 ],
1811 'context_id' => [
1812 'required' => false,
1813 'type' => 'integer',
1814 'minimum' => 1,
1815 'description' => 'Context ID (not required for site context)'
1816 ],
1817 'schema_types' => [
1818 // generate_schema() rejects a missing or empty value with a 400,
1819 // so the schema has to say so too.
1820 'required' => true,
1821 'type' => 'array',
1822 'minItems' => 1,
1823 'items' => [
1824 'type' => 'string',
1825 'enum' => [
1826 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1827 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1828 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1829 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject',
1830 // The WebPage family (#624). This route is the one the
1831 // per-page selector calls, and it did not accept even
1832 // WebPage — so every entry in that dropdown was refused
1833 // with a 400 before any of the registries below were
1834 // consulted. Appended so existing ordering is unchanged.
1835 'WebPage', 'AboutPage', 'ContactPage', 'ProfilePage'
1836 ]
1837 ],
1838 'description' => 'Schema types to generate'
1839 ],
1840 'options' => [
1841 'required' => false,
1842 'type' => 'object',
1843 'description' => 'Additional generation options'
1844 ],
1845 'content_data' => [
1846 'required' => false,
1847 'type' => 'object',
1848 'description' => 'Custom content data to use for schema generation (overrides post data)',
1849 'properties' => [
1850 'title' => ['type' => 'string'],
1851 'description' => ['type' => 'string'],
1852 'content' => ['type' => 'string'],
1853 'focus_keyword' => ['type' => 'string'],
1854 'post_type' => ['type' => 'string'],
1855 'post_url' => ['type' => 'string']
1856 ]
1857 ]
1858 ];
1859 }
1860
1861 /**
1862 * Get arguments for schema validation endpoint
1863 *
1864 * @since 1.0.0
1865 *
1866 * @return array Arguments array
1867 */
1868 private function get_validate_schema_args(): array {
1869 return [
1870 'schema_data' => [
1871 'required' => true,
1872 'type' => 'object',
1873 'description' => 'Schema data to validate'
1874 ],
1875 'schema_type' => [
1876 'required' => true,
1877 'type' => 'string',
1878 'enum' => [
1879 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1880 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1881 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1882 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1883 // WebPage's subtypes, which validate exactly as it does (#624).
1884 'AboutPage', 'ContactPage', 'ProfilePage'
1885 ],
1886 'description' => 'Schema type'
1887 ],
1888 'options' => [
1889 'required' => false,
1890 'type' => 'object',
1891 'description' => 'Validation options'
1892 ]
1893 ];
1894 }
1895
1896 /**
1897 * Get arguments for schema deployment endpoint
1898 *
1899 * @since 1.0.0
1900 *
1901 * @return array Arguments array
1902 */
1903 private function get_deploy_schema_args(): array {
1904 return [
1905 'context_type' => [
1906 'required' => true,
1907 'type' => 'string',
1908 'enum' => ['site', 'post', 'page', 'product'],
1909 'description' => 'Context type for deployment'
1910 ],
1911 'context_id' => [
1912 'required' => false,
1913 'type' => 'integer',
1914 'minimum' => 1,
1915 'description' => 'Context ID (not required for site context)'
1916 ],
1917 'schema_data' => [
1918 'required' => true,
1919 'type' => 'object',
1920 'description' => 'Schema data to deploy'
1921 ],
1922 'options' => [
1923 'required' => false,
1924 'type' => 'object',
1925 'description' => 'Deployment options'
1926 ]
1927 ];
1928 }
1929
1930 /**
1931 * Get arguments for schema optimization endpoint
1932 *
1933 * @since 1.0.0
1934 *
1935 * @return array Arguments array
1936 */
1937 private function get_optimize_schema_args(): array {
1938 return [
1939 'schema_data' => [
1940 'required' => true,
1941 'type' => 'object',
1942 'description' => 'Schema data to optimize'
1943 ],
1944 'schema_type' => [
1945 'required' => true,
1946 'type' => 'string',
1947 'enum' => [
1948 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1949 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1950 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1951 // WebPage's subtypes, which carry the same properties (#624).
1952 'AboutPage', 'ContactPage', 'ProfilePage'
1953 ],
1954 'description' => 'Schema type'
1955 ],
1956 'options' => [
1957 'required' => false,
1958 'type' => 'object',
1959 'description' => 'Optimization options'
1960 ]
1961 ];
1962 }
1963
1964 /**
1965 * Get arguments for schema preview endpoint
1966 *
1967 * @since 1.0.0
1968 *
1969 * @return array Arguments array
1970 */
1971 private function get_preview_schema_args(): array {
1972 return [
1973 'schema_data' => [
1974 'required' => true,
1975 'type' => 'object',
1976 'description' => 'Schema data to preview'
1977 ],
1978 'schema_type' => [
1979 'required' => true,
1980 'type' => 'string',
1981 'enum' => [
1982 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1983 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1984 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1985 // WebPage's subtypes, which carry the same properties (#624).
1986 'AboutPage', 'ContactPage', 'ProfilePage'
1987 ],
1988 'description' => 'Schema type'
1989 ]
1990 ];
1991 }
1992
1993 /**
1994 * Get arguments for bulk operations endpoint
1995 *
1996 * @since 1.0.0
1997 *
1998 * @return array Arguments array
1999 */
2000 private function get_bulk_operations_args(): array {
2001 return [
2002 'operation' => [
2003 'required' => true,
2004 'type' => 'string',
2005 'enum' => ['generate', 'validate', 'deploy'],
2006 'description' => 'Bulk operation type'
2007 ],
2008 'items' => [
2009 'required' => true,
2010 'type' => 'array',
2011 'items' => [
2012 'type' => 'object'
2013 ],
2014 // Bound aggregate request work: every item can trigger context
2015 // lookups, recursive schema validation, generation, and
2016 // deployment, so cap the count at the REST layer.
2017 'maxItems' => self::MAX_BULK_ITEMS,
2018 'description' => 'Items to process in bulk (max ' . self::MAX_BULK_ITEMS . ')'
2019 ],
2020 'options' => [
2021 'required' => false,
2022 'type' => 'object',
2023 'description' => 'Bulk operation options'
2024 ]
2025 ];
2026 }
2027
2028 /**
2029 * Get schema settings
2030 *
2031 * @since 1.0.0
2032 *
2033 * @param WP_REST_Request $request Request object
2034 * @return WP_REST_Response|WP_Error Response object or error
2035 */
2036 public function get_settings(WP_REST_Request $request) {
2037 try {
2038 // SECURITY: the settings this returns are per-object. save_settings()
2039 // already authorises the object; the read has to as well (#385).
2040 $context = $this->resolve_request_context($request);
2041 if (is_wp_error($context)) {
2042 return $context;
2043 }
2044 [$context_type, $context_id] = $context;
2045
2046 // Get settings from schema manager
2047 $settings = $this->schema_manager->get_settings($context_type, $context_id);
2048
2049 return new WP_REST_Response([
2050 'success' => true,
2051 'data' => [
2052 'settings' => $settings,
2053 'context_type' => $context_type,
2054 'context_id' => $context_id
2055 ],
2056 'message' => 'Schema settings retrieved successfully'
2057 ], 200);
2058
2059 } catch (\Exception $e) {
2060 return new WP_Error(
2061 'settings_fetch_failed',
2062 'Failed to retrieve schema settings: ' . $e->getMessage(),
2063 ['status' => 500]
2064 );
2065 }
2066 }
2067
2068 /**
2069 * Save schema settings
2070 *
2071 * @since 1.0.0
2072 *
2073 * @param WP_REST_Request $request Request object
2074 * @return WP_REST_Response|WP_Error Response object or error
2075 */
2076 public function save_settings(WP_REST_Request $request) {
2077 try {
2078 $settings = $request->get_param('settings');
2079 $context_type = $request->get_param('context_type') ?? 'site';
2080 $context_id = $request->get_param('context_id') ?? null;
2081
2082 // Validate input parameters
2083 if (empty($settings) || !is_array($settings)) {
2084 return new WP_Error(
2085 'invalid_settings',
2086 'Settings parameter is required and must be an array',
2087 ['status' => 400]
2088 );
2089 }
2090
2091 // SECURITY: For non-site contexts (post/page/product), verify the
2092 // caller can edit that specific object — same ownership gate the
2093 // generate/deploy routes use. Site context stays governed by the
2094 // thinkrank_schema capability via the Role Manager gate.
2095 $context_type = sanitize_key((string) $context_type);
2096 if ($context_type !== 'site') {
2097 $context_validation = $this->input_validator->validate_context_parameters(
2098 $context_type,
2099 $context_id !== null ? absint($context_id) : null,
2100 get_current_user_id()
2101 );
2102 if (!$context_validation['valid']) {
2103 return new WP_Error(
2104 'invalid_context',
2105 implode(', ', $context_validation['errors']),
2106 ['status' => 403]
2107 );
2108 }
2109 $context_type = $context_validation['sanitized_data']['context_type'];
2110 $context_id = $context_validation['sanitized_data']['context_id'];
2111 } else {
2112 // Site settings are keyed on a NULL context_id. Passing the
2113 // client's value straight through meant a stray context_id
2114 // wrote a row at an arbitrary id, returned 200, and was never
2115 // read back by anything (#470). validate_context_parameters()
2116 // already normalises this internally for other contexts.
2117 $context_id = null;
2118 }
2119
2120 // Drop unrecognized keys so arbitrary client-supplied keys aren't
2121 // persisted as settings rows (storage bloat / settings drift).
2122 $settings = $this->filter_known_setting_keys($settings, $context_type);
2123 if (empty($settings)) {
2124 return new WP_Error(
2125 'invalid_settings',
2126 'No recognized schema settings were provided',
2127 ['status' => 400]
2128 );
2129 }
2130
2131 // Get validation results for detailed error reporting
2132 $validation = $this->schema_manager->validate_settings($settings);
2133
2134 if (!$validation['valid']) {
2135 // Schema settings validation failed - details available in validation response
2136
2137 return new WP_Error(
2138 'validation_failed',
2139 'Schema settings validation failed',
2140 [
2141 'status' => 400,
2142 'validation_errors' => $validation['errors'],
2143 'validation_warnings' => $validation['warnings'] ?? [],
2144 'validation_suggestions' => $validation['suggestions'] ?? []
2145 ]
2146 );
2147 }
2148
2149 // Save settings using schema manager
2150 $success = $this->schema_manager->save_settings($context_type, $context_id, $settings);
2151
2152 if (!$success) {
2153 // Schema settings save failed - database operation unsuccessful
2154
2155 return new WP_Error(
2156 'settings_save_failed',
2157 'Failed to save schema settings to database',
2158 ['status' => 500]
2159 );
2160 }
2161
2162 return new WP_REST_Response([
2163 'success' => true,
2164 'data' => [
2165 'settings' => $settings,
2166 'context_type' => $context_type,
2167 'context_id' => $context_id,
2168 'validation' => $validation
2169 ],
2170 'message' => 'Schema settings saved successfully'
2171 ], 200);
2172
2173 } catch (\Exception $e) {
2174 // Schema settings save exception - error details in response
2175
2176 return new WP_Error(
2177 'settings_update_failed',
2178 'Failed to update schema settings: ' . $e->getMessage(),
2179 ['status' => 500]
2180 );
2181 }
2182 }
2183
2184 /**
2185 * Restrict a settings payload to recognized keys.
2186 *
2187 * The known set is the context's default settings plus a few keys that are
2188 * legitimately stored/consumed elsewhere (site-identity/local-SEO fields and
2189 * the schema settings schema) but not seeded into the defaults. Filterable
2190 * so Pro/integrations can register additional keys.
2191 *
2192 * @param array $settings Incoming settings.
2193 * @param string $context_type Context type (site/post/page/product).
2194 * @return array Settings limited to known keys.
2195 */
2196 private function filter_known_setting_keys(array $settings, string $context_type): array {
2197 // Defer to the manager instead of maintaining a parallel list here.
2198 // The endpoint's own list ignored additional_setting_keys() and
2199 // dynamic_setting_key_patterns() — the mechanism #452 added so new form
2200 // families stop getting dropped — so the two disagreed in both
2201 // directions: the four enable_*_schema toggles and the software_/howto_/
2202 // product_ families were dropped here but accepted by the manager, while
2203 // deployment_method, site_name and performance_tracking survived here
2204 // only to be dropped one layer down (#470).
2205 $known = [];
2206
2207 foreach (array_keys($settings) as $key) {
2208 if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) {
2209 $known[] = (string) $key;
2210 }
2211 }
2212
2213 /**
2214 * Filter the schema setting keys the REST endpoint will persist.
2215 *
2216 * @since 1.13.0
2217 *
2218 * @param string[] $known Keys accepted by the schema manager.
2219 * @param string $context_type Context type.
2220 */
2221 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2222
2223 return array_intersect_key($settings, array_flip($known));
2224 }
2225
2226 /**
2227 * Get arguments for settings endpoints
2228 *
2229 * @since 1.0.0
2230 *
2231 * @return array Arguments array
2232 */
2233 private function get_settings_args(): array {
2234 return [
2235 'settings' => [
2236 'required' => true,
2237 'type' => 'object',
2238 'description' => 'Schema settings object'
2239 ],
2240 'context_type' => [
2241 'required' => false,
2242 'type' => 'string',
2243 'default' => 'site',
2244 'enum' => ['site', 'post', 'page', 'product'],
2245 'description' => 'Context type for settings'
2246 ],
2247 'context_id' => [
2248 'required' => false,
2249 'type' => 'integer',
2250 'minimum' => 1,
2251 'description' => 'Context ID for settings'
2252 ]
2253 ];
2254 }
2255 }
2256