505 lines
16 KiB
PHP
505 lines
16 KiB
PHP
<?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 );
|
||
}
|
||
}
|