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

CssEmbed::detectMime()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 17
rs 8.8571
cc 5
eloc 11
nc 5
nop 1
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 $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
            if ($mime_types = @file_get_contents(self::MIME_MAGIC_URL)) {
118
                // special case: woff2 is too new
119
                if (strpos($mime_types, 'woff2') === false) {
120
                    $mime_types .= "\napplication/font-woff2 woff2";
121
                }
122
                file_put_contents($path, $mime_types);
123
                clearstatcache();
124
            }
125
        }
126
        if (!file_exists($path)) {
127
            $msg = sprintf('mime.types does not exist and cannot be created: "%s"', $path);
128
            throw new \InvalidArgumentException($msg);            
129
        }
130
        if (!is_readable($path) || !is_file($path)) {
131
            $msg = sprintf('Invalid mime.types file: "%s"', $path);
132
            throw new \InvalidArgumentException($msg);        
133
        }
134
        $this->mime_magic_path = $path;
135
    }
136
137
    /**
138
     * @param $css_file
139
     * @return null|string
140
     * @throws \InvalidArgumentException
141
     */
142
    public function embedCss($css_file)
143
    {
144
        $this->setRootDir(dirname($css_file));
145
        $content = @file_get_contents($css_file);
146
        if ($content === false) {
147
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $css_file));
148
        }
149
        return $this->embedString($content);
150
    }
151
152
    /**
153
     * @param $content
154
     * @return mixed
155
     */
156
    public function embedString($content)
157
    {
158
        return preg_replace_callback(
159
            self::SEARCH_PATTERN,
160
            array($this, 'replace'),
161
            $content
162
        );
163
    }
164
165
    /**
166
     * preg_replace_callback callback for embedString.
167
     *
168
     * @param array $matches
169
     * @return string
170
     */
171
    protected function replace($matches)
172
    {
173
        if ($asset = $this->fetchAsset($matches[1])) {
174
            if ($this->assetIsEmbeddable($asset)) {
175
                return sprintf(
176
                    self::DATA_URI_PATTERN,
177
                    $asset['mime'],
178
                    base64_encode($asset['content'])
179
                );
180
            }
181
        }
182
        if ($url = $this->fetchAssetUrl($matches[1])) {
183
            return sprintf(self::URL_URI_PATTERN, $url);
184
        }
185
        return $matches[0];    
186
    }
187
188
    /**
189
     * Fetch an asset
190
     *
191
     * @param string $path the asset path
192
     * @return array|false an array with keys 'content' for the file content
193
     * and 'mime' for the mime type, or FALSE on error
194
     */
195
    protected function fetchAsset($path)
196
    {
197
        $asset = false;
198
        if ($this->isHttpAsset($path)) {
199
            if ($url = $this->resolveAssetUrl($path)) {
200
                $asset = $this->fetchHttpAsset($url);
201
            }
202
        } else {
203
            if ($absolute_path = $this->resolveAssetPath($path)) {
204
                $asset = $this->fetchLocalAsset($absolute_path);
205
            }
206
        }
207
        return $asset;
208
    }
209
    
210
    /**
211
     * Get the URL to an asset as it would be embedded in a stylesheet
212
     *
213
     * @param string $path the path to the asset as it appears in the stylesheet
214
     * @return string $url the URL to the asset
215
     */
216
    protected function fetchAssetUrl($path)
217
    {
218
        if (!$this->isHttpAsset($path)) {
219
            return $path;
220
        }
221
        $url = $this->resolveAssetUrl($path); 
222
        if (!($this->http_flags & self::HTTP_EMBED_SCHEME)) {
223
            $url = preg_replace('/^https?:/', '', $url);
224
        }
225
        return $url;
226
    }
227
228
    /**
229
     * Fetch an asset stored locally in the filesystem
230
     *
231
     * @param string $absolute_path the absolute path to the asset
232
     * @return array same as fetchAsset
233
     */
234
    protected function fetchLocalAsset($absolute_path)
235
    {
236
        if (!is_file($absolute_path) || !is_readable($absolute_path)) {
237
            $this->error('Cannot read file %s', $absolute_path);
238
            return false;
239
        }
240
        $content = file_get_contents($absolute_path);
241
242
        $mime = $this->detectMime($absolute_path);
243
244
        if (!$mime && function_exists('mime_content_type')) {
245
            $mime = @mime_content_type($absolute_path);
246
        }
247
248
        if (!$mime && $info = @getimagesize($absolute_path)) {
249
            $mime = $info['mime'];
250
        }
251
252
        if (!$mime) {
253
            $mime = 'application/octet-stream';
254
        }
255
256
        return compact('content', 'mime');
257
    }
258
259
    /**
260
     * Fetch an asset stored remotely over HTTP
261
     *
262
     * @param string $url the url to the asset
263
     * @return array same as fetchAsset
264
     */
265
    protected function fetchHttpAsset($url)
266
    {
267
        if ($this->http_flags & self::HTTP_EMBED_URL_ONLY) {
268
            return false;
269
        }
270
        if (false === ($content = @file_get_contents($url))) {
271
            $this->error('Cannot read url %s', $url);
272
            return false;
273
        }
274
        if (!empty($http_response_header)) {
275
            foreach ($http_response_header as $header) {
276
                $header = strtolower($header);
277
                if (strpos($header, 'content-type:') === 0) {
278
                    $mime = trim(substr($header, strlen('content-type:')));
279
                }
280
            }
281
        }
282
        if (empty($mime)) {
283
            $this->error('No mime type sent with "%s"', $url);
284
            return false;
285
        }
286
        return compact('content', 'mime');
287
    }
288
289
    /**
290
     * Check if a successfully fetched an asset is of a type that can be
291
     * embedded given the current options.
292
     *
293
     * @param array $asset the return value of fetchAsset
294
     * @return boolean
295
     */
296
    protected function assetIsEmbeddable(array $asset)
297
    {
298
        $embed_fonts = ($this->flags & self::EMBED_FONTS);
299
        $is_font = strpos($asset['mime'], 'font') !== false;
300
        if ($is_font && !$embed_fonts) {
301
            return false;
302
        }
303
        
304
        $embed_svg = ($this->flags & self::EMBED_SVG);
305
        $is_svg = strpos($asset['mime'], 'svg') !== false;
306
        if ($is_svg && !($embed_svg || $embed_fonts)) {
307
            return false;
308
        }
309
        
310
        return true;
311
    }
312
313
    /**
314
     * Check if an asset is remote or local
315
     *
316
     * @param string $path the path specified in the CSS file
317
     *
318
     * @return bool
319
     */
320
    protected function isHttpAsset($path)
321
    {
322
        if (!$this->http_enabled) {
323
            return false;
324
        }
325
        // if the root directory is remote, all assets are remote
326
        $schemes = array('http://', 'https://', '//');
327
        foreach ($schemes as $scheme) {
328
            if (strpos($this->root_dir, $scheme) === 0) {
329
                return true;
330
            }
331
        }
332
        // check for remote embedded assets
333
        $schemes[] = '/'; // absolutes should be remote
334
        foreach ($schemes as $scheme) {
335
            if (strpos($path, $scheme) === 0) {
336
                return true;
337
            }
338
        }
339
        // otherwise, it's a local asset
340
        return false;
341
    }
342
343
    /**
344
     * Resolve the absolute path to a local asset
345
     *
346
     * @param string $path the path to the asset, relative to root_dir
347
     * @return string|boolean the absolute path, or false if not found
348
     */
349
    protected function resolveAssetPath($path)
350
    {
351
        if (preg_match('/[:\?#]/', $path)) {
352
            return false;
353
        }
354
        return realpath($this->root_dir . DIRECTORY_SEPARATOR . $path);
355
    }
356
357
    /**
358
     * Resolve the URL to an http asset
359
     *
360
     * @param string $root_url the root URL
0 ignored issues
show
Bug introduced by
There is no parameter named $root_url. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
361
     * @param string
362
     */
363
    protected function resolveAssetUrl($path)
364
    {
365
        $root_url = $this->root_dir;
366
        $default_scheme = ($this->http_flags & self::HTTP_DEFAULT_HTTPS)
367
                        ? 'https:'
368
                        : 'http:'
369
                        ;
370
371
        // case 1: path is already fully qualified url
372
        if (strpos($path, '//') === 0) {
373
            $path = $default_scheme . $path;
374
        }
375
        if (preg_match('/^https?:\/\//', $path)) {
376
            if (!filter_var($path, FILTER_VALIDATE_URL)) {
377
                $this->error('Invalid asset url "%s"', $path);
378
                return false;
379
            }
380
            return $path;
381
        }
382
383
        if (strpos($root_url, '//') === 0) {
384
            $root_url = $default_scheme . $root_url;
385
        }
386
        $root_domain = preg_replace('#^(https?://[^/]+).*#', '$1', $root_url);
387
        $root_path = substr($root_url, strlen($root_domain));
388
389
        // case 2: asset is absolute path
390
        if (strpos($path, '/') === 0) {
391
            return $root_domain . $path;
392
        }
393
394
        // case 3: asset is relative path        
395
        // remove directory transversal (file_get_contents seems to choke on it)
396
        $path = explode('/', $path);
397
        $root_path = array_filter(explode('/', $root_path));
398
        $asset_path = array();
399
        while (NULL !== ($part = array_shift($path))) {
400
            if ($part == '..') {
401
                array_pop($root_path);
402
            } elseif ($part && $part !== '.') {
403
                $asset_path[] = $part;
404
            }
405
        }
406
        $asset_path = implode('/', $asset_path);
407
        $root_path = empty($root_path) ? '/' : '/' . implode('/', $root_path) . '/';
408
409
        // ... and build the URL
410
        $url = $root_domain . $root_path . $asset_path;
411
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
412
            $this->error('Could not resolve "%s" with root "%s"', $path, $this->root_dir);
413
            return false;
414
        }
415
        return $url;
416
    }
417
418
    /**
419
     * Check the file mime type against the mime.types file
420
     *
421
     * @param string $path the path to the file
422
     * @return string the mime, or false if it could not be identified
423
     */
424
    protected function detectMime($path)
425
    {
426
        if (!$this->mime_magic_path) {
427
            return false;
428
        }
429
        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
430
        if (!$ext) {
431
            return false;
432
        }
433
        $mime_types = file($this->mime_magic_path);
434
        foreach ($mime_types as $line) {
435
            if ($mime = $this->compareMime($ext, $line)) {
436
                return $mime;
437
            }
438
        }
439
        return false;
440
    }
441
442
    /**
443
     * Compare an extention against the a line in the mime.types
444
     *
445
     * @param string $ext the file extension
446
     * @param string $line the line from the mime.types file
447
     * @return string|bool the mime type if there is a match, false if not
448
     */
449
    protected function compareMime($ext, $line)
450
    {
451
        if (strpos($line, '#') === 0) {
452
            return false;
453
        }
454
        $line = preg_replace('/\s+/', ' ', $line);
455
        $line = array_filter(explode(' ', $line));
456
        $mime = array_shift($line);
457
        return in_array($ext, $line) ? $mime : false;
458
    }
459
460
    /**
461
     * Throw an exception if URL_ON_ERROR is not set
462
     *
463
     * This method accepts an unlimited number of arguments. They will be passed
464
     * to sprintf to generate the error message in the exception.  For example:
465
     *
466
     *     $this->error('My exception about %d %s', 4, 'cats');
467
     *
468
     * would throw an exception with with the message "My error about 4 cats".
469
     *
470
     * @throws \InvalidArgmumentException
471
     * @return void
472
     */
473
    protected function error()
474
    {
475
        if ($this->flags & self::URL_ON_ERROR) {
476
            return;
477
        }
478
        $args = func_get_args();
479
        if (empty($args)) {
480
            $args[] = 'Unknown Error';
481
        }
482
        $msg = count($args) > 1
483
             ? call_user_func_array('sprintf', $args)
484
             : array_shift($args)
485
             ;
486
        throw new \InvalidArgumentException($msg);
487
    }
488
}
489