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

2,128 lines 77.2 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 and the caller's access to it. Returns true or a
945 // WP_Error carrying the right status (400 shape, 403 authorization).
946 $context_validation = $this->validate_context($context_type, $context_id);
947 if (is_wp_error($context_validation)) {
948 return $context_validation;
949 }
950
951 // Get schema output data
952 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
953
954 return new WP_REST_Response([
955 'success' => true,
956 'data' => $schema_data,
957 'message' => 'Context schema retrieved successfully'
958 ], 200);
959
960 } catch (\Exception $e) {
961 return new WP_Error(
962 'retrieval_failed',
963 'Schema retrieval failed: ' . $e->getMessage(),
964 ['status' => 500]
965 );
966 }
967 }
968
969 /**
970 * Optimize rich snippets
971 *
972 * @since 1.0.0
973 *
974 * @param WP_REST_Request $request Request object
975 * @return WP_REST_Response|WP_Error Response object or error
976 */
977 public function optimize_rich_snippets(WP_REST_Request $request) {
978 try {
979 $user_id = get_current_user_id();
980
981 // SECURITY: Validate user permissions and rate limiting
982 $permission_check = $this->input_validator->validate_user_permissions('optimize', $user_id);
983 if (!$permission_check['valid']) {
984 return new WP_Error(
985 'permission_denied',
986 implode(', ', $permission_check['errors']),
987 ['status' => 403]
988 );
989 }
990
991 $schema_data = $request->get_param('schema_data');
992 $schema_type = $request->get_param('schema_type');
993 $options = $request->get_param('options') ?? [];
994
995 // Validate input
996 if (empty($schema_data) || empty($schema_type)) {
997 return new WP_Error(
998 'missing_parameters',
999 'Schema data and type are required',
1000 ['status' => 400]
1001 );
1002 }
1003
1004 // SECURITY: Validate and sanitize schema data using input validator,
1005 // the same way generate/validate/deploy do — this route must not be
1006 // the one path that hands a raw client blob to the schema manager.
1007 if (!is_array($schema_data)) {
1008 return new WP_Error(
1009 'invalid_schema_data',
1010 'Schema data must be an array/object',
1011 ['status' => 400]
1012 );
1013 }
1014
1015 $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
1016 if (!$input_validation['valid']) {
1017 return new WP_Error(
1018 'schema_validation_failed',
1019 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
1020 [
1021 'status' => 400,
1022 'validation_errors' => $input_validation['errors'],
1023 'validation_warnings' => $input_validation['warnings']
1024 ]
1025 );
1026 }
1027
1028 // SECURITY: Sanitize options
1029 $options = $this->input_validator->sanitize_options($options);
1030
1031 // Optimize rich snippets with the sanitized data
1032 $optimization_results = $this->schema_manager->optimize_rich_snippets(
1033 $input_validation['sanitized_data'],
1034 $schema_type,
1035 $options
1036 );
1037
1038 return new WP_REST_Response([
1039 'success' => true,
1040 'data' => $optimization_results,
1041 'message' => 'Rich snippets optimization completed'
1042 ], 200);
1043
1044 } catch (\Exception $e) {
1045 return new WP_Error(
1046 'optimization_failed',
1047 'Rich snippets optimization failed: ' . $e->getMessage(),
1048 ['status' => 500]
1049 );
1050 }
1051 }
1052
1053 /**
1054 * Get schema performance data
1055 *
1056 * @since 1.0.0
1057 *
1058 * @param WP_REST_Request $request Request object
1059 * @return WP_REST_Response|WP_Error Response object or error
1060 */
1061 public function get_schema_performance(WP_REST_Request $request) {
1062 try {
1063 $context_type = $request->get_param('context_type');
1064 $context_id = (int) $request->get_param('context_id');
1065 $options = $request->get_param('options') ?? [];
1066
1067 // Validate context and the caller's access to it. Returns true or a
1068 // WP_Error carrying the right status (400 shape, 403 authorization).
1069 $context_validation = $this->validate_context($context_type, $context_id);
1070 if (is_wp_error($context_validation)) {
1071 return $context_validation;
1072 }
1073
1074 // Track schema performance
1075 $performance_data = $this->schema_manager->track_schema_performance(
1076 $context_type,
1077 $context_id,
1078 $options
1079 );
1080
1081 return new WP_REST_Response([
1082 'success' => true,
1083 'data' => $performance_data,
1084 'message' => 'Schema performance data retrieved successfully'
1085 ], 200);
1086
1087 } catch (\Exception $e) {
1088 return new WP_Error(
1089 'performance_tracking_failed',
1090 'Schema performance tracking failed: ' . $e->getMessage(),
1091 ['status' => 500]
1092 );
1093 }
1094 }
1095
1096 /**
1097 * Get schema preview
1098 *
1099 * @since 1.0.0
1100 *
1101 * @param WP_REST_Request $request Request object
1102 * @return WP_REST_Response|WP_Error Response object or error
1103 */
1104 public function get_schema_preview(WP_REST_Request $request) {
1105 try {
1106 $schema_data = $request->get_param('schema_data');
1107 $schema_type = $request->get_param('schema_type');
1108
1109 // Validate input
1110 if (empty($schema_data) || empty($schema_type)) {
1111 return new WP_Error(
1112 'missing_parameters',
1113 'Schema data and type are required',
1114 ['status' => 400]
1115 );
1116 }
1117
1118 // Generate preview
1119 $preview_data = $this->generate_preview($schema_data, $schema_type);
1120
1121 return new WP_REST_Response([
1122 'success' => true,
1123 'data' => $preview_data,
1124 'message' => 'Schema preview generated successfully'
1125 ], 200);
1126
1127 } catch (\Exception $e) {
1128 return new WP_Error(
1129 'preview_failed',
1130 'Schema preview generation failed: ' . $e->getMessage(),
1131 ['status' => 500]
1132 );
1133 }
1134 }
1135
1136 /**
1137 * Bulk operations for schema management
1138 *
1139 * @since 1.0.0
1140 *
1141 * @param WP_REST_Request $request Request object
1142 * @return WP_REST_Response|WP_Error Response object or error
1143 *
1144 * @throws \Exception On failure.
1145 */
1146 public function bulk_operations(WP_REST_Request $request) {
1147 try {
1148 $user_id = get_current_user_id();
1149
1150 // SECURITY: Validate user permissions and rate limiting
1151 $permission_check = $this->input_validator->validate_user_permissions('bulk_operations', $user_id);
1152 if (!$permission_check['valid']) {
1153 return new WP_Error(
1154 'permission_denied',
1155 implode(', ', $permission_check['errors']),
1156 ['status' => 403]
1157 );
1158 }
1159
1160 $operation = $request->get_param('operation');
1161 $items = $request->get_param('items') ?? [];
1162 $options = $request->get_param('options') ?? [];
1163
1164 // Validate input
1165 if (empty($operation) || empty($items)) {
1166 return new WP_Error(
1167 'missing_parameters',
1168 'Operation and items are required',
1169 ['status' => 400]
1170 );
1171 }
1172
1173 // Defensive recheck of the item cap (the REST arg maxItems already
1174 // enforces it, but never process an unbounded batch even if that
1175 // schema is bypassed).
1176 if (count($items) > self::MAX_BULK_ITEMS) {
1177 return new WP_Error(
1178 'too_many_items',
1179 sprintf('Bulk operations are limited to %d items per request.', self::MAX_BULK_ITEMS),
1180 ['status' => 400]
1181 );
1182 }
1183
1184 $results = [];
1185 $errors = [];
1186
1187 foreach ($items as $item) {
1188 try {
1189 if (!is_array($item)) {
1190 throw new \Exception('Invalid bulk item');
1191 }
1192
1193 // SECURITY: apply the same per-item context-ownership and
1194 // schema validation the single-item routes enforce, and carry
1195 // the validators' NORMALIZED output forward to dispatch. The
1196 // bulk path previously dispatched raw context_id / schema_data
1197 // with no ownership (IDOR) or size/depth/type checks, and even
1198 // after validating still passed the raw item fields on.
1199 $item_context_type = isset($item['context_type']) ? (string) $item['context_type'] : '';
1200 $item_context_id = isset($item['context_id']) ? (int) $item['context_id'] : null;
1201
1202 // Sanitized values actually dispatched (default to the raw
1203 // context for the validate operation, which has no context).
1204 $context_type = $item_context_type;
1205 $context_id = $item_context_id;
1206
1207 if ($operation === 'generate' || $operation === 'deploy') {
1208 $context_check = $this->input_validator->validate_context_parameters(
1209 $item_context_type,
1210 $item_context_id,
1211 $user_id
1212 );
1213 if (!$context_check['valid']) {
1214 throw new \Exception(implode(', ', $context_check['errors']));
1215 }
1216 // Use the sanitized context, matching the single routes.
1217 $context_type = $context_check['sanitized_data']['context_type'];
1218 $context_id = $context_check['sanitized_data']['context_id'];
1219 }
1220
1221 // Sanitize shared options once per item, as the single routes do.
1222 $item_options = $this->input_validator->sanitize_options($options);
1223
1224 switch ($operation) {
1225 case 'generate':
1226 // Sanitize schema types like the single generate route.
1227 $raw_types = (isset($item['schema_types']) && is_array($item['schema_types']))
1228 ? $item['schema_types']
1229 : [];
1230 $schema_types = [];
1231 foreach ($raw_types as $type) {
1232 $type = sanitize_text_field((string) $type);
1233 if ($type !== '') {
1234 $schema_types[] = $type;
1235 }
1236 }
1237 if (empty($schema_types)) {
1238 throw new \Exception('schema_types is required');
1239 }
1240 $result = $this->schema_manager->generate_schema_markup(
1241 $context_type,
1242 $context_id,
1243 $schema_types,
1244 $item_options
1245 );
1246 break;
1247 case 'validate':
1248 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1249 throw new \Exception('schema_data is required');
1250 }
1251 $schema_type = '';
1252 if (isset($item['schema_type']) && is_string($item['schema_type'])) {
1253 $schema_type = sanitize_text_field($item['schema_type']);
1254 } elseif (isset($item['schema_data']['@type']) && is_string($item['schema_data']['@type'])) {
1255 $schema_type = sanitize_text_field($item['schema_data']['@type']);
1256 }
1257 $data_check = $this->input_validator->validate_schema_data($item['schema_data'], $schema_type);
1258 if (!$data_check['valid']) {
1259 throw new \Exception(implode(', ', $data_check['errors']));
1260 }
1261 // Validate the SANITIZED data, not the raw payload.
1262 $result = $this->schema_manager->validate_schema_markup(
1263 $data_check['sanitized_data'],
1264 $schema_type,
1265 $item_options
1266 );
1267 break;
1268 case 'deploy':
1269 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1270 throw new \Exception('schema_data is required');
1271 }
1272 // Mirror the single deploy route: validate EACH schema
1273 // entry in the collection (type resolution + default
1274 // @type/@context) and build a sanitized collection,
1275 // rather than validating the whole map as one schema.
1276 $sanitized_schema_data = [];
1277 foreach ($item['schema_data'] as $schema_key => $schema_content) {
1278 $schema_key = sanitize_text_field((string) $schema_key);
1279 if (!is_array($schema_content)) {
1280 throw new \Exception("Schema content for {$schema_key} must be an array");
1281 }
1282 $schema_type = isset($schema_content['@type'])
1283 ? sanitize_text_field($schema_content['@type'])
1284 : $schema_key;
1285 if (!isset($schema_content['@type'])) {
1286 $schema_content['@type'] = $schema_type;
1287 }
1288 if (!isset($schema_content['@context'])) {
1289 $schema_content['@context'] = 'https://schema.org';
1290 }
1291 $data_check = $this->input_validator->validate_schema_data($schema_content, $schema_type);
1292 if (!$data_check['valid']) {
1293 throw new \Exception("Schema validation failed for {$schema_type}: " . implode(', ', $data_check['errors']));
1294 }
1295 $sanitized_schema_data[$schema_key] = $data_check['sanitized_data'];
1296 }
1297 $result = $this->schema_manager->deploy_schema_markup(
1298 $context_type,
1299 $context_id,
1300 $sanitized_schema_data,
1301 $item_options
1302 );
1303 break;
1304 default:
1305 throw new \Exception("Unsupported operation: {$operation}");
1306 }
1307
1308 $results[] = [
1309 'item' => $item,
1310 'success' => true,
1311 'data' => $result
1312 ];
1313
1314 } catch (\Exception $e) {
1315 $errors[] = [
1316 'item' => $item,
1317 'error' => $e->getMessage()
1318 ];
1319 }
1320 }
1321
1322 return new WP_REST_Response([
1323 'success' => empty($errors),
1324 'data' => [
1325 'results' => $results,
1326 'errors' => $errors,
1327 'total_processed' => count($items),
1328 'successful' => count($results),
1329 'failed' => count($errors)
1330 ],
1331 'message' => "Bulk {$operation} operation completed"
1332 ], 200);
1333
1334 } catch (\Exception $e) {
1335 return new WP_Error(
1336 'bulk_operation_failed',
1337 'Bulk operation failed: ' . $e->getMessage(),
1338 ['status' => 500]
1339 );
1340 }
1341 }
1342
1343 /**
1344 * Permission callbacks
1345 */
1346
1347 /**
1348 * Check permissions for schema generation with CSRF protection
1349 *
1350 * @since 1.0.0
1351 *
1352 * @param WP_REST_Request $request Request object
1353 * @return bool Permission status
1354 */
1355 public function check_generate_permissions(WP_REST_Request $request): bool {
1356 // Check user capability
1357 if (!current_user_can('edit_posts')) {
1358 return false;
1359 }
1360
1361 // SECURITY: Verify nonce for CSRF protection
1362 return $this->verify_request_nonce($request);
1363 }
1364
1365 /**
1366 * Check permissions for schema validation with CSRF protection
1367 *
1368 * @since 1.0.0
1369 *
1370 * @param WP_REST_Request $request Request object
1371 * @return bool Permission status
1372 */
1373 public function check_validate_permissions(WP_REST_Request $request): bool {
1374 // Check user capability
1375 if (!current_user_can('edit_posts')) {
1376 return false;
1377 }
1378
1379 // SECURITY: Verify nonce for CSRF protection
1380 return $this->verify_request_nonce($request);
1381 }
1382
1383 /**
1384 * Check permissions for schema deployment with CSRF protection
1385 *
1386 * @since 1.0.0
1387 *
1388 * @param WP_REST_Request $request Request object
1389 * @return bool Permission status
1390 */
1391 public function check_deploy_permissions(WP_REST_Request $request): bool {
1392 // Check user capability
1393 if (!current_user_can('publish_posts')) {
1394 return false;
1395 }
1396
1397 // SECURITY: Verify nonce for CSRF protection
1398 return $this->verify_request_nonce($request);
1399 }
1400
1401 /**
1402 * Check permissions for reading schema data
1403 *
1404 * @since 1.0.0
1405 *
1406 * @param WP_REST_Request $request Request object
1407 * @return bool Permission status
1408 */
1409 public function check_read_permissions(WP_REST_Request $request): bool {
1410 // Schema config + deployed JSON-LD are not subscriber-visible — require
1411 // the same Schema management capability as the write routes.
1412 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema');
1413 }
1414
1415 /**
1416 * Check permissions for schema optimization with CSRF protection
1417 *
1418 * @since 1.0.0
1419 *
1420 * @param WP_REST_Request $request Request object
1421 * @return bool Permission status
1422 */
1423 public function check_optimize_permissions(WP_REST_Request $request): bool {
1424 // Check user capability
1425 if (!current_user_can('edit_posts')) {
1426 return false;
1427 }
1428
1429 // SECURITY: Verify nonce for CSRF protection
1430 return $this->verify_request_nonce($request);
1431 }
1432
1433 /**
1434 * Check permissions for bulk operations with CSRF protection
1435 *
1436 * @since 1.0.0
1437 *
1438 * @param WP_REST_Request $request Request object
1439 * @return bool Permission status
1440 */
1441 public function check_bulk_permissions(WP_REST_Request $request): bool {
1442 // Check user capability
1443 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1444 return false;
1445 }
1446
1447 // SECURITY: Verify nonce for CSRF protection
1448 return $this->verify_request_nonce($request);
1449 }
1450
1451 /**
1452 * Check permissions for managing schema settings with CSRF protection
1453 *
1454 * @since 1.0.0
1455 *
1456 * @param WP_REST_Request $request Request object
1457 * @return bool Permission status
1458 */
1459 public function check_manage_permissions(WP_REST_Request $request): bool {
1460 // Check user capability
1461 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1462 return false;
1463 }
1464
1465 // SECURITY: Verify nonce for CSRF protection (only for POST requests)
1466 if ($request->get_method() === 'POST') {
1467 return $this->verify_request_nonce($request);
1468 }
1469
1470 return true;
1471 }
1472
1473 /**
1474 * Helper methods
1475 */
1476
1477 /**
1478 * Verify request nonce for CSRF protection
1479 *
1480 * @since 1.0.0
1481 *
1482 * @param WP_REST_Request $request Request object
1483 * @return bool Whether nonce is valid
1484 */
1485 private function verify_request_nonce(WP_REST_Request $request): bool {
1486 // Get nonce from header (preferred method for REST API)
1487 $nonce = $request->get_header('X-WP-Nonce');
1488
1489 // Fallback to parameter if header not present
1490 if (!$nonce) {
1491 $nonce = $request->get_param('_wpnonce');
1492 }
1493
1494 // Verify nonce
1495 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1496 return false;
1497 }
1498
1499 return true;
1500 }
1501
1502 /**
1503 * Validate context type and ID
1504 *
1505 * @since 1.0.0
1506 *
1507 * @param string $context_type Context type
1508 * @param int|null $context_id Context ID
1509 * @return bool Validation status
1510 */
1511 private function validate_context(string $context_type, ?int $context_id) {
1512 $valid_types = ['site', 'post', 'page', 'product'];
1513
1514 $invalid = new WP_Error(
1515 'invalid_context',
1516 'Invalid context type or ID provided',
1517 ['status' => 400]
1518 );
1519
1520 if (!in_array($context_type, $valid_types, true)) {
1521 return $invalid;
1522 }
1523
1524 if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1525 return $invalid;
1526 }
1527
1528 if ($context_id && !get_post($context_id)) {
1529 return $invalid;
1530 }
1531
1532 // SECURITY: everything above establishes that the context *exists*, not
1533 // that this caller may see it. `edit_post` is a meta capability, so
1534 // map_meta_cap() resolves authorship, published state and
1535 // edit_others_posts for this specific post — the same check
1536 // Schema_Input_Validator::validate_context_ownership() makes on the
1537 // write paths, and the one class-social-media-endpoint.php already makes
1538 // on its own context routes. Without it a delegated Schema Manager can
1539 // walk context_id and read SEO data for drafts, pending posts and other
1540 // authors' content.
1541 //
1542 // Site context is deliberately left to the route's capability gate.
1543 // The write paths demand manage_options for it, but applying that here
1544 // would stop a delegated Schema Manager reading site-level schema at
1545 // all, which is the point of delegating the section.
1546 if ($context_type !== 'site' && !current_user_can('edit_post', $context_id)) {
1547 return new WP_Error(
1548 'rest_forbidden',
1549 'You are not allowed to access this content.',
1550 ['status' => 403]
1551 );
1552 }
1553
1554 return true;
1555 }
1556
1557 /**
1558 * Generate schema preview
1559 *
1560 * @since 1.0.0
1561 *
1562 * @param array $schema_data Schema data
1563 * @param string $schema_type Schema type
1564 * @return array Preview data
1565 */
1566 private function generate_preview(array $schema_data, string $schema_type): array {
1567 return [
1568 'rich_snippets' => [
1569 $schema_type => $this->format_rich_snippet_preview($schema_data, $schema_type)
1570 ],
1571 'json_ld' => wp_json_encode($schema_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
1572 'validation_status' => 'pending'
1573 ];
1574 }
1575
1576 /**
1577 * Format rich snippet preview for specific schema type
1578 *
1579 * @since 1.0.0
1580 *
1581 * @param array $schema_data Schema data
1582 * @param string $schema_type Schema type
1583 * @return array Formatted preview data
1584 */
1585 private function format_rich_snippet_preview(array $schema_data, string $schema_type): array {
1586 switch ($schema_type) {
1587 case 'Organization':
1588 return [
1589 'title' => $schema_data['name'] ?? 'Organization Name',
1590 'url' => $schema_data['url'] ?? home_url(),
1591 'description' => $schema_data['description'] ?? 'Organization description',
1592 'additional_info' => $this->format_organization_info($schema_data)
1593 ];
1594
1595 case 'LocalBusiness':
1596 return [
1597 'title' => $schema_data['name'] ?? 'Business Name',
1598 'url' => $schema_data['url'] ?? home_url(),
1599 'description' => $schema_data['description'] ?? 'Business description',
1600 'additional_info' => $this->format_local_business_info($schema_data)
1601 ];
1602
1603 case 'Article':
1604 return [
1605 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Article Title',
1606 'url' => $schema_data['url'] ?? home_url(),
1607 'description' => $schema_data['description'] ?? 'Article description',
1608 'additional_info' => $this->format_article_info($schema_data)
1609 ];
1610
1611 default:
1612 return [
1613 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1614 'url' => $schema_data['url'] ?? home_url(),
1615 'description' => $schema_data['description'] ?? 'Description',
1616 'additional_info' => ''
1617 ];
1618 }
1619 }
1620 private function format_organization_info(array $schema_data): string {
1621 $info = [];
1622
1623 if (!empty($schema_data['contactPoint']['telephone'])) {
1624 $info[] = '📞 ' . $schema_data['contactPoint']['telephone'];
1625 }
1626
1627 if (!empty($schema_data['contactPoint']['email'])) {
1628 $info[] = '✉️ ' . $schema_data['contactPoint']['email'];
1629 }
1630
1631 if (!empty($schema_data['address']['streetAddress'])) {
1632 $info[] = '📍 ' . $schema_data['address']['streetAddress'];
1633 }
1634
1635 return implode('', $info);
1636 }
1637
1638 /**
1639 * Format local business additional info
1640 *
1641 * @since 1.0.0
1642 *
1643 * @param array $schema_data Schema data
1644 * @return string Formatted info
1645 */
1646 private function format_local_business_info(array $schema_data): string {
1647 $info = [];
1648
1649 // Address
1650 if (!empty($schema_data['address'])) {
1651 $address = $schema_data['address'];
1652 $address_parts = [];
1653
1654 if (!empty($address['streetAddress'])) {
1655 $address_parts[] = $address['streetAddress'];
1656 }
1657 if (!empty($address['addressLocality'])) {
1658 $address_parts[] = $address['addressLocality'];
1659 }
1660
1661 if (!empty($address_parts)) {
1662 $info[] = '📍 ' . implode(', ', $address_parts);
1663 }
1664 }
1665
1666 // Phone
1667 if (!empty($schema_data['telephone'])) {
1668 $info[] = '📞 ' . $schema_data['telephone'];
1669 }
1670
1671 // Opening hours
1672 if (!empty($schema_data['openingHours'])) {
1673 $hours = is_array($schema_data['openingHours'])
1674 ? implode(', ', $schema_data['openingHours'])
1675 : $schema_data['openingHours'];
1676 $info[] = '🕒 ' . $hours;
1677 }
1678
1679 return implode('', $info);
1680 }
1681
1682 /**
1683 * Format article additional info
1684 *
1685 * @since 1.0.0
1686 *
1687 * @param array $schema_data Schema data
1688 * @return string Formatted info
1689 */
1690 private function format_article_info(array $schema_data): string {
1691 $info = [];
1692
1693 if (!empty($schema_data['author']['name'])) {
1694 $info[] = '👤 By ' . $schema_data['author']['name'];
1695 }
1696
1697 if (!empty($schema_data['datePublished'])) {
1698 $info[] = '�
1699 ' . gmdate('M j, Y', strtotime($schema_data['datePublished']));
1700 }
1701
1702 if (!empty($schema_data['publisher']['name'])) {
1703 $info[] = '🏢 ' . $schema_data['publisher']['name'];
1704 }
1705
1706 return implode(' ', $info);
1707 }
1708
1709 /**
1710 * Argument validation methods
1711 */
1712
1713 /**
1714 * Get arguments for schema generation endpoint
1715 *
1716 * @since 1.0.0
1717 *
1718 * @return array Arguments array
1719 */
1720 private function get_generate_schema_args(): array {
1721 return [
1722 'context_type' => [
1723 'required' => true,
1724 'type' => 'string',
1725 'enum' => ['site', 'post', 'page', 'product'],
1726 'description' => 'Context type for schema generation'
1727 ],
1728 'context_id' => [
1729 'required' => false,
1730 'type' => 'integer',
1731 'minimum' => 1,
1732 'description' => 'Context ID (not required for site context)'
1733 ],
1734 'schema_types' => [
1735 'required' => false,
1736 'type' => 'array',
1737 'items' => [
1738 'type' => 'string',
1739 'enum' => [
1740 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1741 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1742 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1743 'Event', 'HowTo', 'SoftwareApplication'
1744 ]
1745 ],
1746 'description' => 'Schema types to generate'
1747 ],
1748 'options' => [
1749 'required' => false,
1750 'type' => 'object',
1751 'description' => 'Additional generation options'
1752 ],
1753 'content_data' => [
1754 'required' => false,
1755 'type' => 'object',
1756 'description' => 'Custom content data to use for schema generation (overrides post data)',
1757 'properties' => [
1758 'title' => ['type' => 'string'],
1759 'description' => ['type' => 'string'],
1760 'content' => ['type' => 'string'],
1761 'focus_keyword' => ['type' => 'string'],
1762 'post_type' => ['type' => 'string'],
1763 'post_url' => ['type' => 'string']
1764 ]
1765 ]
1766 ];
1767 }
1768
1769 /**
1770 * Get arguments for schema validation endpoint
1771 *
1772 * @since 1.0.0
1773 *
1774 * @return array Arguments array
1775 */
1776 private function get_validate_schema_args(): array {
1777 return [
1778 'schema_data' => [
1779 'required' => true,
1780 'type' => 'object',
1781 'description' => 'Schema data to validate'
1782 ],
1783 'schema_type' => [
1784 'required' => true,
1785 'type' => 'string',
1786 'enum' => [
1787 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1788 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1789 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1790 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1791 ],
1792 'description' => 'Schema type'
1793 ],
1794 'options' => [
1795 'required' => false,
1796 'type' => 'object',
1797 'description' => 'Validation options'
1798 ]
1799 ];
1800 }
1801
1802 /**
1803 * Get arguments for schema deployment endpoint
1804 *
1805 * @since 1.0.0
1806 *
1807 * @return array Arguments array
1808 */
1809 private function get_deploy_schema_args(): array {
1810 return [
1811 'context_type' => [
1812 'required' => true,
1813 'type' => 'string',
1814 'enum' => ['site', 'post', 'page', 'product'],
1815 'description' => 'Context type for deployment'
1816 ],
1817 'context_id' => [
1818 'required' => false,
1819 'type' => 'integer',
1820 'minimum' => 1,
1821 'description' => 'Context ID (not required for site context)'
1822 ],
1823 'schema_data' => [
1824 'required' => true,
1825 'type' => 'object',
1826 'description' => 'Schema data to deploy'
1827 ],
1828 'options' => [
1829 'required' => false,
1830 'type' => 'object',
1831 'description' => 'Deployment options'
1832 ]
1833 ];
1834 }
1835
1836 /**
1837 * Get arguments for schema optimization endpoint
1838 *
1839 * @since 1.0.0
1840 *
1841 * @return array Arguments array
1842 */
1843 private function get_optimize_schema_args(): array {
1844 return [
1845 'schema_data' => [
1846 'required' => true,
1847 'type' => 'object',
1848 'description' => 'Schema data to optimize'
1849 ],
1850 'schema_type' => [
1851 'required' => true,
1852 'type' => 'string',
1853 'enum' => [
1854 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1855 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1856 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1857 ],
1858 'description' => 'Schema type'
1859 ],
1860 'options' => [
1861 'required' => false,
1862 'type' => 'object',
1863 'description' => 'Optimization options'
1864 ]
1865 ];
1866 }
1867
1868 /**
1869 * Get arguments for schema preview endpoint
1870 *
1871 * @since 1.0.0
1872 *
1873 * @return array Arguments array
1874 */
1875 private function get_preview_schema_args(): array {
1876 return [
1877 'schema_data' => [
1878 'required' => true,
1879 'type' => 'object',
1880 'description' => 'Schema data to preview'
1881 ],
1882 'schema_type' => [
1883 'required' => true,
1884 'type' => 'string',
1885 'enum' => [
1886 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1887 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1888 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1889 ],
1890 'description' => 'Schema type'
1891 ]
1892 ];
1893 }
1894
1895 /**
1896 * Get arguments for bulk operations endpoint
1897 *
1898 * @since 1.0.0
1899 *
1900 * @return array Arguments array
1901 */
1902 private function get_bulk_operations_args(): array {
1903 return [
1904 'operation' => [
1905 'required' => true,
1906 'type' => 'string',
1907 'enum' => ['generate', 'validate', 'deploy'],
1908 'description' => 'Bulk operation type'
1909 ],
1910 'items' => [
1911 'required' => true,
1912 'type' => 'array',
1913 'items' => [
1914 'type' => 'object'
1915 ],
1916 // Bound aggregate request work: every item can trigger context
1917 // lookups, recursive schema validation, generation, and
1918 // deployment, so cap the count at the REST layer.
1919 'maxItems' => self::MAX_BULK_ITEMS,
1920 'description' => 'Items to process in bulk (max ' . self::MAX_BULK_ITEMS . ')'
1921 ],
1922 'options' => [
1923 'required' => false,
1924 'type' => 'object',
1925 'description' => 'Bulk operation options'
1926 ]
1927 ];
1928 }
1929
1930 /**
1931 * Get schema settings
1932 *
1933 * @since 1.0.0
1934 *
1935 * @param WP_REST_Request $request Request object
1936 * @return WP_REST_Response|WP_Error Response object or error
1937 */
1938 public function get_settings(WP_REST_Request $request) {
1939 try {
1940 $context_type = $request->get_param('context_type') ?? 'site';
1941 $context_id = $request->get_param('context_id') ?? null;
1942
1943 // Get settings from schema manager
1944 $settings = $this->schema_manager->get_settings($context_type, $context_id);
1945
1946 return new WP_REST_Response([
1947 'success' => true,
1948 'data' => [
1949 'settings' => $settings,
1950 'context_type' => $context_type,
1951 'context_id' => $context_id
1952 ],
1953 'message' => 'Schema settings retrieved successfully'
1954 ], 200);
1955
1956 } catch (\Exception $e) {
1957 return new WP_Error(
1958 'settings_fetch_failed',
1959 'Failed to retrieve schema settings: ' . $e->getMessage(),
1960 ['status' => 500]
1961 );
1962 }
1963 }
1964
1965 /**
1966 * Save schema settings
1967 *
1968 * @since 1.0.0
1969 *
1970 * @param WP_REST_Request $request Request object
1971 * @return WP_REST_Response|WP_Error Response object or error
1972 */
1973 public function save_settings(WP_REST_Request $request) {
1974 try {
1975 $settings = $request->get_param('settings');
1976 $context_type = $request->get_param('context_type') ?? 'site';
1977 $context_id = $request->get_param('context_id') ?? null;
1978
1979 // Validate input parameters
1980 if (empty($settings) || !is_array($settings)) {
1981 return new WP_Error(
1982 'invalid_settings',
1983 'Settings parameter is required and must be an array',
1984 ['status' => 400]
1985 );
1986 }
1987
1988 // SECURITY: For non-site contexts (post/page/product), verify the
1989 // caller can edit that specific object — same ownership gate the
1990 // generate/deploy routes use. Site context stays governed by the
1991 // thinkrank_schema capability via the Role Manager gate.
1992 $context_type = sanitize_key((string) $context_type);
1993 if ($context_type !== 'site') {
1994 $context_validation = $this->input_validator->validate_context_parameters(
1995 $context_type,
1996 $context_id !== null ? absint($context_id) : null,
1997 get_current_user_id()
1998 );
1999 if (!$context_validation['valid']) {
2000 return new WP_Error(
2001 'invalid_context',
2002 implode(', ', $context_validation['errors']),
2003 ['status' => 403]
2004 );
2005 }
2006 $context_type = $context_validation['sanitized_data']['context_type'];
2007 $context_id = $context_validation['sanitized_data']['context_id'];
2008 }
2009
2010 // Drop unrecognized keys so arbitrary client-supplied keys aren't
2011 // persisted as settings rows (storage bloat / settings drift).
2012 $settings = $this->filter_known_setting_keys($settings, $context_type);
2013 if (empty($settings)) {
2014 return new WP_Error(
2015 'invalid_settings',
2016 'No recognized schema settings were provided',
2017 ['status' => 400]
2018 );
2019 }
2020
2021 // Get validation results for detailed error reporting
2022 $validation = $this->schema_manager->validate_settings($settings);
2023
2024 if (!$validation['valid']) {
2025 // Schema settings validation failed - details available in validation response
2026
2027 return new WP_Error(
2028 'validation_failed',
2029 'Schema settings validation failed',
2030 [
2031 'status' => 400,
2032 'validation_errors' => $validation['errors'],
2033 'validation_warnings' => $validation['warnings'] ?? [],
2034 'validation_suggestions' => $validation['suggestions'] ?? []
2035 ]
2036 );
2037 }
2038
2039 // Save settings using schema manager
2040 $success = $this->schema_manager->save_settings($context_type, $context_id, $settings);
2041
2042 if (!$success) {
2043 // Schema settings save failed - database operation unsuccessful
2044
2045 return new WP_Error(
2046 'settings_save_failed',
2047 'Failed to save schema settings to database',
2048 ['status' => 500]
2049 );
2050 }
2051
2052 return new WP_REST_Response([
2053 'success' => true,
2054 'data' => [
2055 'settings' => $settings,
2056 'context_type' => $context_type,
2057 'context_id' => $context_id,
2058 'validation' => $validation
2059 ],
2060 'message' => 'Schema settings saved successfully'
2061 ], 200);
2062
2063 } catch (\Exception $e) {
2064 // Schema settings save exception - error details in response
2065
2066 return new WP_Error(
2067 'settings_update_failed',
2068 'Failed to update schema settings: ' . $e->getMessage(),
2069 ['status' => 500]
2070 );
2071 }
2072 }
2073
2074 /**
2075 * Restrict a settings payload to recognized keys.
2076 *
2077 * The known set is the context's default settings plus a few keys that are
2078 * legitimately stored/consumed elsewhere (site-identity/local-SEO fields and
2079 * the schema settings schema) but not seeded into the defaults. Filterable
2080 * so Pro/integrations can register additional keys.
2081 *
2082 * @param array $settings Incoming settings.
2083 * @param string $context_type Context type (site/post/page/product).
2084 * @return array Settings limited to known keys.
2085 */
2086 private function filter_known_setting_keys(array $settings, string $context_type): array {
2087 $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type));
2088 // Keys stored/consumed by adjacent features that share the settings
2089 // store but aren't part of the schema defaults.
2090 $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [
2091 'business_name', 'site_name', 'logo_url', 'performance_tracking',
2092 ]);
2093 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2094
2095 return array_intersect_key($settings, array_flip($known));
2096 }
2097
2098 /**
2099 * Get arguments for settings endpoints
2100 *
2101 * @since 1.0.0
2102 *
2103 * @return array Arguments array
2104 */
2105 private function get_settings_args(): array {
2106 return [
2107 'settings' => [
2108 'required' => true,
2109 'type' => 'object',
2110 'description' => 'Schema settings object'
2111 ],
2112 'context_type' => [
2113 'required' => false,
2114 'type' => 'string',
2115 'default' => 'site',
2116 'enum' => ['site', 'post', 'page', 'product'],
2117 'description' => 'Context type for settings'
2118 ],
2119 'context_id' => [
2120 'required' => false,
2121 'type' => 'integer',
2122 'minimum' => 1,
2123 'description' => 'Context ID for settings'
2124 ]
2125 ];
2126 }
2127 }
2128