Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| plugin_updater | plugin_repo | Prevents altering WordPress update routines or using custom updaters, which are not allowed on WordPress.org. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| plugin_uninstall | plugin_repo | Checks related to plugin uninstallation. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/#method-2-uninstall-php) |
| external_admin_menu_links | plugin_repo | Detects external URLs used in top-level WordPress admin menu, which disrupts the expected user experience. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#11-plugins-should-not-hijack-the-admin) |
| menu_image_icon | plugin_repo | Detects the use of raster image files as admin menu icons, which do not adapt to the WordPress admin color schemes. Use a dashicon or an SVG data: URI instead. | [Learn more](https://developer.wordpress.org/resource/dashicons/) |
| wp_functions_compatibility | plugin_repo | Checks whether WordPress functions used by the plugin are compatible with the declared minimum supported WordPress version ("Requires at least"). | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/#header-fields) |
| plugin_review_phpcs | plugin_repo | Runs PHP_CodeSniffer to detect certain best practices plugins should follow for submission on WordPress.org, including heredoc usage detection. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) |
| direct_db_queries | security, plugin_repo | Checks the usage of direct database queries, which should be avoided. | [Learn more](https://developer.wordpress.org/apis/database/) |
Expand Down
304 changes: 304 additions & 0 deletions includes/Checker/Checks/Plugin_Repo/Menu_Image_Icon_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
<?php
/**
* Class Menu_Image_Icon_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\Plugin_Repo;

use WordPress\Plugin_Check\Checker\Check_Categories;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check;
use WordPress\Plugin_Check\Traits\Amend_Check_Result;
use WordPress\Plugin_Check\Traits\Stable_Check;

/**
* Check to detect raster image files used as admin menu icons.
*
* This check detects when plugins use raster image files (such as PNG, JPG, or GIF)
* as the icon parameter in the add_menu_page() function. Raster images do not adapt
* to the different WordPress admin color schemes, so a dashicon or an SVG data: URI
* should be used instead.
*
* @since 2.1.0
*/
class Menu_Image_Icon_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* List of raster image file extensions that do not adapt to admin color schemes.
*
* SVGs are intentionally not listed as they can be used via a data: URI and adapt
* to the active admin color scheme.
*
* @since 2.1.0
* @var array
*/
protected $image_extensions = array(
'png',
'jpg',
'jpeg',
'gif',
'webp',
'ico',
'bmp',
);

/**
* Gets the categories for the check.
*
* Every check must have at least one category.
*
* @since 2.1.0
*
* @return array The categories for the check.
*/
public function get_categories() {
return array( Check_Categories::CATEGORY_PLUGIN_REPO );
}

/**
* Amends the given result by running the check on the given list of files.
*
* @since 2.1.0
*
* @param Check_Result $result The check result to amend, including the plugin context to check.
* @param array $files List of absolute file paths.
*/
protected function check_files( Check_Result $result, array $files ) {
$php_files = self::filter_files_by_extension( $files, 'php' );

$this->look_for_menu_image_icons( $result, $php_files );
}

/**
* Looks for raster image files used as admin menu icons and amends the result with a warning if found.
*
* @since 2.1.0
*
* @param Check_Result $result The check result to amend, including the plugin context to check.
* @param array $php_files List of absolute PHP file paths.
*/
protected function look_for_menu_image_icons( Check_Result $result, array $php_files ) {
$matches = self::file_scan_add_menu_page_icons( $php_files );

if ( empty( $matches ) ) {
return;
}

foreach ( $matches as $match ) {
if ( ! $this->is_raster_image_icon( $match['icon'] ) ) {
continue;
}

$this->add_result_warning_for_file(
$result,
__(
'<strong>Raster image used as admin menu icon.</strong><br>Plugins should use a dashicon or an SVG data: URI as the admin menu icon, as raster image files do not adapt to the WordPress admin color schemes.',
'plugin-check'
),
'menu_image_icon',
$match['file'],
$match['line'],
$match['column'],
'https://developer.wordpress.org/resource/dashicons/',
4
);
}
}

/**
* Scans PHP files for add_menu_page() calls with a quoted string icon parameter.
*
* The icon URL is the sixth parameter of add_menu_page(). A regex is used to count
* to the sixth parameter, capturing the icon string along with the file position.
*
Comment thread
faisalahammad marked this conversation as resolved.
* @since 2.1.0
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity)
*
* @param array $php_files List of absolute PHP file paths.
* @return array List of matches containing the file, line, column, and icon string.
*/
private static function file_scan_add_menu_page_icons( array $php_files ) {
$results = array();

foreach ( $php_files as $file ) {
$contents = self::file_contents( $file );
$tokens = token_get_all( $contents );
$offset = 0;
$scanned = array();

foreach ( $tokens as $token ) {
$text = is_array( $token ) ? $token[1] : $token;
$scanned[] = array(
'id' => is_array( $token ) ? $token[0] : null,
'text' => $text,
'offset' => $offset,
);
$offset += strlen( $text );
}

$count = count( $scanned );
for ( $index = 0; $index < $count; $index++ ) {
if ( T_STRING !== $scanned[ $index ]['id'] || 'add_menu_page' !== strtolower( $scanned[ $index ]['text'] ) ) {
continue;
}

$open = $index + 1;
Comment thread
faisalahammad marked this conversation as resolved.
while ( $open < $count && in_array( $scanned[ $open ]['id'], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
$open += 1;
}
if ( $open >= $count || '(' !== $scanned[ $open ]['text'] ) {
continue;
}

$args = array( '' );
$depth = 1;
for ( $cursor = $open + 1; $cursor < $count; $cursor++ ) {
$text = $scanned[ $cursor ]['text'];
if ( '(' === $text ) {
$depth += 1;
} elseif ( ')' === $text ) {
$depth -= 1;
if ( $depth < 1 ) {
break;
}
} elseif ( ',' === $text && 1 === $depth ) {
$args[] = '';
continue;
}
$args[ count( $args ) - 1 ] .= $text;
}

if ( isset( $args[5] ) && preg_match( '/^\s*([\'"])(.*?)\1\s*$/s', $args[5], $match ) ) {
$results[] = array(
'file' => $file,
'line' => self::offset_to_line( $contents, $scanned[ $index ]['offset'] ),
'column' => self::offset_to_column( $contents, $scanned[ $index ]['offset'] ),
'icon' => $match[2],
);
}
}
}

return $results;
}

/**
* Determines whether the given icon value is a raster image file.
*
* A value is considered a raster image icon when it is a path or URL whose
* basename ends with a flagged image extension. Dashicon classes, SVG data:
* URIs, the 'none' value, and empty strings are all valid and skipped.
*
* @since 2.1.0
*
* @param string $icon The icon parameter value.
* @return bool True if the icon is a raster image file, false otherwise.
*/
private function is_raster_image_icon( $icon ) {
if ( '' === $icon || 'none' === $icon ) {
return false;
}

if ( 0 === strpos( $icon, 'dashicons-' ) ) {
return false;
}

// SVG data: URIs adapt to the admin color scheme and are valid.
// Only SVG data: URIs adapt to the admin color scheme.
if ( 0 === strpos( $icon, 'data:' ) ) {
return 1 === preg_match( '/^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|vnd\.microsoft\.icon)(?:[;,]|$)/i', $icon );
}

// Strip any query string or fragment before checking the extension.
$path = preg_split( '/[?#]/', $icon, 2 );
$path = $path[0];

foreach ( $this->image_extensions as $extension ) {
if ( preg_match( '/\.' . preg_quote( $extension, '/' ) . '$/i', $path ) ) {
return true;
}
}

return false;
}

/**
* Gets the contents of the given file.
*
* This is a caching wrapper around the native file_get_contents() function.
*
* @since 2.1.0
*
* @param string $file The file name.
* @return string The file contents.
*/
private static function file_contents( $file ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
return file_get_contents( $file );
}

/**
* Converts a byte offset into a line number.
*
* @since 2.1.0
*
* @param string $contents The file contents.
* @param int $offset The byte offset of the match.
* @return int The line number (1-based).
*/
private static function offset_to_line( $contents, $offset ) {
return substr_count( $contents, "\n", 0, $offset ) + 1;
}

/**
* Converts a byte offset into a column number.
*
* @since 2.1.0
*
* @param string $contents The file contents.
* @param int $offset The byte offset of the match.
* @return int The column number (1-based).
*/
private static function offset_to_column( $contents, $offset ) {
$last_newline = strrpos( substr( $contents, 0, $offset ), "\n" );

if ( false === $last_newline ) {
return $offset + 1;
}

return $offset - $last_newline;
}

/**
* Gets the description for the check.
*
* Every check must have a short description explaining what the check does.
*
* @since 2.1.0
*
* @return string Description.
*/
public function get_description(): string {
return __( 'Detects the use of raster image files as admin menu icons, which do not adapt to the WordPress admin color schemes. Use a dashicon or an SVG data: URI instead.', 'plugin-check' );
}

/**
* Gets the documentation URL for the check.
*
* Every check must have a URL with further information about the check.
*
* @since 2.1.0
*
* @return string The documentation URL.
*/
public function get_documentation_url(): string {
return __( 'https://developer.wordpress.org/resource/dashicons/', 'plugin-check' );
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ private function register_default_checks() {
'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(),
'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(),
'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(),
'menu_image_icon' => new Checks\Plugin_Repo\Menu_Image_Icon_Check(),
'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(),
'ai_provider' => new Checks\General\AI_Provider_Check(),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
/**
* Plugin Name: Test Plugin Menu Image Icon With Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Test plugin with raster image files used as admin menu icons.
* Requires at least: 6.0
* Requires PHP: 5.6
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-menu-image-icon-with-errors
*
* @package test-plugin-menu-image-icon-with-errors
*/

/**
* These are examples of problematic code that uses raster image files as
* the admin menu icon in add_menu_page().
*/

// Exclamation: PNG image used as menu icon.
add_menu_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
'my_plugin_page',
'img/icon.png',
30
);

// Exclamation: JPG image used as menu icon.
add_menu_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
'my_plugin_page',
'img/icon.jpg'
);

// Exclamation: GIF image used as menu icon.
add_menu_page( 'My Plugin', 'My Plugin', 'manage_options', 'my-plugin', 'my_plugin_page', 'img/icon.gif', 32 );

// Exclamation: WebP image used as menu icon.
add_menu_page( 'My Plugin', 'My Plugin', 'manage_options', 'my-plugin', 'my_plugin_page', 'img/icon.webp', 33 );

// Exclamation: ICO image used as menu icon.
add_menu_page( 'My Plugin', 'My Plugin', 'manage_options', 'my-plugin', 'my_plugin_page', 'img/icon.ico', 34 );

// Exclamation: BMP image used as menu icon.
add_menu_page( 'My Plugin', 'My Plugin', 'manage_options', 'my-plugin', 'my_plugin_page', 'img/icon.bmp', 35 );

// Exclamation: Image with a query string is still a raster image icon.
add_menu_page( 'My Plugin', 'My Plugin', 'manage_options', 'my-plugin', 'my_plugin_page', 'img/icon.png?v=2', 36 );

/**
* Callback function for admin pages.
*/
function my_plugin_page() {
echo '<div class="wrap"><h1>My Plugin</h1></div>';
}
Loading
Loading