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