CommandUtility.php 17.8 KB
Newer Older
1
<?php
2

3
4
declare(strict_types=1);

5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
9
10
 * 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.
11
 *
12
13
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
16
 * The TYPO3 project - inspiring people to share!
 */
Wouter Wolters's avatar
Wouter Wolters committed
17

18
19
namespace TYPO3\CMS\Core\Utility;

20
21
use TYPO3\CMS\Core\Core\Environment;

22
23
/**
 * Class to handle system commands.
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 * 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:
42
 * $TYPO3_CONF_VARS['GFX']['processor_path']
43
44
45
46
47
48
 * $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']
49
 */
50
51
52
53
54
class CommandUtility
{
    /**
     * Tells if object is already initialized
     */
55
    protected static bool $initialized = false;
56
57
58
59
60
61
62
63

    /**
     * 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'.
     *
64
     * @var array<string, array{app: string, path: string, valid: bool}>
65
     */
66
    protected static array $applications = [];
67
68
69
70

    /**
     * Paths where to search for applications
     *
71
72
73
     * 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>
74
     */
75
    protected static array $paths;
76
77
78
79

    /**
     * Wrapper function for php exec function
     *
80
     * Needs to be central to have better control and possible fix for issues
81
     */
82
    public static function exec(string $command, ?array &$output = null, int &$returnValue = 0): string
83
    {
84
        return exec($command, $output, $returnValue);
85
86
87
88
89
90
91
92
    }

    /**
     * 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)
93
     * @return string Compiled command that deals with ImageMagick & GraphicsMagick
94
     */
95
    public static function imageMagickCommand(string $command, string $parameters, string $path = ''): string
96
97
    {
        $gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX'];
98
        $isExt = Environment::isWindows() ? '.exe' : '';
99
        if (!$path) {
100
            $path = $gfxConf['processor_path'];
101
102
103
104
105
106
107
        }
        $path = GeneralUtility::fixWindowsFilePath($path);
        // This is only used internally, has no effect outside
        if ($command === 'combine') {
            $command = 'composite';
        }
        // Compile the path & command
108
        if ($gfxConf['processor'] === 'GraphicsMagick') {
109
110
            $path = self::escapeShellArgument($path . 'gm' . $isExt) . ' ' . self::escapeShellArgument($command);
        } else {
111
112
113
114
115
            if (Environment::isWindows() && !@is_file($path . $command . $isExt)) {
                $path = self::escapeShellArgument($path . 'magick' . $isExt) . ' ' . self::escapeShellArgument($command);
            } else {
                $path = self::escapeShellArgument($path . $command . $isExt);
            }
116
117
        }
        // strip profile information for thumbnails and reduce their size
118
        if ($parameters && $command !== 'identify') {
119
            // Determine whether the strip profile action has be disabled by TypoScript:
120
121
122
            if ($gfxConf['processor_stripColorProfileByDefault']
                && $gfxConf['processor_stripColorProfileCommand'] !== ''
                && $parameters !== '-version'
123
                && !str_contains($parameters, $gfxConf['processor_stripColorProfileCommand'])
124
                && !str_contains($parameters, '###SkipStripProfile###')
125
            ) {
126
127
128
                $parameters = $gfxConf['processor_stripColorProfileCommand'] . ' ' . $parameters;
            } else {
                $parameters = str_replace('###SkipStripProfile###', '', $parameters);
129
130
            }
        }
131
132
133
134
        // Add -auto-orient on convert so IM/GM respects the image orient
        if ($parameters && $command === 'convert') {
            $parameters = '-auto-orient ' . $parameters;
        }
135
136
137
138
        // set interlace parameter for convert command
        if ($command !== 'identify' && $gfxConf['processor_interlace']) {
            $parameters = '-interlace ' . $gfxConf['processor_interlace'] . ' ' . $parameters;
        }
139
        $cmdLine = $path . ' ' . $parameters;
140
141
        // It is needed to change the parameters order when a mask image has been specified
        if ($command === 'composite') {
142
            $paramsArr = self::unQuoteFilenames($parameters);
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
            $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"
158
159
     * @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
160
     */
161
    public static function checkCommand(string $cmd, string $handler = ''): bool|int
162
163
164
165
166
    {
        if (!self::init()) {
            return false;
        }

167
        if ($handler !== '' && !self::checkCommand($handler)) {
168
169
            return -1;
        }
170
        // Already checked and valid
171
        if (self::$applications[$cmd]['valid'] ?? false) {
172
173
            return true;
        }
174
        // Is set but was (above) not TRUE
175
176
177
178
179
180
181
        if (isset(self::$applications[$cmd]['valid'])) {
            return false;
        }

        foreach (self::$paths as $path => $validPath) {
            // Ignore invalid (FALSE) paths
            if ($validPath) {
182
                if (Environment::isWindows()) {
183
                    // Windows OS
184
                    // @todo Why is_executable() is not called here?
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
                    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;
                    }
                }
            }
        }

210
211
        // Try to get the executable with the command 'which'.
        // It does the same like already done, but maybe on other paths
212
        if (!Environment::isWindows()) {
213
            $cmd = @self::exec('which ' . self::escapeShellArgument($cmd));
214
215
            if (@is_executable($cmd)) {
                self::$applications[$cmd]['app'] = $cmd;
216
                self::$applications[$cmd]['path'] = PathUtility::dirname($cmd) . '/';
217
218
219
220
221
222
223
224
225
226
227
228
229
230
                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"
231
     * @return string|bool|int Returns command string, or FALSE if cmd is not found, or -1 if the handler is not found
232
     */
233
    public static function getCommand(string $cmd, string $handler = '', string $handlerOpt = ''): string|bool|int
234
235
236
237
238
    {
        if (!self::init()) {
            return false;
        }

239
        // Handler
240
241
242
243
244
245
        if ($handler) {
            $handler = self::getCommand($handler);

            if (!$handler) {
                return -1;
            }
246
            $handler .= ' ' . escapeshellcmd($handlerOpt) . ' ';
247
248
        }

249
        // Command
250
251
252
253
254
255
256
257
258
259
260
        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
     *
261
     * @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
262
     */
263
    public static function addPaths(string $paths): void
264
265
266
267
268
269
270
271
    {
        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
272
     * @return array<string, string|false> Array of search paths (empty if exec is disabled)
273
     */
274
    public static function getPaths(bool $addInvalid = false): array
275
276
    {
        if (!self::init()) {
277
            return [];
278
279
        }

280
281
282
        return $addInvalid
            ? self::$paths
            : array_filter(self::$paths);
283
284
285
286
287
    }

    /**
     * Initializes this class
     */
288
    protected static function init(): bool
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
    {
        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
     *
304
     * @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
305
     */
306
    protected static function initPaths(string $paths = ''): void
307
308
309
    {
        $doCheck = false;

310
        // Init global paths array if not already done
311
312
313
314
        if (!is_array(self::$paths)) {
            self::$paths = self::getPathsInternal();
            $doCheck = true;
        }
315
        // Merge the submitted paths array to the global
316
317
        if ($paths) {
            $paths = GeneralUtility::trimExplode(',', $paths, true);
318
319
320
321
322
323
324
325
326
327
            foreach ($paths as $path) {
                // Make absolute path of relative
                if (!str_starts_with($path, '/')) {
                    $path = Environment::getPublicPath() . '/' . $path;
                }
                if (!isset(self::$paths[$path])) {
                    if (@is_dir($path)) {
                        self::$paths[$path] = $path;
                    } else {
                        self::$paths[$path] = false;
328
329
330
331
                    }
                }
            }
        }
332
        // Check if new paths are invalid
333
334
335
        if ($doCheck) {
            foreach (self::$paths as $path => $valid) {
                // Ignore invalid (FALSE) paths
336
                if ($valid && !@is_dir($path)) {
337
338
339
340
341
342
343
344
345
                    self::$paths[$path] = false;
                }
            }
        }
    }

    /**
     * Processes and returns the paths from $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']
     *
346
     * @return array<string, array{app: string, path: string, valid: bool}> Array of commands and path
347
     */
348
    protected static function getConfiguredApps(): array
349
    {
350
        $cmdArr = [];
351
352

        if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']) {
353
            $binSetup = str_replace(['\'.chr(10).\'', '\' . LF . \''], LF, $GLOBALS['TYPO3_CONF_VARS']['SYS']['binSetup']);
354
355
356
357
358
            $pathSetup = preg_split('/[\n,]+/', $binSetup);
            foreach ($pathSetup as $val) {
                if (trim($val) === '') {
                    continue;
                }
359
                [$cmd, $cmdPath] = GeneralUtility::trimExplode('=', $val, true, 2);
360
361
                $cmdArr[$cmd]['app'] = PathUtility::basename($cmdPath);
                $cmdArr[$cmd]['path'] = PathUtility::dirname($cmdPath) . '/';
362
363
364
365
366
367
368
369
370
371
                $cmdArr[$cmd]['valid'] = true;
            }
        }

        return $cmdArr;
    }

    /**
     * Sets the search paths from different sources, internal
     *
372
     * @return array<string, string> Array of absolute paths (keys and values are equal)
373
     */
374
    protected static function getPathsInternal(): array
375
    {
376
377
        $pathsArr = [];
        $sysPathArr = [];
378

379
        // Image magick paths first
380
        if ($imPath = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_path']) {
381
382
383
384
            $imPath = self::fixPath($imPath);
            $pathsArr[$imPath] = $imPath;
        }

385
        // Add configured paths
386
387
388
389
390
391
392
393
        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;
            }
        }

394
        // Add path from environment
395
        if (!empty($GLOBALS['_SERVER']['PATH']) || !empty($GLOBALS['_SERVER']['Path'])) {
396
            $sep = Environment::isWindows() ? ';' : ':';
397
398
            $serverPath = $GLOBALS['_SERVER']['PATH'] ?? $GLOBALS['_SERVER']['Path'];
            $envPath = GeneralUtility::trimExplode($sep, $serverPath, true);
399
400
401
402
403
404
            foreach ($envPath as $val) {
                $val = self::fixPath($val);
                $sysPathArr[$val] = $val;
            }
        }

405
        // Set common paths for Unix (only)
406
        if (!Environment::isWindows()) {
407
            $sysPathArr = array_merge($sysPathArr, [
408
409
                '/usr/bin/' => '/usr/bin/',
                '/usr/local/bin/' => '/usr/local/bin/',
410
            ]);
411
412
        }

413
        return array_merge($pathsArr, $sysPathArr);
414
415
416
417
418
419
420
421
    }

    /**
     * Set a path to the right format
     *
     * @param string $path Input path
     * @return string Output path
     */
422
    protected static function fixPath(string $path): string
423
424
425
426
427
428
429
430
431
432
433
434
    {
        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
     */
435
    public static function escapeShellArguments(array $input): array
436
437
    {
        $isUTF8Filesystem = !empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']);
438
        $currentLocale = false;
439
        if ($isUTF8Filesystem) {
440
            $currentLocale = setlocale(LC_CTYPE, '0');
441
442
443
444
445
            setlocale(LC_CTYPE, $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemLocale']);
        }

        $output = array_map('escapeshellarg', $input);

446
        if ($isUTF8Filesystem && $currentLocale !== false) {
447
448
449
450
451
452
            setlocale(LC_CTYPE, $currentLocale);
        }

        return $output;
    }

453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
    /**
     * 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);
    }

483
484
485
486
487
488
489
490
    /**
     * 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
     */
491
    public static function escapeShellArgument(string $input): string
492
    {
493
        return self::escapeShellArguments([$input])[0];
494
    }
495
}