monorepo/native/wordpress/maple-icons-wp/includes/class-mi-downloader.php
2026-02-02 14:17:16 -05:00

505 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Downloader class - Handles fetching icons from CDN and storing locally.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Downloader
*
* Downloads icon sets from CDN and stores them locally.
*/
class MI_Downloader {
/**
* Allowed SVG elements for sanitization.
*
* @var array
*/
private static $allowed_svg_elements = array(
'svg' => array(
'xmlns' => true,
'viewbox' => true,
'width' => true,
'height' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'class' => true,
'aria-hidden' => true,
'role' => true,
'focusable' => true,
'style' => true,
),
'path' => array(
'd' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'fill-rule' => true,
'clip-rule' => true,
'opacity' => true,
'fill-opacity' => true,
'stroke-opacity' => true,
),
'circle' => array(
'cx' => true,
'cy' => true,
'r' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'rect' => array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'rx' => true,
'ry' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'line' => array(
'x1' => true,
'y1' => true,
'x2' => true,
'y2' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'opacity' => true,
),
'polyline' => array(
'points' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'opacity' => true,
),
'polygon' => array(
'points' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'ellipse' => array(
'cx' => true,
'cy' => true,
'rx' => true,
'ry' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'g' => array(
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'transform' => true,
'opacity' => true,
),
'defs' => array(),
'clippath' => array(
'id' => true,
),
'use' => array(
'href' => true,
'xlink:href' => true,
),
);
/**
* Download an entire icon set.
*
* @param string $slug Icon set slug.
* @param callable|null $progress_callback Optional progress callback.
* @return array|WP_Error Download results or error.
*/
public function download_set( $slug, $progress_callback = null ) {
// Validate slug.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error(
'invalid_set',
__( 'Invalid icon set.', 'maple-icons' )
);
}
$set_config = MI_Icon_Sets::get( $slug );
$manifest = MI_Icon_Sets::load_manifest( $slug );
if ( is_wp_error( $manifest ) ) {
return $manifest;
}
// Create base directory.
$base_dir = MI_ICONS_DIR . $slug;
if ( ! $this->ensure_directory( $base_dir ) ) {
return new WP_Error(
'directory_error',
__( 'Could not create icon directory.', 'maple-icons' )
);
}
// Create style directories.
foreach ( $set_config['styles'] as $style_slug => $style_config ) {
$style_dir = $base_dir . '/' . $style_slug;
if ( ! $this->ensure_directory( $style_dir ) ) {
return new WP_Error(
'directory_error',
__( 'Could not create style directory.', 'maple-icons' )
);
}
}
$icons = isset( $manifest['icons'] ) ? $manifest['icons'] : array();
$total_icons = count( $icons );
$downloaded = 0;
$failed = 0;
$errors = array();
$total_to_download = 0;
// Calculate total icons to download (icon × styles).
foreach ( $icons as $icon ) {
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] );
$total_to_download += count( $icon_styles );
}
// Initialize progress transient.
set_transient(
'mi_download_progress_' . $slug,
array(
'completed' => 0,
'total' => $total_to_download,
'status' => 'downloading',
),
HOUR_IN_SECONDS
);
$current = 0;
// Download each icon.
foreach ( $icons as $icon ) {
$icon_name = $icon['name'];
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] );
foreach ( $icon_styles as $style ) {
if ( ! isset( $set_config['styles'][ $style ] ) ) {
continue;
}
$result = $this->download_icon( $slug, $style, $icon_name );
if ( is_wp_error( $result ) ) {
$failed++;
$errors[] = sprintf( '%s/%s: %s', $style, $icon_name, $result->get_error_message() );
} else {
$downloaded++;
}
$current++;
// Update progress.
set_transient(
'mi_download_progress_' . $slug,
array(
'completed' => $current,
'total' => $total_to_download,
'status' => 'downloading',
),
HOUR_IN_SECONDS
);
// Call progress callback if provided.
if ( is_callable( $progress_callback ) ) {
call_user_func( $progress_callback, $current, $total_to_download );
}
// Allow some breathing room for the server.
if ( 0 === $current % MI_DOWNLOAD_BATCH_SIZE ) {
usleep( 100000 ); // 100ms pause every batch.
}
}
}
// Clear progress transient.
delete_transient( 'mi_download_progress_' . $slug );
return array(
'success' => $failed === 0,
'downloaded' => $downloaded,
'failed' => $failed,
'total' => $total_to_download,
'errors' => $errors,
);
}
/**
* Download a single icon from CDN.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string|WP_Error Local file path or error.
*/
public function download_icon( $slug, $style, $name ) {
// Validate inputs.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) );
}
if ( ! MI_Icon_Sets::is_valid_style( $slug, $style ) ) {
return new WP_Error( 'invalid_style', __( 'Invalid icon style.', 'maple-icons' ) );
}
// Validate icon name (only allow alphanumeric, hyphens, underscores).
if ( ! preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) {
return new WP_Error( 'invalid_name', __( 'Invalid icon name.', 'maple-icons' ) );
}
$cdn_url = MI_Icon_Sets::get_cdn_url( $slug, $style, $name );
$local_path = MI_Icon_Sets::get_local_path( $slug, $style, $name );
// Check if already downloaded.
if ( file_exists( $local_path ) ) {
return $local_path;
}
// Fetch from CDN.
$response = wp_remote_get(
$cdn_url,
array(
'timeout' => MI_DOWNLOAD_TIMEOUT,
'sslverify' => true,
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $status_code ) {
return new WP_Error(
'cdn_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'CDN returned status %d.', 'maple-icons' ),
$status_code
)
);
}
$svg_content = wp_remote_retrieve_body( $response );
if ( empty( $svg_content ) ) {
return new WP_Error( 'empty_response', __( 'Empty response from CDN.', 'maple-icons' ) );
}
// Get set config for normalization.
$set_config = MI_Icon_Sets::get( $slug );
// Normalize and sanitize SVG.
$svg_content = $this->normalize_svg( $svg_content, $set_config );
$svg_content = $this->sanitize_svg( $svg_content );
if ( empty( $svg_content ) ) {
return new WP_Error( 'invalid_svg', __( 'Invalid or empty SVG content.', 'maple-icons' ) );
}
// Ensure directory exists.
$dir = dirname( $local_path );
if ( ! $this->ensure_directory( $dir ) ) {
return new WP_Error( 'directory_error', __( 'Could not create directory.', 'maple-icons' ) );
}
// Write file.
global $wp_filesystem;
if ( empty( $wp_filesystem ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if ( ! $wp_filesystem->put_contents( $local_path, $svg_content, FS_CHMOD_FILE ) ) {
return new WP_Error( 'write_error', __( 'Could not write SVG file.', 'maple-icons' ) );
}
return $local_path;
}
/**
* Normalize SVG content based on set configuration.
*
* @param string $svg SVG content.
* @param array $set_config Icon set configuration.
* @return string Normalized SVG content.
*/
private function normalize_svg( $svg, $set_config ) {
// Strip XML declaration.
$svg = preg_replace( '/<\?xml[^>]*\?>/i', '', $svg );
// Strip DOCTYPE.
$svg = preg_replace( '/<!DOCTYPE[^>]*>/i', '', $svg );
// Strip comments.
$svg = preg_replace( '/<!--.*?-->/s', '', $svg );
// Strip title and desc elements.
$svg = preg_replace( '/<title[^>]*>.*?<\/title>/is', '', $svg );
$svg = preg_replace( '/<desc[^>]*>.*?<\/desc>/is', '', $svg );
// Normalize viewBox for Phosphor (256 → 24).
if ( ! empty( $set_config['normalize'] ) ) {
$svg = preg_replace(
'/viewBox=["\']0\s+0\s+256\s+256["\']/i',
'viewBox="0 0 24 24"',
$svg
);
}
// Fix hardcoded colors for Material.
if ( ! empty( $set_config['color_fix'] ) ) {
// Replace hardcoded hex colors.
$svg = preg_replace( '/fill=["\']#[0-9a-fA-F]{3,6}["\']/', 'fill="currentColor"', $svg );
$svg = preg_replace( '/fill=["\']black["\']/', 'fill="currentColor"', $svg );
$svg = preg_replace( '/fill=["\']rgb\([^)]+\)["\']/', 'fill="currentColor"', $svg );
// Same for stroke.
$svg = preg_replace( '/stroke=["\']#[0-9a-fA-F]{3,6}["\']/', 'stroke="currentColor"', $svg );
$svg = preg_replace( '/stroke=["\']black["\']/', 'stroke="currentColor"', $svg );
}
// Remove width/height attributes (let CSS control size).
$svg = preg_replace( '/\s(width|height)=["\'][^"\']*["\']/i', '', $svg );
// Ensure there's no leading/trailing whitespace.
$svg = trim( $svg );
return $svg;
}
/**
* Sanitize SVG content to remove potentially dangerous elements.
*
* @param string $svg SVG content.
* @return string Sanitized SVG content.
*/
private function sanitize_svg( $svg ) {
// Remove script tags.
$svg = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $svg );
// Remove event handlers.
$svg = preg_replace( '/\s+on\w+\s*=/i', ' data-removed=', $svg );
// Remove javascript: URLs.
$svg = preg_replace( '/javascript:/i', 'removed:', $svg );
// Remove data: URLs (except for certain safe uses).
$svg = preg_replace( '/data:[^"\'>\s]+/i', 'removed:', $svg );
// Use WordPress kses with our allowed tags.
$svg = wp_kses( $svg, self::$allowed_svg_elements );
// Verify it's still a valid SVG.
if ( strpos( $svg, '<svg' ) === false || strpos( $svg, '</svg>' ) === false ) {
return '';
}
return $svg;
}
/**
* Ensure a directory exists, creating it if necessary.
*
* @param string $dir Directory path.
* @return bool True if directory exists or was created.
*/
private function ensure_directory( $dir ) {
if ( file_exists( $dir ) ) {
return is_dir( $dir );
}
return wp_mkdir_p( $dir );
}
/**
* Delete all icons for a set.
*
* @param string $slug Icon set slug.
* @return bool True on success.
*/
public function delete_set( $slug ) {
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return false;
}
$set_dir = MI_ICONS_DIR . $slug;
// Validate the path is within our icons directory.
$real_path = realpath( $set_dir );
$allowed_base = realpath( MI_ICONS_DIR );
if ( false === $real_path || false === $allowed_base ) {
// Directory doesn't exist, nothing to delete.
return true;
}
if ( strpos( $real_path, $allowed_base ) !== 0 ) {
// Path traversal attempt.
return false;
}
// Recursively delete the directory.
return $this->delete_directory( $set_dir );
}
/**
* Recursively delete a directory.
*
* @param string $dir Directory path.
* @return bool True on success.
*/
private function delete_directory( $dir ) {
if ( ! is_dir( $dir ) ) {
return true;
}
$files = array_diff( scandir( $dir ), array( '.', '..' ) );
foreach ( $files as $file ) {
$path = $dir . '/' . $file;
if ( is_dir( $path ) ) {
$this->delete_directory( $path );
} else {
wp_delete_file( $path );
}
}
return rmdir( $dir );
}
/**
* Get download progress for a set.
*
* @param string $slug Icon set slug.
* @return array|false Progress data or false if not downloading.
*/
public function get_progress( $slug ) {
return get_transient( 'mi_download_progress_' . $slug );
}
}