Completed
Push — master ( b29d6b...03436d )
by Bjørn
03:06
created

Cwebp::doActualConvert()   B

Complexity

Conditions 8
Paths 48

Size

Total Lines 41
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 8.0052

Importance

Changes 0
Metric Value
cc 8
eloc 22
nc 48
nop 0
dl 0
loc 41
ccs 22
cts 23
cp 0.9565
crap 8.0052
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
namespace WebPConvert\Convert\Converters;
4
5
use WebPConvert\Convert\BaseConverters\AbstractExecConverter;
6
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
7
use WebPConvert\Convert\Exceptions\ConversionFailedException;
8
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperationalException;
9
10
/**
11
 * Convert images to webp by calling cwebp binary.
12
 *
13
 * @package    WebPConvert
14
 * @author     Bjørn Rosell <[email protected]>
15
 * @since      Class available since Release 2.0.0
16
 */
17
class Cwebp extends AbstractExecConverter
18
{
19
    protected $supportsLossless = true;
20
21 8
    protected function getOptionDefinitionsExtra()
22
    {
23
        return [
24 8
            ['autofilter', 'boolean', false],
25
            ['command-line-options', 'string', ''],
26
            ['low-memory', 'boolean', false],
27
            ['method', 'number', 6],
28
            ['near-lossless', 'integer', 60],
29
            ['rel-path-to-precompiled-binaries', 'string', './Binaries'],
30
            ['size-in-percentage', 'number', null],
31
            ['try-common-system-paths', 'boolean', true],
32
            ['try-supplied-binary-for-os', 'boolean', true],
33
            ['use-nice', 'boolean', false],
34
        ];
35
    }
36
37
    // System paths to look for cwebp binary
38
    private static $cwebpDefaultPaths = [
39
        'cwebp',
40
        '/usr/bin/cwebp',
41
        '/usr/local/bin/cwebp',
42
        '/usr/gnu/bin/cwebp',
43
        '/usr/syno/bin/cwebp'
44
    ];
45
46
    // OS-specific binaries included in this library, along with hashes
47
    // If other binaries are going to be added, notice that the first argument is what PHP_OS returns.
48
    // (possible values, see here: https://stackoverflow.com/questions/738823/possible-values-for-php-os)
49
    private static $suppliedBinariesInfo = [
50
        'WINNT' => [ 'cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873'],
51
        'Darwin' => [ 'cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379'],
52
        'SunOS' => [ 'cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f'],
53
        'FreeBSD' => [ 'cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573'],
54
        'Linux' => [ 'cwebp-linux', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568']
55
    ];
56
57 3
    public function checkOperationality()
58
    {
59 3
        $options = $this->options;
60 3
        if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) {
61 1
            throw new ConverterNotOperationalException(
62
                'Configured to neither look for cweb binaries in common system locations, ' .
63
                'nor to use one of the supplied precompiled binaries. But these are the only ways ' .
64 1
                'this converter can convert images. No conversion can be made!'
65
            );
66
        }
67 2
    }
68
69 2
    private function executeBinary($binary, $commandOptions, $useNice)
70
    {
71 2
        $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions;
72
73
        //$logger->logLn('command options:' . $commandOptions);
74
        //$logger->logLn('Trying to execute binary:' . $binary);
75 2
        exec($command, $output, $returnCode);
76
        //$logger->logLn(self::msgForExitCode($returnCode));
77 2
        return intval($returnCode);
78
    }
79
80
    /**
81
     *  Use "escapeshellarg()" on all arguments in a commandline string of options
82
     *
83
     *  For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in:
84
     *  [
85
     *    "-sharpness '5'"
86
     *    "-crop '10' '10' '40' '40'"
87
     *    "-low_memory"
88
     *  ]
89
     * @param  string $commandLineOptions  string which can contain multiple commandline options
90
     * @return array  Array of command options
91
     */
92 1
    private static function escapeShellArgOnCommandLineOptions($commandLineOptions)
93
    {
94 1
        $cmdOptions = [];
95 1
        $arr = explode(' -', ' ' . $commandLineOptions);
96 1
        foreach ($arr as $cmdOption) {
97 1
            $pos = strpos($cmdOption, ' ');
98 1
            $cName = '';
99 1
            if (!$pos) {
100 1
                $cName = $cmdOption;
101 1
                if ($cName == '') {
102 1
                    continue;
103
                }
104 1
                $cmdOptions[] = '-' . $cName;
105
            } else {
106 1
                $cName = substr($cmdOption, 0, $pos);
107 1
                $cValues = substr($cmdOption, $pos + 1);
108 1
                $cValuesArr = explode(' ', $cValues);
109 1
                foreach ($cValuesArr as &$cArg) {
110 1
                    $cArg = escapeshellarg($cArg);
111
                }
112 1
                $cValues = implode(' ', $cValuesArr);
113 1
                $cmdOptions[] = '-' . $cName . ' ' . $cValues;
114
            }
115
        }
116 1
        return $cmdOptions;
117
    }
118
119
    /**
120
     * Build command line options
121
     *
122
     * @return string
123
     */
124 6
    private function createCommandLineOptions()
125
    {
126 6
        $options = $this->options;
127
128 6
        $cmdOptions = [];
129
130
        // Metadata (all, exif, icc, xmp or none (default))
131
        // Comma-separated list of existing metadata to copy from input to output
132 6
        $cmdOptions[] = '-metadata ' . $options['metadata'];
133
134
        // Size
135 6
        $addedSizeOption = false;
136 6
        if (!is_null($options['size-in-percentage'])) {
137 1
            $sizeSource = filesize($this->source);
138 1
            if ($sizeSource !== false) {
139 1
                $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100);
140 1
                $cmdOptions[] = '-size ' . $targetSize;
141 1
                $addedSizeOption = true;
142
            }
143
        }
144
145
        // quality
146 6
        if (!$addedSizeOption) {
147 5
            $cmdOptions[] = '-q ' . $this->getCalculatedQuality();
148
        }
149
150
        // Losless PNG conversion
151 6
        if ($options['lossless'] === true) {
152
            // No need to add -lossless when near-lossless is used
153 4
            if ($options['near-lossless'] === 100) {
154 1
                $cmdOptions[] = '-lossless';
155
            }
156
        }
157
158
        // Near-lossles
159 6
        if ($options['near-lossless'] !== 100) {
160
            // We only let near_lossless have effect when lossless is set.
161
            // otherwise lossless auto would not work as expected
162 5
            if ($options['lossless'] === true) {
163 3
                $cmdOptions[] ='-near_lossless ' . $options['near-lossless'];
164
            }
165
        }
166
167 6
        if ($options['autofilter'] === true) {
168 1
            $cmdOptions[] = '-af';
169
        }
170
171
        // Built-in method option
172 6
        $cmdOptions[] = '-m ' . strval($options['method']);
173
174
        // Built-in low memory option
175 6
        if ($options['low-memory']) {
176 1
            $cmdOptions[] = '-low_memory';
177
        }
178
179
        // command-line-options
180 6
        if ($options['command-line-options']) {
181 1
            array_push(
182 1
                $cmdOptions,
183 1
                ...self::escapeShellArgOnCommandLineOptions($options['command-line-options'])
0 ignored issues
show
Bug introduced by
The method escapeShellArgOnCommandLineOptions() does not exist on WebPConvert\Convert\Converters\Cwebp. ( Ignorable by Annotation )

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

183
                ...self::/** @scrutinizer ignore-call */ escapeShellArgOnCommandLineOptions($options['command-line-options'])

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
184
            );
185
        }
186
187
        // Source file
188 6
        $cmdOptions[] = escapeshellarg($this->source);
189
190
        // Output
191 6
        $cmdOptions[] = '-o ' . escapeshellarg($this->destination);
192
193
        // Redirect stderr to same place as stdout
194
        // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/
195 6
        $cmdOptions[] = '2>&1';
196
197 6
        $commandOptions = implode(' ', $cmdOptions);
198 6
        $this->logLn('command line options:' . $commandOptions);
199
200 6
        return $commandOptions;
201
    }
202
203
    /**
204
     *
205
     *
206
     * @return  string  Error message if failure, empty string if successful
207
     */
208 1
    private function composeErrorMessageForCommonSystemPathsFailures($failureCodes)
209
    {
210 1
        if (count($failureCodes) == 1) {
211 1
            switch ($failureCodes[0]) {
212 1
                case 126:
213
                    return 'Permission denied. The user that the command was run with (' .
214
                        shell_exec('whoami') . ') does not have permission to execute any of the ' .
215
                        'cweb binaries found in common system locations. ';
216 1
                case 127:
217 1
                    return 'Found no cwebp binaries in any common system locations. ';
218
                default:
219
                    return 'Tried executing cwebp binaries in common system locations. ' .
220
                        'All failed (exit code: ' . $failureCodes[0] . '). ';
221
            }
222
        } else {
223
            /**
224
             * $failureCodesBesides127 is used to check first position ($failureCodesBesides127[0])
225
             * however position can vary as index can be 1 or something else. array_values() would
226
             * always start from 0.
227
             */
228
            $failureCodesBesides127 = array_values(array_diff($failureCodes, [127]));
229
230
            if (count($failureCodesBesides127) == 1) {
231
                switch ($failureCodesBesides127[0]) {
232
                    case 126:
233
                        return 'Permission denied. The user that the command was run with (' .
234
                        shell_exec('whoami') . ') does not have permission to execute any of the cweb ' .
235
                        'binaries found in common system locations. ';
236
                        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...
237
                    default:
238
                        return 'Tried executing cwebp binaries in common system locations. ' .
239
                        'All failed (exit code: ' . $failureCodesBesides127[0] . '). ';
240
                }
241
            } else {
242
                return 'None of the cwebp binaries in the common system locations could be executed ' .
243
                '(mixed results - got the following exit codes: ' . implode(',', $failureCodes) . '). ';
244
            }
245
        }
246
    }
247
248
    /**
249
     * Try executing cwebp in common system paths
250
     *
251
     * @param  boolean  $useNice          Whether to use nice
252
     * @param  string   $commandOptions   for the exec call
253
     *
254
     * @return  array  Unique failure codes in case of failure, empty array in case of success
255
     */
256 1
    private function tryCommonSystemPaths($useNice, $commandOptions)
257
    {
258 1
        $errorMsg = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $errorMsg is dead and can be removed.
Loading history...
259
        //$failures = [];
260 1
        $failureCodes = [];
261
262
        // Loop through paths
263 1
        foreach (self::$cwebpDefaultPaths as $index => $binary) {
264 1
            $returnCode = $this->executeBinary($binary, $commandOptions, $useNice);
265 1
            if ($returnCode == 0) {
266
                $this->logLn('Successfully executed binary: ' . $binary);
267
                return [];
268
            } else {
269
                //$failures[] = [$binary, $returnCode];
270 1
                if ($returnCode == 127) {
271 1
                    $this->logLn(
272 1
                        'Trying to execute binary: ' . $binary . '. Failed (not found)'
273
                    );
274
                } else {
275
                    $this->logLn(
276
                        'Trying to execute binary: ' . $binary . '. Failed (return code: ' . $returnCode . ')'
277
                    );
278
                }
279 1
                if (!in_array($returnCode, $failureCodes)) {
280 1
                    $failureCodes[] = $returnCode;
281
                }
282
            }
283
        }
284 1
        return $failureCodes;
285
    }
286
287
    /**
288
     * Try executing supplied cwebp for PHP_OS.
289
     *
290
     * @param  boolean  $useNice          Whether to use nice
291
     * @param  string   $commandOptions   for the exec call
292
     * @param  array    $failureCodesForCommonSystemPaths  Return codes from the other attempt
293
     *                                                     (in order to produce short error message)
294
     *
295
     * @return  string  Error message if failure, empty string if successful
296
     */
297 2
    private function trySuppliedBinaryForOS($useNice, $commandOptions, $failureCodesForCommonSystemPaths)
298
    {
299 2
        $this->logLn('Trying to execute supplied binary for OS: ' . PHP_OS);
300
301
        // Try supplied binary (if available for OS, and hash is correct)
302 2
        $options = $this->options;
303 2
        if (!isset(self::$suppliedBinariesInfo[PHP_OS])) {
304
            return 'No supplied binaries found for OS:' . PHP_OS;
305
        }
306
307 2
        $info = self::$suppliedBinariesInfo[PHP_OS];
308
309 2
        $file = $info[0];
310 2
        $hash = $info[1];
311
312 2
        $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file;
313
314
315
        // The file should exist, but may have been removed manually.
316 2
        if (!file_exists($binaryFile)) {
317
            return 'Supplied binary not found! It ought to be here:' . $binaryFile;
318
        }
319
320
        // File exists, now generate its hash
321
322
        // hash_file() is normally available, but it is not always
323
        // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash
324
        // If available, validate that hash is correct.
325
326 2
        if (function_exists('hash_file')) {
327 2
            $binaryHash = hash_file('sha256', $binaryFile);
328
329 2
            if ($binaryHash != $hash) {
330
                return 'Binary checksum of supplied binary is invalid! ' .
331
                    'Did you transfer with FTP, but not in binary mode? ' .
332
                    'File:' . $binaryFile . '. ' .
333
                    'Expected checksum: ' . $hash . '. ' .
334
                    'Actual checksum:' . $binaryHash . '.';
335
            }
336
        }
337
338 2
        $returnCode = $this->executeBinary($binaryFile, $commandOptions, $useNice);
339 2
        if ($returnCode == 0) {
340
            // yay!
341 2
            $this->logLn('success!');
342 2
            return '';
343
        }
344
345
        $errorMsg = 'Tried executing supplied binary for ' . PHP_OS . ', ' .
346
            ($options['try-common-system-paths'] ? 'but that failed too' : 'but failed');
347
348
349
        if (($options['try-common-system-paths']) && (count($failureCodesForCommonSystemPaths) > 0)) {
350
            // check if it was the same error
351
            // if it was, simply refer to that with "(same problem)"
352
            $majorFailCode = 0;
353
            if (count($failureCodesForCommonSystemPaths) == 1) {
354
                $majorFailCode = $failureCodesForCommonSystemPaths[0];
355
            } else {
356
                $failureCodesBesides127 = array_values(array_diff($failureCodesForCommonSystemPaths, [127]));
357
                if (count($failureCodesBesides127) == 1) {
358
                    $majorFailCode = $failureCodesBesides127[0];
359
                } else {
360
                    // it cannot be summarized into a single code
361
                }
362
            }
363
            if ($majorFailCode != 0) {
364
                $errorMsg .= ' (same problem)';
365
                return $errorMsg;
366
            }
367
        }
368
369
        if ($returnCode > 128) {
370
            $errorMsg .= '. The binary did not work (exit code: ' . $returnCode . '). ' .
371
                'Check out https://github.com/rosell-dk/webp-convert/issues/92';
372
        } else {
373
            switch ($returnCode) {
374
                case 0:
375
                    // success!
376
                    break;
377
                case 126:
378
                    $errorMsg .= ': Permission denied. The user that the command was run' .
379
                        ' with (' . shell_exec('whoami') . ') does not have permission to ' .
380
                        'execute that binary.';
381
                    break;
382
                case 127:
383
                    $errorMsg .= '. The binary was not found! ' .
384
                        'It ought to be here: ' . $binaryFile;
385
                    break;
386
                default:
387
                    $errorMsg .= ' (exit code:' .  $returnCode . ').';
388
            }
389
        }
390
        return $errorMsg;
391
    }
392
393 2
    protected function doActualConvert()
394
    {
395 2
        $errorMsg = '';
396 2
        $options = $this->options;
397 2
        $useNice = (($options['use-nice']) && self::hasNiceSupport());
398
399 2
        $commandOptions = $this->createCommandLineOptions();
400
401
        // Try all common paths that exists
402 2
        $success = false;
403
404 2
        $failureCodes = [];
405
406 2
        if ($options['try-common-system-paths']) {
407 1
            $failureCodes = $this->tryCommonSystemPaths($useNice, $commandOptions);
408 1
            $success = (count($failureCodes) == 0);
409 1
            $errorMsg = $this->composeErrorMessageForCommonSystemPathsFailures($failureCodes);
410
        }
411
412 2
        if (!$success && $options['try-supplied-binary-for-os']) {
413 2
            $errorMsg2 = $this->trySuppliedBinaryForOS($useNice, $commandOptions, $failureCodes);
414 2
            $errorMsg .= $errorMsg2;
415 2
            $success = ($errorMsg2 == '');
416
        }
417
418
        // cwebp sets file permissions to 664 but instead ..
419
        // .. $destination's parent folder's permissions should be used (except executable bits)
420
        // (or perhaps the current umask instead? https://www.php.net/umask)
421
422 2
        if ($success) {
423 2
            $destinationParent = dirname($this->destination);
424 2
            $fileStatistics = stat($destinationParent);
425 2
            if ($fileStatistics !== false) {
426
                // Apply same permissions as parent folder but strip off the executable bits
427 2
                $permissions = $fileStatistics['mode'] & 0000666;
428 2
                chmod($this->destination, $permissions);
429
            }
430
        }
431
432 2
        if (!$success) {
433
            throw new SystemRequirementsNotMetException($errorMsg);
434
        }
435 2
    }
436
}
437