PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
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 2.0.1, at includes/api/class-schema-endpoint.php

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