Completed
Push — master ( b28ce7...3bde8c )
by Bjørn
02:58
created

Cwebp::escapeShellArgOnCommandLineOptions()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 35
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 7.0957

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 7
eloc 24
c 3
b 1
f 0
nc 7
nop 1
dl 0
loc 35
ccs 21
cts 24
cp 0.875
crap 7.0957
rs 8.6026
1
<?php
2
3
namespace WebPConvert\Convert\Converters;
4
5
use WebPConvert\Convert\Converters\AbstractConverter;
6
use WebPConvert\Convert\Converters\ConverterTraits\EncodingAutoTrait;
7
use WebPConvert\Convert\Converters\ConverterTraits\ExecTrait;
8
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
9
use WebPConvert\Convert\Exceptions\ConversionFailedException;
10
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperationalException;
11
use WebPConvert\Options\BooleanOption;
12
use WebPConvert\Options\SensitiveStringOption;
13
use WebPConvert\Options\StringOption;
14
15
/**
16
 * Convert images to webp by calling cwebp binary.
17
 *
18
 * @package    WebPConvert
19
 * @author     Bjørn Rosell <[email protected]>
20
 * @since      Class available since Release 2.0.0
21
 */
22
class Cwebp extends AbstractConverter
23
{
24
25
    use EncodingAutoTrait;
26
    use ExecTrait;
27
28
    protected function getUnsupportedDefaultOptions()
29
    {
30
        return [];
31
    }
32
33 8
    protected function createOptions()
34
    {
35 8
        parent::createOptions();
36
37 8
        $this->options2->addOptions(
38 8
            new StringOption('command-line-options', ''),
39 8
            new SensitiveStringOption('rel-path-to-precompiled-binaries', './Binaries'),
40 8
            new BooleanOption('try-common-system-paths', true),
41 8
            new BooleanOption('try-supplied-binary-for-os', true)
42
        );
43 8
    }
44
45
    // System paths to look for cwebp binary
46
    private static $cwebpDefaultPaths = [
47
        'cwebp',
48
        '/usr/bin/cwebp',
49
        '/usr/local/bin/cwebp',
50
        '/usr/gnu/bin/cwebp',
51
        '/usr/syno/bin/cwebp'
52
    ];
53
54
    // OS-specific binaries included in this library, along with hashes
55
    // If other binaries are going to be added, notice that the first argument is what PHP_OS returns.
56
    // (possible values, see here: https://stackoverflow.com/questions/738823/possible-values-for-php-os)
57
    // Got the precompiled binaries here: https://developers.google.com/speed/webp/docs/precompiled
58
    private static $suppliedBinariesInfo = [
59
        'WINNT' => [['cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873']],
60
        'Darwin' => [['cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379']],
61
        'SunOS' => [['cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f']],
62
        'FreeBSD' => [['cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573']],
63
        'Linux' => [
64
            // Dynamically linked executable.
65
            // It seems it is slightly faster than the statically linked
66
            ['cwebp-linux-1.0.2-shared', 'd6142e9da2f1cab541de10a31527c597225fff5644e66e31d62bb391c41bfbf4'],
67
68
            // Statically linked executable
69
            // It may be that it on some systems works, where the dynamically linked does not (see #196)
70
            ['cwebp-linux-1.0.2-static', 'a67092563d9de0fbced7dde61b521d60d10c0ad613327a42a81845aefa612b29'],
71
72
            // Old executable for systems where both of the above fails
73
            ['cwebp-linux-0.6.1', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568'],
74
        ]
75
    ];
76
77 3
    public function checkOperationality()
78
    {
79 3
        $this->checkOperationalityExecTrait();
80
81 3
        $options = $this->options;
82 3
        if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) {
83 1
            throw new ConverterNotOperationalException(
84
                'Configured to neither look for cweb binaries in common system locations, ' .
85
                'nor to use one of the supplied precompiled binaries. But these are the only ways ' .
86 1
                'this converter can convert images. No conversion can be made!'
87
            );
88
        }
89 2
    }
90
91 2
    private function executeBinary($binary, $commandOptions, $useNice)
92
    {
93
        //$version = $this->detectVersion($binary);
94
95 2
        $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions;
96
97
        //$logger->logLn('command options:' . $commandOptions);
98 2
        $this->logLn('Trying to convert by executing the following command:');
99 2
        $this->logLn($command);
100 2
        exec($command, $output, $returnCode);
101 2
        $this->logExecOutput($output);
102
        /*
103
        if ($returnCode == 255) {
104
            if (isset($output[0])) {
105
                // Could be an error like 'Error! Cannot open output file' or 'Error! ...preset... '
106
                $this->logLn(print_r($output[0], true));
107
            }
108
        }*/
109
        //$logger->logLn(self::msgForExitCode($returnCode));
110 2
        return intval($returnCode);
111
    }
112
113
    /**
114
     *  Use "escapeshellarg()" on all arguments in a commandline string of options
115
     *
116
     *  For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in:
117
     *  [
118
     *    "-sharpness '5'"
119
     *    "-crop '10' '10' '40' '40'"
120
     *    "-low_memory"
121
     *  ]
122
     * @param  string $commandLineOptions  string which can contain multiple commandline options
123
     * @return array  Array of command options
124
     */
125 1
    private static function escapeShellArgOnCommandLineOptions($commandLineOptions)
126
    {
127 1
        if (!ctype_print($commandLineOptions)) {
128
            throw new ConversionFailedException(
129
                'Non-printable characters are not allowed in the extra command line options'
130
            );
131
        }
132
133 1
        if (preg_match('#[^a-zA-Z0-9_\s\-]#', $commandLineOptions)) {
134
            throw new ConversionFailedException('The extra command line options contains inacceptable characters');
135
        }
136
137 1
        $cmdOptions = [];
138 1
        $arr = explode(' -', ' ' . $commandLineOptions);
139 1
        foreach ($arr as $cmdOption) {
140 1
            $pos = strpos($cmdOption, ' ');
141 1
            $cName = '';
142 1
            if (!$pos) {
143 1
                $cName = $cmdOption;
144 1
                if ($cName == '') {
145 1
                    continue;
146
                }
147 1
                $cmdOptions[] = '-' . $cName;
148
            } else {
149 1
                $cName = substr($cmdOption, 0, $pos);
150 1
                $cValues = substr($cmdOption, $pos + 1);
151 1
                $cValuesArr = explode(' ', $cValues);
152 1
                foreach ($cValuesArr as &$cArg) {
153 1
                    $cArg = escapeshellarg($cArg);
154
                }
155 1
                $cValues = implode(' ', $cValuesArr);
156 1
                $cmdOptions[] = '-' . $cName . ' ' . $cValues;
157
            }
158
        }
159 1
        return $cmdOptions;
160
    }
161
162
    /**
163
     * Build command line options for a given version of cwebp.
164
     *
165
     * The "-near_lossless" param is not supported on older versions of cwebp, so skip on those.
166
     *
167
     * @param  string $version  Version of cwebp.
168
     * @return string
169
     */
170 6
    private function createCommandLineOptions($version)
171
    {
172
173 6
        $this->logLn('Creating command line options for version: ' . $version);
174
175
        // we only need two decimal places for version.
176
        // convert to number to make it easier to compare
177 6
        $version = preg_match('#^\d+\.\d+#', $version, $matches);
178 6
        $versionNum = 0;
179 6
        if (isset($matches[0])) {
180 6
            $versionNum = floatval($matches[0]);
181
        } else {
182
            $this->logLn(
183
                'Could not extract version number from the following version string: ' . $version,
184
                'bold'
185
            );
186
        }
187
188
        //$this->logLn('version:' . strval($versionNum));
189
190 6
        $options = $this->options;
191
192 6
        $cmdOptions = [];
193
194
        // Metadata (all, exif, icc, xmp or none (default))
195
        // Comma-separated list of existing metadata to copy from input to output
196 6
        if ($versionNum >= 0.3) {
197 6
            $cmdOptions[] = '-metadata ' . $options['metadata'];
198
        }
199
200
        // preset. Appears first in the list as recommended in the docs
201 6
        if (!is_null($options['preset'])) {
202 6
            if ($options['preset'] != 'none') {
203 1
                $cmdOptions[] = '-preset ' . $options['preset'];
204
            }
205
        }
206
207
        // Size
208 6
        $addedSizeOption = false;
209 6
        if (!is_null($options['size-in-percentage'])) {
210 1
            $sizeSource = filesize($this->source);
211 1
            if ($sizeSource !== false) {
212 1
                $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100);
213 1
                $cmdOptions[] = '-size ' . $targetSize;
214 1
                $addedSizeOption = true;
215
            }
216
        }
217
218
        // quality
219 6
        if (!$addedSizeOption) {
220 5
            $cmdOptions[] = '-q ' . $this->getCalculatedQuality();
221
        }
222
223
        // alpha-quality
224 6
        if ($this->options['alpha-quality'] !== 100) {
225 6
            $cmdOptions[] = '-alpha_q ' . escapeshellarg($this->options['alpha-quality']);
226
        }
227
228
        // Losless PNG conversion
229 6
        if ($options['encoding'] == 'lossless') {
230
            // No need to add -lossless when near-lossless is used (on version >= 0.5)
231 4
            if (($options['near-lossless'] === 100) || ($versionNum < 0.5)) {
232 1
                $cmdOptions[] = '-lossless';
233
            }
234
        }
235
236
        // Near-lossles
237 6
        if ($options['near-lossless'] !== 100) {
238 5
            if ($versionNum < 0.5) {
239
                $this->logLn(
240
                    'The near-lossless option is not supported on this (rather old) version of cwebp' .
241
                        '- skipping it.',
242
                    'italic'
243
                );
244
            } else {
245
                // We only let near_lossless have effect when encoding is set to "lossless"
246
                // otherwise encoding=auto would not work as expected
247
248 5
                if ($options['encoding'] == 'lossless') {
249 3
                    $cmdOptions[] ='-near_lossless ' . $options['near-lossless'];
250
                } else {
251 4
                    $this->logLn(
252 4
                        'The near-lossless option ignored for lossy'
253
                    );
254
                }
255
            }
256
        }
257
258 6
        if ($options['auto-filter'] === true) {
259 1
            $cmdOptions[] = '-af';
260
        }
261
262
        // Built-in method option
263 6
        $cmdOptions[] = '-m ' . strval($options['method']);
264
265
        // Built-in low memory option
266 6
        if ($options['low-memory']) {
267 1
            $cmdOptions[] = '-low_memory';
268
        }
269
270
        // command-line-options
271 6
        if ($options['command-line-options']) {
272
            /*
273
            In some years, we can use the splat instead (requires PHP 5.6)
274
            array_push(
275
                $cmdOptions,
276
                ...self::escapeShellArgOnCommandLineOptions($options['command-line-options'])
277
            );
278
            */
279 1
            foreach (self::escapeShellArgOnCommandLineOptions($options['command-line-options']) as $cmdLineOption) {
280 1
                array_push($cmdOptions, $cmdLineOption);
281
            }
282
283
        }
284
285
        // Source file
286 6
        $cmdOptions[] = escapeshellarg($this->source);
287
288
        // Output
289 6
        $cmdOptions[] = '-o ' . escapeshellarg($this->destination);
290
291
        // Redirect stderr to same place as stdout
292
        // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/
293 6
        $cmdOptions[] = '2>&1';
294
295 6
        $commandOptions = implode(' ', $cmdOptions);
296
        //$this->logLn('command line options:' . $commandOptions);
297
298 6
        return $commandOptions;
299
    }
300
301
    /**
302
     *  Get path for supplied binary for current OS - and validate hash.
303
     *
304
     *  @return  array  Array of supplied binaries (which actually exists, and where hash validates)
305
     */
306 2
    private function getSuppliedBinaryPathForOS()
307
    {
308 2
        $this->log('Checking if we have a supplied binary for OS: ' . PHP_OS . '... ');
309
310
        // Try supplied binary (if available for OS, and hash is correct)
311 2
        $options = $this->options;
312 2
        if (!isset(self::$suppliedBinariesInfo[PHP_OS])) {
313
            $this->logLn('No we dont - not for that OS');
314
            return [];
315
        }
316 2
        $this->logLn('We do.');
317
318 2
        $result = [];
319 2
        $files = self::$suppliedBinariesInfo[PHP_OS];
320 2
        if (count($files) > 0) {
321 2
            $this->logLn('We in fact have ' . count($files));
322
        }
323
324 2
        foreach ($files as $i => list($file, $hash)) {
325
            //$file = $info[0];
326
            //$hash = $info[1];
327
328 2
            $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file;
329
330
            // Replace "/./" with "/" in path (we could alternatively use realpath)
331
            //$binaryFile = preg_replace('#\/\.\/#', '/', $binaryFile);
332
            // The file should exist, but may have been removed manually.
333
            /*
334
            if (!file_exists($binaryFile)) {
335
                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
336
                return false;
337
            }*/
338
339 2
            $realPathResult = realpath($binaryFile);
340 2
            if ($realPathResult === false) {
341
                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
342
                continue;
343
            }
344 2
            $binaryFile = $realPathResult;
345
346
            // File exists, now generate its hash
347
            // hash_file() is normally available, but it is not always
348
            // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash
349
            // If available, validate that hash is correct.
350
351 2
            if (function_exists('hash_file')) {
352 2
                $binaryHash = hash_file('sha256', $binaryFile);
353
354 2
                if ($binaryHash != $hash) {
355
                    $this->logLn(
356
                        'Binary checksum of supplied binary is invalid! ' .
357
                        'Did you transfer with FTP, but not in binary mode? ' .
358
                        'File:' . $binaryFile . '. ' .
359
                        'Expected checksum: ' . $hash . '. ' .
360
                        'Actual checksum:' . $binaryHash . '.',
361
                        'bold'
362
                    );
363
                    continue;
364
                }
365
            }
366 2
            $result[] = $binaryFile;
367
        }
368
369 2
        return $result;
370
    }
371
372 2
    private function discoverBinaries()
373
    {
374 2
        $this->logLn('Locating cwebp binaries');
375
376 2
        if (defined('WEBPCONVERT_CWEBP_PATH')) {
377
            $this->logLn('WEBPCONVERT_CWEBP_PATH was defined, so using that path and ignoring any other');
378
            //$this->logLn('Value: "' . getenv('WEBPCONVERT_CWEBP_PATH') . '"');
379
            return [constant('WEBPCONVERT_CWEBP_PATH')];
380
        }
381 2
        if (!empty(getenv('WEBPCONVERT_CWEBP_PATH'))) {
382
            $this->logLn(
383
                'WEBPCONVERT_CWEBP_PATH environment variable was set, so using that path and ignoring any other'
384
            );
385
            //$this->logLn('Value: "' . getenv('WEBPCONVERT_CWEBP_PATH') . '"');
386
            return [getenv('WEBPCONVERT_CWEBP_PATH')];
387
        }
388
389 2
        $binaries = [];
390 2
        if ($this->options['try-common-system-paths']) {
391 1
            foreach (self::$cwebpDefaultPaths as $binary) {
392 1
                if (@file_exists($binary)) {
393 1
                    $binaries[] = $binary;
394
                }
395
            }
396 1
            if (count($binaries) == 0) {
397 1
                $this->logLn('No cwebp binaries where located in common system locations');
398
            } else {
399
                $this->logLn(strval(count($binaries)) . ' cwebp binaries found in common system locations');
400
            }
401
        }
402
        // TODO: exec('whereis cwebp');
403 2
        if ($this->options['try-supplied-binary-for-os']) {
404 2
            $suppliedBinaries = $this->getSuppliedBinaryPathForOS();
405 2
            foreach ($suppliedBinaries as $suppliedBinary) {
406 2
                $binaries[] = $suppliedBinary;
407
            }
408
        } else {
409
            $this->logLn('Configured not to try the cwebp binary that comes bundled with webp-convert');
410
        }
411
412 2
        if (count($binaries) == 0) {
413
            $this->logLn('No cwebp binaries to try!');
414
        }
415 2
        $this->logLn('A total of ' . strval(count($binaries)) . ' cwebp binaries where found');
416 2
        return $binaries;
417
    }
418
419
    /**
420
     *
421
     * @return  string|int  Version string (ie "1.0.2") OR return code, in case of failure
422
     */
423 2
    private function detectVersion($binary)
424
    {
425
        //$this->logLn('Examining binary: ' . $binary);
426 2
        $command = $binary . ' -version';
427 2
        $this->log('Executing: ' . $command);
428 2
        exec($command, $output, $returnCode);
429
430 2
        if ($returnCode == 0) {
431
            //$this->logLn('Success');
432 2
            if (isset($output[0])) {
433 2
                $this->logLn('. Result: version: ' . $output[0]);
434 2
                return $output[0];
435
            }
436
        } else {
437
            $this->logExecOutput($output);
438
            $this->logLn('');
439
            if ($returnCode == 127) {
440
                $this->logLn('Exec failed (the cwebp binary was not found at path: ' . $binary. ')');
441
            } else {
442
                $this->logLn(
443
                    'Exec failed (return code: ' . $returnCode . ')'
444
                );
445
                if ($returnCode == 126) {
446
                    $this->logLn(
447
                        'PS: Return code 126 means "Permission denied". The user that the command was run with does ' .
448
                            'not have permission to execute that binary.'
449
                    );
450
                    // TODO: further info: shell_exec('whoami')
451
                }
452
            }
453
            return $returnCode;
454
        }
455
    }
456
457
    /**
458
     *  Check versions for binaries, and return array (indexed by the binary, value being the version of the binary).
459
     *
460
     *  @return  array
461
     */
462 2
    private function detectVersions($binaries)
463
    {
464 2
        $binariesWithVersions = [];
465 2
        $binariesWithFailCodes = [];
466
467 2
        $this->logLn(
468 2
            'Detecting versions of the cwebp binaries found (and verifying that they can be executed in the process)'
469
        );
470 2
        foreach ($binaries as $binary) {
471 2
            $versionStringOrFailCode = $this->detectVersion($binary);
472
        //    $this->logLn($binary . ': ' . $versionString);
473 2
            if (gettype($versionStringOrFailCode) == 'string') {
474 2
                $binariesWithVersions[$binary] = $versionStringOrFailCode;
475
            } else {
476 2
                $binariesWithFailCodes[$binary] = $versionStringOrFailCode;
477
            }
478
        }
479 2
        return ['detected' => $binariesWithVersions, 'failed' => $binariesWithFailCodes];
480
    }
481
482
    /**
483
     * @return  boolean  success or not.
484
     */
485 2
    private function tryBinary($binary, $version, $useNice)
486
    {
487
488
        //$this->logLn('Trying binary: ' . $binary);
489 2
        $commandOptions = $this->createCommandLineOptions($version);
490
491 2
        $returnCode = $this->executeBinary($binary, $commandOptions, $useNice);
492 2
        if ($returnCode == 0) {
493
            // It has happened that even with return code 0, there was no file at destination.
494 2
            if (!file_exists($this->destination)) {
495
                $this->logLn('executing cweb returned success code - but no file was found at destination!');
496
                return false;
497
            } else {
498 2
                $this->logLn('Success');
499 2
                return true;
500
            }
501
        } else {
502
            $this->logLn(
503
                'Exec failed (return code: ' . $returnCode . ')'
504
            );
505
            return false;
506
        }
507
    }
508
509 2
    protected function doActualConvert()
510
    {
511 2
        $binaries = $this->discoverBinaries();
512
513 2
        if (count($binaries) == 0) {
514
            throw new SystemRequirementsNotMetException(
515
                'No cwebp binaries located. Check the conversion log for details.'
516
            );
517
        }
518
519 2
        $versions = $this->detectVersions($binaries);
520 2
        if (count($versions['detected']) == 0) {
521
            //$this->logLn('None of the cwebp files located can be executed.');
522
            if (count($binaries) == 1) {
523
                $errorMsg = 'The cwebp file found cannot be can be executed.';
524
            } else {
525
                $errorMsg = 'None of the cwebp files located can be executed.';
526
            }
527
            $uniqueFailCodes = array_unique(array_values($versions['failed']));
528
            if (count($uniqueFailCodes) == 1) {
529
                $errorMsg .= ' ' . (count($binaries) == 1 ? 'It' : 'All') .
530
                    ' failed with return code ' . $uniqueFailCodes[0];
531
                if ($uniqueFailCodes[0] == 126) {
532
                    $errorMsg .= ' (permission denied)';
533
                }
534
            } else {
535
                $errorMsg .= ' Failure codes : ' . implode(', ', $uniqueFailCodes);
536
            }
537
538
            throw new SystemRequirementsNotMetException($errorMsg);
539
        }
540
541 2
        $binaryVersions = $versions['detected'];
542
543 2
        if (count($binaries) > 1) {
544 2
            $this->logLn(
545 2
                'Trying executing the cwebs found until success. Starting with the ones with highest version number.'
546
            );
547
        }
548
        //$this->logLn('binary versions: ' . print_r($binaryVersions, true));
549
550
        // Sort binaries so those with highest numbers comes first
551 2
        arsort($binaryVersions);
552
553
        //$this->logLn('binary versions (ordered by version): ' . print_r($binaryVersions, true));
554
555 2
        $useNice = (($this->options['use-nice']) && self::hasNiceSupport());
556
557 2
        $success = false;
558 2
        foreach ($binaryVersions as $binary => $version) {
559 2
            if ($this->tryBinary($binary, $version, $useNice)) {
560 2
                $success = true;
561 2
                break;
562
            }
563
        }
564
565
        // cwebp sets file permissions to 664 but instead ..
566
        // .. $destination's parent folder's permissions should be used (except executable bits)
567
        // (or perhaps the current umask instead? https://www.php.net/umask)
568
569 2
        if ($success) {
570 2
            $destinationParent = dirname($this->destination);
571 2
            $fileStatistics = stat($destinationParent);
572 2
            if ($fileStatistics !== false) {
573
                // Apply same permissions as parent folder but strip off the executable bits
574 2
                $permissions = $fileStatistics['mode'] & 0000666;
575 2
                chmod($this->destination, $permissions);
576
            }
577
        } else {
578
            throw new SystemRequirementsNotMetException('Failed converting. Check the conversion log for details.');
579
        }
580 2
    }
581
}
582