Completed
Pull Request — master (#16)
by
unknown
02:45
created

CssEmbed::embedHttpAssetUrl()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 7
rs 9.4285
cc 2
eloc 4
nc 2
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 URI_PATTERN = "url(data:%s;base64,%s)";
20
21
    const HTTP_SEARCH_PATTERN = "%url\\(['\" ]*((?!data:)[^'\" ]+)['\" ]*\\)%U";
22
    const HTTP_ENABLED = 1;
23
    const HTTP_DEFAULT_HTTPS = 2;
24
    const HTTP_URL_ON_ERROR = 4;
25
    const HTTP_EMBED_FONTS = 8;
26
    const HTTP_EMBED_SVG = 16;
27
    const HTTP_EMBED_SCHEME = 32;
28
    const HTTP_EMBED_URL_ONLY = 64;
29
30
    protected $root_dir;
31
32
    /** @var integer the http flags */
33
    protected $http_flags = 0;
34
35
    /**
36
     * @param $root_dir
37
     */
38
    public function setRootDir($root_dir)
39
    {
40
        $this->root_dir = $root_dir;
41
    }
42
43
    /**
44
     * Allow assets referenced over HTTP to be embedded, or assets in a css
45
     * file loaded over HTTP. Flags:
46
     *
47
     *     - CssEmbed::HTTP_ENABLED: enable embedding over http;
48
     *     - CssEmbed::HTTP_DEFAULT_HTTPS: for URLs with no scheme, use https to
49
     *       instead of http
50
     *     - CssEmbed::HTTP_URL_ON_ERROR: if there is an error fetching a remote
51
     *       asset, embed the URL instead of throwing an exception
52
     *     - CssEmbed::HTTP_EMBED_FONTS: embedding fonts will usually break them
53
     *       in most browsers.  Enable this flag to force the embed. WARNING:
54
     *       this flag is currently not unit tested, but seems to work.
55
     *     - CssEmbed::HTTP_EMBED_SVG: SVG is often used as a font face; however
56
     *       including these in a stylesheet will cause it to bloat for browsers
57
     *       that don't use it.  By default SVGs will be replaced with the URL
58
     *       to the asset; set this flag to force the embed of SVG files.
59
     *     - CssEmbed::HTTP_EMBED_SCHEME: By default, assets that are converted
60
     *       to URLs instead of data urls have no scheme (eg, "//example.com").
61
     *       This is better for stylesheets that are maybe served over http or
62
     *       https, but it will break stylesheets served from a local HTML file.
63
     *       Set this option to force the schema (eg, "http://example.com").
64
     *     - CssEmbed::HTTP_EMBED_URL_ONLY: do not convert assets to data URLs,
65
     *       only the fully qualified URL.
66
     *
67
     *
68
     * @param integer $flags
69
     *
70
     * @return void
71
     */
72
    public function enableHttp($flags = null)
73
    {
74
        if (is_null($flags)) {
75
            $flags = CssEmbed::HTTP_ENABLED|CssEmbed::HTTP_URL_ON_ERROR;
76
77
        }
78
        $this->http_flags = (int) $flags;
79
    }
80
81
    /**
82
     * Set a single http option flag. See `enableHttp` for a description of
83
     * available flags.
84
     *
85
     * @param integer $flag
86
     *
87
     * @return void
88
     */
89
    public function setHttpFlag($flag)
90
    {
91
        $this->http_flags |= $flag;
92
    }
93
94
    /**
95
     * unset a single http option flag. See `enableHttp` for a description of
96
     * available flags.
97
     *
98
     * @param integer $flag
99
     *
100
     * @return void
101
     */
102
    public function unsetHttpFlag($flag)
103
    {
104
        $this->http_flags = $this->http_flags & (~ $flag);
105
    }
106
107
    /**
108
     * @param $css_file
109
     * @return null|string
110
     * @throws \InvalidArgumentException
111
     */
112
    public function embedCss($css_file)
113
    {
114
        $this->setRootDir(dirname($css_file));
115
        $return = null;
116
        $handle = fopen($css_file, "r");
117
        if ($handle === false) {
118
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $css_file));
119
        }
120
        while (($line = fgets($handle)) !== false) {
121
            $return .= $this->embedString($line);
122
        }
123
        fclose($handle);
124
125
        return $return;
126
    }
127
128
    /**
129
     * @param $content
130
     * @return mixed
131
     */
132
    public function embedString($content)
133
    {
134
        if ($this->http_flags & self::HTTP_ENABLED) {
135
            return preg_replace_callback(
136
                self::HTTP_SEARCH_PATTERN,
137
                array($this, 'httpEnabledReplace'),
138
                $content
139
            );
140
        }
141
        return preg_replace_callback(self::SEARCH_PATTERN, array($this, 'replace'), $content);
142
    }
143
144
145
    /**
146
     * @param $matches
147
     * @return string
148
     */
149
    protected function replace($matches)
150
    {
151
        return $this->embedFile($this->root_dir . DIRECTORY_SEPARATOR . $matches[1]);
152
    }
153
154
    /**
155
     * @param $file
156
     * @return string
157
     */
158
    protected function embedFile($file)
159
    {
160
        return sprintf(self::URI_PATTERN, $this->mimeType($file), $this->base64($file));
161
    }
162
163
    /**
164
     * @param $file
165
     * @return string
166
     */
167
    protected function mimeType($file)
168
    {
169
        if (function_exists('mime_content_type')) {
170
            return mime_content_type($file);
171
        }
172
173
        if ($info = @getimagesize($file)) {
174
            return($info['mime']);
175
        }
176
177
        return 'application/octet-stream';
178
    }
179
180
    /**
181
     * @param $file
182
     * @return string
183
     * @throws \InvalidArgumentException
184
     */
185
    protected function base64($file)
186
    {
187
        if (is_file($file) === false || is_readable($file) === false) {
188
            throw new \InvalidArgumentException(sprintf('Cannot read file %s', $file));
189
        }
190
191
        return base64_encode(file_get_contents($file));
192
    }
193
194
    /**
195
     * @param $matches
196
     * @return string
197
     */
198
    protected function httpEnabledReplace($matches)
199
    {
200
        // fall back to default functionality for non-remote assets
201
        if (!$this->isHttpAsset($matches[1])) {
202
            if (preg_match('/[#\?:]/', $matches[1])) {
203
                return $matches[0];
204
            }
205
            return $this->replace($matches);
206
        }
207
        if ($asset_url = $this->resolveHttpAssetUrl($this->root_dir, $matches[1])) {
208
            if ($replacement = $this->embedHttpAsset($asset_url)) {
209
                return $replacement;
210
            }
211
            return $this->embedHttpAssetUrl($asset_url);
212
        }
213
        return $matches[0];
214
    }
215
216
    /**
217
     * Get the contents of a URL and return it as a data uri within url()
218
     *
219
     * @param string $url the URL to the file to embed
220
     * @return string|bool the string for the CSS url property, or FALSE if the
221
     * url could not/should not be embedded.
222
     */
223
    protected function embedHttpAsset($url)
224
    {
225
        if ($this->http_flags & self::HTTP_EMBED_URL_ONLY) {
226
            return false;
227
        }
228
        if (false === ($content = @file_get_contents($url))) {
229
            $this->httpError('Cannot read url %s', $url);
230
            return false;
231
        }
232
        if (!empty($http_response_header)) {
233
            foreach ($http_response_header as $header) {
234
                $header = strtolower($header);
235
                if (strpos($header, 'content-type:') === 0) {
236
                    $mime = trim(substr($header, strlen('content-type:')));
237
                }
238
            }
239
        }
240
        if (empty($mime)) {
241
            $this->httpError('No mime type sent with "%s"', $url);
242
            return false;
243
        }
244
245
        // handle a special case: fonts will usually break if embedded, but
246
        // user can force
247
        $embed_fonts = ($this->http_flags & self::HTTP_EMBED_FONTS);
248
        $is_font = strpos($mime, 'font') !== false;
249
        if ($is_font && !$embed_fonts) {
250
            return false;
251
        }
252
        
253
        // another special case:  SVG is often a font and will cause the
254
        // stylesheet to bloat if it's embeded for browsers that don't use it.
255
        $embed_svg = ($this->http_flags & self::HTTP_EMBED_SVG);
256
        $is_svg = strpos($mime, 'svg') !== false;
257
        if ($is_svg && !($embed_svg || $embed_fonts)) {
258
            return false;
259
        }
260
        
261
        return sprintf(self::URI_PATTERN, $mime, base64_encode($content));
262
    }
263
264
    /**
265
     * For URLs that could not/should not be embedded, embed the resolved URL
266
     * instead.
267
     *
268
     * @param string $url
269
     * @return string
270
     */
271
    protected function embedHttpAssetUrl($url)
272
    {
273
        if (!($this->http_flags & self::HTTP_EMBED_SCHEME)) {
274
            $url = preg_replace('/^https?:/', '', $url);
275
        }
276
        return sprintf("url('%s')", $url);
277
    }
278
279
    /**
280
     * Check if an asset is remote or local
281
     *
282
     * @param string $path the path specified in the CSS file
283
     *
284
     * @return bool
285
     */
286
    protected function isHttpAsset($path)
287
    {
288
        // if the root directory is remote, all assets are remote
289
        $schemes = array('http://', 'https://', '//');
290
        foreach ($schemes as $scheme) {
291
            if (strpos($this->root_dir, $scheme) === 0) {
292
                return true;
293
            }
294
        }
295
        // check for remote embedded assets
296
        foreach ($schemes as $scheme) {
297
            if (strpos($path, $scheme) === 0) {
298
                return true;
299
            }
300
        }
301
        // absolutes should be remote
302
        if (strpos($path, '/') === 0) {
303
            return true;
304
        }
305
        // otherwise, it's a local asset
306
        return false;
307
    }
308
309
    /**
310
     * Resolve the URL to an http asset
311
     *
312
     * @param string $root_url the root URL
313
     * @param string
314
     */
315
    protected function resolveHttpAssetUrl($root_url, $path)
316
    {
317
        $default_scheme = ($this->http_flags & self::HTTP_DEFAULT_HTTPS)
318
                        ? 'https:'
319
                        : 'http:'
320
                        ;
321
322
        // case 1: path is already fully qualified url
323
        if (strpos($path, '//') === 0) {
324
            $path = $default_scheme . $path;
325
        }
326
        if (preg_match('/^https?:\/\//', $path)) {
327
            if (!filter_var($path, FILTER_VALIDATE_URL)) {
328
                $this->httpError('Invalid asset url "%s"', $path);
329
                return false;
330
            }
331
            return $path;
332
        }
333
334
        if (strpos($root_url, '//') === 0) {
335
            $root_url = $default_scheme . $root_url;
336
        }
337
        $root_domain = preg_replace('#^(https?://[^/]+).*#', '$1', $root_url);
338
        $root_path = substr($root_url, strlen($root_domain));
339
340
        // case 2: asset is absolute path
341
        if (strpos($path, '/') === 0) {
342
            return $root_domain . $path;
343
        }
344
345
        // case 3: asset is relative path        
346
        // remove directory transversal (file_get_contents seems to choke on it)
347
        $path = explode('/', $path);
348
        $root_path = array_filter(explode('/', $root_path));
349
        $asset_path = array();
0 ignored issues
show
Unused Code introduced by
$asset_path is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
350
        while (NULL !== ($part = array_shift($path))) {
351
            if (!$part || $part === '.') {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
352
                // drop the empty part
353
            } elseif ($part == '..') {
354
                array_pop($root_path);
355
            } else {
356
                $_path[] = $part;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$_path was never initialized. Although not strictly required by PHP, it is generally a good practice to add $_path = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
357
            }
358
        }
359
        $asset_path = implode('/', $_path);
0 ignored issues
show
Bug introduced by
The variable $_path does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
360
        $root_path = empty($root_path) ? '/' : '/' . implode('/', $root_path) . '/';
361
362
        // ... and build the URL
363
        $url = $root_domain . $root_path . $asset_path;
364
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
365
            $this->httpError('Could not resolve "%s" with root "%s"', $path, $this->root_dir);
366
            return false;
367
        }
368
        return $url;
369
    }
370
371
    /**
372
     * Throw an exception if HTTP_URL_ON_ERROR is not set
373
     *
374
     * @param string $msg the message
375
     * @param string $interpolations... strings to interpolate in the error message
0 ignored issues
show
Documentation introduced by
There is no parameter named $interpolations.... Did you maybe mean $interpolations?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

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

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

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

Loading history...
376
     * @throws \InvalidArgmumentException
377
     * @return void
378
     */
379
    protected function httpError($msg, $interpolations)
0 ignored issues
show
Unused Code introduced by
The parameter $msg is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $interpolations is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
380
    {
381
        if ($this->http_flags & self::HTTP_URL_ON_ERROR) {
382
            return;
383
        }
384
        $msg = call_user_func_array('sprintf', func_get_args());
385
        throw new \InvalidArgumentException($msg);
386
    }
387
}
388