file name to the application (like 'tar' or 'bzip2') * - path => full path to the application without application name (like '/usr/bin/' for '/usr/bin/tar') * - valid => TRUE or FALSE * Array key is identical to 'app'. * * @var array */ protected static array $applications = []; /** * Paths where to search for applications * * The key is a path. The value is either the same path, or false if the path is not valid. * * @var array|null */ protected static ?array $paths = null; /** * Execute a shell command. * * Needs to be central to have better control and possible fix for issues. Is a wrapper for Symfony's Process * component. * * @see Process */ public static function exec(string|array $command, ?array &$output = null, int &$returnValue = 0, ?float $timeout = 60): string|false { if (is_string($command)) { $process = Process::fromShellCommandline($command, null, null, null, $timeout); } else { $process = new Process($command, null, null, null, $timeout); } try { $returnValue = $process->run(); } catch (RuntimeException $runtimeException) { self::getLogger()->warning('Executing command "{command}" failed.', [ 'command' => $command, 'exception' => $runtimeException, ]); return false; } $processOutput = $process->getOutput(); if (str_ends_with($processOutput, PHP_EOL)) { // Last \n is ignored by PHP exec(): https://github.com/php/php-src/blob/b675db4c56dd0de4ea1f5195d587ed90f0096ed8/ext/standard/exec.c#L148 $processOutput = substr($processOutput, 0, -1); } $output = explode(PHP_EOL, $processOutput); return rtrim(strrchr($processOutput, PHP_EOL) ?: $processOutput); } /** * Compile the command for running ImageMagick/GraphicsMagick. * * @param string $command Command to be run: identify, convert or combine/composite * @param string $parameters The parameters string * @param string $path Override the default path (e.g. used by the install tool) * @return string Compiled command that deals with ImageMagick & GraphicsMagick */ public static function imageMagickCommand(string $command, string $parameters, string $path = ''): string { $gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX']; $isExt = Environment::isWindows() ? '.exe' : ''; if (!$path) { $path = (string)($gfxConf['processor_path'] ?? ''); } $path = GeneralUtility::fixWindowsFilePath($path); // This is only used internally, has no effect outside if ($command === 'combine') { $command = 'composite'; } // Compile the path & command if ($gfxConf['processor'] === 'GraphicsMagick') { $path = self::escapeShellArgument($path . 'gm' . $isExt) . ' ' . self::escapeShellArgument($command); } else { if (Environment::isWindows() && !@is_file($path . $command . $isExt)) { $path = self::escapeShellArgument($path . 'magick' . $isExt) . ' ' . self::escapeShellArgument($command); } else { $path = self::escapeShellArgument($path . $command . $isExt); } } // strip profile information for thumbnails and reduce their size if ($parameters && $command !== 'identify') { // Use legacy processor_stripColorProfileCommand setting if defined, otherwise // use the preferred configuration option processor_stripColorProfileParameters $stripColorProfileCommand = $gfxConf['processor_stripColorProfileCommand'] ?? implode(' ', array_map(CommandUtility::escapeShellArgument(...), $gfxConf['processor_stripColorProfileParameters'] ?? [])); // Determine whether the strip profile action has be disabled by TypoScript: if ($gfxConf['processor_stripColorProfileByDefault'] && $stripColorProfileCommand !== '' && $parameters !== '-version' && !str_contains($parameters, $stripColorProfileCommand) && !str_contains($parameters, '###SkipStripProfile###') ) { $parameters = $stripColorProfileCommand . ' ' . $parameters; } else { $parameters = str_replace('###SkipStripProfile###', '', $parameters); } // When converting images that have background transparency, this needs to be not filled, // but preserved, so that e.g. conversion from SVG into PNG/JPG contains transparency info. // Without this option, the default background color for conversions is white (https://imagemagick.org/script/command-line-options.php#background) $parameters = '-background none ' . $parameters; } // Add -auto-orient on convert so IM/GM respects the image orient if ($parameters && $command === 'convert') { $parameters = '-auto-orient ' . $parameters; } // set interlace parameter for convert command if ($command !== 'identify' && $gfxConf['processor_interlace']) { $parameters = '-interlace ' . CommandUtility::escapeShellArgument($gfxConf['processor_interlace']) . ' ' . $parameters; } $cmdLine = $path . ' ' . $parameters; // It is needed to change the parameters order when a mask image has been specified if ($command === 'composite') { $paramsArr = self::unQuoteFilenames($parameters); $paramsArrCount = count($paramsArr); if ($paramsArrCount > 5) { $tmp = $paramsArr[$paramsArrCount - 3]; $paramsArr[$paramsArrCount - 3] = $paramsArr[$paramsArrCount - 4]; $paramsArr[$paramsArrCount - 4] = $tmp; } $cmdLine = $path . ' ' . implode(' ', $paramsArr); } return $cmdLine; } /** * Checks if a command is valid or not, updates global variables * * @param string $cmd The command that should be executed. eg: "convert" * @param string $handler Executor for the command. eg: "perl" * @return bool|int True if the command is valid; False if cmd is not found; -1 if the handler is not found */ public static function checkCommand(string $cmd, string $handler = ''): bool|int { if (!self::init()) { return false; } if ($handler !== '' && !self::checkCommand($handler)) { return -1; } // Already checked and valid if (self::$applications[$cmd]['valid'] ?? false) { return true; } // Is set but was (above) not TRUE if (isset(self::$applications[$cmd]['valid'])) { return false; } foreach (self::$paths as $path => $validPath) { // Ignore invalid (FALSE) paths if ($validPath) { if (Environment::isWindows()) { // Windows OS // @todo Why is_executable() is not called here? if (@is_file($path . $cmd)) { self::$applications[$cmd]['app'] = $cmd; self::$applications[$cmd]['path'] = $path; self::$applications[$cmd]['valid'] = true; return true; } if (@is_file($path . $cmd . '.exe')) { self::$applications[$cmd]['app'] = $cmd . '.exe'; self::$applications[$cmd]['path'] = $path; self::$applications[$cmd]['valid'] = true; return true; } } else { // Unix-like OS $filePath = realpath($path . $cmd); if ($filePath && @is_executable($filePath)) { self::$applications[$cmd]['app'] = $cmd; self::$applications[$cmd]['path'] = $path; self::$applications[$cmd]['valid'] = true; return true; } } } } // Try to get the executable with the command 'which'. // It does the same like already done, but maybe on other paths if (!Environment::isWindows()) { $output = null; $returnValue = 0; $cmd = @self::exec('which ' . self::escapeShellArgument($cmd), $output, $returnValue); if ($returnValue === 0) { self::$applications[$cmd]['app'] = $cmd; self::$applications[$cmd]['path'] = PathUtility::dirname($cmd) . '/'; self::$applications[$cmd]['valid'] = true; return true; } } return false; } /** * Returns a command string for exec(), system() * * @param string $cmd The command that should be executed. eg: "convert" * @param string $handler Handler (executor) for the command. eg: "perl" * @param string $handlerOpt Options for the handler, like '-w' for "perl" * @return string|bool|int Returns command string, or FALSE if cmd is not found, or -1 if the handler is not found */ public static function getCommand(string $cmd, string $handler = '', string $handlerOpt = ''): string|bool|int { if (!self::init()) { return false; } // Handler if ($handler) { $handler = self::getCommand($handler); if (!$handler) { return -1; } $handler .= ' ' . escapeshellcmd($handlerOpt) . ' '; } // Command if (!self::checkCommand($cmd)) { return false; } $cmd = self::$applications[$cmd]['path'] . self::$applications[$cmd]['app'] . ' '; return trim($handler . $cmd); } /** * Extend the preset paths. This way an extension can install an executable and provide the path to \TYPO3\CMS\Core\Utility\CommandUtility * * @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 */ public static function addPaths(string $paths): void { self::initPaths($paths); } /** * Returns an array of search paths * * @param bool $addInvalid If set the array contains invalid path too. Then the key is the path and the value is empty * @return array Array of search paths (empty if exec is disabled) */ public static function getPaths(bool $addInvalid = false): array { if (!self::init()) { return []; } return $addInvalid ? self::$paths : array_filter(self::$paths); } /** * Initializes this class */ protected static function init(): bool { if ($GLOBALS['TYPO3_CONF_VARS']['BE']['disable_exec_function']) { return false; } if (!self::$initialized) { self::initPaths(); self::$applications = self::getConfiguredApps(); self::$initialized = true; } return true; } /** * Initializes and extends the preset paths with own * * @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 */ protected static function initPaths(string $paths = ''): void { $doCheck = false; // Init global paths array if not already done if (!is_array(self::$paths)) { self::$paths = self::getPathsInternal(); $doCheck = true; } // Merge the submitted paths array to the global if ($paths) { $paths = GeneralUtility::trimExplode(',', $paths, true); foreach ($paths as $path) { // Make absolute path of relative if (!str_starts_with($path, '/')) { $path = Environment::getProjectPath() . '/' . $path; } if (!isset(self::$paths[$path])) { if (@is_dir($path)) { self::$paths[$path] = $path; } else { self::$paths[$path] = false; } } } } // Check if new paths are invalid if ($doCheck) { foreach (self::$paths as $path => $valid) { // Ignore invalid (FALSE) paths if ($valid && !@is_dir($path)) { self::$paths[$path] = false; } } } } /** * Processes and returns the paths from $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup'] * * @return array Array of commands and path */ protected static function getConfiguredApps(): array { $cmdArr = []; if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']) { $binSetup = str_replace(['\'.chr(10).\'', '\' . LF . \''], LF, $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']); $pathSetup = preg_split('/[\n,]+/', $binSetup); foreach ($pathSetup as $val) { if (trim($val) === '') { continue; } [$cmd, $cmdPath] = GeneralUtility::trimExplode('=', $val, true, 2); $cmdArr[$cmd]['app'] = PathUtility::basename($cmdPath); $cmdArr[$cmd]['path'] = PathUtility::dirname($cmdPath) . '/'; $cmdArr[$cmd]['valid'] = true; } } return $cmdArr; } /** * Sets the search paths from different sources, internal * * @return array Array of absolute paths (keys and values are equal) */ protected static function getPathsInternal(): array { $pathsArr = []; $sysPathArr = []; // Image magick paths first if ($imPath = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']) { $imPath = self::fixPath($imPath); $pathsArr[$imPath] = $imPath; } // Add configured paths if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath']) { $sysPath = GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['SYS']['binPath'], true); foreach ($sysPath as $val) { $val = self::fixPath($val); $sysPathArr[$val] = $val; } } // Add path from environment if (!empty($GLOBALS['_SERVER']['PATH']) || !empty($GLOBALS['_SERVER']['Path'])) { $sep = Environment::isWindows() ? ';' : ':'; $serverPath = $GLOBALS['_SERVER']['PATH'] ?? $GLOBALS['_SERVER']['Path']; $envPath = GeneralUtility::trimExplode($sep, $serverPath, true); foreach ($envPath as $val) { $val = self::fixPath($val); $sysPathArr[$val] = $val; } } // Set common paths for Unix (only) if (!Environment::isWindows()) { $sysPathArr = array_merge($sysPathArr, [ '/usr/bin/' => '/usr/bin/', '/usr/local/bin/' => '/usr/local/bin/', ]); } return array_merge($pathsArr, $sysPathArr); } /** * Set a path to the right format * * @param string $path Input path * @return string Output path */ protected static function fixPath(string $path): string { return str_replace('//', '/', $path . '/'); } /** * Escape shell arguments (for example filenames) to be used on the local system. * * The setting UTF8filesystem will be taken into account. * * @param string[] $input Input arguments to be escaped * @return string[] Escaped shell arguments */ public static function escapeShellArguments(array $input): array { $isUTF8Filesystem = !empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']); $currentLocale = false; if ($isUTF8Filesystem) { if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale'] ?? false) { $currentLocale = setlocale(LC_CTYPE, '0'); setlocale(LC_CTYPE, $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale']); } } $output = array_map('escapeshellarg', $input); if ($isUTF8Filesystem && $currentLocale !== false) { setlocale(LC_CTYPE, $currentLocale); } return $output; } /** * Explode a string (normally a list of filenames) with whitespaces by considering quotes in that string. * * @param string $parameters The whole parameters string * @return array Exploded parameters */ protected static function unQuoteFilenames(string $parameters): array { $paramsArr = explode(' ', trim($parameters)); // Whenever a quote character (") is found, $quoteActive is set to the element number inside of $params. // A value of -1 means that there are not open quotes at the current position. $quoteActive = -1; foreach ($paramsArr as $k => $v) { if ($quoteActive > -1) { $paramsArr[$quoteActive] .= ' ' . $v; unset($paramsArr[$k]); if (substr($v, -1) === $paramsArr[$quoteActive][0]) { $quoteActive = -1; } } elseif (!trim($v)) { // Remove empty elements unset($paramsArr[$k]); } elseif (preg_match('/^(["\'])/', $v) && substr($v, -1) !== $v[0]) { $quoteActive = $k; } } // Return re-indexed array return array_values($paramsArr); } /** * Escape a shell argument (for example a filename) to be used on the local system. * * The setting UTF8filesystem will be taken into account. * * @param string $input Input-argument to be escaped * @return string Escaped shell argument */ public static function escapeShellArgument(string $input): string { return self::escapeShellArguments([$input])[0]; } protected static function getLogger(): LoggerInterface { return GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__); } }