PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.27.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.27.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-schema-endpoint.php

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

2,232 lines 80.0 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() / reject_disallowed_fetch_host().
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, re-validating the host against the
420 * SSRF block list on every redirect hop.
421 *
422 * wp_safe_remote_get() re-validates redirect targets with
423 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
424 * redirects are followed manually (redirection => 0) and each hop is checked
425 * with {@see self::reject_disallowed_fetch_host()}.
426 *
427 * @param string $url URL to fetch.
428 * @return array|\WP_Error Response array on success, WP_Error otherwise.
429 */
430 private function fetch_import_url(string $url) {
431 $max_redirects = 5;
432
433 for ($hop = 0; $hop <= $max_redirects; $hop++) {
434 if (!wp_http_validate_url($url)) {
435 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
436 }
437
438 $host_check = $this->reject_disallowed_fetch_host($url);
439 if (is_wp_error($host_check)) {
440 return $host_check;
441 }
442
443 $response = wp_safe_remote_get($url, [
444 'timeout' => 15,
445 'redirection' => 0, // Follow manually so every hop is re-validated.
446 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)',
447 ]);
448
449 if (is_wp_error($response)) {
450 return $response;
451 }
452
453 $code = (int) wp_remote_retrieve_response_code($response);
454 if ($code >= 300 && $code < 400) {
455 $location = trim((string) wp_remote_retrieve_header($response, 'location'));
456 if ('' === $location) {
457 return $response; // Redirect without a target — treat as final.
458 }
459 // Resolve a relative Location against the current URL.
460 $url = (string) \WP_Http::make_absolute_url($location, $url);
461 if ('' === $url) {
462 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
463 }
464 continue;
465 }
466
467 return $response;
468 }
469
470 return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
471 }
472
473 /**
474 * Reject URLs whose host resolves to a private, loopback, link-local, or
475 * otherwise reserved IP range.
476 *
477 * WordPress's wp_http_validate_url() blocks loopback and RFC1918 ranges but
478 * NOT the link-local 169.254.0.0/16 (cloud metadata, e.g. 169.254.169.254)
479 * or 100.64.0.0/10 (CGNAT) ranges. FILTER_FLAG_NO_RES_RANGE covers those
480 * reserved ranges, and FILTER_FLAG_NO_PRIV_RANGE covers the private ranges.
481 *
482 * @param string $url URL to check.
483 * @return true|\WP_Error True when every resolved address is public.
484 */
485 private function reject_disallowed_fetch_host(string $url) {
486 $host = wp_parse_url($url, PHP_URL_HOST);
487 if (empty($host)) {
488 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
489 }
490
491 // Strip IPv6 literal brackets, if present.
492 $host = trim($host, '[]');
493
494 $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolve_host_ips($host);
495 if (empty($ips)) {
496 return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
497 }
498
499 foreach ($ips as $ip) {
500 if (!$this->is_public_ip($ip)) {
501 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
502 }
503 }
504
505 return true;
506 }
507
508 /**
509 * Whether an IP address is a routable public address.
510 *
511 * Combines PHP's private/reserved-range filters with explicit blocks for
512 * reserved IPv4 ranges PHP's FILTER_FLAG_NO_RES_RANGE does not cover — most
513 * importantly 100.64.0.0/10 (CGNAT), which some clouds use for metadata.
514 *
515 * @param string $ip IP address (v4 or v6).
516 * @return bool
517 */
518 private function is_public_ip(string $ip): bool {
519 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
520 return false;
521 }
522
523 // Reserved IPv4 blocks PHP's NO_RES_RANGE misses.
524 $extra_blocks = [
525 '100.64.0.0/10', // Shared address space / CGNAT (RFC 6598).
526 '192.0.0.0/24', // IETF protocol assignments (RFC 6890).
527 '198.18.0.0/15', // Benchmarking (RFC 2544).
528 '192.88.99.0/24', // 6to4 relay anycast (RFC 7526).
529 ];
530 foreach ($extra_blocks as $cidr) {
531 if ($this->ipv4_in_cidr($ip, $cidr)) {
532 return false;
533 }
534 }
535
536 return true;
537 }
538
539 /**
540 * Whether an IPv4 address falls within a CIDR block.
541 *
542 * @param string $ip IPv4 address.
543 * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
544 * @return bool
545 */
546 private function ipv4_in_cidr(string $ip, string $cidr): bool {
547 if (strpos($ip, ':') !== false) {
548 return false; // IPv6 is not covered by these IPv4 blocks.
549 }
550
551 [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
552 $ip_long = ip2long($ip);
553 $subnet_long = ip2long($subnet);
554 if (false === $ip_long || false === $subnet_long) {
555 return false;
556 }
557
558 $mask = -1 << (32 - (int) $bits);
559 return ($ip_long & $mask) === ($subnet_long & $mask);
560 }
561
562 /**
563 * Resolve a hostname to its IPv4 + IPv6 addresses.
564 *
565 * @param string $host Hostname.
566 * @return string[] Resolved IP addresses (may be empty).
567 */
568 private function resolve_host_ips(string $host): array {
569 $ips = [];
570
571 $records = @dns_get_record($host, DNS_A + DNS_AAAA);
572 if (is_array($records)) {
573 foreach ($records as $record) {
574 if (!empty($record['ip'])) {
575 $ips[] = $record['ip']; // A record.
576 } elseif (!empty($record['ipv6'])) {
577 $ips[] = $record['ipv6']; // AAAA record.
578 }
579 }
580 }
581
582 // Fallback where dns_get_record is unavailable or returns nothing.
583 if (empty($ips)) {
584 $resolved = gethostbyname($host);
585 if ($resolved && $resolved !== $host) {
586 $ips[] = $resolved;
587 }
588 }
589
590 return $ips;
591 }
592
593 /**
594 * Generate schema markup
595 *
596 * @since 1.0.0
597 *
598 * @param WP_REST_Request $request Request object
599 * @return WP_REST_Response|WP_Error Response object or error
600 */
601 public function generate_schema(WP_REST_Request $request) {
602 try {
603 $user_id = get_current_user_id();
604
605 // SECURITY: Check rate limits first
606 $rate_limit_check = $this->check_rate_limit('generate_schema', $user_id);
607 if (is_wp_error($rate_limit_check)) {
608 return $rate_limit_check;
609 }
610
611 // SECURITY: Validate user permissions and rate limiting
612 $permission_check = $this->input_validator->validate_user_permissions('generate', $user_id);
613 if (!$permission_check['valid']) {
614 return new WP_Error(
615 'permission_denied',
616 implode(', ', $permission_check['errors']),
617 ['status' => 403]
618 );
619 }
620
621 // SECURITY: Validate and sanitize context parameters with ownership checks
622 $context_type = $request->get_param('context_type');
623 $context_id = $request->get_param('context_id');
624 $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id);
625
626 if (!$context_validation['valid']) {
627 return new WP_Error(
628 'invalid_context',
629 implode(', ', $context_validation['errors']),
630 ['status' => 400]
631 );
632 }
633
634 $context_type = $context_validation['sanitized_data']['context_type'];
635 $context_id = $context_validation['sanitized_data']['context_id'];
636
637 // SECURITY: Validate and sanitize schema types
638 $schema_types = $request->get_param('schema_types') ?? [];
639 if (empty($schema_types) || !is_array($schema_types)) {
640 return new WP_Error(
641 'missing_schema_types',
642 'Schema types array is required',
643 ['status' => 400]
644 );
645 }
646
647 // Sanitize schema types
648 $sanitized_schema_types = [];
649 foreach ($schema_types as $type) {
650 $sanitized_type = sanitize_text_field($type);
651 if (!empty($sanitized_type)) {
652 $sanitized_schema_types[] = $sanitized_type;
653 }
654 }
655
656 if (empty($sanitized_schema_types)) {
657 return new WP_Error(
658 'invalid_schema_types',
659 'No valid schema types provided',
660 ['status' => 400]
661 );
662 }
663
664 // SECURITY: Sanitize options
665 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
666
667 // SECURITY: Sanitize content_data if provided
668 $content_data = $request->get_param('content_data');
669 if ($content_data && is_array($content_data)) {
670 $content_data = [
671 'title' => isset($content_data['title']) ? sanitize_text_field($content_data['title']) : '',
672 'description' => isset($content_data['description']) ? sanitize_textarea_field($content_data['description']) : '',
673 'content' => isset($content_data['content']) ? wp_kses_post($content_data['content']) : '',
674 'word_count' => isset($content_data['word_count']) ? (int) $content_data['word_count'] : 0,
675 'focus_keyword' => isset($content_data['focus_keyword']) ? sanitize_text_field($content_data['focus_keyword']) : '',
676 'post_type' => isset($content_data['post_type']) ? sanitize_text_field($content_data['post_type']) : '',
677 'post_url' => isset($content_data['post_url']) ? esc_url_raw($content_data['post_url']) : ''
678 ];
679
680 // Add content_data to options so schema manager can use it
681 $options['content_data'] = $content_data;
682 }
683
684 // SECURITY: Sanitize schema_form_data if provided
685 $schema_form_data = $request->get_param('schema_form_data');
686 if ($schema_form_data && is_array($schema_form_data)) {
687 // Sanitize all form fields
688 $sanitized_form_data = [];
689 foreach ($schema_form_data as $key => $value) {
690 if (is_string($value)) {
691 $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value);
692 } elseif (is_array($value)) {
693 // Handle array values (like features, steps, etc.)
694 $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value);
695 } elseif (is_numeric($value)) {
696 $sanitized_form_data[sanitize_key($key)] = floatval($value);
697 }
698 }
699
700 // Add schema_form_data to options so schema manager can use it
701 $options['schema_form_data'] = $sanitized_form_data;
702 }
703
704 // Generate schema markup with sanitized inputs
705 $generation_results = $this->schema_manager->generate_schema_markup(
706 $context_type,
707 $context_id,
708 $sanitized_schema_types,
709 $options
710 );
711
712 return new WP_REST_Response([
713 'success' => true,
714 'data' => $generation_results,
715 'message' => 'Schema markup generated successfully'
716 ], 200);
717
718 } catch (\Exception $e) {
719 return new WP_Error(
720 'generation_failed',
721 'Schema generation failed: ' . $e->getMessage(),
722 ['status' => 500]
723 );
724 }
725 }
726
727 /**
728 * Validate schema markup
729 *
730 * @since 1.0.0
731 *
732 * @param WP_REST_Request $request Request object
733 * @return WP_REST_Response|WP_Error Response object or error
734 */
735 public function validate_schema(WP_REST_Request $request) {
736 try {
737 $user_id = get_current_user_id();
738
739 // SECURITY: Validate user permissions and rate limiting
740 $permission_check = $this->input_validator->validate_user_permissions('validate', $user_id);
741 if (!$permission_check['valid']) {
742 return new WP_Error(
743 'permission_denied',
744 implode(', ', $permission_check['errors']),
745 ['status' => 403]
746 );
747 }
748
749 $schema_data = $request->get_param('schema_data');
750 $schema_type = $request->get_param('schema_type');
751 $options = $request->get_param('options') ?? [];
752
753 // SECURITY: Validate input parameters
754 if (empty($schema_data) || empty($schema_type)) {
755 return new WP_Error(
756 'missing_parameters',
757 'Schema data and type are required',
758 ['status' => 400]
759 );
760 }
761
762 // SECURITY: Sanitize schema type
763 $schema_type = sanitize_text_field($schema_type);
764
765 // SECURITY: Validate and sanitize schema data using input validator
766 if (!is_array($schema_data)) {
767 return new WP_Error(
768 'invalid_schema_data',
769 'Schema data must be an array/object',
770 ['status' => 400]
771 );
772 }
773
774 $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
775 if (!$input_validation['valid']) {
776 return new WP_Error(
777 'schema_validation_failed',
778 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
779 [
780 'status' => 400,
781 'validation_errors' => $input_validation['errors'],
782 'validation_warnings' => $input_validation['warnings']
783 ]
784 );
785 }
786
787 // Use sanitized data for validation
788 $sanitized_schema_data = $input_validation['sanitized_data'];
789
790 // SECURITY: Sanitize options
791 $options = $this->input_validator->sanitize_options($options);
792
793 // Validate schema markup with sanitized data
794 $validation_results = $this->schema_manager->validate_schema_markup(
795 $sanitized_schema_data,
796 $schema_type,
797 $options
798 );
799
800 return new WP_REST_Response([
801 'success' => true,
802 'data' => $validation_results,
803 'message' => 'Schema validation completed'
804 ], 200);
805
806 } catch (\Exception $e) {
807 return new WP_Error(
808 'validation_failed',
809 'Schema validation failed: ' . $e->getMessage(),
810 ['status' => 500]
811 );
812 }
813 }
814
815 /**
816 * Deploy schema markup
817 *
818 * @since 1.0.0
819 *
820 * @param WP_REST_Request $request Request object
821 * @return WP_REST_Response|WP_Error Response object or error
822 */
823 public function deploy_schema(WP_REST_Request $request) {
824 try {
825 $user_id = get_current_user_id();
826
827 // SECURITY: Validate user permissions and rate limiting
828 $permission_check = $this->input_validator->validate_user_permissions('deploy', $user_id);
829 if (!$permission_check['valid']) {
830 return new WP_Error(
831 'permission_denied',
832 implode(', ', $permission_check['errors']),
833 ['status' => 403]
834 );
835 }
836
837 // SECURITY: Validate and sanitize context parameters with ownership checks
838 $context_type = $request->get_param('context_type');
839 $context_id = $request->get_param('context_id');
840 $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id);
841
842 if (!$context_validation['valid']) {
843 return new WP_Error(
844 'invalid_context',
845 implode(', ', $context_validation['errors']),
846 ['status' => 400]
847 );
848 }
849
850 $context_type = $context_validation['sanitized_data']['context_type'];
851 $context_id = $context_validation['sanitized_data']['context_id'];
852
853 // SECURITY: Validate schema data
854 $schema_data = $request->get_param('schema_data');
855 if (empty($schema_data) || !is_array($schema_data)) {
856 return new WP_Error(
857 'invalid_schema_data',
858 'Valid schema data array is required',
859 ['status' => 400]
860 );
861 }
862
863 // SECURITY: Validate each schema in the data
864 $sanitized_schema_data = [];
865 foreach ($schema_data as $schema_key => $schema_content) {
866 $schema_key = sanitize_text_field($schema_key);
867
868 if (!is_array($schema_content)) {
869 return new WP_Error(
870 'invalid_schema_content',
871 "Schema content for {$schema_key} must be an array",
872 ['status' => 400]
873 );
874 }
875
876 // Ensure schema has required structure fields before validation
877 // Use @type from schema content if available, otherwise fall back to key
878 $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key;
879
880 if (!isset($schema_content['@type'])) {
881 $schema_content['@type'] = $schema_type;
882 }
883 if (!isset($schema_content['@context'])) {
884 $schema_content['@context'] = 'https://schema.org';
885 }
886
887 // Validate using the actual schema type, not the key
888 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
889 if (!$input_validation['valid']) {
890 return new WP_Error(
891 'schema_validation_failed',
892 "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']),
893 [
894 'status' => 400,
895 'schema_type' => $schema_type,
896 'validation_errors' => $input_validation['errors']
897 ]
898 );
899 }
900
901 // Store using the key (which may be unique like "Article-1")
902 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
903 }
904
905 // SECURITY: Sanitize options
906 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
907
908 // Deploy schema markup with sanitized data
909 $deployment_results = $this->schema_manager->deploy_schema_markup(
910 $context_type,
911 $context_id,
912 $sanitized_schema_data,
913 $options
914 );
915
916 return new WP_REST_Response([
917 'success' => true,
918 'data' => $deployment_results,
919 'message' => 'Schema markup deployed successfully'
920 ], 200);
921
922 } catch (\Exception $e) {
923 return new WP_Error(
924 'deployment_failed',
925 'Schema deployment failed: ' . $e->getMessage(),
926 ['status' => 500]
927 );
928 }
929 }
930
931 /**
932 * Get available schema types
933 *
934 * @since 1.0.0
935 *
936 * @param WP_REST_Request $request Request object
937 * @return WP_REST_Response Response object
938 */
939 public function get_schema_types(WP_REST_Request $request): WP_REST_Response {
940 // Get context parameter to determine which schema types to return
941 $context = $request->get_param('context') ?? 'site';
942
943 // Site-level schema types only (post/page schemas handled by metabox)
944 $site_schema_types = [
945 'Organization' => [
946 'name' => 'Organization',
947 'description' => 'Company or organization information (site-wide)',
948 'context_types' => ['site'],
949 'priority' => 'high'
950 ],
951 'LocalBusiness' => [
952 'name' => 'LocalBusiness',
953 'description' => 'Local businesses and service providers (site-wide)',
954 'context_types' => ['site'],
955 'priority' => 'high'
956 ],
957 'Person' => [
958 'name' => 'Person',
959 'description' => 'Individual person or author information (site-wide)',
960 'context_types' => ['site'],
961 'priority' => 'medium'
962 ],
963 'WebSite' => [
964 'name' => 'WebSite',
965 'description' => 'Website-level information and search functionality',
966 'context_types' => ['site'],
967 'priority' => 'high'
968 ]
969 ];
970
971 // All schema types for metabox context
972 $all_schema_types = [
973 'Article' => [
974 'name' => 'Article',
975 'description' => 'News articles, blog posts, and editorial content',
976 'context_types' => ['post', 'page'],
977 'priority' => 'high'
978 ],
979 'BlogPosting' => [
980 'name' => 'BlogPosting',
981 'description' => 'Blog posts and personal articles',
982 'context_types' => ['post', 'page'],
983 'priority' => 'high'
984 ],
985 'TechnicalArticle' => [
986 'name' => 'TechnicalArticle',
987 'description' => 'Technical documentation and tutorials',
988 'context_types' => ['post', 'page'],
989 'priority' => 'high'
990 ],
991 'NewsArticle' => [
992 'name' => 'NewsArticle',
993 'description' => 'News articles and press releases',
994 'context_types' => ['post', 'page'],
995 'priority' => 'high'
996 ],
997 'ScholarlyArticle' => [
998 'name' => 'ScholarlyArticle',
999 'description' => 'Academic and research articles',
1000 'context_types' => ['post', 'page'],
1001 'priority' => 'high'
1002 ],
1003 'Report' => [
1004 'name' => 'Report',
1005 'description' => 'Reports and analytical content',
1006 'context_types' => ['post', 'page'],
1007 'priority' => 'medium'
1008 ],
1009 'HowTo' => [
1010 'name' => 'HowTo',
1011 'description' => 'Step-by-step instructions and tutorials',
1012 'context_types' => ['post', 'page'],
1013 'priority' => 'medium'
1014 ],
1015 'FAQPage' => [
1016 'name' => 'FAQPage',
1017 'description' => 'Frequently Asked Questions pages',
1018 'context_types' => ['page', 'post'],
1019 'priority' => 'high'
1020 ],
1021 'Event' => [
1022 'name' => 'Event',
1023 'description' => 'Events, conferences, and gatherings',
1024 'context_types' => ['post', 'page'],
1025 'priority' => 'medium'
1026 ],
1027 'Product' => [
1028 'name' => 'Product',
1029 'description' => 'Products for e-commerce and retail',
1030 'context_types' => ['product', 'post', 'page'],
1031 'priority' => 'critical'
1032 ],
1033 'SoftwareApplication' => [
1034 'name' => 'SoftwareApplication',
1035 'description' => 'Software applications and web apps',
1036 'context_types' => ['post', 'page'],
1037 'priority' => 'high'
1038 ]
1039 ] + $site_schema_types;
1040
1041 // Return appropriate schema types based on context
1042 $schema_types = ($context === 'metabox') ? $all_schema_types : $site_schema_types;
1043
1044 return new WP_REST_Response([
1045 'success' => true,
1046 'data' => $schema_types,
1047 'message' => 'Schema types retrieved successfully'
1048 ], 200);
1049 }
1050
1051 /**
1052 * Get deployed schemas
1053 *
1054 * @since 1.0.0
1055 *
1056 * @param WP_REST_Request $request Request object
1057 * @return WP_REST_Response|WP_Error Response object or error
1058 */
1059 public function get_deployed_schemas(WP_REST_Request $request) {
1060 try {
1061 $context_type = $request->get_param('context_type') ?? 'site';
1062 $context_id = $request->get_param('context_id');
1063
1064 // Convert context_id to int if it's a valid numeric string, otherwise null
1065 if ($context_id !== null && is_numeric($context_id)) {
1066 $context_id = (int) $context_id;
1067 } else {
1068 $context_id = null;
1069 }
1070
1071 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1072
1073 return new WP_REST_Response([
1074 'success' => true,
1075 'data' => $deployed_schemas,
1076 'message' => 'Deployed schemas retrieved successfully'
1077 ], 200);
1078
1079 } catch (\Exception $e) {
1080 return new WP_Error(
1081 'deployed_schemas_failed',
1082 'Failed to retrieve deployed schemas: ' . $e->getMessage(),
1083 ['status' => 500]
1084 );
1085 }
1086 }
1087
1088 /**
1089 * Get schema for specific context
1090 *
1091 * @since 1.0.0
1092 *
1093 * @param WP_REST_Request $request Request object
1094 * @return WP_REST_Response|WP_Error Response object or error
1095 */
1096 public function get_context_schema(WP_REST_Request $request) {
1097 try {
1098 $context_type = $request->get_param('context_type');
1099 $context_id = (int) $request->get_param('context_id');
1100
1101 // Validate context
1102 if (!$this->validate_context($context_type, $context_id)) {
1103 return new WP_Error(
1104 'invalid_context',
1105 'Invalid context type or ID provided',
1106 ['status' => 400]
1107 );
1108 }
1109
1110 // Get schema output data
1111 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
1112
1113 return new WP_REST_Response([
1114 'success' => true,
1115 'data' => $schema_data,
1116 'message' => 'Context schema retrieved successfully'
1117 ], 200);
1118
1119 } catch (\Exception $e) {
1120 return new WP_Error(
1121 'retrieval_failed',
1122 'Schema retrieval failed: ' . $e->getMessage(),
1123 ['status' => 500]
1124 );
1125 }
1126 }
1127
1128 /**
1129 * Optimize rich snippets
1130 *
1131 * @since 1.0.0
1132 *
1133 * @param WP_REST_Request $request Request object
1134 * @return WP_REST_Response|WP_Error Response object or error
1135 */
1136 public function optimize_rich_snippets(WP_REST_Request $request) {
1137 try {
1138 $user_id = get_current_user_id();
1139
1140 // SECURITY: Validate user permissions and rate limiting
1141 $permission_check = $this->input_validator->validate_user_permissions('optimize', $user_id);
1142 if (!$permission_check['valid']) {
1143 return new WP_Error(
1144 'permission_denied',
1145 implode(', ', $permission_check['errors']),
1146 ['status' => 403]
1147 );
1148 }
1149
1150 $schema_data = $request->get_param('schema_data');
1151 $schema_type = $request->get_param('schema_type');
1152 $options = $request->get_param('options') ?? [];
1153
1154 // Validate input
1155 if (empty($schema_data) || empty($schema_type)) {
1156 return new WP_Error(
1157 'missing_parameters',
1158 'Schema data and type are required',
1159 ['status' => 400]
1160 );
1161 }
1162
1163 // Optimize rich snippets
1164 $optimization_results = $this->schema_manager->optimize_rich_snippets(
1165 $schema_data,
1166 $schema_type,
1167 $options
1168 );
1169
1170 return new WP_REST_Response([
1171 'success' => true,
1172 'data' => $optimization_results,
1173 'message' => 'Rich snippets optimization completed'
1174 ], 200);
1175
1176 } catch (\Exception $e) {
1177 return new WP_Error(
1178 'optimization_failed',
1179 'Rich snippets optimization failed: ' . $e->getMessage(),
1180 ['status' => 500]
1181 );
1182 }
1183 }
1184
1185 /**
1186 * Get schema performance data
1187 *
1188 * @since 1.0.0
1189 *
1190 * @param WP_REST_Request $request Request object
1191 * @return WP_REST_Response|WP_Error Response object or error
1192 */
1193 public function get_schema_performance(WP_REST_Request $request) {
1194 try {
1195 $context_type = $request->get_param('context_type');
1196 $context_id = (int) $request->get_param('context_id');
1197 $options = $request->get_param('options') ?? [];
1198
1199 // Validate context
1200 if (!$this->validate_context($context_type, $context_id)) {
1201 return new WP_Error(
1202 'invalid_context',
1203 'Invalid context type or ID provided',
1204 ['status' => 400]
1205 );
1206 }
1207
1208 // Track schema performance
1209 $performance_data = $this->schema_manager->track_schema_performance(
1210 $context_type,
1211 $context_id,
1212 $options
1213 );
1214
1215 return new WP_REST_Response([
1216 'success' => true,
1217 'data' => $performance_data,
1218 'message' => 'Schema performance data retrieved successfully'
1219 ], 200);
1220
1221 } catch (\Exception $e) {
1222 return new WP_Error(
1223 'performance_tracking_failed',
1224 'Schema performance tracking failed: ' . $e->getMessage(),
1225 ['status' => 500]
1226 );
1227 }
1228 }
1229
1230 /**
1231 * Get schema preview
1232 *
1233 * @since 1.0.0
1234 *
1235 * @param WP_REST_Request $request Request object
1236 * @return WP_REST_Response|WP_Error Response object or error
1237 */
1238 public function get_schema_preview(WP_REST_Request $request) {
1239 try {
1240 $schema_data = $request->get_param('schema_data');
1241 $schema_type = $request->get_param('schema_type');
1242
1243 // Validate input
1244 if (empty($schema_data) || empty($schema_type)) {
1245 return new WP_Error(
1246 'missing_parameters',
1247 'Schema data and type are required',
1248 ['status' => 400]
1249 );
1250 }
1251
1252 // Generate preview
1253 $preview_data = $this->generate_preview($schema_data, $schema_type);
1254
1255 return new WP_REST_Response([
1256 'success' => true,
1257 'data' => $preview_data,
1258 'message' => 'Schema preview generated successfully'
1259 ], 200);
1260
1261 } catch (\Exception $e) {
1262 return new WP_Error(
1263 'preview_failed',
1264 'Schema preview generation failed: ' . $e->getMessage(),
1265 ['status' => 500]
1266 );
1267 }
1268 }
1269
1270 /**
1271 * Bulk operations for schema management
1272 *
1273 * @since 1.0.0
1274 *
1275 * @param WP_REST_Request $request Request object
1276 * @return WP_REST_Response|WP_Error Response object or error
1277 */
1278 public function bulk_operations(WP_REST_Request $request) {
1279 try {
1280 $user_id = get_current_user_id();
1281
1282 // SECURITY: Validate user permissions and rate limiting
1283 $permission_check = $this->input_validator->validate_user_permissions('bulk_operations', $user_id);
1284 if (!$permission_check['valid']) {
1285 return new WP_Error(
1286 'permission_denied',
1287 implode(', ', $permission_check['errors']),
1288 ['status' => 403]
1289 );
1290 }
1291
1292 $operation = $request->get_param('operation');
1293 $items = $request->get_param('items') ?? [];
1294 $options = $request->get_param('options') ?? [];
1295
1296 // Validate input
1297 if (empty($operation) || empty($items)) {
1298 return new WP_Error(
1299 'missing_parameters',
1300 'Operation and items are required',
1301 ['status' => 400]
1302 );
1303 }
1304
1305 // Defensive recheck of the item cap (the REST arg maxItems already
1306 // enforces it, but never process an unbounded batch even if that
1307 // schema is bypassed).
1308 if (count($items) > self::MAX_BULK_ITEMS) {
1309 return new WP_Error(
1310 'too_many_items',
1311 sprintf('Bulk operations are limited to %d items per request.', self::MAX_BULK_ITEMS),
1312 ['status' => 400]
1313 );
1314 }
1315
1316 $results = [];
1317 $errors = [];
1318
1319 foreach ($items as $item) {
1320 try {
1321 if (!is_array($item)) {
1322 throw new \Exception('Invalid bulk item');
1323 }
1324
1325 // SECURITY: apply the same per-item context-ownership and
1326 // schema validation the single-item routes enforce, and carry
1327 // the validators' NORMALIZED output forward to dispatch. The
1328 // bulk path previously dispatched raw context_id / schema_data
1329 // with no ownership (IDOR) or size/depth/type checks, and even
1330 // after validating still passed the raw item fields on.
1331 $item_context_type = isset($item['context_type']) ? (string) $item['context_type'] : '';
1332 $item_context_id = isset($item['context_id']) ? (int) $item['context_id'] : null;
1333
1334 // Sanitized values actually dispatched (default to the raw
1335 // context for the validate operation, which has no context).
1336 $context_type = $item_context_type;
1337 $context_id = $item_context_id;
1338
1339 if ($operation === 'generate' || $operation === 'deploy') {
1340 $context_check = $this->input_validator->validate_context_parameters(
1341 $item_context_type,
1342 $item_context_id,
1343 $user_id
1344 );
1345 if (!$context_check['valid']) {
1346 throw new \Exception(implode(', ', $context_check['errors']));
1347 }
1348 // Use the sanitized context, matching the single routes.
1349 $context_type = $context_check['sanitized_data']['context_type'];
1350 $context_id = $context_check['sanitized_data']['context_id'];
1351 }
1352
1353 // Sanitize shared options once per item, as the single routes do.
1354 $item_options = $this->input_validator->sanitize_options($options);
1355
1356 switch ($operation) {
1357 case 'generate':
1358 // Sanitize schema types like the single generate route.
1359 $raw_types = (isset($item['schema_types']) && is_array($item['schema_types']))
1360 ? $item['schema_types']
1361 : [];
1362 $schema_types = [];
1363 foreach ($raw_types as $type) {
1364 $type = sanitize_text_field((string) $type);
1365 if ($type !== '') {
1366 $schema_types[] = $type;
1367 }
1368 }
1369 if (empty($schema_types)) {
1370 throw new \Exception('schema_types is required');
1371 }
1372 $result = $this->schema_manager->generate_schema_markup(
1373 $context_type,
1374 $context_id,
1375 $schema_types,
1376 $item_options
1377 );
1378 break;
1379 case 'validate':
1380 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1381 throw new \Exception('schema_data is required');
1382 }
1383 $schema_type = '';
1384 if (isset($item['schema_type']) && is_string($item['schema_type'])) {
1385 $schema_type = sanitize_text_field($item['schema_type']);
1386 } elseif (isset($item['schema_data']['@type']) && is_string($item['schema_data']['@type'])) {
1387 $schema_type = sanitize_text_field($item['schema_data']['@type']);
1388 }
1389 $data_check = $this->input_validator->validate_schema_data($item['schema_data'], $schema_type);
1390 if (!$data_check['valid']) {
1391 throw new \Exception(implode(', ', $data_check['errors']));
1392 }
1393 // Validate the SANITIZED data, not the raw payload.
1394 $result = $this->schema_manager->validate_schema_markup(
1395 $data_check['sanitized_data'],
1396 $schema_type,
1397 $item_options
1398 );
1399 break;
1400 case 'deploy':
1401 if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1402 throw new \Exception('schema_data is required');
1403 }
1404 // Mirror the single deploy route: validate EACH schema
1405 // entry in the collection (type resolution + default
1406 // @type/@context) and build a sanitized collection,
1407 // rather than validating the whole map as one schema.
1408 $sanitized_schema_data = [];
1409 foreach ($item['schema_data'] as $schema_key => $schema_content) {
1410 $schema_key = sanitize_text_field((string) $schema_key);
1411 if (!is_array($schema_content)) {
1412 throw new \Exception("Schema content for {$schema_key} must be an array");
1413 }
1414 $schema_type = isset($schema_content['@type'])
1415 ? sanitize_text_field($schema_content['@type'])
1416 : $schema_key;
1417 if (!isset($schema_content['@type'])) {
1418 $schema_content['@type'] = $schema_type;
1419 }
1420 if (!isset($schema_content['@context'])) {
1421 $schema_content['@context'] = 'https://schema.org';
1422 }
1423 $data_check = $this->input_validator->validate_schema_data($schema_content, $schema_type);
1424 if (!$data_check['valid']) {
1425 throw new \Exception("Schema validation failed for {$schema_type}: " . implode(', ', $data_check['errors']));
1426 }
1427 $sanitized_schema_data[$schema_key] = $data_check['sanitized_data'];
1428 }
1429 $result = $this->schema_manager->deploy_schema_markup(
1430 $context_type,
1431 $context_id,
1432 $sanitized_schema_data,
1433 $item_options
1434 );
1435 break;
1436 default:
1437 throw new \Exception("Unsupported operation: {$operation}");
1438 }
1439
1440 $results[] = [
1441 'item' => $item,
1442 'success' => true,
1443 'data' => $result
1444 ];
1445
1446 } catch (\Exception $e) {
1447 $errors[] = [
1448 'item' => $item,
1449 'error' => $e->getMessage()
1450 ];
1451 }
1452 }
1453
1454 return new WP_REST_Response([
1455 'success' => empty($errors),
1456 'data' => [
1457 'results' => $results,
1458 'errors' => $errors,
1459 'total_processed' => count($items),
1460 'successful' => count($results),
1461 'failed' => count($errors)
1462 ],
1463 'message' => "Bulk {$operation} operation completed"
1464 ], 200);
1465
1466 } catch (\Exception $e) {
1467 return new WP_Error(
1468 'bulk_operation_failed',
1469 'Bulk operation failed: ' . $e->getMessage(),
1470 ['status' => 500]
1471 );
1472 }
1473 }
1474
1475 /**
1476 * Permission callbacks
1477 */
1478
1479 /**
1480 * Check permissions for schema generation with CSRF protection
1481 *
1482 * @since 1.0.0
1483 *
1484 * @param WP_REST_Request $request Request object
1485 * @return bool Permission status
1486 */
1487 public function check_generate_permissions(WP_REST_Request $request): bool {
1488 // Check user capability
1489 if (!current_user_can('edit_posts')) {
1490 return false;
1491 }
1492
1493 // SECURITY: Verify nonce for CSRF protection
1494 return $this->verify_request_nonce($request);
1495 }
1496
1497 /**
1498 * Check permissions for schema validation with CSRF protection
1499 *
1500 * @since 1.0.0
1501 *
1502 * @param WP_REST_Request $request Request object
1503 * @return bool Permission status
1504 */
1505 public function check_validate_permissions(WP_REST_Request $request): bool {
1506 // Check user capability
1507 if (!current_user_can('edit_posts')) {
1508 return false;
1509 }
1510
1511 // SECURITY: Verify nonce for CSRF protection
1512 return $this->verify_request_nonce($request);
1513 }
1514
1515 /**
1516 * Check permissions for schema deployment with CSRF protection
1517 *
1518 * @since 1.0.0
1519 *
1520 * @param WP_REST_Request $request Request object
1521 * @return bool Permission status
1522 */
1523 public function check_deploy_permissions(WP_REST_Request $request): bool {
1524 // Check user capability
1525 if (!current_user_can('publish_posts')) {
1526 return false;
1527 }
1528
1529 // SECURITY: Verify nonce for CSRF protection
1530 return $this->verify_request_nonce($request);
1531 }
1532
1533 /**
1534 * Check permissions for reading schema data
1535 *
1536 * @since 1.0.0
1537 *
1538 * @param WP_REST_Request $request Request object
1539 * @return bool Permission status
1540 */
1541 public function check_read_permissions(WP_REST_Request $request): bool {
1542 // Schema config + deployed JSON-LD are not subscriber-visible — require
1543 // the same Schema management capability as the write routes.
1544 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema');
1545 }
1546
1547 /**
1548 * Check permissions for schema optimization with CSRF protection
1549 *
1550 * @since 1.0.0
1551 *
1552 * @param WP_REST_Request $request Request object
1553 * @return bool Permission status
1554 */
1555 public function check_optimize_permissions(WP_REST_Request $request): bool {
1556 // Check user capability
1557 if (!current_user_can('edit_posts')) {
1558 return false;
1559 }
1560
1561 // SECURITY: Verify nonce for CSRF protection
1562 return $this->verify_request_nonce($request);
1563 }
1564
1565 /**
1566 * Check permissions for bulk operations with CSRF protection
1567 *
1568 * @since 1.0.0
1569 *
1570 * @param WP_REST_Request $request Request object
1571 * @return bool Permission status
1572 */
1573 public function check_bulk_permissions(WP_REST_Request $request): bool {
1574 // Check user capability
1575 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1576 return false;
1577 }
1578
1579 // SECURITY: Verify nonce for CSRF protection
1580 return $this->verify_request_nonce($request);
1581 }
1582
1583 /**
1584 * Check permissions for managing schema settings with CSRF protection
1585 *
1586 * @since 1.0.0
1587 *
1588 * @param WP_REST_Request $request Request object
1589 * @return bool Permission status
1590 */
1591 public function check_manage_permissions(WP_REST_Request $request): bool {
1592 // Check user capability
1593 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1594 return false;
1595 }
1596
1597 // SECURITY: Verify nonce for CSRF protection (only for POST requests)
1598 if ($request->get_method() === 'POST') {
1599 return $this->verify_request_nonce($request);
1600 }
1601
1602 return true;
1603 }
1604
1605 /**
1606 * Helper methods
1607 */
1608
1609 /**
1610 * Verify request nonce for CSRF protection
1611 *
1612 * @since 1.0.0
1613 *
1614 * @param WP_REST_Request $request Request object
1615 * @return bool Whether nonce is valid
1616 */
1617 private function verify_request_nonce(WP_REST_Request $request): bool {
1618 // Get nonce from header (preferred method for REST API)
1619 $nonce = $request->get_header('X-WP-Nonce');
1620
1621 // Fallback to parameter if header not present
1622 if (!$nonce) {
1623 $nonce = $request->get_param('_wpnonce');
1624 }
1625
1626 // Verify nonce
1627 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1628 return false;
1629 }
1630
1631 return true;
1632 }
1633
1634 /**
1635 * Validate context type and ID
1636 *
1637 * @since 1.0.0
1638 *
1639 * @param string $context_type Context type
1640 * @param int|null $context_id Context ID
1641 * @return bool Validation status
1642 */
1643 private function validate_context(string $context_type, ?int $context_id): bool {
1644 $valid_types = ['site', 'post', 'page', 'product'];
1645
1646 if (!in_array($context_type, $valid_types, true)) {
1647 return false;
1648 }
1649
1650 if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1651 return false;
1652 }
1653
1654 if ($context_id && !get_post($context_id)) {
1655 return false;
1656 }
1657
1658 return true;
1659 }
1660
1661 /**
1662 * Generate schema preview
1663 *
1664 * @since 1.0.0
1665 *
1666 * @param array $schema_data Schema data
1667 * @param string $schema_type Schema type
1668 * @return array Preview data
1669 */
1670 private function generate_preview(array $schema_data, string $schema_type): array {
1671 return [
1672 'rich_snippets' => [
1673 $schema_type => $this->format_rich_snippet_preview($schema_data, $schema_type)
1674 ],
1675 'json_ld' => wp_json_encode($schema_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
1676 'validation_status' => 'pending'
1677 ];
1678 }
1679
1680 /**
1681 * Format rich snippet preview for specific schema type
1682 *
1683 * @since 1.0.0
1684 *
1685 * @param array $schema_data Schema data
1686 * @param string $schema_type Schema type
1687 * @return array Formatted preview data
1688 */
1689 private function format_rich_snippet_preview(array $schema_data, string $schema_type): array {
1690 switch ($schema_type) {
1691 case 'Organization':
1692 return [
1693 'title' => $schema_data['name'] ?? 'Organization Name',
1694 'url' => $schema_data['url'] ?? home_url(),
1695 'description' => $schema_data['description'] ?? 'Organization description',
1696 'additional_info' => $this->format_organization_info($schema_data)
1697 ];
1698
1699 case 'LocalBusiness':
1700 return [
1701 'title' => $schema_data['name'] ?? 'Business Name',
1702 'url' => $schema_data['url'] ?? home_url(),
1703 'description' => $schema_data['description'] ?? 'Business description',
1704 'additional_info' => $this->format_local_business_info($schema_data)
1705 ];
1706
1707 case 'Article':
1708 return [
1709 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Article Title',
1710 'url' => $schema_data['url'] ?? home_url(),
1711 'description' => $schema_data['description'] ?? 'Article description',
1712 'additional_info' => $this->format_article_info($schema_data)
1713 ];
1714
1715 default:
1716 return [
1717 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1718 'url' => $schema_data['url'] ?? home_url(),
1719 'description' => $schema_data['description'] ?? 'Description',
1720 'additional_info' => ''
1721 ];
1722 }
1723 }
1724 private function format_organization_info(array $schema_data): string {
1725 $info = [];
1726
1727 if (!empty($schema_data['contactPoint']['telephone'])) {
1728 $info[] = '📞 ' . $schema_data['contactPoint']['telephone'];
1729 }
1730
1731 if (!empty($schema_data['contactPoint']['email'])) {
1732 $info[] = '✉️ ' . $schema_data['contactPoint']['email'];
1733 }
1734
1735 if (!empty($schema_data['address']['streetAddress'])) {
1736 $info[] = '📍 ' . $schema_data['address']['streetAddress'];
1737 }
1738
1739 return implode('', $info);
1740 }
1741
1742 /**
1743 * Format local business additional info
1744 *
1745 * @since 1.0.0
1746 *
1747 * @param array $schema_data Schema data
1748 * @return string Formatted info
1749 */
1750 private function format_local_business_info(array $schema_data): string {
1751 $info = [];
1752
1753 // Address
1754 if (!empty($schema_data['address'])) {
1755 $address = $schema_data['address'];
1756 $address_parts = [];
1757
1758 if (!empty($address['streetAddress'])) {
1759 $address_parts[] = $address['streetAddress'];
1760 }
1761 if (!empty($address['addressLocality'])) {
1762 $address_parts[] = $address['addressLocality'];
1763 }
1764
1765 if (!empty($address_parts)) {
1766 $info[] = '📍 ' . implode(', ', $address_parts);
1767 }
1768 }
1769
1770 // Phone
1771 if (!empty($schema_data['telephone'])) {
1772 $info[] = '📞 ' . $schema_data['telephone'];
1773 }
1774
1775 // Opening hours
1776 if (!empty($schema_data['openingHours'])) {
1777 $hours = is_array($schema_data['openingHours'])
1778 ? implode(', ', $schema_data['openingHours'])
1779 : $schema_data['openingHours'];
1780 $info[] = '🕒 ' . $hours;
1781 }
1782
1783 return implode('', $info);
1784 }
1785
1786 /**
1787 * Format article additional info
1788 *
1789 * @since 1.0.0
1790 *
1791 * @param array $schema_data Schema data
1792 * @return string Formatted info
1793 */
1794 private function format_article_info(array $schema_data): string {
1795 $info = [];
1796
1797 if (!empty($schema_data['author']['name'])) {
1798 $info[] = '👤 By ' . $schema_data['author']['name'];
1799 }
1800
1801 if (!empty($schema_data['datePublished'])) {
1802 $info[] = '�
1803 ' . gmdate('M j, Y', strtotime($schema_data['datePublished']));
1804 }
1805
1806 if (!empty($schema_data['publisher']['name'])) {
1807 $info[] = '🏢 ' . $schema_data['publisher']['name'];
1808 }
1809
1810 return implode(' ', $info);
1811 }
1812
1813 /**
1814 * Argument validation methods
1815 */
1816
1817 /**
1818 * Get arguments for schema generation endpoint
1819 *
1820 * @since 1.0.0
1821 *
1822 * @return array Arguments array
1823 */
1824 private function get_generate_schema_args(): array {
1825 return [
1826 'context_type' => [
1827 'required' => true,
1828 'type' => 'string',
1829 'enum' => ['site', 'post', 'page', 'product'],
1830 'description' => 'Context type for schema generation'
1831 ],
1832 'context_id' => [
1833 'required' => false,
1834 'type' => 'integer',
1835 'minimum' => 1,
1836 'description' => 'Context ID (not required for site context)'
1837 ],
1838 'schema_types' => [
1839 'required' => false,
1840 'type' => 'array',
1841 'items' => [
1842 'type' => 'string',
1843 'enum' => [
1844 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1845 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1846 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1847 'Event', 'HowTo', 'SoftwareApplication'
1848 ]
1849 ],
1850 'description' => 'Schema types to generate'
1851 ],
1852 'options' => [
1853 'required' => false,
1854 'type' => 'object',
1855 'description' => 'Additional generation options'
1856 ],
1857 'content_data' => [
1858 'required' => false,
1859 'type' => 'object',
1860 'description' => 'Custom content data to use for schema generation (overrides post data)',
1861 'properties' => [
1862 'title' => ['type' => 'string'],
1863 'description' => ['type' => 'string'],
1864 'content' => ['type' => 'string'],
1865 'focus_keyword' => ['type' => 'string'],
1866 'post_type' => ['type' => 'string'],
1867 'post_url' => ['type' => 'string']
1868 ]
1869 ]
1870 ];
1871 }
1872
1873 /**
1874 * Get arguments for schema validation endpoint
1875 *
1876 * @since 1.0.0
1877 *
1878 * @return array Arguments array
1879 */
1880 private function get_validate_schema_args(): array {
1881 return [
1882 'schema_data' => [
1883 'required' => true,
1884 'type' => 'object',
1885 'description' => 'Schema data to validate'
1886 ],
1887 'schema_type' => [
1888 'required' => true,
1889 'type' => 'string',
1890 'enum' => [
1891 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1892 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1893 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1894 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1895 ],
1896 'description' => 'Schema type'
1897 ],
1898 'options' => [
1899 'required' => false,
1900 'type' => 'object',
1901 'description' => 'Validation options'
1902 ]
1903 ];
1904 }
1905
1906 /**
1907 * Get arguments for schema deployment endpoint
1908 *
1909 * @since 1.0.0
1910 *
1911 * @return array Arguments array
1912 */
1913 private function get_deploy_schema_args(): array {
1914 return [
1915 'context_type' => [
1916 'required' => true,
1917 'type' => 'string',
1918 'enum' => ['site', 'post', 'page', 'product'],
1919 'description' => 'Context type for deployment'
1920 ],
1921 'context_id' => [
1922 'required' => false,
1923 'type' => 'integer',
1924 'minimum' => 1,
1925 'description' => 'Context ID (not required for site context)'
1926 ],
1927 'schema_data' => [
1928 'required' => true,
1929 'type' => 'object',
1930 'description' => 'Schema data to deploy'
1931 ],
1932 'options' => [
1933 'required' => false,
1934 'type' => 'object',
1935 'description' => 'Deployment options'
1936 ]
1937 ];
1938 }
1939
1940 /**
1941 * Get arguments for schema optimization endpoint
1942 *
1943 * @since 1.0.0
1944 *
1945 * @return array Arguments array
1946 */
1947 private function get_optimize_schema_args(): array {
1948 return [
1949 'schema_data' => [
1950 'required' => true,
1951 'type' => 'object',
1952 'description' => 'Schema data to optimize'
1953 ],
1954 'schema_type' => [
1955 'required' => true,
1956 'type' => 'string',
1957 'enum' => [
1958 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1959 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1960 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1961 ],
1962 'description' => 'Schema type'
1963 ],
1964 'options' => [
1965 'required' => false,
1966 'type' => 'object',
1967 'description' => 'Optimization options'
1968 ]
1969 ];
1970 }
1971
1972 /**
1973 * Get arguments for schema preview endpoint
1974 *
1975 * @since 1.0.0
1976 *
1977 * @return array Arguments array
1978 */
1979 private function get_preview_schema_args(): array {
1980 return [
1981 'schema_data' => [
1982 'required' => true,
1983 'type' => 'object',
1984 'description' => 'Schema data to preview'
1985 ],
1986 'schema_type' => [
1987 'required' => true,
1988 'type' => 'string',
1989 'enum' => [
1990 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1991 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1992 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1993 ],
1994 'description' => 'Schema type'
1995 ]
1996 ];
1997 }
1998
1999 /**
2000 * Get arguments for bulk operations endpoint
2001 *
2002 * @since 1.0.0
2003 *
2004 * @return array Arguments array
2005 */
2006 private function get_bulk_operations_args(): array {
2007 return [
2008 'operation' => [
2009 'required' => true,
2010 'type' => 'string',
2011 'enum' => ['generate', 'validate', 'deploy'],
2012 'description' => 'Bulk operation type'
2013 ],
2014 'items' => [
2015 'required' => true,
2016 'type' => 'array',
2017 'items' => [
2018 'type' => 'object'
2019 ],
2020 // Bound aggregate request work: every item can trigger context
2021 // lookups, recursive schema validation, generation, and
2022 // deployment, so cap the count at the REST layer.
2023 'maxItems' => self::MAX_BULK_ITEMS,
2024 'description' => 'Items to process in bulk (max ' . self::MAX_BULK_ITEMS . ')'
2025 ],
2026 'options' => [
2027 'required' => false,
2028 'type' => 'object',
2029 'description' => 'Bulk operation options'
2030 ]
2031 ];
2032 }
2033
2034 /**
2035 * Get schema settings
2036 *
2037 * @since 1.0.0
2038 *
2039 * @param WP_REST_Request $request Request object
2040 * @return WP_REST_Response|WP_Error Response object or error
2041 */
2042 public function get_settings(WP_REST_Request $request) {
2043 try {
2044 $context_type = $request->get_param('context_type') ?? 'site';
2045 $context_id = $request->get_param('context_id') ?? null;
2046
2047 // Get settings from schema manager
2048 $settings = $this->schema_manager->get_settings($context_type, $context_id);
2049
2050 return new WP_REST_Response([
2051 'success' => true,
2052 'data' => [
2053 'settings' => $settings,
2054 'context_type' => $context_type,
2055 'context_id' => $context_id
2056 ],
2057 'message' => 'Schema settings retrieved successfully'
2058 ], 200);
2059
2060 } catch (\Exception $e) {
2061 return new WP_Error(
2062 'settings_fetch_failed',
2063 'Failed to retrieve schema settings: ' . $e->getMessage(),
2064 ['status' => 500]
2065 );
2066 }
2067 }
2068
2069 /**
2070 * Save schema settings
2071 *
2072 * @since 1.0.0
2073 *
2074 * @param WP_REST_Request $request Request object
2075 * @return WP_REST_Response|WP_Error Response object or error
2076 */
2077 public function save_settings(WP_REST_Request $request) {
2078 try {
2079 $settings = $request->get_param('settings');
2080 $context_type = $request->get_param('context_type') ?? 'site';
2081 $context_id = $request->get_param('context_id') ?? null;
2082
2083 // Validate input parameters
2084 if (empty($settings) || !is_array($settings)) {
2085 return new WP_Error(
2086 'invalid_settings',
2087 'Settings parameter is required and must be an array',
2088 ['status' => 400]
2089 );
2090 }
2091
2092 // SECURITY: For non-site contexts (post/page/product), verify the
2093 // caller can edit that specific object — same ownership gate the
2094 // generate/deploy routes use. Site context stays governed by the
2095 // thinkrank_schema capability via the Role Manager gate.
2096 $context_type = sanitize_key((string) $context_type);
2097 if ($context_type !== 'site') {
2098 $context_validation = $this->input_validator->validate_context_parameters(
2099 $context_type,
2100 $context_id !== null ? absint($context_id) : null,
2101 get_current_user_id()
2102 );
2103 if (!$context_validation['valid']) {
2104 return new WP_Error(
2105 'invalid_context',
2106 implode(', ', $context_validation['errors']),
2107 ['status' => 403]
2108 );
2109 }
2110 $context_type = $context_validation['sanitized_data']['context_type'];
2111 $context_id = $context_validation['sanitized_data']['context_id'];
2112 }
2113
2114 // Drop unrecognized keys so arbitrary client-supplied keys aren't
2115 // persisted as settings rows (storage bloat / settings drift).
2116 $settings = $this->filter_known_setting_keys($settings, $context_type);
2117 if (empty($settings)) {
2118 return new WP_Error(
2119 'invalid_settings',
2120 'No recognized schema settings were provided',
2121 ['status' => 400]
2122 );
2123 }
2124
2125 // Get validation results for detailed error reporting
2126 $validation = $this->schema_manager->validate_settings($settings);
2127
2128 if (!$validation['valid']) {
2129 // Schema settings validation failed - details available in validation response
2130
2131 return new WP_Error(
2132 'validation_failed',
2133 'Schema settings validation failed',
2134 [
2135 'status' => 400,
2136 'validation_errors' => $validation['errors'],
2137 'validation_warnings' => $validation['warnings'] ?? [],
2138 'validation_suggestions' => $validation['suggestions'] ?? []
2139 ]
2140 );
2141 }
2142
2143 // Save settings using schema manager
2144 $success = $this->schema_manager->save_settings($context_type, $context_id, $settings);
2145
2146 if (!$success) {
2147 // Schema settings save failed - database operation unsuccessful
2148
2149 return new WP_Error(
2150 'settings_save_failed',
2151 'Failed to save schema settings to database',
2152 ['status' => 500]
2153 );
2154 }
2155
2156 return new WP_REST_Response([
2157 'success' => true,
2158 'data' => [
2159 'settings' => $settings,
2160 'context_type' => $context_type,
2161 'context_id' => $context_id,
2162 'validation' => $validation
2163 ],
2164 'message' => 'Schema settings saved successfully'
2165 ], 200);
2166
2167 } catch (\Exception $e) {
2168 // Schema settings save exception - error details in response
2169
2170 return new WP_Error(
2171 'settings_update_failed',
2172 'Failed to update schema settings: ' . $e->getMessage(),
2173 ['status' => 500]
2174 );
2175 }
2176 }
2177
2178 /**
2179 * Restrict a settings payload to recognized keys.
2180 *
2181 * The known set is the context's default settings plus a few keys that are
2182 * legitimately stored/consumed elsewhere (site-identity/local-SEO fields and
2183 * the schema settings schema) but not seeded into the defaults. Filterable
2184 * so Pro/integrations can register additional keys.
2185 *
2186 * @param array $settings Incoming settings.
2187 * @param string $context_type Context type (site/post/page/product).
2188 * @return array Settings limited to known keys.
2189 */
2190 private function filter_known_setting_keys(array $settings, string $context_type): array {
2191 $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type));
2192 // Keys stored/consumed by adjacent features that share the settings
2193 // store but aren't part of the schema defaults.
2194 $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [
2195 'business_name', 'site_name', 'logo_url', 'performance_tracking',
2196 ]);
2197 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2198
2199 return array_intersect_key($settings, array_flip($known));
2200 }
2201
2202 /**
2203 * Get arguments for settings endpoints
2204 *
2205 * @since 1.0.0
2206 *
2207 * @return array Arguments array
2208 */
2209 private function get_settings_args(): array {
2210 return [
2211 'settings' => [
2212 'required' => true,
2213 'type' => 'object',
2214 'description' => 'Schema settings object'
2215 ],
2216 'context_type' => [
2217 'required' => false,
2218 'type' => 'string',
2219 'default' => 'site',
2220 'enum' => ['site', 'post', 'page', 'product'],
2221 'description' => 'Context type for settings'
2222 ],
2223 'context_id' => [
2224 'required' => false,
2225 'type' => 'integer',
2226 'minimum' => 1,
2227 'description' => 'Context ID for settings'
2228 ]
2229 ];
2230 }
2231 }
2232