monorepo/native/wordpress/maple-fonts-wp/includes/class-mlf-font-search.php
2026-02-02 08:31:36 -05:00

298 lines
8.7 KiB
PHP

<?php
/**
* Font Search for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Search
*
* Handles searching Google Fonts metadata.
*/
class MLF_Font_Search {
/**
* Cache key for fonts metadata.
*
* @var string
*/
const CACHE_KEY = 'mlf_google_fonts_metadata';
/**
* Cache duration in seconds (24 hours).
*
* @var int
*/
const CACHE_DURATION = DAY_IN_SECONDS;
/**
* Google Fonts metadata URL.
*
* @var string
*/
const METADATA_URL = 'https://fonts.google.com/metadata/fonts';
/**
* Maximum metadata response size (10MB).
*
* Google Fonts metadata for 1500+ fonts can be 5-8MB.
*
* @var int
*/
const MAX_METADATA_SIZE = 10 * 1024 * 1024;
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
add_action('wp_ajax_mlf_search_fonts', [$this, 'handle_search']);
// No nopriv - admin only
// Initialize rate limiter: 30 requests per minute (search can be rapid while typing)
$this->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;
}
}