settings = $settings ?? new Settings(); $this->metadata_generator = $metadata_generator ?? new Metadata_Generator(); $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database()); } /** * Initialize meta box manager * * @return void */ public function init(): void { add_action('add_meta_boxes', [$this, 'add_meta_boxes']); add_action('save_post', [$this, 'save_meta_boxes'], 10, 2); add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']); add_action('init', [$this, 'register_meta_fields']); // AJAX handlers for meta box functionality add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']); add_action('wp_ajax_thinkrank_save_post_metadata', [$this, 'ajax_save_post_metadata']); // Removed debug hooks } /** * Register meta fields for REST API access * * @return void */ public function register_meta_fields(): void { // Register schema form data meta fields register_post_meta('', '_thinkrank_schema_form_data', [ 'show_in_rest' => true, 'single' => true, 'type' => 'string', 'sanitize_callback' => [$this, 'sanitize_json_meta_field'], 'auth_callback' => function() { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); register_post_meta('', '_thinkrank_selected_schema_type', [ 'show_in_rest' => true, 'single' => true, 'type' => 'string', 'auth_callback' => function() { return current_user_can('edit_posts') || current_user_can('edit_pages'); } ]); } /** * Sanitize JSON meta field data * * Validates JSON structure and recursively sanitizes all string values * to prevent XSS and injection attacks. * * @param string $value Raw JSON string value * @return string Sanitized JSON string or empty string if invalid */ public function sanitize_json_meta_field(string $value): string { // Return empty string for non-string values if (!is_string($value) || empty($value)) { return ''; } // Validate JSON structure $decoded = json_decode($value, true); if (json_last_error() !== JSON_ERROR_NONE) { // Invalid JSON - return empty string return ''; } // Check for reasonable data size (prevent JSON bombs) if (strlen($value) > 50000) { // 50KB limit return ''; } // Recursively sanitize all values $sanitized = $this->sanitize_json_recursively($decoded); // Re-encode as JSON $result = wp_json_encode($sanitized); return $result !== false ? $result : ''; } /** * Recursively sanitize JSON data * * @param mixed $data Data to sanitize * @param int $depth Current recursion depth * @return mixed Sanitized data */ private function sanitize_json_recursively($data, int $depth = 0): mixed { // Prevent deep recursion attacks if ($depth > 10) { return null; } if (is_array($data)) { $sanitized = []; foreach ($data as $key => $value) { $clean_key = sanitize_key($key); $sanitized[$clean_key] = $this->sanitize_json_recursively($value, $depth + 1); } return $sanitized; } if (is_string($data)) { // Sanitize string data to prevent XSS return sanitize_textarea_field($data); } if (is_numeric($data)) { return $data; } if (is_bool($data)) { return $data; } // For any other data type, return null return null; } // Removed debug methods /** * Add ThinkRank meta boxes * * @return void */ public function add_meta_boxes(): void { $post_types = $this->get_supported_post_types(); foreach ($post_types as $post_type) { add_meta_box( 'thinkrank-seo-metabox', __('ThinkRank SEO', 'thinkrank'), [$this, 'render_seo_metabox'], $post_type, 'normal', 'high' ); } } /** * Render SEO meta box * * @param \WP_Post $post Post object * @return void */ public function render_seo_metabox(\WP_Post $post): void { // Add nonce for security wp_nonce_field('thinkrank_metabox_nonce', 'thinkrank_metabox_nonce'); // Get existing metadata $existing_metadata = $this->get_post_metadata($post->ID); // Get post content for AI analysis $content_preview = $this->get_content_preview($post); // Render React metabox container with hidden form fields for data ?>

'sanitize_text_field', 'thinkrank_meta_description' => 'sanitize_textarea_field', 'thinkrank_focus_keyword' => 'sanitize_text_field', 'thinkrank_seo_score' => 'absint', 'thinkrank_generated_at' => 'sanitize_text_field', ]; foreach ($fields as $field => $sanitize_callback) { if (isset($_POST[$field])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized via callback below $raw_value = wp_unslash($_POST[$field]); $value = call_user_func($sanitize_callback, $raw_value); update_post_meta($post_id, "_{$field}", $value); } } // Update last modified timestamp update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql')); } /** * Enqueue meta box scripts * * @param string $hook Current admin page hook * @return void */ public function enqueue_metabox_scripts(string $hook): void { // Only load on post edit screens (including block editor) if (!in_array($hook, ['post.php', 'post-new.php'])) { return; } // Get current post type - handle both classic and block editor contexts $current_post_type = $this->get_current_post_type(); if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types())) { return; } // Get post object for additional data global $post; // No chunk dependencies needed - all bundled into main metabox.js // Enqueue React metabox script with direct dependencies $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php'; $asset = file_exists($asset_file) ? include $asset_file : [ 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'], 'version' => THINKRANK_VERSION ]; // Use dependencies directly from asset file $dependencies = $asset['dependencies']; wp_enqueue_script( 'thinkrank-metabox', THINKRANK_PLUGIN_URL . 'assets/metabox.js', $dependencies, $asset['version'], true ); // Localize script data wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'), 'postId' => $post->ID, 'postType' => $post->post_type, 'postPermalink' => get_permalink($post->ID), 'restUrl' => rest_url('thinkrank/v1/'), 'restNonce' => wp_create_nonce('wp_rest'), 'homeUrl' => home_url(), 'siteName' => get_bloginfo('name'), 'faviconUrl' => $this->get_site_favicon_url(), 'featuredImageUrl' => $this->get_post_featured_image_url($post->ID), 'strings' => [ 'generating' => __('Generating...', 'thinkrank'), 'analyzing' => __('Analyzing...', 'thinkrank'), 'error' => __('Error occurred', 'thinkrank'), 'success' => __('Success!', 'thinkrank'), 'generated' => __('Metadata generated successfully', 'thinkrank'), 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'), 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'), ], ]); // Enqueue metabox styles wp_enqueue_style( 'thinkrank-metabox', THINKRANK_PLUGIN_URL . 'assets/metabox.css', [], THINKRANK_VERSION ); } /** * Get current post type in admin context * * Handles both classic editor and block editor contexts * * @return string|null Current post type or null if not found */ private function get_current_post_type(): ?string { global $post, $typenow, $current_screen; // Try to get post type from various sources if ($post && !empty($post->post_type)) { return $post->post_type; } if (!empty($typenow)) { return $typenow; } if ($current_screen && !empty($current_screen->post_type)) { return $current_screen->post_type; } // Fallback: check URL parameters for block editor // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data if (isset($_GET['post_type'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data return sanitize_text_field(wp_unslash($_GET['post_type'])); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data if (isset($_GET['post'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading URL parameters for context determination, not processing form data $post_id = absint($_GET['post']); $post_type = get_post_type($post_id); if ($post_type) { return $post_type; } } return null; } /** * Get supported post types * * @return array Supported post types */ private function get_supported_post_types(): array { $default_types = ['post', 'page']; // Add WooCommerce product if available if (class_exists('WooCommerce')) { $default_types[] = 'product'; } // Add other common e-commerce post types $ecommerce_types = ['product', 'shop_order', 'shop_coupon']; foreach ($ecommerce_types as $type) { if (post_type_exists($type) && !in_array($type, $default_types)) { $default_types[] = $type; } } // Add custom post types that are public and have UI $custom_post_types = get_post_types([ 'public' => true, 'show_ui' => true, '_builtin' => false, ]); foreach ($custom_post_types as $post_type) { // Skip certain post types that shouldn't have SEO metabox $excluded_types = [ 'attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'acf-field', 'acf-field-group', ]; if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) { $default_types[] = $post_type; } } return apply_filters('thinkrank_supported_post_types', $default_types); } /** * Get existing post metadata * * @param int $post_id Post ID * @return array Existing metadata */ private function get_post_metadata(int $post_id): array { return [ 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true), 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true), 'focus_keyword' => get_post_meta($post_id, '_thinkrank_focus_keyword', true), 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true), 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true), ]; } /** * Get site favicon URL * * @return string Favicon URL */ private function get_site_favicon_url(): string { // Try to get site icon first (WordPress 4.3+) $site_icon_id = get_option('site_icon'); if ($site_icon_id) { $site_icon_url = wp_get_attachment_image_url($site_icon_id, 'full'); if ($site_icon_url) { return $site_icon_url; } } // Fallback to common favicon locations $favicon_paths = [ '/favicon.ico', '/favicon.png', '/apple-touch-icon.png', ]; foreach ($favicon_paths as $path) { $favicon_url = home_url($path); $response = wp_remote_head($favicon_url); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { return $favicon_url; } } // Final fallback - return a default favicon URL return home_url('/favicon.ico'); } /** * Get post featured image URL * * @param int $post_id Post ID * @return string|null Featured image URL or null if not available */ private function get_post_featured_image_url(int $post_id): ?string { $thumbnail_id = get_post_thumbnail_id($post_id); if ($thumbnail_id) { $image_url = wp_get_attachment_image_url($thumbnail_id, 'medium'); return $image_url ?: null; } return null; } /** * Get content preview for AI analysis * * @param \WP_Post $post Post object * @return string Content preview */ private function get_content_preview(\WP_Post $post): string { $content = $post->post_title . "\n\n"; if (!empty($post->post_excerpt)) { $content .= $post->post_excerpt . "\n\n"; } $content .= $post->post_content; // Clean and limit content $content = wp_strip_all_tags($content); $content = preg_replace('/\s+/', ' ', $content); return trim(substr($content, 0, 4000)); } /** * AJAX handler for generating post metadata * * @return void */ public function ajax_generate_post_metadata(): void { // Verify nonce $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')); if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) { wp_die('Security check failed'); } // Check permissions $post_id = absint($_POST['post_id'] ?? 0); if (!current_user_can('edit_post', $post_id)) { wp_die('Insufficient permissions'); } try { $options = [ 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')), 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')), 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')), ]; $metadata = $this->metadata_generator->generate_for_post($post_id, $options); wp_send_json_success([ 'metadata' => $metadata, 'message' => __('SEO metadata generated successfully!', 'thinkrank'), ]); } catch (\Exception $e) { wp_send_json_error([ 'message' => $e->getMessage(), ]); } } /** * AJAX handler for saving post metadata * * @return void */ public function ajax_save_post_metadata(): void { // Verify nonce $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')); if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) { wp_die('Security check failed'); } // Check permissions $post_id = absint($_POST['post_id'] ?? 0); if (!current_user_can('edit_post', $post_id)) { wp_die('Insufficient permissions'); } // Save metadata $metadata = [ 'title' => sanitize_text_field(wp_unslash($_POST['title'] ?? '')), 'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')), 'focus_keyword' => sanitize_text_field(wp_unslash($_POST['focus_keyword'] ?? '')), 'seo_score' => absint($_POST['seo_score'] ?? 0), ]; foreach ($metadata as $key => $value) { update_post_meta($post_id, "_thinkrank_{$key}", $value); } update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql')); wp_send_json_success([ 'message' => __('Metadata saved successfully!', 'thinkrank'), ]); } }