Passed
Push — master ( 47232b...207a99 )
by Bjørn
01:36 queued 12s
created

Wpc::supportsLossless()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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