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