1<?php 2 3/** 4 * Device Detector - The Universal Device Detection library for parsing User Agents 5 * 6 * @link https://matomo.org 7 * 8 * @license http://www.gnu.org/licenses/lgpl.html LGPL v3 or later 9 */ 10 11declare(strict_types=1); 12 13namespace DeviceDetector\Parser; 14 15use DeviceDetector\Cache\CacheInterface; 16use DeviceDetector\Cache\StaticCache; 17use DeviceDetector\ClientHints; 18use DeviceDetector\DeviceDetector; 19use DeviceDetector\Yaml\ParserInterface as YamlParser; 20use DeviceDetector\Yaml\Spyc; 21 22/** 23 * Class AbstractParser 24 */ 25abstract class AbstractParser 26{ 27 /** 28 * Holds the path to the yml file containing regexes 29 * @var string 30 */ 31 protected $fixtureFile; 32 33 /** 34 * Holds the internal name of the parser 35 * Used for caching 36 * @var string 37 */ 38 protected $parserName; 39 40 /** 41 * Holds the user agent to be parsed 42 * @var string 43 */ 44 protected $userAgent; 45 46 /** 47 * Holds the client hints to be parsed 48 * @var ?ClientHints 49 */ 50 protected $clientHints = null; 51 52 /** 53 * Contains a list of mappings from names we use to known client hint values 54 * @var array<string, array<string>> 55 */ 56 protected static $clientHintMapping = []; 57 58 /** 59 * Holds an array with method that should be available global 60 * @var array 61 */ 62 protected $globalMethods; 63 64 /** 65 * Holds an array with regexes to parse, if already loaded 66 * @var array 67 */ 68 protected $regexList; 69 70 /** 71 * Holds the concatenated regex for all items in regex list 72 * @var string 73 */ 74 protected $overAllMatch; 75 76 /** 77 * Indicates how deep versioning will be detected 78 * if $maxMinorParts is 0 only the major version will be returned 79 * @var int 80 */ 81 protected static $maxMinorParts = 1; 82 83 /** 84 * Versioning constant used to set max versioning to major version only 85 * Version examples are: 3, 5, 6, 200, 123, ... 86 */ 87 public const VERSION_TRUNCATION_MAJOR = 0; 88 89 /** 90 * Versioning constant used to set max versioning to minor version 91 * Version examples are: 3.4, 5.6, 6.234, 0.200, 1.23, ... 92 */ 93 public const VERSION_TRUNCATION_MINOR = 1; 94 95 /** 96 * Versioning constant used to set max versioning to path level 97 * Version examples are: 3.4.0, 5.6.344, 6.234.2, 0.200.3, 1.2.3, ... 98 */ 99 public const VERSION_TRUNCATION_PATCH = 2; 100 101 /** 102 * Versioning constant used to set versioning to build number 103 * Version examples are: 3.4.0.12, 5.6.334.0, 6.234.2.3, 0.200.3.1, 1.2.3.0, ... 104 */ 105 public const VERSION_TRUNCATION_BUILD = 3; 106 107 /** 108 * Versioning constant used to set versioning to unlimited (no truncation) 109 */ 110 public const VERSION_TRUNCATION_NONE = -1; 111 112 /** 113 * @var CacheInterface|null 114 */ 115 protected $cache = null; 116 117 /** 118 * @var YamlParser|null 119 */ 120 protected $yamlParser = null; 121 122 /** 123 * parses the currently set useragents and returns possible results 124 * 125 * @return array|null 126 */ 127 abstract public function parse(): ?array; 128 129 /** 130 * AbstractParser constructor. 131 * 132 * @param string $ua 133 * @param ?ClientHints $clientHints 134 */ 135 public function __construct(string $ua = '', ?ClientHints $clientHints = null) 136 { 137 $this->setUserAgent($ua); 138 $this->setClientHints($clientHints); 139 } 140 141 /** 142 * @inheritdoc 143 */ 144 public function restoreUserAgentFromClientHints(): void 145 { 146 if (null === $this->clientHints) { 147 return; 148 } 149 150 $deviceModel = $this->clientHints->getModel(); 151 152 if ('' === $deviceModel) { 153 return; 154 } 155 156 // Restore Android User Agent 157 if ($this->hasUserAgentClientHintsFragment()) { 158 $osVersion = $this->clientHints->getOperatingSystemVersion(); 159 $this->setUserAgent((string) \preg_replace( 160 '(Android (?:10[.\d]*; K|1[1-5]))', 161 \sprintf('Android %s; %s', '' !== $osVersion ? $osVersion : '10', $deviceModel), 162 $this->userAgent 163 )); 164 } 165 166 // Restore Desktop User Agent 167 if (!$this->hasDesktopFragment()) { 168 return; 169 } 170 171 $this->setUserAgent((string) \preg_replace( 172 '(X11; Linux x86_64)', 173 \sprintf('X11; Linux x86_64; %s', $deviceModel), 174 $this->userAgent 175 )); 176 } 177 178 /** 179 * Set how DeviceDetector should return versions 180 * @param int $type Any of the VERSION_TRUNCATION_* constants 181 */ 182 public static function setVersionTruncation(int $type): void 183 { 184 if (!\in_array($type, [ 185 self::VERSION_TRUNCATION_BUILD, 186 self::VERSION_TRUNCATION_NONE, 187 self::VERSION_TRUNCATION_MAJOR, 188 self::VERSION_TRUNCATION_MINOR, 189 self::VERSION_TRUNCATION_PATCH, 190 ]) 191 ) { 192 return; 193 } 194 195 static::$maxMinorParts = $type; 196 } 197 198 /** 199 * Sets the user agent to parse 200 * 201 * @param string $ua user agent 202 */ 203 public function setUserAgent(string $ua): void 204 { 205 $this->userAgent = $ua; 206 } 207 208 /** 209 * Sets the client hints to parse 210 * 211 * @param ?ClientHints $clientHints client hints 212 */ 213 public function setClientHints(?ClientHints $clientHints): void 214 { 215 $this->clientHints = $clientHints; 216 } 217 218 /** 219 * Returns the internal name of the parser 220 * 221 * @return string 222 */ 223 public function getName(): string 224 { 225 return $this->parserName; 226 } 227 228 /** 229 * Sets the Cache class 230 * 231 * @param CacheInterface $cache 232 */ 233 public function setCache(CacheInterface $cache): void 234 { 235 $this->cache = $cache; 236 } 237 238 /** 239 * Returns Cache object 240 * 241 * @return CacheInterface 242 */ 243 public function getCache(): CacheInterface 244 { 245 if (!empty($this->cache)) { 246 return $this->cache; 247 } 248 249 return new StaticCache(); 250 } 251 252 /** 253 * Sets the YamlParser class 254 * 255 * @param YamlParser $yamlParser 256 */ 257 public function setYamlParser(YamlParser $yamlParser): void 258 { 259 $this->yamlParser = $yamlParser; 260 } 261 262 /** 263 * Returns YamlParser object 264 * 265 * @return YamlParser 266 */ 267 public function getYamlParser(): YamlParser 268 { 269 if (!empty($this->yamlParser)) { 270 return $this->yamlParser; 271 } 272 273 return new Spyc(); 274 } 275 276 /** 277 * Returns the result of the parsed yml file defined in $fixtureFile 278 * 279 * @return array 280 */ 281 protected function getRegexes(): array 282 { 283 if (empty($this->regexList)) { 284 $cacheKey = 'DeviceDetector-' . DeviceDetector::VERSION . 'regexes-' . $this->getName(); 285 $cacheKey = (string) \preg_replace('/([^a-z0-9_-]+)/i', '', $cacheKey); 286 $cacheContent = $this->getCache()->fetch($cacheKey); 287 288 if (\is_array($cacheContent)) { 289 $this->regexList = $cacheContent; 290 } 291 292 if (empty($this->regexList)) { 293 $parsedContent = $this->getYamlParser()->parseFile( 294 $this->getRegexesDirectory() . DIRECTORY_SEPARATOR . $this->fixtureFile 295 ); 296 297 if (!\is_array($parsedContent)) { 298 $parsedContent = []; 299 } 300 301 $this->regexList = $parsedContent; 302 $this->getCache()->save($cacheKey, $this->regexList); 303 } 304 } 305 306 return $this->regexList; 307 } 308 309 /** 310 * Returns the provided name after applying client hint mappings. 311 * This is used to map names provided in client hints to the names we use. 312 * 313 * @param string $name 314 * 315 * @return string 316 */ 317 protected function applyClientHintMapping(string $name): string 318 { 319 foreach (static::$clientHintMapping as $mappedName => $clientHints) { 320 foreach ($clientHints as $clientHint) { 321 if (\strtolower($name) === \strtolower($clientHint)) { 322 return $mappedName; 323 } 324 } 325 } 326 327 return $name; 328 } 329 330 /** 331 * @return string 332 */ 333 protected function getRegexesDirectory(): string 334 { 335 return \dirname(__DIR__); 336 } 337 338 /** 339 * Returns if the parsed UA contains the 'Windows NT;' or 'X11; Linux x86_64' fragments 340 * 341 * @return bool 342 */ 343 protected function hasDesktopFragment(): bool 344 { 345 $regexExcludeDesktopFragment = \implode('|', [ 346 'CE-HTML', 347 ' Mozilla/|Andr[o0]id|Tablet|Mobile|iPhone|Windows Phone|ricoh|OculusBrowser', 348 'PicoBrowser|Lenovo|compatible; MSIE|Trident/|Tesla/|XBOX|FBMD/|ARM; ?([^)]+)', 349 ]); 350 351 return 352 $this->matchUserAgent('(?:Windows (?:NT|IoT)|X11; Linux x86_64)') && 353 !$this->matchUserAgent($regexExcludeDesktopFragment); 354 } 355 356 /** 357 * Returns if the parsed UA contains the 'Android 10 K;' or Android 10 K Build/` fragment 358 * 359 * @return bool 360 */ 361 protected function hasUserAgentClientHintsFragment(): bool 362 { 363 return (bool) \preg_match('~Android (?:10[.\d]*; K(?: Build/|[;)])|1[1-5]\)) AppleWebKit~i', $this->userAgent); 364 } 365 366 /** 367 * Matches the useragent against the given regex 368 * 369 * @param string $regex 370 * 371 * @return ?array 372 * 373 * @throws \Exception 374 */ 375 protected function matchUserAgent(string $regex): ?array 376 { 377 $matches = []; 378 379 // only match if useragent begins with given regex or there is no letter before it 380 $regex = '/(?:^|[^A-Z0-9_-]|[^A-Z0-9-]_|sprd-|MZ-)(?:' . \str_replace('/', '\/', $regex) . ')/i'; 381 382 try { 383 if (\preg_match($regex, $this->userAgent, $matches)) { 384 return $matches; 385 } 386 } catch (\Exception $exception) { 387 throw new \Exception( 388 \sprintf("%s\nRegex: %s", $exception->getMessage(), $regex), 389 $exception->getCode(), 390 $exception 391 ); 392 } 393 394 return null; 395 } 396 397 /** 398 * @param string $item 399 * @param array $matches 400 * 401 * @return string 402 */ 403 protected function buildByMatch(string $item, array $matches): string 404 { 405 $search = []; 406 $replace = []; 407 408 for ($nb = 1; $nb <= \count($matches); $nb++) { 409 $search[] = '$' . $nb; 410 $replace[] = $matches[$nb] ?? ''; 411 } 412 413 return \trim(\str_replace($search, $replace, $item)); 414 } 415 416 /** 417 * Builds the version with the given $versionString and $matches 418 * 419 * Example: 420 * $versionString = 'v$2' 421 * $matches = ['version_1_0_1', '1_0_1'] 422 * return value would be v1.0.1 423 * 424 * @param string $versionString 425 * @param array $matches 426 * 427 * @return string 428 */ 429 protected function buildVersion(string $versionString, array $matches): string 430 { 431 $versionString = $this->buildByMatch($versionString, $matches); 432 $versionString = \str_replace('_', '.', $versionString); 433 434 if (self::VERSION_TRUNCATION_NONE !== static::$maxMinorParts 435 && \substr_count($versionString, '.') > static::$maxMinorParts 436 ) { 437 $versionParts = \explode('.', $versionString); 438 $versionParts = \array_slice($versionParts, 0, 1 + static::$maxMinorParts); 439 $versionString = \implode('.', $versionParts); 440 } 441 442 return \trim($versionString, ' .'); 443 } 444 445 /** 446 * Tests the useragent against a combination of all regexes 447 * 448 * All regexes returned by getRegexes() will be reversed and concatenated with '|' 449 * Afterwards the big regex will be tested against the user agent 450 * 451 * Method can be used to speed up detections by making a big check before doing checks for every single regex 452 * 453 * @return ?array 454 */ 455 protected function preMatchOverall(): ?array 456 { 457 $regexes = $this->getRegexes(); 458 459 $cacheKey = $this->parserName . DeviceDetector::VERSION . '-all'; 460 $cacheKey = (string) \preg_replace('/([^a-z0-9_-]+)/i', '', $cacheKey); 461 462 if (empty($this->overAllMatch)) { 463 $overAllMatch = $this->getCache()->fetch($cacheKey); 464 465 if (\is_string($overAllMatch)) { 466 $this->overAllMatch = $overAllMatch; 467 } 468 } 469 470 if (empty($this->overAllMatch)) { 471 // reverse all regexes, so we have the generic one first, which already matches most patterns 472 $this->overAllMatch = \array_reduce(\array_reverse($regexes), static function ($val1, $val2) { 473 return !empty($val1) ? $val1 . '|' . $val2['regex'] : $val2['regex']; 474 }); 475 $this->getCache()->save($cacheKey, $this->overAllMatch); 476 } 477 478 return $this->matchUserAgent($this->overAllMatch); 479 } 480 481 /** 482 * Compares if two strings equals after lowering their case and removing spaces 483 * 484 * @param string $value1 485 * @param string $value2 486 * 487 * @return bool 488 */ 489 protected function fuzzyCompare(string $value1, string $value2): bool 490 { 491 return \str_replace(' ', '', \strtolower($value1)) === 492 \str_replace(' ', '', \strtolower($value2)); 493 } 494} 495