Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.02% covered (warning)
57.02%
69 / 121
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpc
57.02% covered (warning)
57.02%
69 / 121
18.18% covered (danger)
18.18%
2 / 11
230.87
0.00% covered (danger)
0.00%
0 / 1
 getUnsupportedDefaultOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUniqueOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 supportsLossless
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 passOnEncodingAuto
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRandomSaltForBlowfish
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getApiKey
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
11.60
 getApiUrl
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 checkOperationality
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
31.75
 createOptionsToSend
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 createPostData
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
4.30
 doActualConvert
54.76% covered (warning)
54.76%
23 / 42
0.00% covered (danger)
0.00%
0 / 1
25.33
1<?php
2
3namespace WebPConvert\Convert\Converters;
4
5use WebPConvert\Convert\Converters\AbstractConverter;
6use WebPConvert\Convert\Converters\ConverterTraits\CloudConverterTrait;
7use WebPConvert\Convert\Converters\ConverterTraits\CurlTrait;
8use WebPConvert\Convert\Converters\ConverterTraits\EncodingAutoTrait;
9use WebPConvert\Convert\Exceptions\ConversionFailedException;
10use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperationalException;
11use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
12use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\InvalidApiKeyException;
13use WebPConvert\Options\BooleanOption;
14use WebPConvert\Options\IntegerOption;
15use WebPConvert\Options\SensitiveStringOption;
16use WebPConvert\Options\OptionFactory;
17
18/**
19 * Convert images to webp using Wpc (a cloud converter based on WebP Convert).
20 *
21 * @package    WebPConvert
22 * @author     Bjørn Rosell <it@rosell.dk>
23 * @since      Class available since Release 2.0.0
24 */
25class Wpc extends AbstractConverter
26{
27    use CloudConverterTrait;
28    use CurlTrait;
29    use EncodingAutoTrait;
30
31    protected function getUnsupportedDefaultOptions()
32    {
33        return [];
34    }
35
36    public function getUniqueOptions($imageType)
37    {
38        return OptionFactory::createOptions([
39            ['api-key', 'string', [
40               'title' => 'API key',
41               'description' => 'The API key is set up on the remote. Copy that.',
42               'default' => '',
43               'sensitive' => true,
44               'ui' => [
45                   'component' => 'password',
46                   'advanced' => false,
47                   'display' => "option('wpc-api-version') != 0"
48               ]
49            ]],
50            ['secret', 'string', [
51               'title' => 'Secret',
52               'description' => '',
53               'default' => '',
54               'sensitive' => true,
55               'ui' => [
56                   'component' => 'password',
57                   'advanced' => false,
58                   'display' => "option('wpc-api-version') == 0"
59               ]
60            ]],
61            ['api-url', 'string', [
62               'title' => 'API url',
63               'description' => 'The endpoint of the web service. Copy it from the remote setup',
64               'default' => '',
65               'sensitive' => true,
66               'ui' => [
67                   'component' => 'password',
68                   'advanced' => false,
69               ]
70            ]],
71            ['api-version', 'int', [
72               'title' => 'API version',
73               'description' =>
74                    'Refers to the major version of Wpc. ' .
75                    'It is probably 2, as it is a long time since 2.0 was released',
76               'default' => 2,
77               'minimum' => 0,
78               'maximum' => 2,
79               'ui' => [
80                   'component' => 'select',
81                   'advanced' => false,
82                   'options' => [0, 1, 2],
83               ]
84            ]],
85            ['crypt-api-key-in-transfer', 'boolean', [
86               'title' => 'Crypt API key in transfer',
87               'description' =>
88                  'If checked, the api key will be crypted in requests. ' .
89                  'Crypting the api-key protects it from being stolen during transfer',
90               'default' => false,
91               'ui' => [
92                   'component' => 'checkbox',
93                   'advanced' => true,
94                   'display' => "option('wpc-api-version') >= 1"
95               ]
96            ]],
97        ]);
98
99        /*return [
100            new SensitiveStringOption('api-key', ''),
101            new SensitiveStringOption('secret', ''),
102            new SensitiveStringOption('api-url', ''),
103            new SensitiveStringOption('url', ''),       // DO NOT USE. Only here to keep the protection
104            new IntegerOption('api-version', 2, 0, 2),
105            new BooleanOption('crypt-api-key-in-transfer', false)  // new in api v.1
106        ];*/
107    }
108
109    public function supportsLossless()
110    {
111        return ($this->options['api-version'] >= 2);
112    }
113
114    public function passOnEncodingAuto()
115    {
116        // We could make this configurable. But I guess passing it on is always to be preferred
117        // for api >= 2.
118        return ($this->options['api-version'] >= 2);
119    }
120
121    private static function createRandomSaltForBlowfish()
122    {
123        $salt = '';
124        $validCharsForSalt = array_merge(
125            range('A', 'Z'),
126            range('a', 'z'),
127            range('0', '9'),
128            ['.', '/']
129        );
130
131        for ($i = 0; $i < 22; $i++) {
132            $salt .= $validCharsForSalt[array_rand($validCharsForSalt)];
133        }
134        return $salt;
135    }
136
137    /**
138     * Get api key from options or environment variable
139     *
140     * @return string  api key or empty string if none is set
141     */
142    private function getApiKey()
143    {
144        if ($this->options['api-version'] == 0) {
145            if (!empty($this->options['secret'])) {
146                return $this->options['secret'];
147            }
148        } elseif ($this->options['api-version'] >= 1) {
149            if (!empty($this->options['api-key'])) {
150                return $this->options['api-key'];
151            }
152        }
153        if (defined('WEBPCONVERT_WPC_API_KEY')) {
154            return constant('WEBPCONVERT_WPC_API_KEY');
155        }
156        if (!empty(getenv('WEBPCONVERT_WPC_API_KEY'))) {
157            return getenv('WEBPCONVERT_WPC_API_KEY');
158        }
159        return '';
160    }
161
162    /**
163     * Get url from options or environment variable
164     *
165     * @return string  URL to WPC or empty string if none is set
166     */
167    private function getApiUrl()
168    {
169        if (!empty($this->options['api-url'])) {
170            return $this->options['api-url'];
171        }
172        if (defined('WEBPCONVERT_WPC_API_URL')) {
173            return constant('WEBPCONVERT_WPC_API_URL');
174        }
175        if (!empty(getenv('WEBPCONVERT_WPC_API_URL'))) {
176            return getenv('WEBPCONVERT_WPC_API_URL');
177        }
178        return '';
179    }
180
181
182    /**
183     * Check operationality of Wpc converter.
184     *
185     * @throws SystemRequirementsNotMetException  if system requirements are not met (curl)
186     * @throws ConverterNotOperationalException   if key is missing or invalid, or quota has exceeded
187     */
188    public function checkOperationality()
189    {
190
191        $options = $this->options;
192
193        $apiVersion = $options['api-version'];
194
195        if ($this->getApiUrl() == '') {
196            if (isset($this->options['url']) && ($this->options['url'] != '')) {
197                throw new ConverterNotOperationalException(
198                    'The "url" option has been renamed to "api-url" in webp-convert 2.0. ' .
199                    'You must change the configuration accordingly.'
200                );
201            }
202            throw new ConverterNotOperationalException(
203                'Missing URL. You must install Webp Convert Cloud Service on a server, ' .
204                'or the WebP Express plugin for Wordpress - and supply the url.'
205            );
206        }
207
208        if ($apiVersion == 0) {
209            if (!empty($this->getApiKey())) {
210                // if secret is set, we need md5() and md5_file() functions
211                if (!function_exists('md5')) {
212                    throw new ConverterNotOperationalException(
213                        'A secret has been set, which requires us to create a md5 hash from the secret and the file ' .
214                        'contents. ' .
215                        'But the required md5() PHP function is not available.'
216                    );
217                }
218                if (!function_exists('md5_file')) {
219                    throw new ConverterNotOperationalException(
220                        'A secret has been set, which requires us to create a md5 hash from the secret and the file ' .
221                        'contents. But the required md5_file() PHP function is not available.'
222                    );
223                }
224            }
225        } else {
226            if ($options['crypt-api-key-in-transfer']) {
227                if (!function_exists('crypt')) {
228                    throw new ConverterNotOperationalException(
229                        'Configured to crypt the api-key, but crypt() function is not available.'
230                    );
231                }
232
233                if (!defined('CRYPT_BLOWFISH')) {
234                    throw new ConverterNotOperationalException(
235                        'Configured to crypt the api-key. ' .
236                        'That requires Blowfish encryption, which is not available on your current setup.'
237                    );
238                }
239            }
240        }
241
242        // Check for curl requirements
243        $this->checkOperationalityForCurlTrait();
244    }
245
246    /*
247    public function checkConvertability()
248    {
249        // check upload limits
250        $this->checkConvertabilityCloudConverterTrait();
251
252        // TODO: some from below can be moved up here
253    }
254    */
255
256    private function createOptionsToSend()
257    {
258        $optionsToSend = $this->options;
259
260        if ($this->isQualityDetectionRequiredButFailing()) {
261            // quality was set to "auto", but we could not meassure the quality of the jpeg locally
262            // Ask the cloud service to do it, rather than using what we came up with.
263            $optionsToSend['quality'] = 'auto';
264        } else {
265            $optionsToSend['quality'] = $this->getCalculatedQuality();
266        }
267
268        // The following are unset for security reasons.
269        unset($optionsToSend['converters']);
270        unset($optionsToSend['secret']);
271        unset($optionsToSend['api-key']);
272        unset($optionsToSend['api-url']);
273
274        $apiVersion = $optionsToSend['api-version'];
275
276        if ($apiVersion == 1) {
277            // Lossless can be "auto" in api 2, but in api 1 "auto" is not supported
278            //unset($optionsToSend['lossless']);
279        } elseif ($apiVersion == 2) {
280            //unset($optionsToSend['png']);
281            //unset($optionsToSend['jpeg']);
282
283            // The following are unset for security reasons.
284            unset($optionsToSend['cwebp-command-line-options']);
285            unset($optionsToSend['command-line-options']);
286        }
287
288        return $optionsToSend;
289    }
290
291    private function createPostData()
292    {
293        $options = $this->options;
294
295        $postData = [
296            'file' => curl_file_create($this->source),
297            'options' => json_encode($this->createOptionsToSend()),
298            'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '')
299        ];
300
301        $apiVersion = $options['api-version'];
302
303        $apiKey = $this->getApiKey();
304
305        if ($apiVersion == 0) {
306            $postData['hash'] = md5(md5_file($this->source) . $apiKey);
307        } else {
308            //$this->logLn('api key: ' . $apiKey);
309
310            if ($options['crypt-api-key-in-transfer']) {
311                $salt = self::createRandomSaltForBlowfish();
312                $postData['salt'] = $salt;
313
314                // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt)
315                $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28);
316            } else {
317                $postData['api-key'] = $apiKey;
318            }
319        }
320        return $postData;
321    }
322
323    protected function doActualConvert()
324    {
325        $ch = self::initCurl();
326
327        //$this->logLn('api url: ' . $this->getApiUrl());
328
329        curl_setopt_array($ch, [
330            CURLOPT_URL => $this->getApiUrl(),
331            CURLOPT_POST => 1,
332            CURLOPT_POSTFIELDS => $this->createPostData(),
333            CURLOPT_BINARYTRANSFER => true,
334            CURLOPT_RETURNTRANSFER => true,
335            CURLOPT_HEADER => false,
336            CURLOPT_SSL_VERIFYPEER => false
337        ]);
338
339        $response = curl_exec($ch);
340        if (curl_errno($ch)) {
341            $this->logLn('Curl error: ', 'bold');
342            $this->logLn(curl_error($ch));
343            throw new ConverterNotOperationalException('Curl error:');
344        }
345
346        // Check if we got a 404
347        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
348        if ($httpCode == 404) {
349            curl_close($ch);
350            throw new ConversionFailedException(
351                'WPC was not found at the specified URL - we got a 404 response.'
352            );
353        }
354
355        // Check for empty response
356        if (empty($response)) {
357            throw new ConversionFailedException(
358                'Error: Unexpected result. We got nothing back. ' .
359                    'HTTP CODE: ' . $httpCode . '. ' .
360                    'Content type:' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE)
361            );
362        };
363
364        // The WPC cloud service either returns an image or an error message
365        // Images has application/octet-stream.
366        // Verify that we got an image back.
367        if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') {
368            curl_close($ch);
369
370            if (substr($response, 0, 1) == '{') {
371                $responseObj = json_decode($response, true);
372                if (isset($responseObj['errorCode'])) {
373                    switch ($responseObj['errorCode']) {
374                        case 0:
375                            throw new ConverterNotOperationalException(
376                                'There are problems with the server setup: "' .
377                                $responseObj['errorMessage'] . '"'
378                            );
379                        case 1:
380                            throw new InvalidApiKeyException(
381                                'Access denied. ' . $responseObj['errorMessage']
382                            );
383                        default:
384                            throw new ConversionFailedException(
385                                'Conversion failed: "' . $responseObj['errorMessage'] . '"'
386                            );
387                    }
388                }
389            }
390
391            // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that.
392            if (substr($response, 0, 7) == 'failed!') {
393                throw new ConversionFailedException(
394                    'WPC failed converting image: "' . substr($response, 7) . '"'
395                );
396            }
397
398            $this->logLn('Bummer, we did not receive an image');
399            $this->log('What we received starts with: "');
400            $this->logLn(
401                str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))) . '..."'
402            );
403
404            throw new ConversionFailedException('Unexpected result. We did not receive an image but something else.');
405            //throw new ConverterNotOperationalException($response);
406        }
407
408        $success = file_put_contents($this->destination, $response);
409        curl_close($ch);
410
411        if (!$success) {
412            throw new ConversionFailedException('Error saving file. Check file permissions');
413        }
414    }
415}