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