1<?php 2 3/* 4 * This file is part of the TYPO3 CMS project. 5 * 6 * It is free software; you can redistribute it and/or modify it under 7 * the terms of the GNU General Public License, either version 2 8 * of the License, or any later version. 9 * 10 * For the full copyright and license information, please read the 11 * LICENSE.txt file that was distributed with this source code. 12 * 13 * The TYPO3 project - inspiring people to share! 14 */ 15 16namespace TYPO3\CMS\Core\Utility; 17 18use TYPO3\CMS\Core\Core\Environment; 19 20/** 21 * Class to handle system commands. 22 * finds executables (programs) on Unix and Windows without knowing where they are 23 * 24 * returns exec command for a program 25 * or FALSE 26 * 27 * This class is meant to be used without instance: 28 * $cmd = CommandUtility::getCommand ('awstats','perl'); 29 * 30 * The data of this class is cached. 31 * That means if a program is found once it don't have to be searched again. 32 * 33 * user functions: 34 * 35 * addPaths() could be used to extend the search paths 36 * getCommand() get a command string 37 * checkCommand() returns TRUE if a command is available 38 * 39 * Search paths that are included: 40 * $TYPO3_CONF_VARS['GFX']['processor_path_lzw'] or $TYPO3_CONF_VARS['GFX']['processor_path'] 41 * $TYPO3_CONF_VARS['SYS']['binPath'] 42 * $GLOBALS['_SERVER']['PATH'] 43 * '/usr/bin/,/usr/local/bin/' on Unix 44 * 45 * binaries can be preconfigured with 46 * $TYPO3_CONF_VARS['SYS']['binSetup'] 47 */ 48class CommandUtility 49{ 50 /** 51 * Tells if object is already initialized 52 * 53 * @var bool 54 */ 55 protected static $initialized = false; 56 57 /** 58 * Contains application list. This is an array with the following structure: 59 * - app => file name to the application (like 'tar' or 'bzip2') 60 * - path => full path to the application without application name (like '/usr/bin/' for '/usr/bin/tar') 61 * - valid => TRUE or FALSE 62 * Array key is identical to 'app'. 63 * 64 * @var array 65 */ 66 protected static $applications = []; 67 68 /** 69 * Paths where to search for applications 70 * 71 * @var array 72 */ 73 protected static $paths; 74 75 /** 76 * Wrapper function for php exec function 77 * Needs to be central to have better control and possible fix for issues 78 * 79 * @param string $command 80 * @param array|null $output 81 * @param int $returnValue 82 * @return string 83 */ 84 public static function exec($command, &$output = null, &$returnValue = 0) 85 { 86 return exec($command, $output, $returnValue); 87 } 88 89 /** 90 * Compile the command for running ImageMagick/GraphicsMagick. 91 * 92 * @param string $command Command to be run: identify, convert or combine/composite 93 * @param string $parameters The parameters string 94 * @param string $path Override the default path (e.g. used by the install tool) 95 * @return string Compiled command that deals with ImageMagick & GraphicsMagick 96 */ 97 public static function imageMagickCommand($command, $parameters, $path = '') 98 { 99 $gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX']; 100 $isExt = Environment::isWindows() ? '.exe' : ''; 101 if (!$path) { 102 $path = $gfxConf['processor_path']; 103 } 104 $path = GeneralUtility::fixWindowsFilePath($path); 105 // This is only used internally, has no effect outside 106 if ($command === 'combine') { 107 $command = 'composite'; 108 } 109 // Compile the path & command 110 if ($gfxConf['processor'] === 'GraphicsMagick') { 111 $path = self::escapeShellArgument($path . 'gm' . $isExt) . ' ' . self::escapeShellArgument($command); 112 } else { 113 if (Environment::isWindows() && !@is_file($path . $command . $isExt)) { 114 $path = self::escapeShellArgument($path . 'magick' . $isExt) . ' ' . self::escapeShellArgument($command); 115 } else { 116 $path = self::escapeShellArgument($path . $command . $isExt); 117 } 118 } 119 // strip profile information for thumbnails and reduce their size 120 if ($parameters && $command !== 'identify') { 121 // Determine whether the strip profile action has be disabled by TypoScript: 122 if ($gfxConf['processor_stripColorProfileByDefault'] 123 && $gfxConf['processor_stripColorProfileCommand'] !== '' 124 && !str_contains($parameters, $gfxConf['processor_stripColorProfileCommand']) 125 && $parameters !== '-version' 126 && !str_contains($parameters, '###SkipStripProfile###') 127 ) { 128 $parameters = $gfxConf['processor_stripColorProfileCommand'] . ' ' . $parameters; 129 } else { 130 $parameters = str_replace('###SkipStripProfile###', '', $parameters); 131 } 132 } 133 // Add -auto-orient on convert so IM/GM respects the image orient 134 if ($parameters && $command === 'convert') { 135 $parameters = '-auto-orient ' . $parameters; 136 } 137 // set interlace parameter for convert command 138 if ($command !== 'identify' && $gfxConf['processor_interlace']) { 139 $parameters = '-interlace ' . $gfxConf['processor_interlace'] . ' ' . $parameters; 140 } 141 $cmdLine = $path . ' ' . $parameters; 142 // It is needed to change the parameters order when a mask image has been specified 143 if ($command === 'composite') { 144 $paramsArr = self::unQuoteFilenames($parameters); 145 $paramsArrCount = count($paramsArr); 146 if ($paramsArrCount > 5) { 147 $tmp = $paramsArr[$paramsArrCount - 3]; 148 $paramsArr[$paramsArrCount - 3] = $paramsArr[$paramsArrCount - 4]; 149 $paramsArr[$paramsArrCount - 4] = $tmp; 150 } 151 $cmdLine = $path . ' ' . implode(' ', $paramsArr); 152 } 153 return $cmdLine; 154 } 155 156 /** 157 * Checks if a command is valid or not, updates global variables 158 * 159 * @param string $cmd The command that should be executed. eg: "convert" 160 * @param string $handler Executer for the command. eg: "perl" 161 * @return bool FALSE if cmd is not found, or -1 if the handler is not found 162 */ 163 public static function checkCommand($cmd, $handler = '') 164 { 165 if (!self::init()) { 166 return false; 167 } 168 169 if ($handler && !self::checkCommand($handler)) { 170 return -1; 171 } 172 // Already checked and valid 173 if (self::$applications[$cmd]['valid'] ?? false) { 174 return true; 175 } 176 // Is set but was (above) not TRUE 177 if (isset(self::$applications[$cmd]['valid'])) { 178 return false; 179 } 180 181 foreach (self::$paths as $path => $validPath) { 182 // Ignore invalid (FALSE) paths 183 if ($validPath) { 184 if (Environment::isWindows()) { 185 // Windows OS 186 // @todo Why is_executable() is not called here? 187 if (@is_file($path . $cmd)) { 188 self::$applications[$cmd]['app'] = $cmd; 189 self::$applications[$cmd]['path'] = $path; 190 self::$applications[$cmd]['valid'] = true; 191 return true; 192 } 193 if (@is_file($path . $cmd . '.exe')) { 194 self::$applications[$cmd]['app'] = $cmd . '.exe'; 195 self::$applications[$cmd]['path'] = $path; 196 self::$applications[$cmd]['valid'] = true; 197 return true; 198 } 199 } else { 200 // Unix-like OS 201 $filePath = realpath($path . $cmd); 202 if ($filePath && @is_executable($filePath)) { 203 self::$applications[$cmd]['app'] = $cmd; 204 self::$applications[$cmd]['path'] = $path; 205 self::$applications[$cmd]['valid'] = true; 206 return true; 207 } 208 } 209 } 210 } 211 212 // Try to get the executable with the command 'which'. 213 // It does the same like already done, but maybe on other paths 214 if (!Environment::isWindows()) { 215 $cmd = @self::exec('which ' . self::escapeShellArgument($cmd)); 216 if (@is_executable($cmd)) { 217 self::$applications[$cmd]['app'] = $cmd; 218 self::$applications[$cmd]['path'] = PathUtility::dirname($cmd) . '/'; 219 self::$applications[$cmd]['valid'] = true; 220 return true; 221 } 222 } 223 224 return false; 225 } 226 227 /** 228 * Returns a command string for exec(), system() 229 * 230 * @param string $cmd The command that should be executed. eg: "convert" 231 * @param string $handler Handler (executor) for the command. eg: "perl" 232 * @param string $handlerOpt Options for the handler, like '-w' for "perl" 233 * @return mixed Returns command string, or FALSE if cmd is not found, or -1 if the handler is not found 234 */ 235 public static function getCommand($cmd, $handler = '', $handlerOpt = '') 236 { 237 if (!self::init()) { 238 return false; 239 } 240 241 // Handler 242 if ($handler) { 243 $handler = self::getCommand($handler); 244 245 if (!$handler) { 246 return -1; 247 } 248 $handler .= ' ' . escapeshellcmd($handlerOpt) . ' '; 249 } 250 251 // Command 252 if (!self::checkCommand($cmd)) { 253 return false; 254 } 255 $cmd = self::$applications[$cmd]['path'] . self::$applications[$cmd]['app'] . ' '; 256 257 return trim($handler . $cmd); 258 } 259 260 /** 261 * Extend the preset paths. This way an extension can install an executable and provide the path to \TYPO3\CMS\Core\Utility\CommandUtility 262 * 263 * @param string $paths Comma separated list of extra paths where a command should be searched. Relative paths (without leading "/") are prepend with public web path 264 */ 265 public static function addPaths($paths) 266 { 267 self::initPaths($paths); 268 } 269 270 /** 271 * Returns an array of search paths 272 * 273 * @param bool $addInvalid If set the array contains invalid path too. Then the key is the path and the value is empty 274 * @return array Array of search paths (empty if exec is disabled) 275 */ 276 public static function getPaths($addInvalid = false) 277 { 278 if (!self::init()) { 279 return []; 280 } 281 282 $paths = self::$paths; 283 284 if (!$addInvalid) { 285 foreach ($paths as $path => $validPath) { 286 if (!$validPath) { 287 unset($paths[$path]); 288 } 289 } 290 } 291 return $paths; 292 } 293 294 /** 295 * Initializes this class 296 * 297 * @return bool 298 */ 299 protected static function init() 300 { 301 if ($GLOBALS['TYPO3_CONF_VARS']['BE']['disable_exec_function']) { 302 return false; 303 } 304 if (!self::$initialized) { 305 self::initPaths(); 306 self::$applications = self::getConfiguredApps(); 307 self::$initialized = true; 308 } 309 return true; 310 } 311 312 /** 313 * Initializes and extends the preset paths with own 314 * 315 * @param string $paths Comma separated list of extra paths where a command should be searched. Relative paths (without leading "/") are prepend with public web path 316 */ 317 protected static function initPaths($paths = '') 318 { 319 $doCheck = false; 320 321 // Init global paths array if not already done 322 if (!is_array(self::$paths)) { 323 self::$paths = self::getPathsInternal(); 324 $doCheck = true; 325 } 326 // Merge the submitted paths array to the global 327 if ($paths) { 328 $paths = GeneralUtility::trimExplode(',', $paths, true); 329 if (is_array($paths)) { 330 foreach ($paths as $path) { 331 // Make absolute path of relative 332 if (!preg_match('#^/#', $path)) { 333 $path = Environment::getPublicPath() . '/' . $path; 334 } 335 if (!isset(self::$paths[$path])) { 336 if (@is_dir($path)) { 337 self::$paths[$path] = $path; 338 } else { 339 self::$paths[$path] = false; 340 } 341 } 342 } 343 } 344 } 345 // Check if new paths are invalid 346 if ($doCheck) { 347 foreach (self::$paths as $path => $valid) { 348 // Ignore invalid (FALSE) paths 349 if ($valid && !@is_dir($path)) { 350 self::$paths[$path] = false; 351 } 352 } 353 } 354 } 355 356 /** 357 * Processes and returns the paths from $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup'] 358 * 359 * @return array Array of commands and path 360 */ 361 protected static function getConfiguredApps() 362 { 363 $cmdArr = []; 364 365 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']) { 366 $binSetup = str_replace(['\'.chr(10).\'', '\' . LF . \''], LF, $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']); 367 $pathSetup = preg_split('/[\n,]+/', $binSetup); 368 foreach ($pathSetup as $val) { 369 if (trim($val) === '') { 370 continue; 371 } 372 [$cmd, $cmdPath] = GeneralUtility::trimExplode('=', $val, true, 2); 373 $cmdArr[$cmd]['app'] = PathUtility::basename($cmdPath); 374 $cmdArr[$cmd]['path'] = PathUtility::dirname($cmdPath) . '/'; 375 $cmdArr[$cmd]['valid'] = true; 376 } 377 } 378 379 return $cmdArr; 380 } 381 382 /** 383 * Sets the search paths from different sources, internal 384 * 385 * @return array Array of absolute paths (keys and values are equal) 386 */ 387 protected static function getPathsInternal() 388 { 389 $pathsArr = []; 390 $sysPathArr = []; 391 392 // Image magick paths first 393 // processor_path_lzw take precedence over processor_path 394 if ($imPath = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path_lzw'] ?: $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']) { 395 $imPath = self::fixPath($imPath); 396 $pathsArr[$imPath] = $imPath; 397 } 398 399 // Add configured paths 400 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath']) { 401 $sysPath = GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath'], true); 402 foreach ($sysPath as $val) { 403 $val = self::fixPath($val); 404 $sysPathArr[$val] = $val; 405 } 406 } 407 408 // Add path from environment 409 if (!empty($GLOBALS['_SERVER']['PATH']) || !empty($GLOBALS['_SERVER']['Path'])) { 410 $sep = Environment::isWindows() ? ';' : ':'; 411 $serverPath = $GLOBALS['_SERVER']['PATH'] ?? $GLOBALS['_SERVER']['Path']; 412 $envPath = GeneralUtility::trimExplode($sep, $serverPath, true); 413 foreach ($envPath as $val) { 414 $val = self::fixPath($val); 415 $sysPathArr[$val] = $val; 416 } 417 } 418 419 // Set common paths for Unix (only) 420 if (!Environment::isWindows()) { 421 $sysPathArr = array_merge($sysPathArr, [ 422 '/usr/bin/' => '/usr/bin/', 423 '/usr/local/bin/' => '/usr/local/bin/', 424 ]); 425 } 426 427 $pathsArr = array_merge($pathsArr, $sysPathArr); 428 429 return $pathsArr; 430 } 431 432 /** 433 * Set a path to the right format 434 * 435 * @param string $path Input path 436 * @return string Output path 437 */ 438 protected static function fixPath($path) 439 { 440 return str_replace('//', '/', $path . '/'); 441 } 442 443 /** 444 * Escape shell arguments (for example filenames) to be used on the local system. 445 * 446 * The setting UTF8filesystem will be taken into account. 447 * 448 * @param string[] $input Input arguments to be escaped 449 * @return string[] Escaped shell arguments 450 */ 451 public static function escapeShellArguments(array $input) 452 { 453 $isUTF8Filesystem = !empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']); 454 $currentLocale = false; 455 if ($isUTF8Filesystem) { 456 $currentLocale = setlocale(LC_CTYPE, '0'); 457 setlocale(LC_CTYPE, $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale']); 458 } 459 460 $output = array_map('escapeshellarg', $input); 461 462 if ($isUTF8Filesystem && $currentLocale !== false) { 463 setlocale(LC_CTYPE, $currentLocale); 464 } 465 466 return $output; 467 } 468 469 /** 470 * Explode a string (normally a list of filenames) with whitespaces by considering quotes in that string. 471 * 472 * @param string $parameters The whole parameters string 473 * @return array Exploded parameters 474 */ 475 protected static function unQuoteFilenames(string $parameters): array 476 { 477 $paramsArr = explode(' ', trim($parameters)); 478 // Whenever a quote character (") is found, $quoteActive is set to the element number inside of $params. 479 // A value of -1 means that there are not open quotes at the current position. 480 $quoteActive = -1; 481 foreach ($paramsArr as $k => $v) { 482 if ($quoteActive > -1) { 483 $paramsArr[$quoteActive] .= ' ' . $v; 484 unset($paramsArr[$k]); 485 if (substr($v, -1) === $paramsArr[$quoteActive][0]) { 486 $quoteActive = -1; 487 } 488 } elseif (!trim($v)) { 489 // Remove empty elements 490 unset($paramsArr[$k]); 491 } elseif (preg_match('/^(["\'])/', $v) && substr($v, -1) !== $v[0]) { 492 $quoteActive = $k; 493 } 494 } 495 // Return re-indexed array 496 return array_values($paramsArr); 497 } 498 499 /** 500 * Escape a shell argument (for example a filename) to be used on the local system. 501 * 502 * The setting UTF8filesystem will be taken into account. 503 * 504 * @param string $input Input-argument to be escaped 505 * @return string Escaped shell argument 506 */ 507 public static function escapeShellArgument($input) 508 { 509 return self::escapeShellArguments([$input])[0]; 510 } 511} 512