1<?php
2/**
3 * Part of the Joomla Framework Application Package
4 *
5 * @copyright  Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
6 * @license    GNU General Public License version 2 or later; see LICENSE
7 */
8
9namespace Joomla\Application\Web;
10
11/**
12 * Class to model a Web Client.
13 *
14 * @property-read  integer  $platform        The detected platform on which the web client runs.
15 * @property-read  boolean  $mobile          True if the web client is a mobile device.
16 * @property-read  integer  $engine          The detected rendering engine used by the web client.
17 * @property-read  integer  $browser         The detected browser used by the web client.
18 * @property-read  string   $browserVersion  The detected browser version used by the web client.
19 * @property-read  array    $languages       The priority order detected accepted languages for the client.
20 * @property-read  array    $encodings       The priority order detected accepted encodings for the client.
21 * @property-read  string   $userAgent       The web client's user agent string.
22 * @property-read  string   $acceptEncoding  The web client's accepted encoding string.
23 * @property-read  string   $acceptLanguage  The web client's accepted languages string.
24 * @property-read  array    $detection       An array of flags determining whether or not a detection routine has been run.
25 * @property-read  boolean  $robot           True if the web client is a robot
26 * @property-read  array    $headers         An array of all headers sent by client
27 *
28 * @since  1.0
29 */
30class WebClient
31{
32	const WINDOWS       = 1;
33	const WINDOWS_PHONE = 2;
34	const WINDOWS_CE    = 3;
35	const IPHONE        = 4;
36	const IPAD          = 5;
37	const IPOD          = 6;
38	const MAC           = 7;
39	const BLACKBERRY    = 8;
40	const ANDROID       = 9;
41	const LINUX         = 10;
42	const TRIDENT       = 11;
43	const WEBKIT        = 12;
44	const GECKO         = 13;
45	const PRESTO        = 14;
46	const KHTML         = 15;
47	const AMAYA         = 16;
48	const IE            = 17;
49	const FIREFOX       = 18;
50	const CHROME        = 19;
51	const SAFARI        = 20;
52	const OPERA         = 21;
53	const ANDROIDTABLET = 22;
54	const EDGE          = 23;
55	const BLINK         = 24;
56	const EDG           = 25;
57
58	/**
59	 * @var    integer  The detected platform on which the web client runs.
60	 * @since  1.0
61	 */
62	protected $platform;
63
64	/**
65	 * @var    boolean  True if the web client is a mobile device.
66	 * @since  1.0
67	 */
68	protected $mobile = false;
69
70	/**
71	 * @var    integer  The detected rendering engine used by the web client.
72	 * @since  1.0
73	 */
74	protected $engine;
75
76	/**
77	 * @var    integer  The detected browser used by the web client.
78	 * @since  1.0
79	 */
80	protected $browser;
81
82	/**
83	 * @var    string  The detected browser version used by the web client.
84	 * @since  1.0
85	 */
86	protected $browserVersion;
87
88	/**
89	 * @var    array  The priority order detected accepted languages for the client.
90	 * @since  1.0
91	 */
92	protected $languages = array();
93
94	/**
95	 * @var    array  The priority order detected accepted encodings for the client.
96	 * @since  1.0
97	 */
98	protected $encodings = array();
99
100	/**
101	 * @var    string  The web client's user agent string.
102	 * @since  1.0
103	 */
104	protected $userAgent;
105
106	/**
107	 * @var    string  The web client's accepted encoding string.
108	 * @since  1.0
109	 */
110	protected $acceptEncoding;
111
112	/**
113	 * @var    string  The web client's accepted languages string.
114	 * @since  1.0
115	 */
116	protected $acceptLanguage;
117
118	/**
119	 * @var    boolean  True if the web client is a robot.
120	 * @since  1.0
121	 */
122	protected $robot = false;
123
124	/**
125	 * @var    array  An array of flags determining whether or not a detection routine has been run.
126	 * @since  1.0
127	 */
128	protected $detection = array();
129
130	/**
131	 * @var    array  An array of headers sent by client
132	 * @since  1.3.0
133	 */
134	protected $headers;
135
136	/**
137	 * Class constructor.
138	 *
139	 * @param   string  $userAgent       The optional user-agent string to parse.
140	 * @param   string  $acceptEncoding  The optional client accept encoding string to parse.
141	 * @param   string  $acceptLanguage  The optional client accept language string to parse.
142	 *
143	 * @since   1.0
144	 */
145	public function __construct($userAgent = null, $acceptEncoding = null, $acceptLanguage = null)
146	{
147		// If no explicit user agent string was given attempt to use the implicit one from server environment.
148		if (empty($userAgent) && isset($_SERVER['HTTP_USER_AGENT']))
149		{
150			$this->userAgent = $_SERVER['HTTP_USER_AGENT'];
151		}
152		else
153		{
154			$this->userAgent = $userAgent;
155		}
156
157		// If no explicit acceptable encoding string was given attempt to use the implicit one from server environment.
158		if (empty($acceptEncoding) && isset($_SERVER['HTTP_ACCEPT_ENCODING']))
159		{
160			$this->acceptEncoding = $_SERVER['HTTP_ACCEPT_ENCODING'];
161		}
162		else
163		{
164			$this->acceptEncoding = $acceptEncoding;
165		}
166
167		// If no explicit acceptable languages string was given attempt to use the implicit one from server environment.
168		if (empty($acceptLanguage) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE']))
169		{
170			$this->acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
171		}
172		else
173		{
174			$this->acceptLanguage = $acceptLanguage;
175		}
176	}
177
178	/**
179	 * Magic method to get an object property's value by name.
180	 *
181	 * @param   string  $name  Name of the property for which to return a value.
182	 *
183	 * @return  mixed  The requested value if it exists.
184	 *
185	 * @since   1.0
186	 */
187	public function __get($name)
188	{
189		switch ($name)
190		{
191			case 'mobile':
192			case 'platform':
193				if (empty($this->detection['platform']))
194				{
195					$this->detectPlatform($this->userAgent);
196				}
197
198				break;
199
200			case 'engine':
201				if (empty($this->detection['engine']))
202				{
203					$this->detectEngine($this->userAgent);
204				}
205
206				break;
207
208			case 'browser':
209			case 'browserVersion':
210				if (empty($this->detection['browser']))
211				{
212					$this->detectBrowser($this->userAgent);
213				}
214
215				break;
216
217			case 'languages':
218				if (empty($this->detection['acceptLanguage']))
219				{
220					$this->detectLanguage($this->acceptLanguage);
221				}
222
223				break;
224
225			case 'encodings':
226				if (empty($this->detection['acceptEncoding']))
227				{
228					$this->detectEncoding($this->acceptEncoding);
229				}
230
231				break;
232
233			case 'robot':
234				if (empty($this->detection['robot']))
235				{
236					$this->detectRobot($this->userAgent);
237				}
238
239				break;
240
241			case 'headers':
242				if (empty($this->detection['headers']))
243				{
244					$this->detectHeaders();
245				}
246
247				break;
248		}
249
250		// Return the property if it exists.
251		if (isset($this->$name))
252		{
253			return $this->$name;
254		}
255	}
256
257	/**
258	 * Detects the client browser and version in a user agent string.
259	 *
260	 * @param   string  $userAgent  The user-agent string to parse.
261	 *
262	 * @return  void
263	 *
264	 * @since   1.0
265	 */
266	protected function detectBrowser($userAgent)
267	{
268		// Attempt to detect the browser type.  Obviously we are only worried about major browsers.
269		if ((stripos($userAgent, 'MSIE') !== false) && (stripos($userAgent, 'Opera') === false))
270		{
271			$this->browser  = self::IE;
272			$patternBrowser = 'MSIE';
273		}
274		elseif (stripos($userAgent, 'Trident') !== false)
275		{
276			$this->browser  = self::IE;
277			$patternBrowser = ' rv';
278		}
279		elseif (stripos($userAgent, 'Edge') !== false)
280		{
281			$this->browser  = self::EDGE;
282			$patternBrowser = 'Edge';
283		}
284		elseif (stripos($userAgent, 'Edg') !== false)
285		{
286			$this->browser  = self::EDG;
287			$patternBrowser = 'Edg';
288		}
289		elseif ((stripos($userAgent, 'Firefox') !== false) && (stripos($userAgent, 'like Firefox') === false))
290		{
291			$this->browser  = self::FIREFOX;
292			$patternBrowser = 'Firefox';
293		}
294		elseif (stripos($userAgent, 'OPR') !== false)
295		{
296			$this->browser  = self::OPERA;
297			$patternBrowser = 'OPR';
298		}
299		elseif (stripos($userAgent, 'Chrome') !== false)
300		{
301			$this->browser  = self::CHROME;
302			$patternBrowser = 'Chrome';
303		}
304		elseif (stripos($userAgent, 'Safari') !== false)
305		{
306			$this->browser  = self::SAFARI;
307			$patternBrowser = 'Safari';
308		}
309		elseif (stripos($userAgent, 'Opera') !== false)
310		{
311			$this->browser  = self::OPERA;
312			$patternBrowser = 'Opera';
313		}
314
315		// If we detected a known browser let's attempt to determine the version.
316		if ($this->browser)
317		{
318			// Build the REGEX pattern to match the browser version string within the user agent string.
319			$pattern = '#(?<browser>Version|' . $patternBrowser . ')[/ :]+(?<version>[0-9.|a-zA-Z.]*)#';
320
321			// Attempt to find version strings in the user agent string.
322			$matches = array();
323
324			if (preg_match_all($pattern, $userAgent, $matches))
325			{
326				// Do we have both a Version and browser match?
327				if (\count($matches['browser']) == 2)
328				{
329					// See whether Version or browser came first, and use the number accordingly.
330					if (strripos($userAgent, 'Version') < strripos($userAgent, $patternBrowser))
331					{
332						$this->browserVersion = $matches['version'][0];
333					}
334					else
335					{
336						$this->browserVersion = $matches['version'][1];
337					}
338				}
339				elseif (\count($matches['browser']) > 2)
340				{
341					$key = array_search('Version', $matches['browser']);
342
343					if ($key)
344					{
345						$this->browserVersion = $matches['version'][$key];
346					}
347				}
348				else
349				{
350					// We only have a Version or a browser so use what we have.
351					$this->browserVersion = $matches['version'][0];
352				}
353			}
354		}
355
356		// Mark this detection routine as run.
357		$this->detection['browser'] = true;
358	}
359
360	/**
361	 * Method to detect the accepted response encoding by the client.
362	 *
363	 * @param   string  $acceptEncoding  The client accept encoding string to parse.
364	 *
365	 * @return  void
366	 *
367	 * @since   1.0
368	 */
369	protected function detectEncoding($acceptEncoding)
370	{
371		// Parse the accepted encodings.
372		$this->encodings = array_map('trim', (array) explode(',', $acceptEncoding));
373
374		// Mark this detection routine as run.
375		$this->detection['acceptEncoding'] = true;
376	}
377
378	/**
379	 * Detects the client rendering engine in a user agent string.
380	 *
381	 * @param   string  $userAgent  The user-agent string to parse.
382	 *
383	 * @return  void
384	 *
385	 * @since   1.0
386	 */
387	protected function detectEngine($userAgent)
388	{
389		if (stripos($userAgent, 'MSIE') !== false || stripos($userAgent, 'Trident') !== false)
390		{
391			// Attempt to detect the client engine -- starting with the most popular ... for now.
392			$this->engine = self::TRIDENT;
393		}
394		elseif (stripos($userAgent, 'Edge') !== false || stripos($userAgent, 'EdgeHTML') !== false)
395		{
396			$this->engine = self::EDGE;
397		}
398		elseif (stripos($userAgent, 'Edg') !== false)
399		{
400			$this->engine = self::BLINK;
401		}
402		elseif (stripos($userAgent, 'Chrome') !== false)
403		{
404			$result  = explode('/', stristr($userAgent, 'Chrome'));
405			$version = explode(' ', $result[1]);
406
407			if ($version[0] >= 28)
408			{
409				$this->engine = self::BLINK;
410			}
411			else
412			{
413				$this->engine = self::WEBKIT;
414			}
415		}
416		elseif (stripos($userAgent, 'AppleWebKit') !== false || stripos($userAgent, 'blackberry') !== false)
417		{
418			if (stripos($userAgent, 'AppleWebKit') !== false)
419			{
420				$result  = explode('/', stristr($userAgent, 'AppleWebKit'));
421				$version = explode(' ', $result[1]);
422
423				if ($version[0] === 537.36)
424				{
425					// AppleWebKit/537.36 is Blink engine specific, exception is Blink emulated IEMobile, Trident or Edge
426					$this->engine = self::BLINK;
427				}
428			}
429
430			// Evidently blackberry uses WebKit and doesn't necessarily report it.  Bad RIM.
431			$this->engine = self::WEBKIT;
432		}
433		elseif (stripos($userAgent, 'Gecko') !== false && stripos($userAgent, 'like Gecko') === false)
434		{
435			// We have to check for like Gecko because some other browsers spoof Gecko.
436			$this->engine = self::GECKO;
437		}
438		elseif (stripos($userAgent, 'Opera') !== false || stripos($userAgent, 'Presto') !== false)
439		{
440			$version = false;
441
442			if (preg_match('/Opera[\/| ]?([0-9.]+)/u', $userAgent, $match))
443			{
444				$version = \floatval($match[1]);
445			}
446
447			if (preg_match('/Version\/([0-9.]+)/u', $userAgent, $match))
448			{
449				if (\floatval($match[1]) >= 10)
450				{
451					$version = \floatval($match[1]);
452				}
453			}
454
455			if ($version !== false && $version >= 15)
456			{
457				$this->engine = self::BLINK;
458			}
459			else
460			{
461				$this->engine = self::PRESTO;
462			}
463		}
464		elseif (stripos($userAgent, 'KHTML') !== false)
465		{
466			// *sigh*
467			$this->engine = self::KHTML;
468		}
469		elseif (stripos($userAgent, 'Amaya') !== false)
470		{
471			// Lesser known engine but it finishes off the major list from Wikipedia :-)
472			$this->engine = self::AMAYA;
473		}
474
475		// Mark this detection routine as run.
476		$this->detection['engine'] = true;
477	}
478
479	/**
480	 * Method to detect the accepted languages by the client.
481	 *
482	 * @param   mixed  $acceptLanguage  The client accept language string to parse.
483	 *
484	 * @return  void
485	 *
486	 * @since   1.0
487	 */
488	protected function detectLanguage($acceptLanguage)
489	{
490		// Parse the accepted encodings.
491		$this->languages = array_map('trim', (array) explode(',', $acceptLanguage));
492
493		// Mark this detection routine as run.
494		$this->detection['acceptLanguage'] = true;
495	}
496
497	/**
498	 * Detects the client platform in a user agent string.
499	 *
500	 * @param   string  $userAgent  The user-agent string to parse.
501	 *
502	 * @return  void
503	 *
504	 * @since   1.0
505	 */
506	protected function detectPlatform($userAgent)
507	{
508		// Attempt to detect the client platform.
509		if (stripos($userAgent, 'Windows') !== false)
510		{
511			$this->platform = self::WINDOWS;
512
513			// Let's look at the specific mobile options in the Windows space.
514			if (stripos($userAgent, 'Windows Phone') !== false)
515			{
516				$this->mobile   = true;
517				$this->platform = self::WINDOWS_PHONE;
518			}
519			elseif (stripos($userAgent, 'Windows CE') !== false)
520			{
521				$this->mobile   = true;
522				$this->platform = self::WINDOWS_CE;
523			}
524		}
525		elseif (stripos($userAgent, 'iPhone') !== false)
526		{
527			// Interestingly 'iPhone' is present in all iOS devices so far including iPad and iPods.
528			$this->mobile   = true;
529			$this->platform = self::IPHONE;
530
531			// Let's look at the specific mobile options in the iOS space.
532			if (stripos($userAgent, 'iPad') !== false)
533			{
534				$this->platform = self::IPAD;
535			}
536			elseif (stripos($userAgent, 'iPod') !== false)
537			{
538				$this->platform = self::IPOD;
539			}
540		}
541		elseif (stripos($userAgent, 'iPad') !== false)
542		{
543			// In case where iPhone is not mentioed in iPad user agent string
544			$this->mobile   = true;
545			$this->platform = self::IPAD;
546		}
547		elseif (stripos($userAgent, 'iPod') !== false)
548		{
549			// In case where iPhone is not mentioed in iPod user agent string
550			$this->mobile   = true;
551			$this->platform = self::IPOD;
552		}
553		elseif (preg_match('/macintosh|mac os x/i', $userAgent))
554		{
555			// This has to come after the iPhone check because mac strings are also present in iOS devices.
556			$this->platform = self::MAC;
557		}
558		elseif (stripos($userAgent, 'Blackberry') !== false)
559		{
560			$this->mobile   = true;
561			$this->platform = self::BLACKBERRY;
562		}
563		elseif (stripos($userAgent, 'Android') !== false)
564		{
565			$this->mobile   = true;
566			$this->platform = self::ANDROID;
567			/**
568			 * Attempt to distinguish between Android phones and tablets
569			 * There is no totally foolproof method but certain rules almost always hold
570			 *   Android 3.x is only used for tablets
571			 *   Some devices and browsers encourage users to change their UA string to include Tablet.
572			 *   Google encourages manufacturers to exclude the string Mobile from tablet device UA strings.
573			 *   In some modes Kindle Android devices include the string Mobile but they include the string Silk.
574			 */
575			if (stripos($userAgent, 'Android 3') !== false || stripos($userAgent, 'Tablet') !== false
576				|| stripos($userAgent, 'Mobile') === false || stripos($userAgent, 'Silk') !== false)
577			{
578				$this->platform = self::ANDROIDTABLET;
579			}
580		}
581		elseif (stripos($userAgent, 'Linux') !== false)
582		{
583			$this->platform = self::LINUX;
584		}
585
586		// Mark this detection routine as run.
587		$this->detection['platform'] = true;
588	}
589
590	/**
591	 * Determines if the browser is a robot or not.
592	 *
593	 * @param   string  $userAgent  The user-agent string to parse.
594	 *
595	 * @return  void
596	 *
597	 * @since   1.0
598	 */
599	protected function detectRobot($userAgent)
600	{
601		if (preg_match('/http|bot|bingbot|googlebot|robot|spider|slurp|crawler|curl|^$/i', $userAgent))
602		{
603			$this->robot = true;
604		}
605		else
606		{
607			$this->robot = false;
608		}
609
610		$this->detection['robot'] = true;
611	}
612
613	/**
614	 * Fills internal array of headers
615	 *
616	 * @return  void
617	 *
618	 * @since   1.3.0
619	 */
620	protected function detectHeaders()
621	{
622		if (\function_exists('getallheaders'))
623		{
624			// If php is working under Apache, there is a special function
625			$this->headers = getallheaders();
626		}
627		else
628		{
629			// Else we fill headers from $_SERVER variable
630			$this->headers = array();
631
632			foreach ($_SERVER as $name => $value)
633			{
634				if (substr($name, 0, 5) == 'HTTP_')
635				{
636					$this->headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
637				}
638			}
639		}
640
641		// Mark this detection routine as run.
642		$this->detection['headers'] = true;
643	}
644}
645