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( '/]*>/i', '', $svg ); // Strip comments. $svg = preg_replace( '//s', '', $svg ); // Strip title and desc elements. $svg = preg_replace( '/]*>.*?<\/title>/is', '', $svg ); $svg = preg_replace( '/]*>.*?<\/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>/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, '' ) === 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 ); } }