Test Failed
Push — master ( 975525...307699 )
by Bjørn
02:39
created

Cwebp::getUnsupportedDefaultOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 1
cts 1
cp 1
crap 1
rs 10
c 0
b 0
f 0
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 8
    use EncodingAutoTrait;
26
    use ExecTrait;
27
28 8
    protected function getUnsupportedDefaultOptions()
29
    {
30
        return [];
31
    }
32
33
    protected function createOptions()
34
    {
35
        parent::createOptions();
36
37
        $this->options2->addOptions(
38
            new StringOption('command-line-options', ''),
39
            new SensitiveStringOption('rel-path-to-precompiled-binaries', './Binaries'),
40
            new BooleanOption('try-common-system-paths', true),
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $defaultValue of WebPConvert\Options\BooleanOption::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

40
            new BooleanOption('try-common-system-paths', /** @scrutinizer ignore-type */ true),
Loading history...
41
            new BooleanOption('try-supplied-binary-for-os', true),
42
        );
43
    }
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 3
    // 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 3
    private static $suppliedBinariesInfo = [
58
        'WINNT' => [ 'cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873'],
59 3
        'Darwin' => [ 'cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379'],
60 3
        'SunOS' => [ 'cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f'],
61 1
        'FreeBSD' => [ 'cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573'],
62
        'Linux' => [ 'cwebp-linux', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568']
63
    ];
64 1
65
    public function checkOperationality()
66
    {
67 2
        $this->checkOperationalityExecTrait();
68
69 2
        $options = $this->options;
70
        if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) {
71 2
            throw new ConverterNotOperationalException(
72
                'Configured to neither look for cweb binaries in common system locations, ' .
73
                'nor to use one of the supplied precompiled binaries. But these are the only ways ' .
74
                'this converter can convert images. No conversion can be made!'
75 2
            );
76
        }
77 2
    }
78
79
    private function executeBinary($binary, $commandOptions, $useNice)
80
    {
81
        $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions;
82
83
        //$logger->logLn('command options:' . $commandOptions);
84
        //$logger->logLn('Trying to execute binary:' . $binary);
85
        exec($command, $output, $returnCode);
86
        if ($returnCode == 255) {
87
            if (isset($output[0])) {
88
                // Could be an error like 'Error! Cannot open output file' or 'Error! ...preset... '
89
                $this->logLn(print_r($output[0], true));
90
            }
91
        }
92 1
        //$logger->logLn(self::msgForExitCode($returnCode));
93
        return intval($returnCode);
94 1
    }
95 1
96 1
    /**
97 1
     *  Use "escapeshellarg()" on all arguments in a commandline string of options
98 1
     *
99 1
     *  For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in:
100 1
     *  [
101 1
     *    "-sharpness '5'"
102 1
     *    "-crop '10' '10' '40' '40'"
103
     *    "-low_memory"
104 1
     *  ]
105
     * @param  string $commandLineOptions  string which can contain multiple commandline options
106 1
     * @return array  Array of command options
107 1
     */
108 1
    private static function escapeShellArgOnCommandLineOptions($commandLineOptions)
109 1
    {
110 1
        $cmdOptions = [];
111
        $arr = explode(' -', ' ' . $commandLineOptions);
112 1
        foreach ($arr as $cmdOption) {
113 1
            $pos = strpos($cmdOption, ' ');
114
            $cName = '';
115
            if (!$pos) {
116 1
                $cName = $cmdOption;
117
                if ($cName == '') {
118
                    continue;
119
                }
120
                $cmdOptions[] = '-' . $cName;
121
            } else {
122
                $cName = substr($cmdOption, 0, $pos);
123
                $cValues = substr($cmdOption, $pos + 1);
124 6
                $cValuesArr = explode(' ', $cValues);
125
                foreach ($cValuesArr as &$cArg) {
126 6
                    $cArg = escapeshellarg($cArg);
127
                }
128 6
                $cValues = implode(' ', $cValuesArr);
129
                $cmdOptions[] = '-' . $cName . ' ' . $cValues;
130
            }
131
        }
132 6
        return $cmdOptions;
133
    }
134
135 6
    /**
136 1
     * Build command line options
137
     *
138
     * @return string
139
     */
140 6
    private function createCommandLineOptions()
141 6
    {
142 1
        $options = $this->options;
143 1
144 1
        $cmdOptions = [];
145 1
146 1
        // Metadata (all, exif, icc, xmp or none (default))
147
        // Comma-separated list of existing metadata to copy from input to output
148
        $cmdOptions[] = '-metadata ' . $options['metadata'];
149
150
        // preset. Appears first in the list as recommended in the docs
151 6
        if (!is_null($options['preset'])) {
152 5
            if ($options['preset'] != 'none') {
153
                $cmdOptions[] = '-preset ' . $options['preset'];
154
            }
155
        }
156 6
157 6
        // Size
158
        $addedSizeOption = false;
159
        if (!is_null($options['size-in-percentage'])) {
160
            $sizeSource = filesize($this->source);
161 6
            if ($sizeSource !== false) {
162
                $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100);
163 4
                $cmdOptions[] = '-size ' . $targetSize;
164 1
                $addedSizeOption = true;
165
            }
166
        }
167
168
        // quality
169 6
        if (!$addedSizeOption) {
170
            $cmdOptions[] = '-q ' . $this->getCalculatedQuality();
171
        }
172 5
173 3
        // alpha-quality
174
        if ($this->options['alpha-quality'] !== 100) {
175
            $cmdOptions[] = '-alpha_q ' . escapeshellarg($this->options['alpha-quality']);
176
        }
177 6
178 1
        // Losless PNG conversion
179
        if ($options['encoding'] == 'lossless') {
180
            // No need to add -lossless when near-lossless is used
181
            if ($options['near-lossless'] === 100) {
182 6
                $cmdOptions[] = '-lossless';
183
            }
184
        }
185 6
186 1
        // Near-lossles
187
        if ($options['near-lossless'] !== 100) {
188
            // We only let near_lossless have effect when encoding is set to "lossless"
189
            // otherwise encoding=auto would not work as expected
190 6
            if ($options['encoding'] == 'lossless') {
191 1
                $cmdOptions[] ='-near_lossless ' . $options['near-lossless'];
192 1
            }
193 1
        }
194
195
        if ($options['auto-filter'] === true) {
196
            $cmdOptions[] = '-af';
197
        }
198 6
199
        // Built-in method option
200
        $cmdOptions[] = '-m ' . strval($options['method']);
201 6
202
        // Built-in low memory option
203
        if ($options['low-memory']) {
204
            $cmdOptions[] = '-low_memory';
205 6
        }
206
207 6
        // command-line-options
208 6
        if ($options['command-line-options']) {
209
            array_push(
210 6
                $cmdOptions,
211
                ...self::escapeShellArgOnCommandLineOptions($options['command-line-options'])
212
            );
213
        }
214
215
        // Source file
216
        $cmdOptions[] = escapeshellarg($this->source);
217
218 1
        // Output
219
        $cmdOptions[] = '-o ' . escapeshellarg($this->destination);
220 1
221 1
        // Redirect stderr to same place as stdout
222 1
        // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/
223
        $cmdOptions[] = '2>&1';
224
225
        $commandOptions = implode(' ', $cmdOptions);
226 1
        $this->logLn('command line options:' . $commandOptions);
227 1
228
        return $commandOptions;
229
    }
230
231
    /**
232
     *
233
     *
234
     * @return  string  Error message if failure, empty string if successful
235
     */
236
    private function composeErrorMessageForCommonSystemPathsFailures($failureCodes)
237
    {
238
        if (count($failureCodes) == 1) {
239
            switch ($failureCodes[0]) {
240
                case 126:
241
                    return 'Permission denied. The user that the command was run with (' .
242
                        shell_exec('whoami') . ') does not have permission to execute any of the ' .
243
                        'cweb binaries found in common system locations. ';
244
                case 127:
245
                    return 'Found no cwebp binaries in any common system locations. ';
246
                default:
247
                    return 'Tried executing cwebp binaries in common system locations. ' .
248
                        'All failed (exit code: ' . $failureCodes[0] . '). ';
249
            }
250
        } else {
251
            /**
252
             * $failureCodesBesides127 is used to check first position ($failureCodesBesides127[0])
253
             * however position can vary as index can be 1 or something else. array_values() would
254
             * always start from 0.
255
             */
256
            $failureCodesBesides127 = array_values(array_diff($failureCodes, [127]));
257
258
            if (count($failureCodesBesides127) == 1) {
259
                switch ($failureCodesBesides127[0]) {
260
                    case 126:
261
                        return 'Permission denied. The user that the command was run with (' .
262
                        shell_exec('whoami') . ') does not have permission to execute any of the cweb ' .
263
                        'binaries found in common system locations. ';
264
                        break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
265
                    default:
266 1
                        return 'Tried executing cwebp binaries in common system locations. ' .
267
                        'All failed (exit code: ' . $failureCodesBesides127[0] . '). ';
268 1
                }
269
            } else {
270
                return 'None of the cwebp binaries in the common system locations could be executed ' .
271 1
                '(mixed results - got the following exit codes: ' . implode(',', $failureCodes) . '). ';
272 1
            }
273 1
        }
274
    }
275
276
    /**
277 1
     * Try executing cwebp in common system paths
278 1
     *
279 1
     * @param  boolean  $useNice          Whether to use nice
280
     * @param  string   $commandOptions   for the exec call
281
     *
282
     * @return  array  Unique failure codes in case of failure, empty array in case of success
283
     */
284
    private function tryCommonSystemPaths($useNice, $commandOptions)
285
    {
286 1
        $failureCodes = [];
287 1
288
        // Loop through paths
289
        foreach (self::$cwebpDefaultPaths as $index => $binary) {
290
            $returnCode = $this->executeBinary($binary, $commandOptions, $useNice);
291 1
            if ($returnCode == 0) {
292
                $this->logLn('Successfully executed binary: ' . $binary);
293
                return [];
294
            } else {
295
                if ($returnCode == 127) {
296
                    $this->logLn(
297
                        'Trying to execute binary: ' . $binary . '. Failed (not found)'
298
                    );
299
                } else {
300
                    $this->logLn(
301
                        'Trying to execute binary: ' . $binary . '. Failed (return code: ' . $returnCode . ')'
302
                    );
303
                }
304 2
                if (!in_array($returnCode, $failureCodes)) {
305
                    $failureCodes[] = $returnCode;
306 2
                }
307
            }
308
        }
309 2
        return $failureCodes;
310 2
    }
311
312
    /**
313
     * Try executing supplied cwebp for PHP_OS.
314 2
     *
315
     * @param  boolean  $useNice          Whether to use nice
316 2
     * @param  string   $commandOptions   for the exec call
317 2
     * @param  array    $failureCodesForCommonSystemPaths  Return codes from the other attempt
318
     *                                                     (in order to produce short error message)
319 2
     *
320
     * @return  string  Error message if failure, empty string if successful
321
     */
322
    private function trySuppliedBinaryForOS($useNice, $commandOptions, $failureCodesForCommonSystemPaths)
323 2
    {
324
        $this->logLn('Trying to execute supplied binary for OS: ' . PHP_OS);
325
326
        // Try supplied binary (if available for OS, and hash is correct)
327
        $options = $this->options;
328
        if (!isset(self::$suppliedBinariesInfo[PHP_OS])) {
329
            return 'No supplied binaries found for OS:' . PHP_OS;
330
        }
331
332
        $info = self::$suppliedBinariesInfo[PHP_OS];
333 2
334 2
        $file = $info[0];
335
        $hash = $info[1];
336 2
337
        $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file;
338
339
340
        // The file should exist, but may have been removed manually.
341
        if (!file_exists($binaryFile)) {
342
            return 'Supplied binary not found! It ought to be here:' . $binaryFile;
343
        }
344
345 2
        // File exists, now generate its hash
346 2
347
        // hash_file() is normally available, but it is not always
348 2
        // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash
349 2
        // If available, validate that hash is correct.
350
351
        if (function_exists('hash_file')) {
352
            $binaryHash = hash_file('sha256', $binaryFile);
353
354
            if ($binaryHash != $hash) {
355
                return 'Binary checksum of supplied binary is invalid! ' .
356
                    'Did you transfer with FTP, but not in binary mode? ' .
357
                    'File:' . $binaryFile . '. ' .
358
                    'Expected checksum: ' . $hash . '. ' .
359
                    'Actual checksum:' . $binaryHash . '.';
360
            }
361
        }
362
363
        $returnCode = $this->executeBinary($binaryFile, $commandOptions, $useNice);
364
        if ($returnCode == 0) {
365
            // yay!
366
            $this->logLn('success!');
367
            return '';
368
        }
369
370
        $errorMsg = 'Tried executing supplied binary for ' . PHP_OS . ', ' .
371
            ($options['try-common-system-paths'] ? 'but that failed too' : 'but failed');
372
373
374
        if (($options['try-common-system-paths']) && (count($failureCodesForCommonSystemPaths) > 0)) {
375
            // check if it was the same error
376
            // if it was, simply refer to that with "(same problem)"
377
            $majorFailCode = 0;
378
            if (count($failureCodesForCommonSystemPaths) == 1) {
379
                $majorFailCode = $failureCodesForCommonSystemPaths[0];
380
            } else {
381
                $failureCodesBesides127 = array_values(array_diff($failureCodesForCommonSystemPaths, [127]));
382
                if (count($failureCodesBesides127) == 1) {
383
                    $majorFailCode = $failureCodesBesides127[0];
384
                } else {
385
                    // it cannot be summarized into a single code
386
                }
387
            }
388
            if ($majorFailCode != 0) {
389
                $errorMsg .= ' (same problem)';
390
                return $errorMsg;
391
            }
392
        }
393
394
        if ($returnCode > 128) {
395
            $errorMsg .= '. The binary did not work (exit code: ' . $returnCode . '). ' .
396
                'Check out https://github.com/rosell-dk/webp-convert/issues/92';
397
        } else {
398
            switch ($returnCode) {
399
                case 0:
400 2
                    // success!
401
                    break;
402 2
                case 126:
403 2
                    $errorMsg .= ': Permission denied. The user that the command was run' .
404 2
                        ' with (' . shell_exec('whoami') . ') does not have permission to ' .
405
                        'execute that binary.';
406 2
                    break;
407
                case 127:
408
                    $errorMsg .= '. The binary was not found! ' .
409 2
                        'It ought to be here: ' . $binaryFile;
410
                    break;
411 2
                default:
412
                    $errorMsg .= ' (exit code:' .  $returnCode . ').';
413 2
            }
414 1
        }
415 1
        return $errorMsg;
416 1
    }
417
418
    protected function doActualConvert()
419 2
    {
420 2
        $errorMsg = '';
421 2
        $options = $this->options;
422 2
        $useNice = (($options['use-nice']) && self::hasNiceSupport());
423
424
        $commandOptions = $this->createCommandLineOptions();
425
426
        // Try all common paths that exists
427
        $success = false;
428
429 2
        $failureCodes = [];
430 2
431 2
        if ($options['try-common-system-paths']) {
432 2
            $failureCodes = $this->tryCommonSystemPaths($useNice, $commandOptions);
433
            $success = (count($failureCodes) == 0);
434 2
            $errorMsg = $this->composeErrorMessageForCommonSystemPathsFailures($failureCodes);
435 2
        }
436
437
        if (!$success && $options['try-supplied-binary-for-os']) {
438
            $errorMsg2 = $this->trySuppliedBinaryForOS($useNice, $commandOptions, $failureCodes);
439 2
            $errorMsg .= $errorMsg2;
440
            $success = ($errorMsg2 == '');
441
        }
442 2
443
        // cwebp sets file permissions to 664 but instead ..
444
        // .. $destination's parent folder's permissions should be used (except executable bits)
445
        // (or perhaps the current umask instead? https://www.php.net/umask)
446
447
        if ($success) {
448
            $destinationParent = dirname($this->destination);
449
            $fileStatistics = stat($destinationParent);
450
            if ($fileStatistics !== false) {
451
                // Apply same permissions as parent folder but strip off the executable bits
452
                $permissions = $fileStatistics['mode'] & 0000666;
453
                chmod($this->destination, $permissions);
454
            }
455
        }
456
457
        if (!$success) {
458
            throw new SystemRequirementsNotMetException($errorMsg);
459
        }
460
    }
461
}
462