Completed
Push — master ( b520c4...41a82a )
by Bjørn
02:59
created

AbstractConverter::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 4
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
// TODO:
4
// Read this: https://sourcemaking.com/design_patterns/strategy
5
6
namespace WebPConvert\Convert\Converters;
7
8
use WebPConvert\Convert\Exceptions\ConversionFailedException;
9
use WebPConvert\Convert\Exceptions\ConversionFailed\UnhandledException;
10
use WebPConvert\Convert\Exceptions\ConversionFailed\FileSystemProblems\CreateDestinationFileException;
11
use WebPConvert\Convert\Exceptions\ConversionFailed\FileSystemProblems\CreateDestinationFolderException;
12
use WebPConvert\Convert\Exceptions\ConversionFailed\InvalidInput\InvalidImageTypeException;
13
use WebPConvert\Convert\Exceptions\ConversionFailed\InvalidInput\TargetNotFoundException;
14
use WebPConvert\Convert\Converters\BaseTraits\AutoQualityTrait;
15
use WebPConvert\Convert\Converters\BaseTraits\LoggerTrait;
16
use WebPConvert\Convert\Converters\BaseTraits\OptionsTrait;
17
use WebPConvert\Convert\Converters\BaseTraits\WarningLoggerTrait;
18
use WebPConvert\Loggers\BaseLogger;
19
20
use ImageMimeTypeGuesser\ImageMimeTypeGuesser;
21
22
/**
23
 * Base for all converter classes.
24
 *
25
 * @package    WebPConvert
26
 * @author     Bjørn Rosell <[email protected]>
27
 * @since      Class available since Release 2.0.0
28
 */
29
abstract class AbstractConverter
30
{
31
    use AutoQualityTrait;
32
    use LoggerTrait;
33
    use OptionsTrait;
34
    use WarningLoggerTrait;
35
36
    /**
37
     * The actual conversion is be done by a concrete converter extending this class.
38
     *
39
     * At the stage this method is called, the abstract converter has taken preparational steps.
40
     * - It has created the destination folder (if neccesary)
41
     * - It has checked the input (valid mime type)
42
     * - It has set up an error handler, mostly in order to catch and log warnings during the doConvert fase
43
     *
44
     * Note: This method is not meant to be called from the outside. Use the static *convert* method for converting
45
     *       or, if you wish, create an instance with ::createInstance() and then call ::doConvert()
46
     *
47
     * @throws ConversionFailedException in case conversion failed in an antipiciated way (or subclass)
48
     * @throws \Exception in case conversion failed in an unantipiciated way
49
     */
50
    abstract protected function doActualConvert();
51
52
    /**
53
     * Get to know whether the converter supports lossless webp encoding, even for jpegs.
54
     *
55
     * The following converters supports lossless encoding, even for jpegs: cwebp, vips, stack and wpc
56
     * The following converters does not: ewww, gd, gmagick, gmagickbinary, imagick, imagickbinary
57
     *
58
     * @return  boolean  whether lossless encoding is supported for the concrete converter.
59
     */
60
61
    /**
62
     * Whether or not the converter supports lossless encoding (even for jpegs)
63
     *
64
     * PS: Converters that supports lossless encoding all use the LosslessAutoTrait, which
65
     * overrides this function.
66
     *
67
     * @return  boolean  Whether the converter supports lossless encoding (even for jpegs).
68
     */
69
    public function supportsLossless()
70
    {
71
        return false;
72
    }
73
74
    /** @var string  The filename of the image to convert (complete path) */
75
    protected $source;
76
77
    /** @var string  Where to save the webp (complete path) */
78
    protected $destination;
79
80
    /** @var string|false|null  Where to save the webp (complete path) */
81
    private $sourceMimeType;
82
83
    public static $allowedMimeTypes = ['image/jpeg', 'image/png'];
84
85
    /**
86
     * Check basis operationality
87
     *
88
     * Converters may override this method for the purpose of performing basic operationaly checks. It is for
89
     * running general operation checks for a conversion method.
90
     * If some requirement is not met, it should throw a ConverterNotOperationalException (or subtype)
91
     *
92
     * The method is called internally right before calling doActualConvert() method.
93
     * - It SHOULD take options into account when relevant. For example, a missing api key for a
94
     *   cloud converter should be detected here
95
     * - It should NOT take the actual filename into consideration, as the purpose is *general*
96
     *   For that pupose, converters should override checkConvertability
97
     *   Also note that doConvert method is allowed to throw ConverterNotOperationalException too.
98
     *
99
     * @return  void
100
     */
101 2
    public function checkOperationality()
102
    {
103 2
    }
104
105
    /**
106
     * Converters may override this for the purpose of performing checks on the concrete file.
107
     *
108
     * This can for example be used for rejecting big uploads in cloud converters or rejecting unsupported
109
     * image types.
110
     *
111
     * @return  void
112
     */
113 3
    public function checkConvertability()
114
    {
115 3
    }
116
117
    /**
118
     * Constructor.
119
     *
120
     * @param   string  $source              path to source file
121
     * @param   string  $destination         path to destination
122
     * @param   array   $options (optional)  options for conversion
123
     * @param   BaseLogger $logger (optional)
124
     */
125 33
    public function __construct($source, $destination, $options = [], $logger = null)
126
    {
127 33
        $this->source = $source;
128 33
        $this->destination = $destination;
129
130 33
        $this->setLogger($logger);
131 33
        $this->setProvidedOptions($options);
132 33
    }
133
134
    /**
135
     * Get destination.
136
     *
137
     * @return string  The destination.
138
     */
139
    public function getDestination()
140
    {
141
        return $this->destination;
142
    }
143
144
    /**
145
     * Set destination.
146
     *
147
     * @param   string  $destination         path to destination
148
     * @return string  The destination.
149
     */
150
    public function setDestination($destination)
151
    {
152
        $this->destination = $destination;
153
    }
154
155
156
    /**
157
     *  Default display name is simply the class name (short).
158
     *  Converters can override this.
159
     *
160
     * @return string  A display name, ie "Gd"
161
     */
162 1
    protected static function getConverterDisplayName()
163
    {
164
        // https://stackoverflow.com/questions/19901850/how-do-i-get-an-objects-unqualified-short-class-name/25308464
165 1
        return substr(strrchr('\\' . static::class, '\\'), 1);
166
    }
167
168
    /**
169
     * Create an instance of this class
170
     *
171
     * @param  string  $source       The path to the file to convert
172
     * @param  string  $destination  The path to save the converted file to
173
     * @param  array   $options      (optional)
174
     * @param  \WebPConvert\Loggers\BaseLogger   $logger       (optional)
175
     *
176
     * @return static
177
     */
178 22
    public static function createInstance($source, $destination, $options = [], $logger = null)
179
    {
180 22
        return new static($source, $destination, $options, $logger);
181
    }
182
183
    //$instance->logLn($instance->getConverterDisplayName() . ' converter ignited');
184
    //$instance->logLn(self::getConverterDisplayName() . ' converter ignited');
185
186 13
    public function doConvert()
187
    {
188 13
        $beginTime = microtime(true);
189
190 13
        $this->activateWarningLogger();
191
        //set_error_handler(array($this, "errorHandler"));
192
193
        try {
194
            // Prepare options
195
            //$this->prepareOptions();
196
197 13
            $this->checkOptions();
198
199
            // Prepare destination folder
200 13
            $this->createWritableDestinationFolder();
201 12
            $this->removeExistingDestinationIfExists();
202
203 12
            if (!isset($this->options['_skip_input_check'])) {
204
                // Run basic input validations (if source exists and if file extension is valid)
205 12
                $this->checkInput();
206
207
                // Check that a file can be written to destination
208 11
                $this->checkFileSystem();
209
            }
210
211 11
            $this->checkOperationality();
212 4
            $this->checkConvertability();
213 4
            $this->runActualConvert();
214 11
        } catch (ConversionFailedException $e) {
215 11
            $this->deactivateWarningLogger();
216 11
            throw $e;
217
        } catch (\Exception $e) {
218
            $this->deactivateWarningLogger();
219
            throw new UnhandledException('Conversion failed due to uncaught exception', 0, $e);
220
        } catch (\Error $e) {
221
            $this->deactivateWarningLogger();
222
            // https://stackoverflow.com/questions/7116995/is-it-possible-in-php-to-prevent-fatal-error-call-to-undefined-function
223
            //throw new UnhandledException('Conversion failed due to uncaught error', 0, $e);
224
            throw $e;
225
        }
226 3
        $this->deactivateWarningLogger();
227
228 3
        $source = $this->source;
229 3
        $destination = $this->destination;
230
231 3
        if (!@file_exists($destination)) {
232
            throw new ConversionFailedException('Destination file is not there: ' . $destination);
233 3
        } elseif (@filesize($destination) === 0) {
234
            unlink($destination);
235
            throw new ConversionFailedException('Destination file was completely empty');
236
        } else {
237 3
            if (!isset($this->options['_suppress_success_message'])) {
238 3
                $this->ln();
239
                $msg = 'Converted image in ' .
240 3
                    round((microtime(true) - $beginTime) * 1000) . ' ms';
241
242 3
                $sourceSize = @filesize($source);
243 3
                if ($sourceSize !== false) {
244
                    $msg .= ', reducing file size with ' .
245 3
                        round((filesize($source) - filesize($destination))/filesize($source) * 100) . '% ';
246
247 3
                    if ($sourceSize < 10000) {
248 1
                        $msg .= '(went from ' . round(filesize($source)) . ' bytes to ';
249 1
                        $msg .= round(filesize($destination)) . ' bytes)';
250
                    } else {
251 3
                        $msg .= '(went from ' . round(filesize($source)/1024) . ' kb to ';
252 3
                        $msg .= round(filesize($destination)/1024) . ' kb)';
253
                    }
254
                }
255 3
                $this->logLn($msg);
256
            }
257
        }
258 3
    }
259
260 4
    protected function runActualConvert()
261
    {
262 4
        $this->doActualConvert();
263 3
    }
264
265
    /**
266
     * Convert an image to webp.
267
     *
268
     * @param   string  $source              path to source file
269
     * @param   string  $destination         path to destination
270
     * @param   array   $options (optional)  options for conversion
271
     * @param   BaseLogger $logger (optional)
272
     *
273
     * @throws  ConversionFailedException   in case conversion fails
274
     * @return  void
275
     */
276 13
    public static function convert($source, $destination, $options = [], $logger = null)
277
    {
278 13
        $instance = self::createInstance($source, $destination, $options, $logger);
279 13
        $instance->doConvert();
280
        //echo $instance->id;
281 3
    }
282
283
    /**
284
     * Get mime type for image (best guess).
285
     *
286
     * It falls back to using file extension. If that fails too, false is returned
287
     *
288
     * PS: Is it a security risk to fall back on file extension?
289
     * - By setting file extension to "jpg", one can lure our library into trying to convert a file, which isn't a jpg.
290
     * hmm, seems very unlikely, though not unthinkable that one of the converters could be exploited
291
     *
292
     * @return  string|false|null mimetype (if it is an image, and type could be determined / guessed),
293
     *    false (if it is not an image type that the server knowns about)
294
     *    or null (if nothing can be determined)
295
     */
296 33
    public function getMimeTypeOfSource()
297
    {
298 33
        if (!isset($this->sourceMimeType)) {
299 33
            $this->sourceMimeType = ImageMimeTypeGuesser::lenientGuess($this->source);
300
        }
301 33
        return $this->sourceMimeType;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->sourceMimeType also could return the type boolean which is incompatible with the documented return type false|null|string.
Loading history...
302
    }
303
304
    /**
305
     *  Note: As the input validations are only run one time in a stack,
306
     *  this method is not overridable
307
     */
308 12
    private function checkInput()
309
    {
310
        // Check if source exists
311 12
        if (!@file_exists($this->source)) {
312 5
            throw new TargetNotFoundException('File or directory not found: ' . $this->source);
313
        }
314
315
        // Check if the provided file's mime type is valid
316 11
        $fileMimeType = $this->getMimeTypeOfSource();
317 11
        if (is_null($fileMimeType)) {
318
            throw new InvalidImageTypeException('Image type could not be detected');
319 11
        } elseif ($fileMimeType === false) {
0 ignored issues
show
introduced by
The condition $fileMimeType === false is always false.
Loading history...
320
            throw new InvalidImageTypeException('File seems not to be an image.');
321 11
        } elseif (!in_array($fileMimeType, self::$allowedMimeTypes)) {
322
            throw new InvalidImageTypeException('Unsupported mime type: ' . $fileMimeType);
323
        }
324 11
    }
325
326 11
    private function checkFileSystem()
327
    {
328
        // TODO:
329
        // Instead of creating dummy file,
330
        // perhaps something like this ?
331
        // if (@is_writable($dirName) && @is_executable($dirName) || self::isWindows() )
332
        // Or actually, probably best with a mix.
333
        // First we test is_writable and is_executable. If that fails and we are on windows, we can do the dummy
334
        // function isWindows(){
335
        // return (boolean) preg_match('/^win/i', PHP_OS);
336
        //}
337
338
        // Try to create a dummy file here, with that name, just to see if it is possible (we delete it again)
339 11
        file_put_contents($this->destination, '');
340 11
        if (file_put_contents($this->destination, '') === false) {
341
            throw new CreateDestinationFileException(
342
                'Cannot create file: ' . basename($this->destination) . ' in dir:' . dirname($this->destination)
343
            );
344
        }
345 11
        unlink($this->destination);
346 11
    }
347
348 12
    private function removeExistingDestinationIfExists()
349
    {
350 12
        if (file_exists($this->destination)) {
351
            // A file already exists in this folder...
352
            // We delete it, to make way for a new webp
353 5
            if (!unlink($this->destination)) {
354
                throw new CreateDestinationFileException(
355
                    'Existing file cannot be removed: ' . basename($this->destination)
356
                );
357
            }
358
        }
359 12
    }
360
361
    // Creates folder in provided path & sets correct permissions
362
    // also deletes the file at filePath (if it already exists)
363 13
    private function createWritableDestinationFolder()
364
    {
365 13
        $filePath = $this->destination;
366
367 13
        $folder = dirname($filePath);
368 13
        if (!file_exists($folder)) {
369 1
            $this->logLn('Destination folder does not exist. Creating folder: ' . $folder);
370
            // TODO: what if this is outside open basedir?
371
            // see http://php.net/manual/en/ini.core.php#ini.open-basedir
372
373
            // Trying to create the given folder (recursively)
374 1
            if (!mkdir($folder, 0777, true)) {
375 1
                throw new CreateDestinationFolderException(
376 1
                    'Failed creating folder. Check the permissions!',
377 1
                    'Failed creating folder: ' . $folder . '. Check permissions!'
378
                );
379
            }
380
        }
381 12
    }
382
}
383