PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.29.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.29.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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 1.29.0, at includes/api/class-schema-endpoint.php

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