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

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