initial commit

This commit is contained in:
rodolfomartinez 2026-01-30 22:33:40 -05:00
parent d066133bd4
commit e6f71e3706
55 changed files with 11928 additions and 0 deletions

View file

@ -0,0 +1,210 @@
<?php
/**
* Admin Page for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Admin_Page
*
* Handles the admin settings page rendering.
*/
class MLF_Admin_Page {
/**
* Available font weights.
*
* @var array
*/
private $weights = [
100 => 'Thin',
200 => 'Extra Light',
300 => 'Light',
400 => 'Regular',
500 => 'Medium',
600 => 'Semi Bold',
700 => 'Bold',
800 => 'Extra Bold',
900 => 'Black',
];
/**
* Constructor.
*/
public function __construct() {
// Empty constructor - class is instantiated for rendering
}
/**
* Render the admin page.
*/
public function render() {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'maple-local-fonts'));
}
$registry = new MLF_Font_Registry();
$installed_fonts = $registry->get_imported_fonts();
?>
<div class="wrap mlf-wrap">
<h1><?php esc_html_e('Maple Local Fonts', 'maple-local-fonts'); ?></h1>
<div class="mlf-container">
<!-- Import Section -->
<div class="mlf-section mlf-import-section">
<h2><?php esc_html_e('Import from Google Fonts', 'maple-local-fonts'); ?></h2>
<form id="mlf-import-form" class="mlf-form">
<div class="mlf-form-row">
<label for="mlf-font-name"><?php esc_html_e('Font Name', 'maple-local-fonts'); ?></label>
<input type="text" id="mlf-font-name" name="font_name" placeholder="<?php esc_attr_e('e.g., Open Sans', 'maple-local-fonts'); ?>" required />
<p class="description"><?php esc_html_e('Enter the exact font name as it appears on Google Fonts.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row">
<label><?php esc_html_e('Weights', 'maple-local-fonts'); ?></label>
<div class="mlf-checkbox-grid">
<?php foreach ($this->weights as $weight => $label) : ?>
<label class="mlf-checkbox-label">
<input type="checkbox" name="weights[]" value="<?php echo esc_attr($weight); ?>" <?php checked(in_array($weight, [400, 700], true)); ?> />
<span><?php echo esc_html($weight); ?> (<?php echo esc_html($label); ?>)</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="mlf-form-row">
<label><?php esc_html_e('Styles', 'maple-local-fonts'); ?></label>
<div class="mlf-checkbox-grid mlf-checkbox-grid-small">
<label class="mlf-checkbox-label">
<input type="checkbox" name="styles[]" value="normal" checked />
<span><?php esc_html_e('Normal', 'maple-local-fonts'); ?></span>
</label>
<label class="mlf-checkbox-label">
<input type="checkbox" name="styles[]" value="italic" />
<span><?php esc_html_e('Italic', 'maple-local-fonts'); ?></span>
</label>
</div>
</div>
<div class="mlf-form-row mlf-form-row-info">
<span class="mlf-file-count">
<?php esc_html_e('Files to download:', 'maple-local-fonts'); ?>
<strong id="mlf-file-count">2</strong>
</span>
</div>
<!-- Font Preview Section -->
<div class="mlf-form-row mlf-preview-section" id="mlf-preview-section" style="display: none;">
<label><?php esc_html_e('Preview', 'maple-local-fonts'); ?></label>
<div class="mlf-preview-box" id="mlf-preview-box">
<div class="mlf-preview-text" id="mlf-preview-text">
<span class="mlf-preview-sample mlf-preview-heading"><?php esc_html_e('The quick brown fox jumps over the lazy dog', 'maple-local-fonts'); ?></span>
<span class="mlf-preview-sample mlf-preview-paragraph"><?php esc_html_e('ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789', 'maple-local-fonts'); ?></span>
</div>
<div class="mlf-preview-loading" id="mlf-preview-loading" style="display: none;">
<span class="spinner is-active"></span>
<span><?php esc_html_e('Loading preview...', 'maple-local-fonts'); ?></span>
</div>
<div class="mlf-preview-error" id="mlf-preview-error" style="display: none;">
<?php esc_html_e('Could not load font preview. The font may not exist on Google Fonts.', 'maple-local-fonts'); ?>
</div>
</div>
<p class="description"><?php esc_html_e('Preview is loaded directly from Google Fonts. After installation, the font will be served locally.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row mlf-form-row-submit">
<button type="submit" class="button button-primary" id="mlf-download-btn">
<?php esc_html_e('Download & Install', 'maple-local-fonts'); ?>
</button>
<span class="spinner" id="mlf-spinner"></span>
</div>
<div id="mlf-message" class="mlf-message" style="display: none;"></div>
</form>
</div>
<!-- Installed Fonts Section -->
<div class="mlf-section mlf-installed-section">
<h2><?php esc_html_e('Installed Fonts', 'maple-local-fonts'); ?></h2>
<?php if (empty($installed_fonts)) : ?>
<p class="mlf-no-fonts"><?php esc_html_e('No fonts installed yet.', 'maple-local-fonts'); ?></p>
<?php else : ?>
<div id="mlf-font-list" class="mlf-font-list">
<?php foreach ($installed_fonts as $font) : ?>
<div class="mlf-font-item" data-font-id="<?php echo esc_attr($font['id']); ?>">
<div class="mlf-font-info">
<h3 class="mlf-font-name"><?php echo esc_html($font['name']); ?></h3>
<p class="mlf-font-variants">
<?php
$variant_strings = [];
foreach ($font['variants'] as $variant) {
$variant_strings[] = sprintf('%s %s', $variant['weight'], $variant['style']);
}
echo esc_html(implode(', ', $variant_strings));
?>
</p>
</div>
<div class="mlf-font-actions">
<button type="button" class="button mlf-delete-btn" data-font-id="<?php echo esc_attr($font['id']); ?>">
<?php esc_html_e('Delete', 'maple-local-fonts'); ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Info Section -->
<div class="mlf-section mlf-info-section">
<?php if (wp_is_block_theme()) : ?>
<div class="mlf-info-box">
<span class="dashicons dashicons-info"></span>
<p>
<?php
printf(
/* translators: %s: link to WordPress Editor */
esc_html__('Use %s to apply fonts to your site.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('site-editor.php?path=%2Fwp_global_styles')) . '">' . esc_html__('Appearance → Editor → Styles → Typography', 'maple-local-fonts') . '</a>'
);
?>
</p>
</div>
<?php else : ?>
<div class="mlf-info-box mlf-info-box-classic">
<span class="dashicons dashicons-info"></span>
<div class="mlf-classic-theme-info">
<p><strong><?php esc_html_e('Classic Theme Detected', 'maple-local-fonts'); ?></strong></p>
<p><?php esc_html_e('Your theme does not support the Full Site Editor. To use imported fonts, add custom CSS to your theme:', 'maple-local-fonts'); ?></p>
<pre class="mlf-code-example">body {
font-family: "Open Sans", sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Open Sans", sans-serif;
}</pre>
<p class="description">
<?php
printf(
/* translators: %s: link to Customizer */
esc_html__('Add this CSS in %s or your theme\'s style.css file.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('customize.php')) . '">' . esc_html__('Appearance → Customize → Additional CSS', 'maple-local-fonts') . '</a>'
);
?>
</p>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php
}
}

View file

@ -0,0 +1,227 @@
<?php
/**
* AJAX Handler for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Ajax_Handler
*
* Handles all AJAX requests for font download and deletion.
*/
class MLF_Ajax_Handler {
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
// NEVER add wp_ajax_nopriv_ - admin only functionality
// Initialize rate limiter: 10 requests per minute
$this->rate_limiter = new MLF_Rate_Limiter(10, 60);
}
/**
* Handle font download AJAX request.
*/
public function handle_download() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_download_font', '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('download')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
// Validate font name
$font_name = isset($_POST['font_name']) ? sanitize_text_field(wp_unslash($_POST['font_name'])) : '';
if (empty($font_name)) {
wp_send_json_error(['message' => __('Font name is required.', 'maple-local-fonts')]);
}
// Strict allowlist pattern - alphanumeric, spaces, hyphens only
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
wp_send_json_error(['message' => __('Invalid font name: only letters, numbers, spaces, and hyphens allowed.', 'maple-local-fonts')]);
}
if (strlen($font_name) > 100) {
wp_send_json_error(['message' => __('Font name is too long.', 'maple-local-fonts')]);
}
// Validate weights
$weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : [];
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
if (empty($weights)) {
wp_send_json_error(['message' => __('At least one weight is required.', 'maple-local-fonts')]);
}
if (count($weights) > MLF_MAX_WEIGHTS_PER_FONT) {
wp_send_json_error(['message' => __('Too many weights selected.', 'maple-local-fonts')]);
}
// Validate styles
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
$allowed_styles = ['normal', 'italic'];
// Sanitize each style value before filtering
$styles = array_map('sanitize_text_field', $styles);
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
if (empty($styles)) {
wp_send_json_error(['message' => __('At least one style is required.', 'maple-local-fonts')]);
}
// 5. PROCESS REQUEST
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $weights, $styles);
if (is_wp_error($download_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($download_result)]);
}
// Register font with WordPress
$registry = new MLF_Font_Registry();
$result = $registry->register_font(
$download_result['font_name'],
$download_result['font_slug'],
$download_result['files']
);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $this->get_user_error_message($result)]);
}
wp_send_json_success([
'message' => sprintf(
/* translators: %s: font name */
__('Successfully installed %s.', 'maple-local-fonts'),
esc_html($font_name)
),
'font_id' => $result,
]);
} catch (Exception $e) {
// Sanitize exception message before logging (defense in depth)
error_log('MLF Download Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Handle font deletion AJAX request.
*/
public function handle_delete() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_delete_font', '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('delete')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => __('Invalid font ID.', 'maple-local-fonts')]);
}
// Verify font exists and is a font family
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => __('Font not found.', 'maple-local-fonts')]);
}
// Verify it's one we imported (not a theme font)
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => __('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts')]);
}
// 5. PROCESS REQUEST
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $this->get_user_error_message($result)]);
}
wp_send_json_success(['message' => __('Font deleted successfully.', 'maple-local-fonts')]);
} catch (Exception $e) {
// Sanitize exception message before logging (defense in depth)
error_log('MLF Delete Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Convert internal error codes to user-friendly messages.
*
* @param WP_Error $error The error object.
* @return string User-friendly message.
*/
private function get_user_error_message($error) {
$code = $error->get_error_code();
$messages = [
'font_not_found' => __('Font not found on Google Fonts. Please check the spelling and try again.', 'maple-local-fonts'),
'font_exists' => __('This font is already installed.', 'maple-local-fonts'),
'request_failed' => __('Could not connect to Google Fonts. Please check your internet connection and try again.', 'maple-local-fonts'),
'http_error' => __('Google Fonts returned an error. Please try again later.', 'maple-local-fonts'),
'parse_failed' => __('Could not process the font data. The font may not be available.', 'maple-local-fonts'),
'download_failed' => __('Could not download the font files. Please try again.', 'maple-local-fonts'),
'write_failed' => __('Could not save font files. Please check that wp-content/fonts is writable.', 'maple-local-fonts'),
'mkdir_failed' => __('Could not create fonts directory. Please check file permissions.', 'maple-local-fonts'),
'invalid_path' => __('Invalid file path.', 'maple-local-fonts'),
'invalid_url' => __('Invalid font URL.', 'maple-local-fonts'),
'invalid_name' => __('Invalid font name.', 'maple-local-fonts'),
'invalid_weights' => __('No valid weights specified.', 'maple-local-fonts'),
'invalid_styles' => __('No valid styles specified.', 'maple-local-fonts'),
'not_found' => __('Font not found.', 'maple-local-fonts'),
'not_ours' => __('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts'),
'response_too_large' => __('The font data is too large to process. Please try selecting fewer weights.', 'maple-local-fonts'),
'file_too_large' => __('The font file is too large to download.', 'maple-local-fonts'),
];
return $messages[$code] ?? __('An unexpected error occurred. Please try again.', 'maple-local-fonts');
}
}

View file

@ -0,0 +1,504 @@
<?php
/**
* Font Downloader for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Downloader
*
* Handles downloading fonts from Google Fonts CSS2 API.
*/
class MLF_Font_Downloader {
/**
* User agent to send with requests (needed to get WOFF2 format).
*
* @var string
*/
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* Download a font from Google Fonts.
*
* @param string $font_name Font family name.
* @param array $weights Weights to download.
* @param array $styles Styles to download.
* @return array|WP_Error Download result or error.
*/
public function download($font_name, $weights, $styles) {
// Validate inputs
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid font name');
}
$weights = array_intersect(array_map('absint', $weights), [100, 200, 300, 400, 500, 600, 700, 800, 900]);
if (empty($weights)) {
return new WP_Error('invalid_weights', 'No valid weights specified');
}
$styles = array_intersect($styles, ['normal', 'italic']);
if (empty($styles)) {
return new WP_Error('invalid_styles', 'No valid styles specified');
}
// Fetch CSS from Google
$css = $this->fetch_css($font_name, $weights, $styles);
if (is_wp_error($css)) {
return $css;
}
// Parse CSS to get font face data
$font_faces = $this->parse_css($css, $font_name);
if (is_wp_error($font_faces)) {
return $font_faces;
}
// Download each font file
$font_slug = sanitize_title($font_name);
$downloaded = $this->download_files($font_faces, $font_slug);
if (is_wp_error($downloaded)) {
return $downloaded;
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
];
}
/**
* Build Google Fonts CSS2 API URL.
*
* @param string $font_name Font family name.
* @param array $weights Array of weights.
* @param array $styles Array of styles.
* @return string Google Fonts CSS2 URL.
*/
private function build_url($font_name, $weights, $styles) {
// URL-encode font name (spaces become +)
$family = str_replace(' ', '+', $font_name);
// Sort for consistent URLs
sort($weights);
$has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true);
// If only normal styles, simpler format
if ($has_normal && !$has_italic) {
$wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
}
// Full format with ital axis
$variations = [];
foreach ($weights as $weight) {
if ($has_normal) {
$variations[] = "0,{$weight}";
}
if ($has_italic) {
$variations[] = "1,{$weight}";
}
}
$variation_string = implode(';', $variations);
return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap";
}
/**
* Fetch CSS from Google Fonts API.
*
* @param string $font_name Font family name.
* @param array $weights Weights to fetch.
* @param array $styles Styles to fetch.
* @return string|WP_Error CSS content or error.
*/
private function fetch_css($font_name, $weights, $styles) {
$url = $this->build_url($font_name, $weights, $styles);
// Validate URL
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'Invalid Google Fonts URL');
}
// CRITICAL: Must use modern browser user-agent to get WOFF2
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('request_failed', $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found');
}
if ($status !== 200) {
return new WP_Error('http_error', 'HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
// Check CSS response size to prevent memory issues
$max_size = defined('MLF_MAX_CSS_SIZE') ? MLF_MAX_CSS_SIZE : 512 * 1024;
if (strlen($css) > $max_size) {
return new WP_Error('response_too_large', 'CSS response exceeds maximum size limit');
}
// Verify we got WOFF2 (sanity check)
if (strpos($css, '.woff2)') === false) {
return new WP_Error('wrong_format', 'Did not receive WOFF2 format');
}
return $css;
}
/**
* Parse Google Fonts CSS and extract font face data.
*
* @param string $css CSS content from Google Fonts.
* @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error.
*/
private function parse_css($css, $font_name) {
$font_faces = [];
// Match all @font-face blocks
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'Could not parse CSS - no @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = $this->parse_font_face_block($block);
if (is_wp_error($face_data)) {
continue; // Skip malformed blocks
}
// Verify font family matches (security)
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
// Create unique key for weight+style combo
$key = $face_data['weight'] . '-' . $face_data['style'];
// Prefer latin subset (usually comes after latin-ext)
$is_latin = $this->is_latin_subset($face_data['unicode_range']);
// Only store if:
// 1. We don't have this weight/style yet, OR
// 2. This is latin and replaces non-latin
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
if (empty($font_faces)) {
return new WP_Error('no_fonts', 'No valid font faces found in CSS');
}
// Limit number of font faces to prevent excessive downloads
$max_faces = defined('MLF_MAX_FONT_FACES') ? MLF_MAX_FONT_FACES : 20;
$font_faces_array = array_values($font_faces);
if (count($font_faces_array) > $max_faces) {
$font_faces_array = array_slice($font_faces_array, 0, $max_faces);
}
return $font_faces_array;
}
/**
* Parse a single @font-face block.
*
* @param string $block Content inside @font-face { }.
* @return array|WP_Error Parsed data or error.
*/
private function parse_font_face_block($block) {
$data = [];
// Extract font-family
if (preg_match('/font-family:\s*[\'"]?([^;\'"]+)[\'"]?;/i', $block, $m)) {
$data['family'] = trim($m[1]);
} else {
return new WP_Error('missing_family', 'Missing font-family');
}
// Extract font-weight
if (preg_match('/font-weight:\s*(\d+);/i', $block, $m)) {
$data['weight'] = $m[1];
} else {
return new WP_Error('missing_weight', 'Missing font-weight');
}
// Extract font-style
if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) {
$data['style'] = $m[1];
} else {
$data['style'] = 'normal'; // Default
}
// Extract src URL - MUST be fonts.gstatic.com
if (preg_match('/src:\s*url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/i', $block, $m)) {
$data['url'] = $m[1];
} else {
return new WP_Error('missing_src', 'Missing or invalid src URL');
}
// Extract unicode-range (optional, for subset detection)
if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) {
$data['unicode_range'] = trim($m[1]);
} else {
$data['unicode_range'] = '';
}
return $data;
}
/**
* Check if unicode-range indicates latin subset.
*
* @param string $range Unicode range string.
* @return bool True if appears to be latin subset.
*/
private function is_latin_subset($range) {
if (empty($range)) {
return true; // Assume latin if no range specified
}
// Latin subset typically includes basic ASCII range
// and does NOT include extended Latin (U+0100+) as primary
if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) {
return true;
}
return false;
}
/**
* Download all font files.
*
* @param array $font_faces Array of font face data.
* @param string $font_slug Font slug for filename.
* @return array|WP_Error Array of downloaded file info or error.
*/
private function download_files($font_faces, $font_slug) {
$downloaded = [];
$errors = [];
foreach ($font_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
$face['style']
);
if (is_wp_error($result)) {
$errors[] = $result->get_error_message();
continue;
}
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => $face['style'],
];
}
// If no files downloaded, return error
if (empty($downloaded)) {
return new WP_Error(
'download_failed',
'Could not download any font files: ' . implode(', ', $errors)
);
}
return $downloaded;
}
/**
* Download a single WOFF2 file from Google Fonts.
*
* @param string $url Google Fonts static URL.
* @param string $font_slug Font slug for filename.
* @param string $weight Font weight.
* @param string $style Font style.
* @return string|WP_Error Local file path or error.
*/
private function download_single_file($url, $font_slug, $weight, $style) {
// Validate URL is from Google
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'URL is not from Google Fonts');
}
// Build local filename
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight);
$filename = sanitize_file_name($filename);
// Validate filename
$filename = $this->sanitize_font_filename($filename);
if ($filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename');
}
// Get destination path
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $filename;
// Validate destination path before any file operations
if (!$this->validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid destination path');
}
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
// Download file
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('download_failed', 'Failed to download font file: ' . $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Font download returned HTTP ' . $status);
}
$content = wp_remote_retrieve_body($response);
if (empty($content)) {
return new WP_Error('empty_file', 'Downloaded font file is empty');
}
// Check font file size to prevent memory issues
$max_size = defined('MLF_MAX_FONT_FILE_SIZE') ? MLF_MAX_FONT_FILE_SIZE : 5 * 1024 * 1024;
if (strlen($content) > $max_size) {
return new WP_Error('file_too_large', 'Font file exceeds maximum size limit');
}
// Verify it looks like a WOFF2 file (magic bytes: wOF2)
if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2');
}
// Write file using WP Filesystem
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) {
return new WP_Error('write_failed', 'Could not write font file');
}
return $destination;
}
/**
* Validate that a URL is a legitimate Google Fonts URL.
*
* @param string $url URL to validate.
* @return bool True if valid Google Fonts URL.
*/
private function is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
return false;
}
// Only allow Google Fonts domains
$allowed_hosts = [
'fonts.googleapis.com',
'fonts.gstatic.com',
];
return in_array($parsed['host'], $allowed_hosts, true);
}
/**
* Sanitize and validate a font filename.
*
* @param string $filename The filename to validate.
* @return string|false Sanitized filename or false if invalid.
*/
private function sanitize_font_filename($filename) {
// WordPress sanitization first
$filename = sanitize_file_name($filename);
// Must have .woff2 extension
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// No path components
if ($filename !== basename($filename)) {
return false;
}
// Reasonable length
if (strlen($filename) > 200) {
return false;
}
return $filename;
}
/**
* Validate that a path is within the WordPress fonts directory.
*
* @param string $path Full path to validate.
* @return bool True if path is safe, false otherwise.
*/
private function validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
// Directory doesn't exist yet, check parent
$parent_dir = dirname($dir);
$real_parent = realpath($parent_dir);
if ($real_parent === false) {
return false;
}
$real_path = wp_normalize_path($real_parent . '/' . basename($dir) . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
}
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
}

View file

@ -0,0 +1,287 @@
<?php
/**
* Font Registry for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Registry
*
* Handles registering fonts with WordPress Font Library API.
*/
class MLF_Font_Registry {
/**
* Register a font family with WordPress Font Library.
*
* @param string $font_name Display name (e.g., "Open Sans").
* @param string $font_slug Slug (e.g., "open-sans").
* @param array $files Array of downloaded file data.
* @return int|WP_Error Font family post ID or error.
*/
public function register_font($font_name, $font_slug, $files) {
// Check if font already exists
$existing = get_posts([
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'publish',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Get font directory
$font_dir = wp_get_font_dir();
// Build font face array for WordPress
$font_faces = [];
foreach ($files as $file) {
$filename = basename($file['path']);
$font_faces[] = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:./' . $filename,
];
}
// Build font family settings
$font_family_settings = [
'name' => $font_name,
'slug' => $font_slug,
'fontFamily' => sprintf('"%s", sans-serif', $font_name),
'fontFace' => $font_faces,
];
// Create font family post
$family_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $font_name,
'post_name' => $font_slug,
'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings),
]);
if (is_wp_error($family_id)) {
return $family_id;
}
// Mark as imported by our plugin (for identification)
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Create font face posts (children)
foreach ($files as $file) {
$filename = basename($file['path']);
$face_settings = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:./' . $filename,
];
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_title' => sprintf('%s %s %s', $font_name, $file['weight'], $file['style']),
'post_status' => 'publish',
'post_content' => wp_json_encode($face_settings),
]);
}
// Clear font caches
delete_transient('wp_font_library_fonts');
delete_transient('mlf_imported_fonts_list');
return $family_id;
}
/**
* Delete a font family and its files.
*
* @param int $family_id Font family post ID.
* @return bool|WP_Error True on success, error on failure.
*/
public function delete_font($family_id) {
$family = get_post($family_id);
if (!$family || $family->post_type !== 'wp_font_family') {
return new WP_Error('not_found', 'Font family not found');
}
// Verify it's one we imported
if (get_post_meta($family_id, '_mlf_imported', true) !== '1') {
return new WP_Error('not_ours', 'Cannot delete fonts not imported by this plugin');
}
// Get font faces (children)
$faces = get_children([
'post_parent' => $family_id,
'post_type' => 'wp_font_face',
]);
$font_dir = wp_get_font_dir();
// Delete font face files and posts
foreach ($faces as $face) {
$settings = json_decode($face->post_content, true);
if (isset($settings['src'])) {
// Convert file:. URL to path
$src = $settings['src'];
$src = str_replace('file:./', '', $src);
$file_path = trailingslashit($font_dir['path']) . basename($src);
// Validate path and extension before deletion
if ($this->validate_font_path($file_path)
&& pathinfo($file_path, PATHINFO_EXTENSION) === 'woff2'
&& file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family post
wp_delete_post($family_id, true);
// Clear caches
delete_transient('wp_font_library_fonts');
delete_transient('mlf_imported_fonts_list');
return true;
}
/**
* Get all fonts imported by this plugin.
*
* Uses optimized queries to avoid N+1 pattern.
*
* @return array Array of font data.
*/
public function get_imported_fonts() {
// Check transient cache first
$cached = get_transient('mlf_imported_fonts_list');
if ($cached !== false) {
return $cached;
}
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
if (empty($fonts)) {
set_transient('mlf_imported_fonts_list', [], 5 * MINUTE_IN_SECONDS);
return [];
}
// Collect all font IDs for batch query
$font_ids = wp_list_pluck($fonts, 'ID');
// Single query to get ALL font faces for ALL fonts (fixes N+1)
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => 1000, // Max 100 fonts × 9 weights × 2 styles = 1800, but limit reasonably
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
// Group faces by parent font ID
$faces_by_font = [];
foreach ($all_faces as $face) {
$parent_id = $face->post_parent;
if (!isset($faces_by_font[$parent_id])) {
$faces_by_font[$parent_id] = [];
}
$faces_by_font[$parent_id][] = $face;
}
// Batch get all import dates in single query
$import_dates = [];
foreach ($font_ids as $font_id) {
$import_dates[$font_id] = get_post_meta($font_id, '_mlf_import_date', true);
}
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants from pre-fetched data
$faces = $faces_by_font[$font->ID] ?? [];
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
];
}
// Sort variants by weight then style
usort($variants, function($a, $b) {
$weight_cmp = intval($a['weight']) - intval($b['weight']);
if ($weight_cmp !== 0) {
return $weight_cmp;
}
return strcmp($a['style'], $b['style']);
});
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
'import_date' => $import_dates[$font->ID] ?? '',
];
}
// Cache for 5 minutes
set_transient('mlf_imported_fonts_list', $result, 5 * MINUTE_IN_SECONDS);
return $result;
}
/**
* Validate that a path is within the WordPress fonts directory.
*
* @param string $path Full path to validate.
* @return bool True if path is safe, false otherwise.
*/
private function validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
}

View file

@ -0,0 +1,188 @@
<?php
/**
* Rate Limiter for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Rate_Limiter
*
* Provides rate limiting functionality to prevent abuse of AJAX endpoints.
*/
class MLF_Rate_Limiter {
/**
* Default rate limit (requests per window).
*
* @var int
*/
private $limit = 10;
/**
* Default time window in seconds.
*
* @var int
*/
private $window = 60;
/**
* Constructor.
*
* @param int $limit Maximum requests allowed per window.
* @param int $window Time window in seconds.
*/
public function __construct($limit = 10, $window = 60) {
$this->limit = absint($limit);
$this->window = absint($window);
}
/**
* Check if the current user/IP is rate limited.
*
* @param string $action The action being rate limited.
* @return bool True if rate limited (should block), false if allowed.
*/
public function is_limited($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
// First request, not limited
return false;
}
return $data['count'] >= $this->limit;
}
/**
* Record a request for rate limiting purposes.
*
* @param string $action The action being recorded.
* @return void
*/
public function record_request($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
// First request in this window
$data = [
'count' => 1,
'start' => time(),
];
} else {
$data['count']++;
}
// Set/update transient with remaining window time
$elapsed = time() - $data['start'];
$remaining = max(1, $this->window - $elapsed);
set_transient($key, $data, $remaining);
}
/**
* Check rate limit and record request in one call.
*
* @param string $action The action being checked.
* @return bool True if request is allowed, false if rate limited.
*/
public function check_and_record($action) {
if ($this->is_limited($action)) {
return false;
}
$this->record_request($action);
return true;
}
/**
* Get the number of remaining requests in the current window.
*
* @param string $action The action to check.
* @return int Number of remaining requests.
*/
public function get_remaining($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
return $this->limit;
}
return max(0, $this->limit - $data['count']);
}
/**
* Get the rate limit key for the current user/IP.
*
* @param string $action The action being rate limited.
* @return string Transient key.
*/
private function get_rate_limit_key($action) {
// Use user ID if logged in, otherwise IP
$user_id = get_current_user_id();
if ($user_id > 0) {
$identifier = 'user_' . $user_id;
} else {
// Sanitize and hash IP for privacy
$ip = $this->get_client_ip();
$identifier = 'ip_' . md5($ip);
}
return 'mlf_rate_' . sanitize_key($action) . '_' . $identifier;
}
/**
* Get the client IP address.
*
* @return string Client IP address.
*/
private function get_client_ip() {
$ip = '';
// Check for various headers (in order of reliability)
$headers = [
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can contain multiple IPs, get the first one
$ips = explode(',', sanitize_text_field(wp_unslash($_SERVER[$header])));
$ip = trim($ips[0]);
// Validate IP
if (filter_var($ip, FILTER_VALIDATE_IP)) {
break;
}
}
}
// Fallback to localhost if no valid IP found
return $ip ?: '127.0.0.1';
}
/**
* Clear rate limit for a specific action and user/IP.
*
* @param string $action The action to clear.
* @return void
*/
public function clear($action) {
$key = $this->get_rate_limit_key($action);
delete_transient($key);
}
}

View file

@ -0,0 +1,525 @@
<?php
/**
* REST API Controller for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Rest_Controller
*
* Provides REST API endpoints for font management.
*/
class MLF_Rest_Controller extends WP_REST_Controller {
/**
* Namespace for the API.
*
* @var string
*/
protected $namespace = 'mlf/v1';
/**
* Resource name.
*
* @var string
*/
protected $rest_base = 'fonts';
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
$this->rate_limiter = new MLF_Rate_Limiter(10, 60);
}
/**
* Register the routes for the controller.
*/
public function register_routes() {
// GET /wp-json/mlf/v1/fonts - List all fonts
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_items'],
'permission_callback' => [$this, 'get_items_permissions_check'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_item'],
'permission_callback' => [$this, 'create_item_permissions_check'],
'args' => $this->get_create_item_args(),
],
'schema' => [$this, 'get_public_item_schema'],
]
);
// DELETE /wp-json/mlf/v1/fonts/{id} - Delete a font
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete_item'],
'permission_callback' => [$this, 'delete_item_permissions_check'],
'args' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'required' => true,
],
],
],
]
);
// GET /wp-json/mlf/v1/fonts/{id} - Get a single font
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_item'],
'permission_callback' => [$this, 'get_items_permissions_check'],
'args' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'required' => true,
],
],
],
]
);
}
/**
* Check if a given request has access to get items.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_items_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to view fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
return true;
}
/**
* Check if a given request has access to create items.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has create access, WP_Error object otherwise.
*/
public function create_item_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to create fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
// Rate limit check
if (!$this->rate_limiter->check_and_record('rest_create')) {
return new WP_Error(
'rest_rate_limited',
__('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
['status' => 429]
);
}
return true;
}
/**
* Check if a given request has access to delete a specific item.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has delete access, WP_Error object otherwise.
*/
public function delete_item_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to delete fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
// Rate limit check
if (!$this->rate_limiter->check_and_record('rest_delete')) {
return new WP_Error(
'rest_rate_limited',
__('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
['status' => 429]
);
}
return true;
}
/**
* Get a collection of fonts.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items($request) {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
$data = [];
foreach ($fonts as $font) {
$data[] = $this->prepare_item_for_response($font, $request);
}
return rest_ensure_response($data);
}
/**
* Get a single font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item($request) {
$font_id = absint($request->get_param('id'));
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
// Verify it's one we imported
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
$font_data = null;
foreach ($fonts as $f) {
if ($f['id'] === $font_id) {
$font_data = $f;
break;
}
}
if (!$font_data) {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
return rest_ensure_response($this->prepare_item_for_response($font_data, $request));
}
/**
* Create a font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_item($request) {
$font_name = sanitize_text_field($request->get_param('font_name'));
$weights = array_map('absint', (array) $request->get_param('weights'));
$styles = array_map('sanitize_text_field', (array) $request->get_param('styles'));
// Validate font name
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error(
'rest_invalid_param',
__('Invalid font name: only letters, numbers, spaces, and hyphens allowed.', 'maple-local-fonts'),
['status' => 400]
);
}
if (strlen($font_name) > 100) {
return new WP_Error(
'rest_invalid_param',
__('Font name is too long.', 'maple-local-fonts'),
['status' => 400]
);
}
// Validate weights
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
if (empty($weights)) {
return new WP_Error(
'rest_invalid_param',
__('At least one valid weight is required.', 'maple-local-fonts'),
['status' => 400]
);
}
if (count($weights) > MLF_MAX_WEIGHTS_PER_FONT) {
return new WP_Error(
'rest_invalid_param',
__('Too many weights selected.', 'maple-local-fonts'),
['status' => 400]
);
}
// Validate styles
$allowed_styles = ['normal', 'italic'];
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
if (empty($styles)) {
return new WP_Error(
'rest_invalid_param',
__('At least one valid style is required.', 'maple-local-fonts'),
['status' => 400]
);
}
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $weights, $styles);
if (is_wp_error($download_result)) {
return new WP_Error(
'rest_download_failed',
$download_result->get_error_message(),
['status' => 400]
);
}
// Register font with WordPress
$registry = new MLF_Font_Registry();
$result = $registry->register_font(
$download_result['font_name'],
$download_result['font_slug'],
$download_result['files']
);
if (is_wp_error($result)) {
return new WP_Error(
'rest_register_failed',
$result->get_error_message(),
['status' => 400]
);
}
// Return the created font
$fonts = $registry->get_imported_fonts();
$created_font = null;
foreach ($fonts as $font) {
if ($font['id'] === $result) {
$created_font = $font;
break;
}
}
$response = rest_ensure_response($this->prepare_item_for_response($created_font, $request));
$response->set_status(201);
$response->header('Location', rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $result)));
return $response;
} catch (Exception $e) {
error_log('MLF REST Create Error: ' . sanitize_text_field($e->getMessage()));
return new WP_Error(
'rest_internal_error',
__('An unexpected error occurred.', 'maple-local-fonts'),
['status' => 500]
);
}
}
/**
* Delete a font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function delete_item($request) {
$font_id = absint($request->get_param('id'));
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
// Verify it's one we imported
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
return new WP_Error(
'rest_cannot_delete',
__('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts'),
['status' => 403]
);
}
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
return new WP_Error(
'rest_delete_failed',
$result->get_error_message(),
['status' => 400]
);
}
return rest_ensure_response([
'deleted' => true,
'message' => __('Font deleted successfully.', 'maple-local-fonts'),
]);
} catch (Exception $e) {
error_log('MLF REST Delete Error: ' . sanitize_text_field($e->getMessage()));
return new WP_Error(
'rest_internal_error',
__('An unexpected error occurred.', 'maple-local-fonts'),
['status' => 500]
);
}
}
/**
* Prepare a font for the REST response.
*
* @param array $font Font data.
* @param WP_REST_Request $request Request object.
* @return array Prepared font data.
*/
public function prepare_item_for_response($font, $request) {
return [
'id' => $font['id'],
'name' => $font['name'],
'slug' => $font['slug'],
'variants' => $font['variants'],
'_links' => [
'self' => [
['href' => rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $font['id']))],
],
'collection' => [
['href' => rest_url(sprintf('%s/%s', $this->namespace, $this->rest_base))],
],
],
];
}
/**
* Get the argument schema for creating items.
*
* @return array Arguments schema.
*/
protected function get_create_item_args() {
return [
'font_name' => [
'description' => __('The font family name from Google Fonts.', 'maple-local-fonts'),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
'weights' => [
'description' => __('Array of font weights to download.', 'maple-local-fonts'),
'type' => 'array',
'required' => true,
'items' => [
'type' => 'integer',
'enum' => [100, 200, 300, 400, 500, 600, 700, 800, 900],
],
],
'styles' => [
'description' => __('Array of font styles to download.', 'maple-local-fonts'),
'type' => 'array',
'required' => true,
'items' => [
'type' => 'string',
'enum' => ['normal', 'italic'],
],
],
];
}
/**
* Get the font schema.
*
* @return array Schema definition.
*/
public function get_item_schema() {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'font',
'type' => 'object',
'properties' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'context' => ['view'],
'readonly' => true,
],
'name' => [
'description' => __('The font family name.', 'maple-local-fonts'),
'type' => 'string',
'context' => ['view'],
],
'slug' => [
'description' => __('The font slug.', 'maple-local-fonts'),
'type' => 'string',
'context' => ['view'],
],
'variants' => [
'description' => __('Array of font variants.', 'maple-local-fonts'),
'type' => 'array',
'context' => ['view'],
'items' => [
'type' => 'object',
'properties' => [
'weight' => [
'type' => 'string',
],
'style' => [
'type' => 'string',
],
],
],
],
],
];
}
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}