Completed
Pull Request — master (#17)
by
unknown
02:27
created

CssEmbed   D

Complexity

Total Complexity 80

Size/Duplication

Total Lines 501
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 0

Importance

Changes 20
Bugs 6 Features 6
Metric Value
wmc 80
c 20
b 6
f 6
lcom 1
cbo 0
dl 0
loc 501
rs 4.8717

21 Methods

Rating   Name   Duplication   Size   Complexity  
A setRootDir() 0 4 1
A setOptions() 0 4 1
A enableHttp() 0 7 1
B enableEnhancedMimeTypes() 0 15 6
A embedCss() 0 9 2
A embedString() 0 8 1
A replace() 0 16 4
A fetchAsset() 0 14 4
A fetchAssetUrl() 0 11 3
C fetchLocalAsset() 0 24 8
C fetchHttpAsset() 0 23 7
B assetIsEmbeddable() 0 16 6
B isHttpAsset() 0 22 6
A resolveAssetPath() 0 7 2
A resolveAssetUrl() 0 9 2
B buildAssetUrl() 0 32 6
B removePathTraversals() 0 13 5
B detectMime() 0 17 5
A compareMime() 0 10 3
A createMimesFile() 0 13 3
A error() 0 15 4

How to fix   Complexity   

Complex Class

Complex classes like CssEmbed often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CssEmbed, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * For the full copyright and license information, please view the LICENSE
4
 * file that was distributed with this source code.
5
 *  08/08/12 15:22
6
 */
7
8
namespace CssEmbed;
9
10
/**
11
 * CssEmbed
12
 *
13
 * @author Pierre Tachoire <[email protected]>
14
 */
15
class CssEmbed
16
{
17
18
    const SEARCH_PATTERN = "%url\\(['\" ]*((?!data:)[^'\" ]+)['\" ]*\\)%U";
19
    const DATA_URI_PATTERN = "url(data:%s;base64,%s)";
20
    const URL_URI_PATTERN = "url('%s')";
21
    const MIME_MAGIC_URL = 'http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types';
22
    const EMBED_FONTS = 1;
23
    const EMBED_SVG = 2;
24
    const URL_ON_ERROR = 4;
25
    const HTTP_DEFAULT_HTTPS = 1;
26
    const HTTP_EMBED_SCHEME = 2;
27
    const HTTP_EMBED_URL_ONLY = 4;
28
29
    /** @var string the root directory for finding assets */
30
    protected $root_dir;
31
32
    /** @var string the path to the local mime.magic database */
33
    protected $mime_magic_path = null;
34
35
    /** @var integer flags that modify behavior, embed SVG by default for BC */
36
    protected $flags = 2;
37
38
    /** @var bool enable HTTP asset fetching */
39
    protected $http_enabled = false;
40
41
    /** @var integer flags that modify behavior in HTTP only */
42
    protected $http_flags = 0;
43
44
    /**
45
     * @param string $root_dir
46
     */
47
    public function setRootDir($root_dir)
48
    {
49
        $this->root_dir = $root_dir;
50
    }
51
52
    /**
53
     * Set embedding options. Flags:
54
     *
55
     *     - CssEmbed::EMBED_FONTS: embedding fonts will usually break them
56
     *       in most browsers.  Enable this flag to force the embed. WARNING:
57
     *       this flag is currently not unit tested, but seems to work.
58
     *     - CssEmbed::EMBED_SVG: SVG is often used as a font face; however
59
     *       including these in a stylesheet will cause it to bloat for browsers
60
     *       that don't use it.  SVGs will be embedded by default.
61
     *     - CssEmbed::URL_ON_ERROR: if there is an error fetching an asset,
62
     *       embed a URL (or best guess at URL) instead of throwing an exception
63
     *
64
     * @param integer $flags
65
     *
66
     * @return void
67
     */
68
    public function setOptions($flags)
69
    {
70
        $this->flags = $flags;
71
    }
72
73
    /**
74
     * Enable embedding assets over HTTP, or processing stylesheets from HTTP
75
     * locations. Available flags:
76
     *
77
     *     - CssEmbed::HTTP_DEFAULT_HTTPS: when HTTP assets are enabled, use
78
     *       HTTPS for URLs with no scheme
79
     *     - CssEmbed::HTTP_EMBED_SCHEME: By default, assets that are converted
80
     *       to URLs instead of data urls have no scheme (eg, "//example.com").
81
     *       This is better for stylesheets that are maybe served over http or
82
     *       https, but it will break stylesheets served from a local HTML file.
83
     *       Set this option to force the schema (eg, "http://example.com").
84
     *     - CssEmbed::HTTP_EMBED_URL_ONLY: do not convert assets to data URLs,
85
     *       only the fully qualified URL.
86
     *
87
     * @note this method will turn the options URL_ON_ERROR on and EMBED_SVG
88
     * off. You will need to use setOptions() after this method to change that.
89
     *
90
     * @param bool $enable
91
     * @param int $flags flags that modify HTTP behaviour
92
     * @return void
93
     */
94
    public function enableHttp($enable = true, $flags = 0)
95
    {
96
        $this->http_enabled = (bool) $enable;
97
        $this->flags = $this->flags|self::URL_ON_ERROR;
98
        $this->flags = $this->flags & (~ self::EMBED_SVG);
99
        $this->http_flags = (int) $flags;
100
    }
101
102
    /**
103
     * Enable the functionality to compare mimes against a custom mime.types file.
104
     *
105
     * @param string $path the path to the mime types file
106
     * @param bool $create download and save the Apache mime types file if the
107
     * specified path does not exist
108
     * @throws \InvalidArgumentException if the mime file does not exist and
109
     * cannot be created.
110
     * @return void
111
     */
112
    public function enableEnhancedMimeTypes(
113
        $path = '/tmp/cssembed.mime.types',
114
        $create = true
115
    ) {
116
        if (!file_exists($path) && $create) {
117
            $this->createMimesFile($path);
118
        }
119
        if (!file_exists($path)) {
120
            $this->error('mime.types does not exist and cannot be created: "%s"', $path);
121
        }
122
        if (!is_readable($path) || !is_file($path)) {
123
            $this->error('Invalid mime.types file: "%s"', $path);
124
        }
125
        $this->mime_magic_path = $path;
126
    }
127
128
    /**
129
     * @param string $css_file
130
     * @return null|string
131
     * @throws \InvalidArgumentException
132
     */
133
    public function embedCss($css_file)
134
    {
135
        $this->setRootDir(dirname($css_file));
136
        $content = @file_get_contents($css_file);
137
        if ($content === false) {
138
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $css_file));
139
        }
140
        return $this->embedString($content);
141
    }
142
143
    /**
144
     * @param $content
145
     * @return mixed
146
     */
147
    public function embedString($content)
148
    {
149
        return preg_replace_callback(
150
            self::SEARCH_PATTERN,
151
            array($this, 'replace'),
152
            $content
153
        );
154
    }
155
156
    /**
157
     * preg_replace_callback callback for embedString.
158
     *
159
     * @param array $matches
160
     * @return string
161
     */
162
    protected function replace($matches)
163
    {
164
        if ($asset = $this->fetchAsset($matches[1])) {
165
            if ($this->assetIsEmbeddable($asset)) {
166
                return sprintf(
167
                    self::DATA_URI_PATTERN,
168
                    $asset['mime'],
169
                    base64_encode($asset['content'])
170
                );
171
            }
172
        }
173
        if ($url = $this->fetchAssetUrl($matches[1])) {
174
            return sprintf(self::URL_URI_PATTERN, $url);
175
        }
176
        return $matches[0];    
177
    }
178
179
    /**
180
     * Fetch an asset
181
     *
182
     * @param string $path the asset path
183
     * @return array|false an array with keys 'content' for the file content
184
     * and 'mime' for the mime type, or FALSE on error
185
     */
186
    protected function fetchAsset($path)
187
    {
188
        $asset = false;
189
        if ($this->isHttpAsset($path)) {
190
            if ($url = $this->resolveAssetUrl($path)) {
191
                $asset = $this->fetchHttpAsset($url);
192
            }
193
        } else {
194
            if ($absolute_path = $this->resolveAssetPath($path)) {
195
                $asset = $this->fetchLocalAsset($absolute_path);
196
            }
197
        }
198
        return $asset;
199
    }
200
    
201
    /**
202
     * Get the URL to an asset as it would be embedded in a stylesheet
203
     *
204
     * @param string $path the path to the asset as it appears in the stylesheet
205
     * @return string $url the URL to the asset
206
     */
207
    protected function fetchAssetUrl($path)
208
    {
209
        if (!$this->isHttpAsset($path)) {
210
            return $path;
211
        }
212
        $url = $this->resolveAssetUrl($path); 
213
        if (!($this->http_flags & self::HTTP_EMBED_SCHEME)) {
214
            $url = preg_replace('/^https?:/', '', $url);
215
        }
216
        return $url;
217
    }
218
219
    /**
220
     * Fetch an asset stored locally in the filesystem
221
     *
222
     * @param string $absolute_path the absolute path to the asset
223
     * @return array same as fetchAsset
224
     */
225
    protected function fetchLocalAsset($absolute_path)
226
    {
227
        if (!is_file($absolute_path) || !is_readable($absolute_path)) {
228
            $this->error('Cannot read file %s', $absolute_path);
229
            return false;
230
        }
231
        $content = file_get_contents($absolute_path);
232
233
        $mime = $this->detectMime($absolute_path);
234
235
        if (!$mime && function_exists('mime_content_type')) {
236
            $mime = @mime_content_type($absolute_path);
237
        }
238
239
        if (!$mime && $info = @getimagesize($absolute_path)) {
240
            $mime = $info['mime'];
241
        }
242
243
        if (!$mime) {
244
            $mime = 'application/octet-stream';
245
        }
246
247
        return compact('content', 'mime');
248
    }
249
250
    /**
251
     * Fetch an asset stored remotely over HTTP
252
     *
253
     * @param string $url the url to the asset
254
     * @return array same as fetchAsset
255
     */
256
    protected function fetchHttpAsset($url)
257
    {
258
        if ($this->http_flags & self::HTTP_EMBED_URL_ONLY) {
259
            return false;
260
        }
261
        if (false === ($content = @file_get_contents($url))) {
262
            $this->error('Cannot read url %s', $url);
263
            return false;
264
        }
265
        if (!empty($http_response_header)) {
266
            foreach ($http_response_header as $header) {
267
                $header = strtolower($header);
268
                if (strpos($header, 'content-type:') === 0) {
269
                    $mime = trim(substr($header, strlen('content-type:')));
270
                }
271
            }
272
        }
273
        if (empty($mime)) {
274
            $this->error('No mime type sent with "%s"', $url);
275
            return false;
276
        }
277
        return compact('content', 'mime');
278
    }
279
280
    /**
281
     * Check if a successfully fetched an asset is of a type that can be
282
     * embedded given the current options.
283
     *
284
     * @param array $asset the return value of fetchAsset
285
     * @return boolean
286
     */
287
    protected function assetIsEmbeddable(array $asset)
288
    {
289
        $embed_fonts = ($this->flags & self::EMBED_FONTS);
290
        $is_font = strpos($asset['mime'], 'font') !== false;
291
        if ($is_font && !$embed_fonts) {
292
            return false;
293
        }
294
        
295
        $embed_svg = ($this->flags & self::EMBED_SVG);
296
        $is_svg = strpos($asset['mime'], 'svg') !== false;
297
        if ($is_svg && !($embed_svg || $embed_fonts)) {
298
            return false;
299
        }
300
        
301
        return true;
302
    }
303
304
    /**
305
     * Check if an asset is remote or local
306
     *
307
     * @param string $path the path specified in the CSS file
308
     *
309
     * @return bool
310
     */
311
    protected function isHttpAsset($path)
312
    {
313
        if (!$this->http_enabled) {
314
            return false;
315
        }
316
        // if the root directory is remote, all assets are remote
317
        $schemes = array('http://', 'https://', '//');
318
        foreach ($schemes as $scheme) {
319
            if (strpos($this->root_dir, $scheme) === 0) {
320
                return true;
321
            }
322
        }
323
        // check for remote embedded assets
324
        $schemes[] = '/'; // absolutes should be remote
325
        foreach ($schemes as $scheme) {
326
            if (strpos($path, $scheme) === 0) {
327
                return true;
328
            }
329
        }
330
        // otherwise, it's a local asset
331
        return false;
332
    }
333
334
    /**
335
     * Resolve the absolute path to a local asset
336
     *
337
     * @param string $path the path to the asset, relative to root_dir
338
     * @return false|string the absolute path, or false if not found
339
     */
340
    protected function resolveAssetPath($path)
341
    {
342
        if (preg_match('/[:\?#]/', $path)) {
343
            return false;
344
        }
345
        return realpath($this->root_dir . DIRECTORY_SEPARATOR . $path);
346
    }
347
348
    /**
349
     * Resolve the URL to an http asset
350
     *
351
     * @param string $path
352
     * @return false|string the url, or false if not resolvable
353
     */
354
    protected function resolveAssetUrl($path)
355
    {
356
        $url = $this->buildAssetUrl($path);
357
        if (filter_var($url, FILTER_VALIDATE_URL)) {
358
            return $url;
359
        }
360
        $this->error('Invalid asset url "%s"', $url);
361
        return false;
362
    }
363
364
365
    /**
366
     * Resolve the URL to an http asset
367
     *
368
     * @param string $path
369
     * @return false|string the url, or false if not resolvable
370
     */
371
    protected function buildAssetUrl($path)
372
    {
373
        $default_scheme = ($this->http_flags & self::HTTP_DEFAULT_HTTPS)
374
                        ? 'https:'
375
                        : 'http:'
376
                        ;
377
378
        // case 1: path is already fully qualified url
379
        if (strpos($path, '//') === 0) {
380
            $path = $default_scheme . $path;
381
        }
382
        if (preg_match('/^https?:\/\//', $path)) {
383
            return $path;
384
        }
385
386
        $root_url = $this->root_dir;
387
        if (strpos($root_url, '//') === 0) {
388
            $root_url = $default_scheme . $root_url;
389
        }
390
        $root_domain = preg_replace('#^(https?://[^/]+).*#', '$1', $root_url);
391
        $root_path = substr($root_url, strlen($root_domain));
392
393
        // case 2: asset is absolute path
394
        if (strpos($path, '/') === 0) {
395
            return $root_domain . $path;
396
        }
397
398
        // case 3: asset is relative path
399
        $path = $this->removePathTraversals($root_path . '/' . $path);
400
        $url = $root_domain . '/' . $path;
401
        return $url;
402
    }
403
404
    /**
405
     * Remove directory traversals from a path. Exists because file_get_contents
406
     * seems to choke on http://example.com/path/to/dir/../other-dir/file.txt
407
     *
408
     * @param string $path
409
     * @return string
410
     */
411
    protected function removePathTraversals($path)
412
    {
413
        $path = explode('/', $path);
414
        $return = array();
415
        foreach ($path as $part) {
416
            if ($part == '..') {
417
                array_pop($return);
418
            } elseif ($part && $part !== '.') {
419
                $return[] = $part;
420
            }
421
        }
422
        return implode('/', $return);
423
    }
424
425
    /**
426
     * Check the file mime type against the mime.types file
427
     *
428
     * @param string $path the path to the file
429
     * @return string the mime, or false if it could not be identified
430
     */
431
    protected function detectMime($path)
432
    {
433
        if (!$this->mime_magic_path) {
434
            return false;
435
        }
436
        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
437
        if (!$ext) {
438
            return false;
439
        }
440
        $mime_types = file($this->mime_magic_path);
441
        foreach ($mime_types as $line) {
442
            if ($mime = $this->compareMime($ext, $line)) {
443
                return $mime;
444
            }
445
        }
446
        return false;
447
    }
448
449
    /**
450
     * Compare an extention against the a line in the mime.types
451
     *
452
     * @param string $ext the file extension
453
     * @param string $line the line from the mime.types file
454
     * @return string|bool the mime type if there is a match, false if not
455
     */
456
    protected function compareMime($ext, $line)
457
    {
458
        if (strpos($line, '#') === 0) {
459
            return false;
460
        }
461
        $line = preg_replace('/\s+/', ' ', $line);
462
        $line = array_filter(explode(' ', $line));
463
        $mime = array_shift($line);
464
        return in_array($ext, $line) ? $mime : false;
465
    }
466
467
    /**
468
     * Download the Apache mimes.types file and save it locally
469
     *
470
     * @param string $path the path to save the file to
471
     * @return void
472
     */
473
    protected function createMimesFile($path)
474
    {
475
        $mime_types = @file_get_contents(self::MIME_MAGIC_URL);
476
        if ($mime_types === false) {
477
            return;
478
        }
479
        // special case: woff2 is too new
480
        if (strpos($mime_types, 'woff2') === false) {
481
            $mime_types .= "\napplication/font-woff2 woff2";
482
        }
483
        file_put_contents($path, $mime_types);
484
        clearstatcache();
485
    }
486
487
    /**
488
     * Throw an exception if URL_ON_ERROR is not set
489
     *
490
     * This method accepts an unlimited number of arguments. They will be passed
491
     * to sprintf to generate the error message in the exception.  For example:
492
     *
493
     *     $this->error('My exception about %d %s', 4, 'cats');
494
     *
495
     * would throw an exception with with the message "My error about 4 cats".
496
     *
497
     * @throws \InvalidArgmumentException
498
     * @return void
499
     */
500
    protected function error()
501
    {
502
        if ($this->flags & self::URL_ON_ERROR) {
503
            return;
504
        }
505
        $args = func_get_args();
506
        if (empty($args)) {
507
            $args[] = 'Unknown Error';
508
        }
509
        $msg = count($args) > 1
510
             ? call_user_func_array('sprintf', $args)
511
             : array_shift($args)
512
             ;
513
        throw new \InvalidArgumentException($msg);
514
    }
515
}
516