Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.93% covered (warning)
65.93%
178 / 270
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpc
65.93% covered (warning)
65.93%
178 / 270
28.57% covered (danger)
28.57%
4 / 14
244.59
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%
72 / 72
100.00% covered (success)
100.00%
1 / 1
1
 supportsLossless
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 passOnEncodingAuto
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRandomSaltForBlowfish
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getApiKey
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 getApiUrl
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 isPublicIp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 urlToIps
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 checkIfUrlIsPublicIp
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 checkOperationality
27.27% covered (danger)
27.27%
12 / 44
0.00% covered (danger)
0.00%
0 / 1
101.55
 createOptionsToSend
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 createPostData
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 doActualConvert
68.85% covered (warning)
68.85%
42 / 61
0.00% covered (danger)
0.00%
0 / 1
16.35
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            ['require-public-ip', 'boolean', [
98              'title' => 'Require public IP',
99              'description' =>
100                 'If enabled, private IPs will be rejected. A DNS lookup is performed to ' .
101                 'get the IP, in order to settle if its public. So it brings some overhead, ' .
102                 'although it is small compared to an image conversion',
103              'default' => false,
104              'ui' => [
105                  'component' => 'checkbox',
106                  'advanced' => true,
107              ]
108            ]],
109        ]);
110
111        /*return [
112            new SensitiveStringOption('api-key', ''),
113            new SensitiveStringOption('secret', ''),
114            new SensitiveStringOption('api-url', ''),
115            new SensitiveStringOption('url', ''),       // DO NOT USE. Only here to keep the protection
116            new IntegerOption('api-version', 2, 0, 2),
117            new BooleanOption('crypt-api-key-in-transfer', false)  // new in api v.1
118        ];*/
119    }
120
121    public function supportsLossless()
122    {
123        return ($this->options['api-version'] >= 2);
124    }
125
126    public function passOnEncodingAuto()
127    {
128        // We could make this configurable. But I guess passing it on is always to be preferred
129        // for api >= 2.
130        return ($this->options['api-version'] >= 2);
131    }
132
133    private static function createRandomSaltForBlowfish()
134    {
135        $salt = '';
136        $validCharsForSalt = array_merge(
137            range('A', 'Z'),
138            range('a', 'z'),
139            range('0', '9'),
140            ['.', '/']
141        );
142
143        for ($i = 0; $i < 22; $i++) {
144            $salt .= $validCharsForSalt[array_rand($validCharsForSalt)];
145        }
146        return $salt;
147    }
148
149    /**
150     * Get api key from options or environment variable
151     *
152     * @return string  api key or empty string if none is set
153     */
154    private function getApiKey()
155    {
156        if ($this->options['api-version'] == 0) {
157            if (!empty($this->options['secret'])) {
158                return $this->options['secret'];
159            }
160        } elseif ($this->options['api-version'] >= 1) {
161            if (!empty($this->options['api-key'])) {
162                return $this->options['api-key'];
163            }
164        }
165        if (defined('WEBPCONVERT_WPC_API_KEY')) {
166            return constant('WEBPCONVERT_WPC_API_KEY');
167        }
168        if (!empty(getenv('WEBPCONVERT_WPC_API_KEY'))) {
169            return getenv('WEBPCONVERT_WPC_API_KEY');
170        }
171        return '';
172    }
173
174    /**
175     * Get url from options or environment variable
176     *
177     * @return string  URL to WPC or empty string if none is set
178     */
179    private function getApiUrl()
180    {
181        if (!empty($this->options['api-url'])) {
182            return $this->options['api-url'];
183        }
184        if (defined('WEBPCONVERT_WPC_API_URL')) {
185            return constant('WEBPCONVERT_WPC_API_URL');
186        }
187        if (!empty(getenv('WEBPCONVERT_WPC_API_URL'))) {
188            return getenv('WEBPCONVERT_WPC_API_URL');
189        }
190        return '';
191    }
192
193    private static function isPublicIp(string $ip): bool
194    {
195        return filter_var(
196            $ip,
197            FILTER_VALIDATE_IP,
198            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
199        ) !== false;
200    }
201
202    private static function urlToIps(string $url): array
203    {
204        $host = parse_url($url, PHP_URL_HOST);
205
206        if (!$host) {
207            return [];
208        }
209
210        // URL already contains an IP
211        if (filter_var($host, FILTER_VALIDATE_IP)) {
212            return [$host];
213        }
214
215        $records = dns_get_record($host, DNS_A | DNS_AAAA);
216
217        if ($records === false) {
218            return [];
219        }
220
221        $ips = [];
222
223        foreach ($records as $record) {
224            if (isset($record['ip'])) {
225                $ips[] = $record['ip'];
226            }
227
228            if (isset($record['ipv6'])) {
229                $ips[] = $record['ipv6'];
230            }
231        }
232
233        return array_unique($ips);
234    }
235
236    private static function checkIfUrlIsPublicIp(string $url): bool
237    {
238        $host = parse_url($url, PHP_URL_HOST);
239
240        if ($host === false || $host === null) {
241            return false;
242        }
243
244        if (strcasecmp($host, 'localhost') === 0) {
245            return false;
246        }
247
248        $ips = self::urlToIps($url);
249
250        if (empty($ips)) {
251            return false;
252        }
253
254        foreach ($ips as $ip) {
255            if (!self::isPublicIp($ip)) {
256                return false;
257            }
258        }
259
260        return true;
261    }
262
263    /**
264     * Check operationality of Wpc converter.
265     *
266     * @throws SystemRequirementsNotMetException  if system requirements are not met (curl)
267     * @throws ConverterNotOperationalException   if key is missing or invalid, or quota has exceeded
268     */
269    public function checkOperationality()
270    {
271        $options = $this->options;
272
273        $apiVersion = $options['api-version'];
274
275        if ($this->getApiUrl() == '') {
276            if (isset($this->options['url']) && ($this->options['url'] != '')) {
277                throw new ConverterNotOperationalException(
278                    'The "url" option has been renamed to "api-url" in webp-convert 2.0. ' .
279                    'You must change the configuration accordingly.'
280                );
281            }
282            throw new ConverterNotOperationalException(
283                'Missing URL. You must install Webp Convert Cloud Service on a server, ' .
284                'or the WebP Express plugin for Wordpress - and supply the url.'
285            );
286        }
287
288        $parts = parse_url($this->getApiUrl());
289        if (!isset($parts['scheme']) || !in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
290            throw new ConverterNotOperationalException('API URL must use HTTP or HTTPS');
291        }
292
293        if ($options['require-public-ip']) {
294            if (!self::checkIfUrlIsPublicIp($this->getApiUrl())) {
295                throw new ConverterNotOperationalException(
296                    'API URL must be public IP (as WPC has been configured to disallow private IPs)'
297                );
298            }
299        }
300
301        if ($apiVersion == 0) {
302            if (!empty($this->getApiKey())) {
303                // if secret is set, we need md5() and md5_file() functions
304                if (!function_exists('md5')) {
305                    throw new ConverterNotOperationalException(
306                        'A secret has been set, which requires us to create a md5 hash from the secret and the file ' .
307                        'contents. ' .
308                        'But the required md5() PHP function is not available.'
309                    );
310                }
311                if (!function_exists('md5_file')) {
312                    throw new ConverterNotOperationalException(
313                        'A secret has been set, which requires us to create a md5 hash from the secret and the file ' .
314                        'contents. But the required md5_file() PHP function is not available.'
315                    );
316                }
317            }
318        } else {
319            if ($options['crypt-api-key-in-transfer']) {
320                if (!function_exists('crypt')) {
321                    throw new ConverterNotOperationalException(
322                        'Configured to crypt the api-key, but crypt() function is not available.'
323                    );
324                }
325
326                if (!defined('CRYPT_BLOWFISH')) {
327                    throw new ConverterNotOperationalException(
328                        'Configured to crypt the api-key. ' .
329                        'That requires Blowfish encryption, which is not available on your current setup.'
330                    );
331                }
332            }
333        }
334
335        // Check for curl requirements
336        $this->checkOperationalityForCurlTrait();
337    }
338
339    /*
340    public function checkConvertability()
341    {
342        // check upload limits
343        $this->checkConvertabilityCloudConverterTrait();
344
345        // TODO: some from below can be moved up here
346    }
347    */
348
349    private function createOptionsToSend()
350    {
351        $optionsToSend = $this->options;
352
353        if ($this->isQualityDetectionRequiredButFailing()) {
354            // quality was set to "auto", but we could not meassure the quality of the jpeg locally
355            // Ask the cloud service to do it, rather than using what we came up with.
356            $optionsToSend['quality'] = 'auto';
357        } else {
358            $optionsToSend['quality'] = $this->getCalculatedQuality();
359        }
360
361        // The following are unset for security reasons.
362        unset($optionsToSend['converters']);
363        unset($optionsToSend['secret']);
364        unset($optionsToSend['api-key']);
365        unset($optionsToSend['api-url']);
366
367        $apiVersion = $optionsToSend['api-version'];
368
369        if ($apiVersion == 1) {
370            // Lossless can be "auto" in api 2, but in api 1 "auto" is not supported
371            //unset($optionsToSend['lossless']);
372        } elseif ($apiVersion == 2) {
373            //unset($optionsToSend['png']);
374            //unset($optionsToSend['jpeg']);
375
376            // The following are unset for security reasons.
377            unset($optionsToSend['cwebp-command-line-options']);
378            unset($optionsToSend['command-line-options']);
379        }
380
381        return $optionsToSend;
382    }
383
384    private function createPostData()
385    {
386        $options = $this->options;
387
388        $postData = [
389            'file' => curl_file_create($this->source),
390            'options' => json_encode($this->createOptionsToSend()),
391            'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '')
392        ];
393
394        $apiVersion = $options['api-version'];
395
396        $apiKey = $this->getApiKey();
397
398        if ($apiVersion == 0) {
399            $postData['hash'] = md5(md5_file($this->source) . $apiKey);
400        } else {
401            //$this->logLn('api key: ' . $apiKey);
402
403            if ($options['crypt-api-key-in-transfer']) {
404                $salt = self::createRandomSaltForBlowfish();
405                $postData['salt'] = $salt;
406
407                // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt)
408                $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28);
409            } else {
410                $postData['api-key'] = $apiKey;
411            }
412        }
413        return $postData;
414    }
415
416    protected function doActualConvert()
417    {
418        $ch = self::initCurl();
419        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
420        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
421        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
422
423        //$this->logLn('api url: ' . $this->getApiUrl());
424
425        curl_setopt_array($ch, [
426            CURLOPT_URL => $this->getApiUrl(),
427            CURLOPT_POST => 1,
428            CURLOPT_POSTFIELDS => $this->createPostData(),
429            CURLOPT_RETURNTRANSFER => true,
430            CURLOPT_HEADER => false,
431            CURLOPT_SSL_VERIFYPEER => false
432        ]);
433
434        $response = curl_exec($ch);
435        if (curl_errno($ch)) {
436            $this->logLn('Curl error: ', 'bold');
437            $this->logLn(curl_error($ch));
438            throw new ConverterNotOperationalException('Curl error:');
439        }
440
441        // Check if we got a 404
442        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
443        if ($httpCode == 404) {
444            self::closeCurl($ch);
445            throw new ConversionFailedException(
446                'WPC was not found at the specified URL - we got a 404 response.'
447            );
448        }
449
450        // Check for empty response
451        if (empty($response)) {
452            throw new ConversionFailedException(
453                'Error: Unexpected result. We got nothing back. ' .
454                    'HTTP CODE: ' . $httpCode . '. ' .
455                    'Content type:' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE)
456            );
457        };
458
459        // The WPC cloud service either returns an image or an error message
460        // Images has application/octet-stream.
461        // Verify that we got an image back.
462        if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') {
463            self::closeCurl($ch);
464
465            if (substr($response, 0, 1) == '{') {
466                $responseObj = json_decode($response, true);
467                if (isset($responseObj['errorCode'])) {
468                    switch ($responseObj['errorCode']) {
469                        case 0:
470                            throw new ConverterNotOperationalException(
471                                'There are problems with the server setup: "' .
472                                $responseObj['errorMessage'] . '"'
473                            );
474                        case 1:
475                            throw new InvalidApiKeyException(
476                                'Access denied. ' . $responseObj['errorMessage']
477                            );
478                        default:
479                            throw new ConversionFailedException(
480                                'Conversion failed: "' . $responseObj['errorMessage'] . '"'
481                            );
482                    }
483                }
484            }
485
486            // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that.
487            if (substr($response, 0, 7) == 'failed!') {
488                throw new ConversionFailedException(
489                    'WPC failed converting image: "' . substr($response, 7) . '"'
490                );
491            }
492
493            $this->logLn('Bummer, we did not receive an image');
494            $this->log('What we received starts with: "');
495            $this->logLn(
496                str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))) . '..."'
497            );
498
499            throw new ConversionFailedException('Unexpected result. We did not receive an image but something else.');
500            //throw new ConverterNotOperationalException($response);
501        }
502
503        $success = file_put_contents($this->destination, $response);
504        self::closeCurl($ch);
505
506        if (!$success) {
507            throw new ConversionFailedException('Error saving file. Check file permissions');
508        }
509    }
510}