550 lines
20 KiB
PHP
550 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Utility;
|
|
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\Process\Exception\RuntimeException;
|
|
use Symfony\Component\Process\Process;
|
|
use TYPO3\CMS\Core\Core\Environment;
|
|
use TYPO3\CMS\Core\Log\LogManager;
|
|
|
|
/**
|
|
* Class to handle system commands.
|
|
* finds executables (programs) on Unix and Windows without knowing where they are
|
|
*
|
|
* returns exec command for a program
|
|
* or FALSE
|
|
*
|
|
* This class is meant to be used without instance:
|
|
*
|
|
* ```
|
|
* $cmd = CommandUtility::getCommand ('awstats','perl');
|
|
* ```
|
|
*
|
|
* The data of this class is cached.
|
|
* That means if a program is found once it don't have to be searched again.
|
|
*
|
|
* user functions:
|
|
*
|
|
* addPaths() could be used to extend the search paths
|
|
* getCommand() get a command string
|
|
* checkCommand() returns TRUE if a command is available
|
|
*
|
|
* Search paths that are included:
|
|
* $TYPO3_CONF_VARS['GFX']['processor_path']
|
|
* $TYPO3_CONF_VARS['SYS']['binPath']
|
|
* $GLOBALS['_SERVER']['PATH']
|
|
* '/usr/bin/,/usr/local/bin/' on Unix
|
|
*
|
|
* binaries can be preconfigured with
|
|
* $TYPO3_CONF_VARS['SYS']['binSetup']
|
|
*/
|
|
class CommandUtility
|
|
{
|
|
/**
|
|
* Tells if object is already initialized
|
|
*/
|
|
protected static bool $initialized = false;
|
|
|
|
/**
|
|
* Contains application list. This is an array with the following structure:
|
|
* - app => 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<string, array{app: string, path: string, valid: bool}>
|
|
*/
|
|
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<string, string|false>|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<string, string|false> 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<string, array{app: string, path: string, valid: bool}> 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<string, string> 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__);
|
|
}
|
|
}
|