298 lines
8.7 KiB
PHP
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;
|
|
}
|
|
}
|