rate_limiter = new MLF_Rate_Limiter(30, 60); } /** * Handle font search AJAX request. */ public function handle_search() { // 1. NONCE CHECK if (!check_ajax_referer('mlf_search_fonts', 'nonce', false)) { wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403); } // 2. CAPABILITY CHECK $capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options'; if (!current_user_can($capability)) { wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403); } // 3. RATE LIMIT CHECK if (!$this->rate_limiter->check_and_record('search')) { wp_send_json_error([ 'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'), ], 429); } // 4. INPUT VALIDATION $query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : ''; if (strlen($query) < 2) { wp_send_json_success(['fonts' => []]); } // Limit query length if (strlen($query) > 100) { wp_send_json_error(['message' => __('Search query too long.', 'maple-local-fonts')]); } // 5. PROCESS REQUEST $fonts = $this->get_fonts_metadata(); if (is_wp_error($fonts)) { wp_send_json_error(['message' => $fonts->get_error_message()]); } // Search fonts $results = $this->search_fonts($fonts, $query); wp_send_json_success(['fonts' => $results]); } /** * Get Google Fonts metadata (cached). * * @return array|WP_Error Array of fonts or error. */ public function get_fonts_metadata() { // Check cache first $cached = get_transient(self::CACHE_KEY); if ($cached !== false) { return $cached; } // Fetch from Google $response = wp_remote_get(self::METADATA_URL, [ 'timeout' => 30, 'sslverify' => true, ]); if (is_wp_error($response)) { return new WP_Error('fetch_failed', __('Could not fetch fonts list from Google.', 'maple-local-fonts')); } $status = wp_remote_retrieve_response_code($response); if ($status !== 200) { return new WP_Error('fetch_failed', __('Google Fonts returned an error.', 'maple-local-fonts')); } $body = wp_remote_retrieve_body($response); // Validate response size if (strlen($body) > self::MAX_METADATA_SIZE) { return new WP_Error('response_too_large', __('Font metadata response too large.', 'maple-local-fonts')); } // Validate response is not empty if (empty($body)) { return new WP_Error('empty_response', __('Empty response from Google Fonts.', 'maple-local-fonts')); } // Google's metadata has a )]}' prefix for security, remove it $body = preg_replace('/^\)\]\}\'\s*/', '', $body); $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts')); } if (!$data || !isset($data['familyMetadataList'])) { return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts')); } // Process and simplify the data $fonts = []; foreach ($data['familyMetadataList'] as $font) { $fonts[] = [ 'family' => $font['family'], 'category' => $font['category'] ?? 'sans-serif', 'variants' => $font['fonts'] ?? [], 'subsets' => $font['subsets'] ?? ['latin'], 'version' => $font['version'] ?? '', 'lastModified' => $font['lastModified'] ?? '', ]; } // Cache for 24 hours set_transient(self::CACHE_KEY, $fonts, self::CACHE_DURATION); return $fonts; } /** * Search fonts by query. * * @param array $fonts All fonts. * @param string $query Search query. * @return array Matching fonts (max 20). */ private function search_fonts($fonts, $query) { $query_lower = strtolower($query); $exact_matches = []; $starts_with = []; $contains = []; foreach ($fonts as $font) { $family_lower = strtolower($font['family']); // Exact match if ($family_lower === $query_lower) { $exact_matches[] = $this->format_font_result($font); } // Starts with query elseif (strpos($family_lower, $query_lower) === 0) { $starts_with[] = $this->format_font_result($font); } // Contains query elseif (strpos($family_lower, $query_lower) !== false) { $contains[] = $this->format_font_result($font); } } // Combine results: exact matches first, then starts with, then contains $results = array_merge($exact_matches, $starts_with, $contains); // Limit to 20 results return array_slice($results, 0, 20); } /** * Format a font for the search results. * * @param array $font Font data. * @return array Formatted font. */ private function format_font_result($font) { // Check if font has variable version $has_variable = false; $has_italic = false; $weights = []; if (!empty($font['variants'])) { foreach ($font['variants'] as $variant => $data) { // Variable fonts have ranges like "100..900" if (strpos($variant, '..') !== false) { $has_variable = true; } if (strpos($variant, 'i') !== false || strpos($variant, 'italic') !== false) { $has_italic = true; } // Extract weight $weight = preg_replace('/[^0-9]/', '', $variant); if ($weight && !in_array($weight, $weights, true)) { $weights[] = $weight; } } } // Sort weights sort($weights, SORT_NUMERIC); return [ 'family' => $font['family'], 'category' => $font['category'], 'has_variable' => $has_variable, 'has_italic' => $has_italic, 'weights' => $weights, 'version' => $font['version'] ?? '', 'lastModified' => $font['lastModified'] ?? '', ]; } /** * Clear the fonts metadata cache. */ public function clear_cache() { delete_transient(self::CACHE_KEY); } /** * Get version info for a specific font. * * @param string $font_family Font family name. * @return array|null Version info or null if not found. */ public function get_font_version($font_family) { $fonts = $this->get_fonts_metadata(); if (is_wp_error($fonts)) { return null; } $font_family_lower = strtolower($font_family); foreach ($fonts as $font) { if (strtolower($font['family']) === $font_family_lower) { return [ 'version' => $font['version'] ?? '', 'lastModified' => $font['lastModified'] ?? '', ]; } } return null; } }