schema_manager = new Schema_Management_System(); $this->input_validator = new Schema_Input_Validator(); } /** * Register API routes * * @since 1.0.0 */ public function register_routes(): void { // Generate schema markup register_rest_route( $this->namespace, '/' . $this->rest_base . '/generate', [ [ 'methods' => 'POST', 'callback' => [$this, 'generate_schema'], 'permission_callback' => [$this, 'check_generate_permissions'], 'args' => $this->get_generate_schema_args() ] ] ); // Validate schema markup register_rest_route( $this->namespace, '/' . $this->rest_base . '/validate', [ [ 'methods' => 'POST', 'callback' => [$this, 'validate_schema'], 'permission_callback' => [$this, 'check_validate_permissions'], 'args' => $this->get_validate_schema_args() ] ] ); // Deploy schema markup register_rest_route( $this->namespace, '/' . $this->rest_base . '/deploy', [ [ 'methods' => 'POST', 'callback' => [$this, 'deploy_schema'], 'permission_callback' => [$this, 'check_deploy_permissions'], 'args' => $this->get_deploy_schema_args() ] ] ); // Get schema types register_rest_route( $this->namespace, '/' . $this->rest_base . '/types', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_schema_types'], 'permission_callback' => [$this, 'check_read_permissions'] ] ] ); // Get deployed schemas register_rest_route( $this->namespace, '/' . $this->rest_base . '/deployed', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_deployed_schemas'], 'permission_callback' => [$this, 'check_read_permissions'], 'args' => $this->get_context_route_args() ] ] ); // Get schema for context register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[a-zA-Z0-9_-]+)/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_context_schema'], 'permission_callback' => [$this, 'check_read_permissions'], 'args' => [ 'context_type' => [ 'required' => true, 'type' => 'string', 'enum' => ['site', 'post', 'page', 'product'] ], 'context_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1 ] ] ] ] ); // Optimize rich snippets register_rest_route( $this->namespace, '/' . $this->rest_base . '/optimize', [ [ 'methods' => 'POST', 'callback' => [$this, 'optimize_rich_snippets'], 'permission_callback' => [$this, 'check_optimize_permissions'], 'args' => $this->get_optimize_schema_args() ] ] ); // Track schema performance register_rest_route( $this->namespace, '/' . $this->rest_base . '/performance/(?P[a-zA-Z0-9_-]+)/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_schema_performance'], 'permission_callback' => [$this, 'check_read_permissions'], 'args' => [ 'context_type' => [ 'required' => true, 'type' => 'string', 'enum' => ['site', 'post', 'page', 'product'] ], 'context_id' => [ 'required' => true, 'type' => 'integer', 'minimum' => 1 ] ] ] ] ); // Get schema preview register_rest_route( $this->namespace, '/' . $this->rest_base . '/preview', [ [ 'methods' => 'POST', 'callback' => [$this, 'get_schema_preview'], 'permission_callback' => [$this, 'check_read_permissions'], 'args' => $this->get_preview_schema_args() ] ] ); // Bulk operations register_rest_route( $this->namespace, '/' . $this->rest_base . '/bulk', [ [ 'methods' => 'POST', 'callback' => [$this, 'bulk_operations'], 'permission_callback' => [$this, 'check_bulk_permissions'], 'args' => $this->get_bulk_operations_args() ] ] ); // Schema settings management register_rest_route( $this->namespace, '/' . $this->rest_base . '/settings', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_settings'], 'permission_callback' => [$this, 'check_read_permissions'], 'args' => $this->get_context_route_args() ], [ 'methods' => 'POST', 'callback' => [$this, 'save_settings'], 'permission_callback' => [$this, 'check_manage_permissions'], 'args' => $this->get_settings_args() ] ] ); // Import schema from URL register_rest_route( $this->namespace, '/' . $this->rest_base . '/import', [ [ 'methods' => 'POST', 'callback' => [$this, 'import_schema_from_url'], 'permission_callback' => [$this, 'check_manage_permissions'], 'args' => [ 'url' => [ 'required' => true, 'type' => 'string', 'format' => 'uri' ] ] ] ] ); } /** * Import schema from URL * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function import_schema_from_url(WP_REST_Request $request) { try { $url = esc_url_raw($request->get_param('url')); if (empty($url)) { return new WP_Error( 'invalid_url', 'A valid URL is required', ['status' => 400] ); } // Block SSRF: reject non-http(s)/malformed URLs and any host that // resolves to a private, loopback, link-local, or otherwise reserved // IP range — including the link-local 169.254.0.0/16 (cloud metadata, // e.g. 169.254.169.254) and 100.64.0.0/10 (CGNAT) ranges that // wp_http_validate_url() does NOT block — re-validated on every // redirect hop. See fetch_import_url() / \ThinkRank\Core\Url_Safety. $response = $this->fetch_import_url($url); if (is_wp_error($response)) { // Preserve the SSRF/redirect block responses (they already carry a // 4xx status); wrap transport-level failures as a 500. $error_data = $response->get_error_data(); if (is_array($error_data) && isset($error_data['status'])) { return $response; } return new WP_Error( 'fetch_failed', 'Failed to fetch data from URL: ' . $response->get_error_message(), ['status' => 500] ); } $response_code = wp_remote_retrieve_response_code($response); if ($response_code !== 200) { return new WP_Error( 'fetch_error', 'Failed to fetch data from URL (HTTP ' . $response_code . ')', ['status' => 400] ); } $body = wp_remote_retrieve_body($response); if (empty($body)) { return new WP_Error( 'empty_response', 'Returned content is empty', ['status' => 400] ); } // Suppress DOM errors for malformed HTML libxml_use_internal_errors(true); $dom = new \DOMDocument(); // Prepend an XML encoding hint so DOMDocument parses UTF-8 correctly. // Avoids the deprecated mb_convert_encoding($body, 'HTML-ENTITIES') call, // which emits deprecation notices on PHP 8.2+. $dom->loadHTML('' . $body, LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); $xpath = new \DOMXPath($dom); $scripts = $xpath->query('//script[@type="application/ld+json"]'); $found_schemas = []; if ($scripts->length > 0) { foreach ($scripts as $script) { $json = trim($script->nodeValue); $data = json_decode($json, true); if (json_last_error() === JSON_ERROR_NONE && !empty($data)) { // A script block may hold a single entity, a bare list // of entities, or an object wrapping @graph. Treating // every block as one flat object collapsed lists into // numeric keys and never opened @graph — the shape Yoast // and Rank Math emit — so the import produced entries // with no top-level @type that deploy silently dropped // (#467). foreach ($this->extract_schema_entities($data) as $entity) { // Strictly set @context to https://schema.org $entity['@context'] = 'https://schema.org'; $found_schemas[] = $entity; } } } } if (empty($found_schemas)) { return new WP_Error( 'no_schema_found', 'No valid JSON-LD schema markup found on this page', ['status' => 404] ); } return new WP_REST_Response([ 'success' => true, 'data' => $found_schemas[0], 'all_found' => $found_schemas, 'message' => 'Schema imported successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'import_failed', 'Schema import failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Sanitize schema form data of arbitrary depth. * * The metabox forms post nested structures — `faq_questions` is a list of * `{question, answer}` objects and `howto_steps` a list of `{name, text}` * objects. A flat `array_map('sanitize_text_field', $value)` handed those * inner arrays to a string sanitizer, which returns '', so every question * and step was blanked before the builder saw it and FAQPage generated with * an empty `mainEntity` (failing its own required-property validation). * Recursing keeps the shape and still sanitizes every scalar leaf. * * @since 2.0.2 * * @param array $data Raw form data. * @return array Sanitized form data with structure preserved. */ private function sanitize_schema_form_data(array $data): array { $sanitized = []; foreach ($data as $key => $value) { $clean_key = is_int($key) ? $key : sanitize_key($key); if (is_array($value)) { $sanitized[$clean_key] = $this->sanitize_schema_form_data($value); } elseif (is_bool($value)) { $sanitized[$clean_key] = $value; } elseif (is_string($value)) { $sanitized[$clean_key] = sanitize_text_field($value); } elseif (is_numeric($value)) { $sanitized[$clean_key] = floatval($value); } } return $sanitized; } /** * Flatten one decoded JSON-LD script block into individual entities. * * JSON-LD allows a script tag to carry a single object, an array of objects, * or an object whose `@graph` holds the entities. Mirrors the Pro file * importer's extract_schemas() so both paths agree (#467). * * @since 1.16.0 * * @param array $decoded Decoded JSON-LD. * @return array One entry per entity. */ private function extract_schema_entities(array $decoded): array { // Object wrapping @graph — the shape Yoast and Rank Math emit. if (!empty($decoded['@graph']) && is_array($decoded['@graph'])) { $context = $decoded['@context'] ?? null; $entities = []; foreach ($decoded['@graph'] as $entity) { if (!is_array($entity) || empty($entity)) { continue; } // Carry the outer @context onto entities that lack their own. if (null !== $context && !isset($entity['@context'])) { $entity['@context'] = $context; } $entities[] = $entity; } return $entities; } // Bare list of entities: [{...}, {...}] if (isset($decoded[0]) && is_array($decoded[0])) { return array_values(array_filter($decoded, static function ($entity) { return is_array($entity) && !empty($entity); })); } // Single entity. return [$decoded]; } /** * Fetch a remote URL for schema import. * * Delegates to the shared SSRF guard, which follows redirects manually and * re-validates the resolved host against the block list on every hop — * wp_safe_remote_get()'s own redirect validation goes through * wp_http_validate_url(), which shares the link-local/CGNAT blind spot. * * @param string $url URL to fetch. * @return array|\WP_Error Response array on success, WP_Error otherwise. */ private function fetch_import_url(string $url) { return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [ 'timeout' => 15, 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)', // Without a cap the whole body is buffered into memory and then // handed to DOMDocument at roughly twice the size, so a hostile or // simply enormous page could exhaust the request (#473). 'limit_response_size' => 2 * MB_IN_BYTES, ]); } /** * Generate schema markup * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function generate_schema(WP_REST_Request $request) { try { $user_id = get_current_user_id(); // SECURITY: Check rate limits first $rate_limit_check = $this->check_rate_limit('generate_schema', $user_id); if (is_wp_error($rate_limit_check)) { return $rate_limit_check; } // SECURITY: Validate user permissions and rate limiting $permission_check = $this->input_validator->validate_user_permissions('generate', $user_id); if (!$permission_check['valid']) { return new WP_Error( 'permission_denied', implode(', ', $permission_check['errors']), ['status' => 403] ); } // SECURITY: Validate and sanitize context parameters with ownership checks $context_type = $request->get_param('context_type'); $context_id = $request->get_param('context_id'); $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id); if (!$context_validation['valid']) { return new WP_Error( 'invalid_context', implode(', ', $context_validation['errors']), ['status' => 400] ); } $context_type = $context_validation['sanitized_data']['context_type']; $context_id = $context_validation['sanitized_data']['context_id']; // SECURITY: Validate and sanitize schema types $schema_types = $request->get_param('schema_types') ?? []; if (empty($schema_types) || !is_array($schema_types)) { return new WP_Error( 'missing_schema_types', 'Schema types array is required', ['status' => 400] ); } // Sanitize schema types $sanitized_schema_types = []; foreach ($schema_types as $type) { $sanitized_type = sanitize_text_field($type); if (!empty($sanitized_type)) { $sanitized_schema_types[] = $sanitized_type; } } if (empty($sanitized_schema_types)) { return new WP_Error( 'invalid_schema_types', 'No valid schema types provided', ['status' => 400] ); } // SECURITY: Sanitize options $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []); // SECURITY: Sanitize content_data if provided $content_data = $request->get_param('content_data'); if ($content_data && is_array($content_data)) { $content_data = [ 'title' => isset($content_data['title']) ? sanitize_text_field($content_data['title']) : '', 'description' => isset($content_data['description']) ? sanitize_textarea_field($content_data['description']) : '', 'content' => isset($content_data['content']) ? wp_kses_post($content_data['content']) : '', 'word_count' => isset($content_data['word_count']) ? (int) $content_data['word_count'] : 0, 'focus_keyword' => isset($content_data['focus_keyword']) ? sanitize_text_field($content_data['focus_keyword']) : '', 'post_type' => isset($content_data['post_type']) ? sanitize_text_field($content_data['post_type']) : '', 'post_url' => isset($content_data['post_url']) ? esc_url_raw($content_data['post_url']) : '' ]; // Add content_data to options so schema manager can use it $options['content_data'] = $content_data; } // SECURITY: Sanitize schema_form_data if provided $schema_form_data = $request->get_param('schema_form_data'); if ($schema_form_data && is_array($schema_form_data)) { // Add schema_form_data to options so schema manager can use it $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data); } // Generate schema markup with sanitized inputs $generation_results = $this->schema_manager->generate_schema_markup( $context_type, $context_id, $sanitized_schema_types, $options ); return new WP_REST_Response([ 'success' => true, 'data' => $generation_results, 'message' => 'Schema markup generated successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'generation_failed', 'Schema generation failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Validate schema markup * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function validate_schema(WP_REST_Request $request) { try { $user_id = get_current_user_id(); // SECURITY: Validate user permissions and rate limiting $permission_check = $this->input_validator->validate_user_permissions('validate', $user_id); if (!$permission_check['valid']) { return new WP_Error( 'permission_denied', implode(', ', $permission_check['errors']), ['status' => 403] ); } $schema_data = $request->get_param('schema_data'); $schema_type = $request->get_param('schema_type'); $options = $request->get_param('options') ?? []; // SECURITY: Validate input parameters if (empty($schema_data) || empty($schema_type)) { return new WP_Error( 'missing_parameters', 'Schema data and type are required', ['status' => 400] ); } // SECURITY: Sanitize schema type $schema_type = sanitize_text_field($schema_type); // SECURITY: Validate and sanitize schema data using input validator if (!is_array($schema_data)) { return new WP_Error( 'invalid_schema_data', 'Schema data must be an array/object', ['status' => 400] ); } $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type); if (!$input_validation['valid']) { return new WP_Error( 'schema_validation_failed', 'Schema data validation failed: ' . implode(', ', $input_validation['errors']), [ 'status' => 400, 'validation_errors' => $input_validation['errors'], 'validation_warnings' => $input_validation['warnings'] ] ); } // Use sanitized data for validation $sanitized_schema_data = $input_validation['sanitized_data']; // SECURITY: Sanitize options $options = $this->input_validator->sanitize_options($options); // Validate schema markup with sanitized data $validation_results = $this->schema_manager->validate_schema_markup( $sanitized_schema_data, $schema_type, $options ); return new WP_REST_Response([ 'success' => true, 'data' => $validation_results, 'message' => 'Schema validation completed' ], 200); } catch (\Exception $e) { return new WP_Error( 'validation_failed', 'Schema validation failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Deploy schema markup * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function deploy_schema(WP_REST_Request $request) { try { $user_id = get_current_user_id(); // SECURITY: Validate user permissions and rate limiting $permission_check = $this->input_validator->validate_user_permissions('deploy', $user_id); if (!$permission_check['valid']) { return new WP_Error( 'permission_denied', implode(', ', $permission_check['errors']), ['status' => 403] ); } // SECURITY: Validate and sanitize context parameters with ownership checks $context_type = $request->get_param('context_type'); $context_id = $request->get_param('context_id'); $context_validation = $this->input_validator->validate_context_parameters($context_type, $context_id, $user_id); if (!$context_validation['valid']) { return new WP_Error( 'invalid_context', implode(', ', $context_validation['errors']), ['status' => 400] ); } $context_type = $context_validation['sanitized_data']['context_type']; $context_id = $context_validation['sanitized_data']['context_id']; // SECURITY: Validate schema data $schema_data = $request->get_param('schema_data'); if (empty($schema_data) || !is_array($schema_data)) { return new WP_Error( 'invalid_schema_data', 'Valid schema data array is required', ['status' => 400] ); } // SECURITY: Validate each schema in the data $sanitized_schema_data = []; $skipped_schemas = []; foreach ($schema_data as $schema_key => $schema_content) { $schema_key = sanitize_text_field($schema_key); if (!is_array($schema_content)) { return new WP_Error( 'invalid_schema_content', "Schema content for {$schema_key} must be an array", ['status' => 400] ); } // Ensure schema has required structure fields before validation // Use @type from schema content if available, otherwise fall back to key. // `@type` may legitimately be an array ("@type": ["Product","Offer"]); // sanitize_text_field() on an array yields '', which then failed the // whitelist lookup with "Invalid schema type:" (#468). Resolve the // primary type for lookup and leave the original value in the payload. if (isset($schema_content['@type'])) { $raw_type = $schema_content['@type']; $schema_type = is_array($raw_type) ? sanitize_text_field((string) reset($raw_type)) : sanitize_text_field((string) $raw_type); } else { $schema_type = $schema_key; } if (!isset($schema_content['@type'])) { $schema_content['@type'] = $schema_type; } if (!isset($schema_content['@context'])) { $schema_content['@context'] = 'https://schema.org'; } // Validate using the actual schema type, not the key. // A failure skips this entry instead of aborting the batch: the // UI sends every schema in one payload, so one unsupported type // used to block the valid entries alongside it (#468). $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type); if (!$input_validation['valid']) { $skipped_schemas[] = [ 'key' => $schema_key, 'type' => $schema_type, 'errors' => $input_validation['errors'], ]; continue; } // Store using the key (which may be unique like "Article-1") $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data']; } // Every entry failed — that is a request-level error worth a 400, // since there is nothing to deploy. if (empty($sanitized_schema_data) && !empty($skipped_schemas)) { return new WP_Error( 'schema_validation_failed', sprintf( /* translators: %s: comma-separated list of schema types. */ __('No schema could be deployed. Failed types: %s', 'thinkrank'), implode(', ', wp_list_pluck($skipped_schemas, 'type')) ), [ 'status' => 400, 'skipped' => $skipped_schemas, ] ); } // SECURITY: Sanitize options $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []); // This route is the user pressing Deploy, so the payload is the full // intended set for the context — types missing from it were removed // deliberately and must come off the page (#464). $options['authoritative'] = true; // Deploy schema markup with sanitized data $deployment_results = $this->schema_manager->deploy_schema_markup( $context_type, $context_id, $sanitized_schema_data, $options ); $response = [ 'success' => true, 'data' => $deployment_results, 'message' => 'Schema markup deployed successfully' ]; // Report what was skipped so the UI can say "3 deployed, 1 skipped" // rather than silently dropping entries (#468). if (!empty($skipped_schemas)) { $response['skipped'] = $skipped_schemas; $response['message'] = sprintf( /* translators: 1: number deployed, 2: number skipped. */ __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'), count($sanitized_schema_data), count($skipped_schemas) ); } return new WP_REST_Response($response, 200); } catch (\Exception $e) { return new WP_Error( 'deployment_failed', 'Schema deployment failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Get available schema types * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function get_schema_types(WP_REST_Request $request): WP_REST_Response { // Get context parameter to determine which schema types to return $context = $request->get_param('context') ?? 'site'; // Site-level schema types only (post/page schemas handled by metabox) $site_schema_types = [ 'Organization' => [ 'name' => 'Organization', 'description' => 'Company or organization information (site-wide)', 'context_types' => ['site'], 'priority' => 'high' ], 'LocalBusiness' => [ 'name' => 'LocalBusiness', 'description' => 'Local businesses and service providers (site-wide)', 'context_types' => ['site'], 'priority' => 'high' ], 'Person' => [ 'name' => 'Person', 'description' => 'Individual person or author information (site-wide)', 'context_types' => ['site'], 'priority' => 'medium' ], 'WebSite' => [ 'name' => 'WebSite', 'description' => 'Website-level information and search functionality', 'context_types' => ['site'], 'priority' => 'high' ] ]; // All schema types for metabox context $all_schema_types = [ 'Article' => [ 'name' => 'Article', 'description' => 'News articles, blog posts, and editorial content', 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'BlogPosting' => [ 'name' => 'BlogPosting', 'description' => 'Blog posts and personal articles', 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'TechnicalArticle' => [ 'name' => 'TechnicalArticle', 'description' => 'Technical documentation and tutorials', 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'NewsArticle' => [ 'name' => 'NewsArticle', 'description' => 'News articles and press releases', 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'ScholarlyArticle' => [ 'name' => 'ScholarlyArticle', 'description' => 'Academic and research articles', 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'Report' => [ 'name' => 'Report', 'description' => 'Reports and analytical content', 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'HowTo' => [ 'name' => 'HowTo', 'description' => 'Step-by-step instructions and tutorials', 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'FAQPage' => [ 'name' => 'FAQPage', 'description' => 'Frequently Asked Questions pages', 'context_types' => ['page', 'post'], 'priority' => 'high' ], 'Event' => [ 'name' => 'Event', 'description' => 'Events, conferences, and gatherings', 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'Product' => [ 'name' => 'Product', 'description' => 'Products for e-commerce and retail', 'context_types' => ['product', 'post', 'page'], 'priority' => 'critical' ], 'SoftwareApplication' => [ 'name' => 'SoftwareApplication', 'description' => 'Software applications and web apps', 'context_types' => ['post', 'page'], 'priority' => 'high' ] ] + $site_schema_types; // Return appropriate schema types based on context $schema_types = ($context === 'metabox') ? $all_schema_types : $site_schema_types; return new WP_REST_Response([ 'success' => true, 'data' => $schema_types, 'message' => 'Schema types retrieved successfully' ], 200); } /** * Get deployed schemas * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function get_deployed_schemas(WP_REST_Request $request) { try { // SECURITY: this route reads the schema deployed against a specific // object. The thinkrank_schema capability authorises the section, not // every post on the site, so the object itself has to be authorised // before the read (#385). $context = $this->resolve_request_context($request); if (is_wp_error($context)) { return $context; } [$context_type, $context_id] = $context; $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); return new WP_REST_Response([ 'success' => true, 'data' => $deployed_schemas, 'message' => 'Deployed schemas retrieved successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'deployed_schemas_failed', 'Failed to retrieve deployed schemas: ' . $e->getMessage(), ['status' => 500] ); } } /** * Get schema for specific context * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function get_context_schema(WP_REST_Request $request) { try { $context_type = $request->get_param('context_type'); $context_id = (int) $request->get_param('context_id'); // Validate context and the caller's access to it. Returns true or a // WP_Error carrying the right status (400 shape, 403 authorization). $context_validation = $this->validate_context($context_type, $context_id); if (is_wp_error($context_validation)) { return $context_validation; } // Get schema output data $schema_data = $this->schema_manager->get_output_data($context_type, $context_id); return new WP_REST_Response([ 'success' => true, 'data' => $schema_data, 'message' => 'Context schema retrieved successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'retrieval_failed', 'Schema retrieval failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Optimize rich snippets * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function optimize_rich_snippets(WP_REST_Request $request) { try { $user_id = get_current_user_id(); // SECURITY: Validate user permissions and rate limiting $permission_check = $this->input_validator->validate_user_permissions('optimize', $user_id); if (!$permission_check['valid']) { return new WP_Error( 'permission_denied', implode(', ', $permission_check['errors']), ['status' => 403] ); } $schema_data = $request->get_param('schema_data'); $schema_type = $request->get_param('schema_type'); $options = $request->get_param('options') ?? []; // Validate input if (empty($schema_data) || empty($schema_type)) { return new WP_Error( 'missing_parameters', 'Schema data and type are required', ['status' => 400] ); } // SECURITY: Validate and sanitize schema data using input validator, // the same way generate/validate/deploy do — this route must not be // the one path that hands a raw client blob to the schema manager. if (!is_array($schema_data)) { return new WP_Error( 'invalid_schema_data', 'Schema data must be an array/object', ['status' => 400] ); } $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type); if (!$input_validation['valid']) { return new WP_Error( 'schema_validation_failed', 'Schema data validation failed: ' . implode(', ', $input_validation['errors']), [ 'status' => 400, 'validation_errors' => $input_validation['errors'], 'validation_warnings' => $input_validation['warnings'] ] ); } // SECURITY: Sanitize options $options = $this->input_validator->sanitize_options($options); // Optimize rich snippets with the sanitized data $optimization_results = $this->schema_manager->optimize_rich_snippets( $input_validation['sanitized_data'], $schema_type, $options ); return new WP_REST_Response([ 'success' => true, 'data' => $optimization_results, 'message' => 'Rich snippets optimization completed' ], 200); } catch (\Exception $e) { return new WP_Error( 'optimization_failed', 'Rich snippets optimization failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Get schema performance data * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function get_schema_performance(WP_REST_Request $request) { try { $context_type = $request->get_param('context_type'); $context_id = (int) $request->get_param('context_id'); $options = $request->get_param('options') ?? []; // Validate context and the caller's access to it. Returns true or a // WP_Error carrying the right status (400 shape, 403 authorization). $context_validation = $this->validate_context($context_type, $context_id); if (is_wp_error($context_validation)) { return $context_validation; } // Track schema performance $performance_data = $this->schema_manager->track_schema_performance( $context_type, $context_id, $options ); return new WP_REST_Response([ 'success' => true, 'data' => $performance_data, 'message' => 'Schema performance data retrieved successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'performance_tracking_failed', 'Schema performance tracking failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Get schema preview * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function get_schema_preview(WP_REST_Request $request) { try { $schema_data = $request->get_param('schema_data'); $schema_type = $request->get_param('schema_type'); // Validate input if (empty($schema_data) || empty($schema_type)) { return new WP_Error( 'missing_parameters', 'Schema data and type are required', ['status' => 400] ); } // Generate preview $preview_data = $this->generate_preview($schema_data, $schema_type); return new WP_REST_Response([ 'success' => true, 'data' => $preview_data, 'message' => 'Schema preview generated successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'preview_failed', 'Schema preview generation failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Bulk operations for schema management * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error * * @throws \Exception On failure. */ public function bulk_operations(WP_REST_Request $request) { try { $user_id = get_current_user_id(); // SECURITY: Validate user permissions and rate limiting $permission_check = $this->input_validator->validate_user_permissions('bulk_operations', $user_id); if (!$permission_check['valid']) { return new WP_Error( 'permission_denied', implode(', ', $permission_check['errors']), ['status' => 403] ); } $operation = $request->get_param('operation'); $items = $request->get_param('items') ?? []; $options = $request->get_param('options') ?? []; // Validate input if (empty($operation) || empty($items)) { return new WP_Error( 'missing_parameters', 'Operation and items are required', ['status' => 400] ); } // Defensive recheck of the item cap (the REST arg maxItems already // enforces it, but never process an unbounded batch even if that // schema is bypassed). if (count($items) > self::MAX_BULK_ITEMS) { return new WP_Error( 'too_many_items', sprintf('Bulk operations are limited to %d items per request.', self::MAX_BULK_ITEMS), ['status' => 400] ); } $results = []; $errors = []; foreach ($items as $item) { try { if (!is_array($item)) { throw new \Exception('Invalid bulk item'); } // SECURITY: apply the same per-item context-ownership and // schema validation the single-item routes enforce, and carry // the validators' NORMALIZED output forward to dispatch. The // bulk path previously dispatched raw context_id / schema_data // with no ownership (IDOR) or size/depth/type checks, and even // after validating still passed the raw item fields on. $item_context_type = isset($item['context_type']) ? (string) $item['context_type'] : ''; $item_context_id = isset($item['context_id']) ? (int) $item['context_id'] : null; // Sanitized values actually dispatched (default to the raw // context for the validate operation, which has no context). $context_type = $item_context_type; $context_id = $item_context_id; if ($operation === 'generate' || $operation === 'deploy') { $context_check = $this->input_validator->validate_context_parameters( $item_context_type, $item_context_id, $user_id ); if (!$context_check['valid']) { throw new \Exception(implode(', ', $context_check['errors'])); } // Use the sanitized context, matching the single routes. $context_type = $context_check['sanitized_data']['context_type']; $context_id = $context_check['sanitized_data']['context_id']; } // Sanitize shared options once per item, as the single routes do. $item_options = $this->input_validator->sanitize_options($options); switch ($operation) { case 'generate': // Sanitize schema types like the single generate route. $raw_types = (isset($item['schema_types']) && is_array($item['schema_types'])) ? $item['schema_types'] : []; $schema_types = []; foreach ($raw_types as $type) { $type = sanitize_text_field((string) $type); if ($type !== '') { $schema_types[] = $type; } } if (empty($schema_types)) { throw new \Exception('schema_types is required'); } $result = $this->schema_manager->generate_schema_markup( $context_type, $context_id, $schema_types, $item_options ); break; case 'validate': if (!isset($item['schema_data']) || !is_array($item['schema_data'])) { throw new \Exception('schema_data is required'); } $schema_type = ''; if (isset($item['schema_type']) && is_string($item['schema_type'])) { $schema_type = sanitize_text_field($item['schema_type']); } elseif (isset($item['schema_data']['@type']) && is_string($item['schema_data']['@type'])) { $schema_type = sanitize_text_field($item['schema_data']['@type']); } $data_check = $this->input_validator->validate_schema_data($item['schema_data'], $schema_type); if (!$data_check['valid']) { throw new \Exception(implode(', ', $data_check['errors'])); } // Validate the SANITIZED data, not the raw payload. $result = $this->schema_manager->validate_schema_markup( $data_check['sanitized_data'], $schema_type, $item_options ); break; case 'deploy': if (!isset($item['schema_data']) || !is_array($item['schema_data'])) { throw new \Exception('schema_data is required'); } // Mirror the single deploy route: validate EACH schema // entry in the collection (type resolution + default // @type/@context) and build a sanitized collection, // rather than validating the whole map as one schema. $sanitized_schema_data = []; foreach ($item['schema_data'] as $schema_key => $schema_content) { $schema_key = sanitize_text_field((string) $schema_key); if (!is_array($schema_content)) { throw new \Exception("Schema content for {$schema_key} must be an array"); } $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key; if (!isset($schema_content['@type'])) { $schema_content['@type'] = $schema_type; } if (!isset($schema_content['@context'])) { $schema_content['@context'] = 'https://schema.org'; } $data_check = $this->input_validator->validate_schema_data($schema_content, $schema_type); if (!$data_check['valid']) { throw new \Exception("Schema validation failed for {$schema_type}: " . implode(', ', $data_check['errors'])); } $sanitized_schema_data[$schema_key] = $data_check['sanitized_data']; } $result = $this->schema_manager->deploy_schema_markup( $context_type, $context_id, $sanitized_schema_data, $item_options ); break; default: throw new \Exception("Unsupported operation: {$operation}"); } $results[] = [ 'item' => $item, 'success' => true, 'data' => $result ]; } catch (\Exception $e) { $errors[] = [ 'item' => $item, 'error' => $e->getMessage() ]; } } return new WP_REST_Response([ 'success' => empty($errors), 'data' => [ 'results' => $results, 'errors' => $errors, 'total_processed' => count($items), 'successful' => count($results), 'failed' => count($errors) ], 'message' => "Bulk {$operation} operation completed" ], 200); } catch (\Exception $e) { return new WP_Error( 'bulk_operation_failed', 'Bulk operation failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Permission callbacks */ /** * Check permissions for schema generation with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_generate_permissions(WP_REST_Request $request): bool { // Gate on the Role Manager's schema capability, like the read and // settings routes. Core post caps were both too loose in principle and // too strict in practice: a role granted schema access but without // publish_posts could not deploy (#457). if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection return $this->verify_request_nonce($request); } /** * Check permissions for schema validation with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_validate_permissions(WP_REST_Request $request): bool { // Gate on the Role Manager's schema capability, like the read and // settings routes. Core post caps were both too loose in principle and // too strict in practice: a role granted schema access but without // publish_posts could not deploy (#457). if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection return $this->verify_request_nonce($request); } /** * Check permissions for schema deployment with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_deploy_permissions(WP_REST_Request $request): bool { // Gate on the Role Manager's schema capability, like the read and // settings routes. Core post caps were both too loose in principle and // too strict in practice: a role granted schema access but without // publish_posts could not deploy (#457). if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection return $this->verify_request_nonce($request); } /** * Check permissions for reading schema data * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_read_permissions(WP_REST_Request $request): bool { // Schema config + deployed JSON-LD are not subscriber-visible — require // the same Schema management capability as the write routes. return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema'); } /** * Check permissions for schema optimization with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_optimize_permissions(WP_REST_Request $request): bool { // Gate on the Role Manager's schema capability, like the read and // settings routes. Core post caps were both too loose in principle and // too strict in practice: a role granted schema access but without // publish_posts could not deploy (#457). if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection return $this->verify_request_nonce($request); } /** * Check permissions for bulk operations with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_bulk_permissions(WP_REST_Request $request): bool { // Check user capability if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection return $this->verify_request_nonce($request); } /** * Check permissions for managing schema settings with CSRF protection * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return bool Permission status */ public function check_manage_permissions(WP_REST_Request $request): bool { // Check user capability if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) { return false; } // SECURITY: Verify nonce for CSRF protection (only for POST requests) if ($request->get_method() === 'POST') { return $this->verify_request_nonce($request); } return true; } /** * Helper methods */ // verify_request_nonce() now comes from the shared CSRF_Protection trait // used by the other endpoints; the local copy was identical (#457). /** * Generate schema preview * * @since 1.0.0 * * @param array $schema_data Schema data * @param string $schema_type Schema type * @return array Preview data */ private function generate_preview(array $schema_data, string $schema_type): array { return [ 'rich_snippets' => [ $schema_type => $this->format_rich_snippet_preview($schema_data, $schema_type) ], 'json_ld' => wp_json_encode($schema_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), 'validation_status' => 'pending' ]; } /** * Format rich snippet preview for specific schema type * * @since 1.0.0 * * @param array $schema_data Schema data * @param string $schema_type Schema type * @return array Formatted preview data */ private function format_rich_snippet_preview(array $schema_data, string $schema_type): array { switch ($schema_type) { case 'Organization': return [ 'title' => $schema_data['name'] ?? 'Organization Name', 'url' => $schema_data['url'] ?? home_url(), 'description' => $schema_data['description'] ?? 'Organization description', 'additional_info' => $this->format_organization_info($schema_data) ]; case 'LocalBusiness': return [ 'title' => $schema_data['name'] ?? 'Business Name', 'url' => $schema_data['url'] ?? home_url(), 'description' => $schema_data['description'] ?? 'Business description', 'additional_info' => $this->format_local_business_info($schema_data) ]; case 'Article': return [ 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Article Title', 'url' => $schema_data['url'] ?? home_url(), 'description' => $schema_data['description'] ?? 'Article description', 'additional_info' => $this->format_article_info($schema_data) ]; default: return [ 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title', 'url' => $schema_data['url'] ?? home_url(), 'description' => $schema_data['description'] ?? 'Description', 'additional_info' => '' ]; } } private function format_organization_info(array $schema_data): string { $info = []; if (!empty($schema_data['contactPoint']['telephone'])) { $info[] = '📞 ' . $schema_data['contactPoint']['telephone']; } if (!empty($schema_data['contactPoint']['email'])) { $info[] = '✉️ ' . $schema_data['contactPoint']['email']; } if (!empty($schema_data['address']['streetAddress'])) { $info[] = '📍 ' . $schema_data['address']['streetAddress']; } return implode(' • ', $info); } /** * Format local business additional info * * @since 1.0.0 * * @param array $schema_data Schema data * @return string Formatted info */ private function format_local_business_info(array $schema_data): string { $info = []; // Address if (!empty($schema_data['address'])) { $address = $schema_data['address']; $address_parts = []; if (!empty($address['streetAddress'])) { $address_parts[] = $address['streetAddress']; } if (!empty($address['addressLocality'])) { $address_parts[] = $address['addressLocality']; } if (!empty($address_parts)) { $info[] = '📍 ' . implode(', ', $address_parts); } } // Phone if (!empty($schema_data['telephone'])) { $info[] = '📞 ' . $schema_data['telephone']; } // Opening hours if (!empty($schema_data['openingHours'])) { $hours = is_array($schema_data['openingHours']) ? implode(', ', $schema_data['openingHours']) : $schema_data['openingHours']; $info[] = '🕒 ' . $hours; } return implode(' • ', $info); } /** * Format article additional info * * @since 1.0.0 * * @param array $schema_data Schema data * @return string Formatted info */ private function format_article_info(array $schema_data): string { $info = []; if (!empty($schema_data['author']['name'])) { $info[] = '👤 By ' . $schema_data['author']['name']; } if (!empty($schema_data['datePublished'])) { $info[] = '📅 ' . gmdate('M j, Y', strtotime($schema_data['datePublished'])); } if (!empty($schema_data['publisher']['name'])) { $info[] = '🏢 ' . $schema_data['publisher']['name']; } return implode(' • ', $info); } /** * Argument validation methods */ /** * Get arguments for schema generation endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_generate_schema_args(): array { return [ 'context_type' => [ 'required' => true, 'type' => 'string', 'enum' => ['site', 'post', 'page', 'product'], 'description' => 'Context type for schema generation' ], 'context_id' => [ 'required' => false, 'type' => 'integer', 'minimum' => 1, 'description' => 'Context ID (not required for site context)' ], 'schema_types' => [ // generate_schema() rejects a missing or empty value with a 400, // so the schema has to say so too. 'required' => true, 'type' => 'array', 'minItems' => 1, 'items' => [ 'type' => 'string', 'enum' => [ 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', 'ScholarlyArticle', 'Report', 'Product', 'Organization', 'LocalBusiness', 'Person', 'WebSite', 'FAQPage', 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject' ] ], 'description' => 'Schema types to generate' ], 'options' => [ 'required' => false, 'type' => 'object', 'description' => 'Additional generation options' ], 'content_data' => [ 'required' => false, 'type' => 'object', 'description' => 'Custom content data to use for schema generation (overrides post data)', 'properties' => [ 'title' => ['type' => 'string'], 'description' => ['type' => 'string'], 'content' => ['type' => 'string'], 'focus_keyword' => ['type' => 'string'], 'post_type' => ['type' => 'string'], 'post_url' => ['type' => 'string'] ] ] ]; } /** * Get arguments for schema validation endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_validate_schema_args(): array { return [ 'schema_data' => [ 'required' => true, 'type' => 'object', 'description' => 'Schema data to validate' ], 'schema_type' => [ 'required' => true, 'type' => 'string', 'enum' => [ 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle', 'ScholarlyArticle', 'Report', 'Product', 'Organization', 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' ], 'description' => 'Schema type' ], 'options' => [ 'required' => false, 'type' => 'object', 'description' => 'Validation options' ] ]; } /** * Get arguments for schema deployment endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_deploy_schema_args(): array { return [ 'context_type' => [ 'required' => true, 'type' => 'string', 'enum' => ['site', 'post', 'page', 'product'], 'description' => 'Context type for deployment' ], 'context_id' => [ 'required' => false, 'type' => 'integer', 'minimum' => 1, 'description' => 'Context ID (not required for site context)' ], 'schema_data' => [ 'required' => true, 'type' => 'object', 'description' => 'Schema data to deploy' ], 'options' => [ 'required' => false, 'type' => 'object', 'description' => 'Deployment options' ] ]; } /** * Get arguments for schema optimization endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_optimize_schema_args(): array { return [ 'schema_data' => [ 'required' => true, 'type' => 'object', 'description' => 'Schema data to optimize' ], 'schema_type' => [ 'required' => true, 'type' => 'string', 'enum' => [ 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' ], 'description' => 'Schema type' ], 'options' => [ 'required' => false, 'type' => 'object', 'description' => 'Optimization options' ] ]; } /** * Get arguments for schema preview endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_preview_schema_args(): array { return [ 'schema_data' => [ 'required' => true, 'type' => 'object', 'description' => 'Schema data to preview' ], 'schema_type' => [ 'required' => true, 'type' => 'string', 'enum' => [ 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication', 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject' ], 'description' => 'Schema type' ] ]; } /** * Get arguments for bulk operations endpoint * * @since 1.0.0 * * @return array Arguments array */ private function get_bulk_operations_args(): array { return [ 'operation' => [ 'required' => true, 'type' => 'string', 'enum' => ['generate', 'validate', 'deploy'], 'description' => 'Bulk operation type' ], 'items' => [ 'required' => true, 'type' => 'array', 'items' => [ 'type' => 'object' ], // Bound aggregate request work: every item can trigger context // lookups, recursive schema validation, generation, and // deployment, so cap the count at the REST layer. 'maxItems' => self::MAX_BULK_ITEMS, 'description' => 'Items to process in bulk (max ' . self::MAX_BULK_ITEMS . ')' ], 'options' => [ 'required' => false, 'type' => 'object', 'description' => 'Bulk operation options' ] ]; } /** * Get schema settings * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function get_settings(WP_REST_Request $request) { try { // SECURITY: the settings this returns are per-object. save_settings() // already authorises the object; the read has to as well (#385). $context = $this->resolve_request_context($request); if (is_wp_error($context)) { return $context; } [$context_type, $context_id] = $context; // Get settings from schema manager $settings = $this->schema_manager->get_settings($context_type, $context_id); return new WP_REST_Response([ 'success' => true, 'data' => [ 'settings' => $settings, 'context_type' => $context_type, 'context_id' => $context_id ], 'message' => 'Schema settings retrieved successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'settings_fetch_failed', 'Failed to retrieve schema settings: ' . $e->getMessage(), ['status' => 500] ); } } /** * Save schema settings * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object or error */ public function save_settings(WP_REST_Request $request) { try { $settings = $request->get_param('settings'); $context_type = $request->get_param('context_type') ?? 'site'; $context_id = $request->get_param('context_id') ?? null; // Validate input parameters if (empty($settings) || !is_array($settings)) { return new WP_Error( 'invalid_settings', 'Settings parameter is required and must be an array', ['status' => 400] ); } // SECURITY: For non-site contexts (post/page/product), verify the // caller can edit that specific object — same ownership gate the // generate/deploy routes use. Site context stays governed by the // thinkrank_schema capability via the Role Manager gate. $context_type = sanitize_key((string) $context_type); if ($context_type !== 'site') { $context_validation = $this->input_validator->validate_context_parameters( $context_type, $context_id !== null ? absint($context_id) : null, get_current_user_id() ); if (!$context_validation['valid']) { return new WP_Error( 'invalid_context', implode(', ', $context_validation['errors']), ['status' => 403] ); } $context_type = $context_validation['sanitized_data']['context_type']; $context_id = $context_validation['sanitized_data']['context_id']; } else { // Site settings are keyed on a NULL context_id. Passing the // client's value straight through meant a stray context_id // wrote a row at an arbitrary id, returned 200, and was never // read back by anything (#470). validate_context_parameters() // already normalises this internally for other contexts. $context_id = null; } // Drop unrecognized keys so arbitrary client-supplied keys aren't // persisted as settings rows (storage bloat / settings drift). $settings = $this->filter_known_setting_keys($settings, $context_type); if (empty($settings)) { return new WP_Error( 'invalid_settings', 'No recognized schema settings were provided', ['status' => 400] ); } // Get validation results for detailed error reporting $validation = $this->schema_manager->validate_settings($settings); if (!$validation['valid']) { // Schema settings validation failed - details available in validation response return new WP_Error( 'validation_failed', 'Schema settings validation failed', [ 'status' => 400, 'validation_errors' => $validation['errors'], 'validation_warnings' => $validation['warnings'] ?? [], 'validation_suggestions' => $validation['suggestions'] ?? [] ] ); } // Save settings using schema manager $success = $this->schema_manager->save_settings($context_type, $context_id, $settings); if (!$success) { // Schema settings save failed - database operation unsuccessful return new WP_Error( 'settings_save_failed', 'Failed to save schema settings to database', ['status' => 500] ); } return new WP_REST_Response([ 'success' => true, 'data' => [ 'settings' => $settings, 'context_type' => $context_type, 'context_id' => $context_id, 'validation' => $validation ], 'message' => 'Schema settings saved successfully' ], 200); } catch (\Exception $e) { // Schema settings save exception - error details in response return new WP_Error( 'settings_update_failed', 'Failed to update schema settings: ' . $e->getMessage(), ['status' => 500] ); } } /** * Restrict a settings payload to recognized keys. * * The known set is the context's default settings plus a few keys that are * legitimately stored/consumed elsewhere (site-identity/local-SEO fields and * the schema settings schema) but not seeded into the defaults. Filterable * so Pro/integrations can register additional keys. * * @param array $settings Incoming settings. * @param string $context_type Context type (site/post/page/product). * @return array Settings limited to known keys. */ private function filter_known_setting_keys(array $settings, string $context_type): array { // Defer to the manager instead of maintaining a parallel list here. // The endpoint's own list ignored additional_setting_keys() and // dynamic_setting_key_patterns() — the mechanism #452 added so new form // families stop getting dropped — so the two disagreed in both // directions: the four enable_*_schema toggles and the software_/howto_/ // product_ families were dropped here but accepted by the manager, while // deployment_method, site_name and performance_tracking survived here // only to be dropped one layer down (#470). $known = []; foreach (array_keys($settings) as $key) { if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) { $known[] = (string) $key; } } /** * Filter the schema setting keys the REST endpoint will persist. * * @since 1.13.0 * * @param string[] $known Keys accepted by the schema manager. * @param string $context_type Context type. */ $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type); return array_intersect_key($settings, array_flip($known)); } /** * Get arguments for settings endpoints * * @since 1.0.0 * * @return array Arguments array */ private function get_settings_args(): array { return [ 'settings' => [ 'required' => true, 'type' => 'object', 'description' => 'Schema settings object' ], 'context_type' => [ 'required' => false, 'type' => 'string', 'default' => 'site', 'enum' => ['site', 'post', 'page', 'product'], 'description' => 'Context type for settings' ], 'context_id' => [ 'required' => false, 'type' => 'integer', 'minimum' => 1, 'description' => 'Context ID for settings' ] ]; } }