Completed
Pull Request — master (#5408)
by Damian
23:40 queued 12:41
created

Oembed::autodiscover_from_url()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 11
rs 9.4285
cc 3
eloc 7
nc 2
nop 1
1
<?php
2
/**
3
 * Format of the Oembed config. Autodiscover allows discovery of all URLs.
4
 *
5
 * Endpoint set to true means autodiscovery for this specific provider is
6
 * allowed (even if autodiscovery in general has been disabled).
7
 *
8
 * <code>
9
 *
10
 * name: Oembed
11
 * ---
12
 * Oembed:
13
 *   providers:
14
 *     'http://*.youtube.com/watch*':
15
 *     'http://www.youtube.com/oembed/'
16
 *   autodiscover:
17
 *     true
18
 * </code>
19
 *
20
 * @package framework
21
 * @subpackage oembed
22
 */
23
24
class Oembed implements ShortcodeHandler {
25
26
	public static function is_enabled() {
27
		return Config::inst()->get('Oembed', 'enabled');
28
	}
29
30
	/**
31
	 * Gets the autodiscover setting from the config.
32
	 */
33
	public static function get_autodiscover() {
34
		return Config::inst()->get('Oembed', 'autodiscover');
35
	}
36
37
	/**
38
	 * Gets providers from config.
39
	 */
40
	public static function get_providers() {
41
		return Config::inst()->get('Oembed', 'providers');
42
	}
43
44
	/**
45
	 * Returns an endpoint (a base Oembed URL) from first matching provider.
46
	 *
47
	 * @param $url Human-readable URL.
48
	 * @returns string/bool URL of an endpoint, or false if no matching provider exists.
49
	 */
50
	protected static function find_endpoint($url) {
51
		foreach(self::get_providers() as $scheme=>$endpoint) {
0 ignored issues
show
Bug introduced by
The expression self::get_providers() of type array|integer|double|string|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
52
			if(self::matches_scheme($url, $scheme)) {
53
				$protocol = Director::is_https() ? 'https' : 'http';
54
55
				if (is_array($endpoint)) {
56
					if (array_key_exists($protocol, $endpoint)) $endpoint = $endpoint[$protocol];
57
					else $endpoint = reset($endpoint);
58
				}
59
60
				return $endpoint;
61
			}
62
		}
63
		return false;
64
	}
65
66
	/**
67
	 * Checks the URL if it matches against the scheme (pattern).
68
	 *
69
	 * @param $url Human-readable URL to be checked.
70
	 * @param $scheme Pattern to be matched against.
71
	 * @returns bool Whether the pattern matches or not.
72
	 */
73
	protected static function matches_scheme($url, $scheme) {
74
		$urlInfo = parse_url($url);
75
		$schemeInfo = parse_url($scheme);
76
77
		foreach($schemeInfo as $k=>$v) {
0 ignored issues
show
Bug introduced by
The expression $schemeInfo of type array<string,string>|false is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
78
			if(!array_key_exists($k, $urlInfo)) {
79
				return false;
80
			}
81
			if(strpos($v, '*') !== false) {
82
				$v = preg_quote($v, '/');
83
				$v = str_replace('\*', '.*', $v);
84
				if($k == 'host') {
85
					$v = str_replace('*\.', '*', $v);
86
				}
87
				if(!preg_match('/' . $v . '/', $urlInfo[$k])) {
88
					return false;
89
				}
90
			} elseif(strcasecmp($urlInfo[$k], $v)) {
91
				return false;
92
			}
93
		}
94
		return true;
95
	}
96
97
	/**
98
	 * Performs a HTTP request to the URL and scans the response for resource links
99
	 * that mention oembed in their type.
100
	 *
101
	 * @param $url Human readable URL.
102
	 * @returns string/bool Oembed URL, or false.
103
	 */
104
	protected static function autodiscover_from_url($url)
105
	{
106
		// Fetch the URL (cache for a week by default)
107
		$service = new RestfulService($url, 60 * 60 * 24 * 7);
108
		$body = $service->request();
109
		if (!$body || $body->isError()) {
110
			return false;
111
		}
112
		$body = $body->getBody();
113
		return static::autodiscover_from_body($body);
114
	}
115
116
	/**
117
	 * Given a response body, determine if there is an autodiscover url
118
	 *
119
	 * @param string $body
120
	 * @return bool|string
121
	 */
122
	public static function autodiscover_from_body($body) {
123
		// Look within the body for an oembed link.
124
		$pcreOmbed = '#<link[^>]+?(?:href=[\'"](?<first>[^\'"]+?)[\'"][^>]+?)'
125
			. '?type=["\']application/json\+oembed["\']'
126
			. '(?:[^>]+?href=[\'"](?<second>[^\'"]+?)[\'"])?#';
127
128
		if(preg_match_all($pcreOmbed, $body, $matches, PREG_SET_ORDER)) {
129
			$match = $matches[0];
130
			if(!empty($match['second'])) {
131
				return html_entity_decode($match['second']);
132
			}
133
			if(!empty($match['first'])) {
134
				return html_entity_decode($match['first']);
135
			}
136
		}
137
		return false;
138
	}
139
140
	/**
141
	 * Takes the human-readable URL of an embeddable resource and converts it into an
142
	 * Oembed_Result descriptor (which contains a full Oembed resource URL).
143
	 *
144
	 * @param $url Human-readable URL
145
	 * @param $type ?
146
	 * @param $options array Options to be used for constructing the resulting descriptor.
147
	 * @returns Oembed_Result/bool An Oembed descriptor, or false
148
	 */
149
	public static function get_oembed_from_url($url, $type = false, array $options = array()) {
150
		if(!self::is_enabled()) return false;
151
152
		// Find or build the Oembed URL.
153
		$endpoint = self::find_endpoint($url);
154
		$oembedUrl = false;
155
		if(!$endpoint) {
156
			if(self::get_autodiscover()) {
157
				$oembedUrl = self::autodiscover_from_url($url);
158
			}
159
		} elseif($endpoint === true) {
160
			$oembedUrl = self::autodiscover_from_url($url);
161
		} else {
162
			// Build the url manually - we gave all needed information.
163
			$oembedUrl = Controller::join_links($endpoint, '?format=json&url=' . rawurlencode($url));
164
		}
165
166
		// If autodescovery failed the resource might be a direct link to a file
167
		if(!$oembedUrl) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $oembedUrl of type false|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
168
			if(File::get_app_category(File::get_file_extension($url)) == "image") {
169
				return new Oembed_Result($url, $url, $type, $options);
170
			}
171
		}
172
173
		if($oembedUrl) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $oembedUrl of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
174
			// Inject the options into the Oembed URL.
175
			if($options) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $options of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
176
				if(isset($options['width']) && !isset($options['maxwidth'])) {
177
					$options['maxwidth'] = $options['width'];
178
				}
179
				if(isset($options['height']) && !isset($options['maxheight'])) {
180
					$options['maxheight'] = $options['height'];
181
				}
182
				$oembedUrl = Controller::join_links($oembedUrl, '?' . http_build_query($options, '', '&'));
183
			}
184
185
			return new Oembed_Result($oembedUrl, $url, $type, $options);
186
		}
187
188
		// No matching Oembed resource found.
189
		return false;
190
	}
191
192
	public static function get_shortcodes() {
193
		return 'embed';
194
	}
195
196
	public static function handle_shortcode($arguments, $content, $parser, $shortcode, $extra = array()) {
197
		if(isset($arguments['type'])) {
198
			$type = $arguments['type'];
199
			unset($arguments['type']);
200
		} else {
201
			$type = false;
202
		}
203
		$oembed = self::get_oembed_from_url($content, $type, $arguments);
204
		if($oembed && $oembed->exists()) {
205
			return $oembed->forTemplate();
206
		} else {
207
			return '<a href="' . $content . '">' . $content . '</a>';
208
		}
209
	}
210
}
211
212
/**
213
 * @property string $Type Oembed type
214
 * @property string $Title Title
215
 * @property string $URL URL to asset
216
 * @property string $Provider_URL Url for provider
217
 * @property int $Width
218
 * @property int $Height
219
 * @property string $Info Descriptive text for this oembed
220
 *
221
 * @package framework
222
 * @subpackage oembed
223
 */
224
class Oembed_Result extends ViewableData {
225
	/**
226
	 * JSON data fetched from the Oembed URL.
227
	 * This data is accessed dynamically by getField and hasField.
228
	 */
229
	protected $data = false;
230
231
	/**
232
	 * Human readable URL
233
	 */
234
	protected $origin = false;
235
236
	/**
237
	 * ?
238
	 */
239
	protected $type = false;
240
241
	/**
242
	 * Oembed URL
243
	 */
244
	protected $url;
245
246
	/**
247
	 * Class to be injected into the resulting HTML element.
248
	 */
249
	protected $extraClass;
250
251
	private static $casting = array(
252
		'html' => 'HTMLText',
253
	);
254
255
	public function __construct($url, $origin = false, $type = false, array $options = array()) {
256
		$this->url = $url;
257
		$this->origin = $origin;
258
		$this->type = $type;
259
260
		if(isset($options['class'])) {
261
			$this->extraClass = $options['class'];
262
		}
263
		if($options) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $options of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
264
			if(isset($options['width'])) {
265
				$this->Width = $options['width'];
266
			}
267
			if(isset($options['height'])) {
268
				$this->Height = $options['height'];
269
			}
270
		}
271
		parent::__construct();
272
	}
273
274
	public function getOembedURL() {
275
		return $this->url;
276
	}
277
278
	/**
279
	 * Fetches the JSON data from the Oembed URL (cached).
280
	 * Only sets the internal variable.
281
	 */
282
	protected function loadData() {
283
		if($this->data !== false) {
284
			return;
285
		}
286
287
		// Fetch from Oembed URL (cache for a week by default)
288
		$service = new RestfulService($this->url, 60*60*24*7);
289
		$body = $service->request();
290
		if(!$body || $body->isError()) {
291
			$this->data = array();
0 ignored issues
show
Documentation Bug introduced by
It seems like array() of type array is incompatible with the declared type boolean of property $data.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
292
			return;
293
		}
294
		$body = $body->getBody();
295
		$data = json_decode($body, true);
296
		if(!$data) {
297
			// if the response is no valid JSON we might have received a binary stream to an image
298
			$data = array();
299
			if (!function_exists('imagecreatefromstring')) {
300
				throw new LogicException('imagecreatefromstring function does not exist - Please make sure GD is installed');
301
			}
302
			$image = imagecreatefromstring($body);
303
			if($image !== FALSE) {
304
				preg_match("/^(http:\/\/)?([^\/]+)/i", $this->url, $matches);
305
				$protocoll = $matches[1];
306
				$host = $matches[2];
307
				$data['type'] = "photo";
308
				$data['title'] = basename($this->url) . " ($host)";
309
				$data['url'] = $this->url;
310
				$data['provider_url'] = $protocoll.$host;
311
				$data['width'] = imagesx($image);
312
				$data['height'] = imagesy($image);
313
				$data['info'] = _t('UploadField.HOTLINKINFO',
314
					'Info: This image will be hotlinked. Please ensure you have permissions from the'
315
					. ' original site creator to do so.');
316
			}
317
		}
318
319
		// Convert all keys to lowercase
320
		$data = array_change_key_case($data, CASE_LOWER);
321
322
		// Check if we can guess thumbnail
323
		if(empty($data['thumbnail_url']) && $thumbnail = $this->findThumbnail($data)) {
324
			$data['thumbnail_url'] = $thumbnail;
325
		}
326
327
		// Purge everything if the type does not match.
328
		if($this->type && $this->type != $data['type']) {
329
			$data = array();
330
		}
331
332
		$this->data = $data;
0 ignored issues
show
Documentation Bug introduced by
It seems like $data of type array is incompatible with the declared type boolean of property $data.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
333
	}
334
335
	/**
336
	 * Find thumbnail if omitted from data
337
	 *
338
	 * @param array $data
339
	 * @return string
340
	 */
341
	public function findThumbnail($data) {
342
		if(!empty($data['thumbnail_url'])) {
343
			return $data['thumbnail_url'];
344
		}
345
346
		// Hack in facebook graph thumbnail
347
		if(!empty($data['provider_name']) && $data['provider_name'] === 'Facebook') {
348
			$id = preg_replace("/.*\\/(\\d+?)\\/?($|\\?.*)/", "$1", $data["url"]);
349
			return "https://graph.facebook.com/{$id}/picture";
350
		}
351
352
		// no thumbnail found
353
		return null;
354
	}
355
356
	/**
357
	 * Wrap the check for looking into Oembed JSON within $this->data.
358
	 */
359
	public function hasField($field) {
360
		$this->loadData();
361
		return array_key_exists(strtolower($field), $this->data);
362
	}
363
364
	/**
365
	 * Wrap the field calls to fetch data from Oembed JSON (within $this->data)
366
	 */
367
	public function getField($field) {
368
		$field = strtolower($field);
369
		if($this->hasField($field)) {
370
			return $this->data[$field];
371
		}
372
	}
373
374
	public function forTemplate() {
375
		$this->loadData();
376
		switch($this->Type) {
377
		case 'video':
378
		case 'rich':
379
			if($this->extraClass) {
380
				return "<div class='media $this->extraClass'>$this->HTML</div>";
381
			} else {
382
				return "<div class='media'>$this->HTML</div>";
383
			}
384
			break;
0 ignored issues
show
Unused Code introduced by
break; does not seem to be reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
385
		case 'link':
386
			return '<a class="' . $this->extraClass . '" href="' . $this->origin . '">' . $this->Title . '</a>';
387
			break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
388
		case 'photo':
389
			return "<img src='$this->URL' width='$this->Width' height='$this->Height' class='$this->extraClass' />";
390
			break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
391
		}
392
	}
393
394
	public function exists() {
395
		$this->loadData();
396
		return count($this->data) > 0;
397
	}
398
}
399
400