Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
85
src/Framework/UserAgent/Enums/BrowserType.php
Normal file
85
src/Framework/UserAgent/Enums/BrowserType.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Enums;
|
||||
|
||||
/**
|
||||
* Enum representing different browser types
|
||||
*/
|
||||
enum BrowserType: string
|
||||
{
|
||||
case CHROME = 'chrome';
|
||||
case FIREFOX = 'firefox';
|
||||
case SAFARI = 'safari';
|
||||
case EDGE = 'edge';
|
||||
case OPERA = 'opera';
|
||||
case INTERNET_EXPLORER = 'internet_explorer';
|
||||
case SAMSUNG_BROWSER = 'samsung_browser';
|
||||
case BRAVE = 'brave';
|
||||
case VIVALDI = 'vivaldi';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Get human-readable name
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CHROME => 'Chrome',
|
||||
self::FIREFOX => 'Firefox',
|
||||
self::SAFARI => 'Safari',
|
||||
self::EDGE => 'Edge',
|
||||
self::OPERA => 'Opera',
|
||||
self::INTERNET_EXPLORER => 'Internet Explorer',
|
||||
self::SAMSUNG_BROWSER => 'Samsung Browser',
|
||||
self::BRAVE => 'Brave',
|
||||
self::VIVALDI => 'Vivaldi',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser engine family
|
||||
*/
|
||||
public function getEngine(): EngineType
|
||||
{
|
||||
return match ($this) {
|
||||
self::CHROME, self::EDGE, self::OPERA, self::SAMSUNG_BROWSER, self::BRAVE, self::VIVALDI => EngineType::BLINK,
|
||||
self::SAFARI => EngineType::WEBKIT,
|
||||
self::FIREFOX => EngineType::GECKO,
|
||||
self::INTERNET_EXPLORER => EngineType::TRIDENT,
|
||||
self::UNKNOWN => EngineType::UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is considered modern
|
||||
*/
|
||||
public function isModern(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::CHROME, self::FIREFOX, self::SAFARI, self::EDGE, self::OPERA,
|
||||
self::SAMSUNG_BROWSER, self::BRAVE, self::VIVALDI => true,
|
||||
self::INTERNET_EXPLORER, self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum version considered modern for this browser
|
||||
*/
|
||||
public function getModernVersionThreshold(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CHROME => '60.0',
|
||||
self::FIREFOX => '55.0',
|
||||
self::SAFARI => '11.0',
|
||||
self::EDGE => '79.0',
|
||||
self::OPERA => '47.0',
|
||||
self::SAMSUNG_BROWSER => '7.0',
|
||||
self::BRAVE => '1.0',
|
||||
self::VIVALDI => '2.0',
|
||||
self::INTERNET_EXPLORER, self::UNKNOWN => '999.0', // Never modern
|
||||
};
|
||||
}
|
||||
}
|
||||
59
src/Framework/UserAgent/Enums/EngineType.php
Normal file
59
src/Framework/UserAgent/Enums/EngineType.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Enums;
|
||||
|
||||
/**
|
||||
* Enum representing different browser engine types
|
||||
*/
|
||||
enum EngineType: string
|
||||
{
|
||||
case BLINK = 'blink';
|
||||
case WEBKIT = 'webkit';
|
||||
case GECKO = 'gecko';
|
||||
case TRIDENT = 'trident';
|
||||
case EDGE_HTML = 'edge_html';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Get human-readable name
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BLINK => 'Blink',
|
||||
self::WEBKIT => 'WebKit',
|
||||
self::GECKO => 'Gecko',
|
||||
self::TRIDENT => 'Trident',
|
||||
self::EDGE_HTML => 'EdgeHTML',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if engine supports modern web standards
|
||||
*/
|
||||
public function isModern(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BLINK, self::WEBKIT, self::GECKO => true,
|
||||
self::TRIDENT, self::EDGE_HTML, self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine developer/maintainer
|
||||
*/
|
||||
public function getDeveloper(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BLINK => 'Google',
|
||||
self::WEBKIT => 'Apple',
|
||||
self::GECKO => 'Mozilla',
|
||||
self::TRIDENT => 'Microsoft',
|
||||
self::EDGE_HTML => 'Microsoft',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
72
src/Framework/UserAgent/Enums/PlatformType.php
Normal file
72
src/Framework/UserAgent/Enums/PlatformType.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Enums;
|
||||
|
||||
/**
|
||||
* Enum representing different platform/operating system types
|
||||
*/
|
||||
enum PlatformType: string
|
||||
{
|
||||
case WINDOWS = 'windows';
|
||||
case MACOS = 'macos';
|
||||
case IOS = 'ios';
|
||||
case ANDROID = 'android';
|
||||
case LINUX = 'linux';
|
||||
case CHROME_OS = 'chrome_os';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Get human-readable name
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WINDOWS => 'Windows',
|
||||
self::MACOS => 'macOS',
|
||||
self::IOS => 'iOS',
|
||||
self::ANDROID => 'Android',
|
||||
self::LINUX => 'Linux',
|
||||
self::CHROME_OS => 'Chrome OS',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if platform is mobile
|
||||
*/
|
||||
public function isMobile(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::IOS, self::ANDROID => true,
|
||||
self::WINDOWS, self::MACOS, self::LINUX, self::CHROME_OS, self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if platform is desktop
|
||||
*/
|
||||
public function isDesktop(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WINDOWS, self::MACOS, self::LINUX, self::CHROME_OS => true,
|
||||
self::IOS, self::ANDROID, self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform family
|
||||
*/
|
||||
public function getFamily(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WINDOWS => 'Microsoft',
|
||||
self::MACOS, self::IOS => 'Apple',
|
||||
self::ANDROID => 'Google',
|
||||
self::LINUX => 'Unix-like',
|
||||
self::CHROME_OS => 'Google',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
226
src/Framework/UserAgent/ParsedUserAgent.php
Normal file
226
src/Framework/UserAgent/ParsedUserAgent.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent;
|
||||
|
||||
use App\Framework\UserAgent\Enums\BrowserType;
|
||||
use App\Framework\UserAgent\Enums\EngineType;
|
||||
use App\Framework\UserAgent\Enums\PlatformType;
|
||||
|
||||
/**
|
||||
* Value Object representing a parsed User-Agent with rich metadata
|
||||
* Immutable data structure optimized for business logic
|
||||
*/
|
||||
final readonly class ParsedUserAgent
|
||||
{
|
||||
public function __construct(
|
||||
public string $raw,
|
||||
public BrowserType $browser,
|
||||
public string $browserVersion,
|
||||
public PlatformType $platform,
|
||||
public string $platformVersion,
|
||||
public EngineType $engine,
|
||||
public string $engineVersion,
|
||||
public bool $isMobile,
|
||||
public bool $isBot,
|
||||
public bool $isModern
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original UserAgent object
|
||||
*/
|
||||
public function getUserAgent(): UserAgent
|
||||
{
|
||||
return new UserAgent($this->raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser display name with version
|
||||
*/
|
||||
public function getBrowserName(): string
|
||||
{
|
||||
if ($this->browserVersion === 'Unknown') {
|
||||
return $this->browser->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->browser->getDisplayName() . ' ' . $this->browserVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform display name with version
|
||||
*/
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
if ($this->platformVersion === 'Unknown') {
|
||||
return $this->platform->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->platform->getDisplayName() . ' ' . $this->platformVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engine display name with version
|
||||
*/
|
||||
public function getEngineName(): string
|
||||
{
|
||||
if ($this->engineVersion === 'Unknown') {
|
||||
return $this->engine->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->engine->getDisplayName() . ' ' . $this->engineVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a comprehensive summary
|
||||
*/
|
||||
public function getSummary(): string
|
||||
{
|
||||
$parts = [$this->getBrowserName()];
|
||||
|
||||
$parts[] = 'on ' . $this->getPlatformName();
|
||||
|
||||
if ($this->isMobile) {
|
||||
$parts[] = '(Mobile)';
|
||||
}
|
||||
|
||||
if ($this->isBot) {
|
||||
$parts[] = '(Bot)';
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser supports specific web features
|
||||
*/
|
||||
public function supports(string $feature): bool
|
||||
{
|
||||
if (! $this->isModern || $this->isBot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($feature) {
|
||||
// Image formats
|
||||
'webp' => $this->browser->getEngine() === EngineType::BLINK ||
|
||||
($this->browser === BrowserType::FIREFOX && version_compare($this->browserVersion, '65.0', '>=')),
|
||||
'avif' => $this->browser->getEngine() === EngineType::BLINK &&
|
||||
version_compare($this->browserVersion, '85.0', '>='),
|
||||
|
||||
// JavaScript features
|
||||
'es6' => $this->isModern,
|
||||
'es2017' => $this->isModern && version_compare($this->browserVersion, $this->getEs2017MinVersion(), '>='),
|
||||
'es2020' => $this->isModern && version_compare($this->browserVersion, $this->getEs2020MinVersion(), '>='),
|
||||
|
||||
// CSS features
|
||||
'css-grid' => $this->isModern,
|
||||
'css-flexbox' => $this->isModern,
|
||||
'css-custom-properties' => $this->isModern,
|
||||
|
||||
// Web APIs
|
||||
'service-worker' => $this->isModern && $this->platform !== PlatformType::IOS,
|
||||
'web-push' => $this->isModern && $this->browser !== BrowserType::SAFARI,
|
||||
'webrtc' => $this->isModern,
|
||||
'websockets' => $this->isModern,
|
||||
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum browser version for ES2017 support
|
||||
*/
|
||||
private function getEs2017MinVersion(): string
|
||||
{
|
||||
return match ($this->browser) {
|
||||
BrowserType::CHROME => '58.0',
|
||||
BrowserType::FIREFOX => '52.0',
|
||||
BrowserType::SAFARI => '10.1',
|
||||
BrowserType::EDGE => '79.0',
|
||||
BrowserType::OPERA => '45.0',
|
||||
default => '999.0'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum browser version for ES2020 support
|
||||
*/
|
||||
private function getEs2020MinVersion(): string
|
||||
{
|
||||
return match ($this->browser) {
|
||||
BrowserType::CHROME => '80.0',
|
||||
BrowserType::FIREFOX => '72.0',
|
||||
BrowserType::SAFARI => '13.1',
|
||||
BrowserType::EDGE => '80.0',
|
||||
BrowserType::OPERA => '67.0',
|
||||
default => '999.0'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device category
|
||||
*/
|
||||
public function getDeviceCategory(): string
|
||||
{
|
||||
if ($this->isBot) {
|
||||
return 'bot';
|
||||
}
|
||||
|
||||
if ($this->platform->isMobile()) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
if ($this->platform->isDesktop()) {
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array representation
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'raw' => $this->raw,
|
||||
'browser' => [
|
||||
'type' => $this->browser->value,
|
||||
'name' => $this->browser->getDisplayName(),
|
||||
'version' => $this->browserVersion,
|
||||
'fullName' => $this->getBrowserName(),
|
||||
],
|
||||
'platform' => [
|
||||
'type' => $this->platform->value,
|
||||
'name' => $this->platform->getDisplayName(),
|
||||
'version' => $this->platformVersion,
|
||||
'fullName' => $this->getPlatformName(),
|
||||
'family' => $this->platform->getFamily(),
|
||||
],
|
||||
'engine' => [
|
||||
'type' => $this->engine->value,
|
||||
'name' => $this->engine->getDisplayName(),
|
||||
'version' => $this->engineVersion,
|
||||
'fullName' => $this->getEngineName(),
|
||||
'developer' => $this->engine->getDeveloper(),
|
||||
],
|
||||
'flags' => [
|
||||
'isMobile' => $this->isMobile,
|
||||
'isBot' => $this->isBot,
|
||||
'isModern' => $this->isModern,
|
||||
],
|
||||
'deviceCategory' => $this->getDeviceCategory(),
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getSummary();
|
||||
}
|
||||
}
|
||||
143
src/Framework/UserAgent/Patterns/BrowserPatterns.php
Normal file
143
src/Framework/UserAgent/Patterns/BrowserPatterns.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Patterns;
|
||||
|
||||
use App\Framework\UserAgent\Enums\BrowserType;
|
||||
|
||||
/**
|
||||
* Browser detection patterns optimized for modern browsers
|
||||
*/
|
||||
final readonly class BrowserPatterns
|
||||
{
|
||||
/**
|
||||
* Get browser detection patterns in priority order
|
||||
* Order matters! More specific patterns first
|
||||
* @return array<array{pattern: string, browser: BrowserType, versionPattern: string}>
|
||||
*/
|
||||
public static function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
// Edge must be checked before Chrome (contains Chrome in UA)
|
||||
[
|
||||
'pattern' => '/Edg\/([\d.]+)/',
|
||||
'browser' => BrowserType::EDGE,
|
||||
'versionPattern' => '/Edg\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Samsung Browser
|
||||
[
|
||||
'pattern' => '/SamsungBrowser\/([\d.]+)/',
|
||||
'browser' => BrowserType::SAMSUNG_BROWSER,
|
||||
'versionPattern' => '/SamsungBrowser\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Brave Browser
|
||||
[
|
||||
'pattern' => '/Chrome\/.+\s+Brave\/([\d.]+)/',
|
||||
'browser' => BrowserType::BRAVE,
|
||||
'versionPattern' => '/Brave\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Vivaldi
|
||||
[
|
||||
'pattern' => '/Vivaldi\/([\d.]+)/',
|
||||
'browser' => BrowserType::VIVALDI,
|
||||
'versionPattern' => '/Vivaldi\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Opera (check before Chrome)
|
||||
[
|
||||
'pattern' => '/OPR\/([\d.]+)/',
|
||||
'browser' => BrowserType::OPERA,
|
||||
'versionPattern' => '/OPR\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Chrome (must be after Edge, Opera, Samsung, etc.)
|
||||
[
|
||||
'pattern' => '/Chrome\/([\d.]+)/',
|
||||
'browser' => BrowserType::CHROME,
|
||||
'versionPattern' => '/Chrome\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Firefox
|
||||
[
|
||||
'pattern' => '/Firefox\/([\d.]+)/',
|
||||
'browser' => BrowserType::FIREFOX,
|
||||
'versionPattern' => '/Firefox\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Safari (check after Chrome-based browsers)
|
||||
[
|
||||
'pattern' => '/Version\/([\d.]+).*Safari\/([\d.]+)/',
|
||||
'browser' => BrowserType::SAFARI,
|
||||
'versionPattern' => '/Version\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Internet Explorer 11
|
||||
[
|
||||
'pattern' => '/Trident\/7\.0.*rv:([\d.]+)/',
|
||||
'browser' => BrowserType::INTERNET_EXPLORER,
|
||||
'versionPattern' => '/rv:([\d.]+)/',
|
||||
],
|
||||
|
||||
// Internet Explorer 6-10
|
||||
[
|
||||
'pattern' => '/MSIE\s+([\d.]+)/',
|
||||
'browser' => BrowserType::INTERNET_EXPLORER,
|
||||
'versionPattern' => '/MSIE\s+([\d.]+)/',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback patterns for edge cases
|
||||
* @return array<array{pattern: string, browser: BrowserType}>
|
||||
*/
|
||||
public static function getFallbackPatterns(): array
|
||||
{
|
||||
return [
|
||||
['pattern' => '/WebKit/', 'browser' => BrowserType::SAFARI],
|
||||
['pattern' => '/Gecko/', 'browser' => BrowserType::FIREFOX],
|
||||
['pattern' => '/Trident/', 'browser' => BrowserType::INTERNET_EXPLORER],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns that indicate a bot/crawler (basic detection)
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getBotPatterns(): array
|
||||
{
|
||||
return [
|
||||
'/bot/i',
|
||||
'/crawler/i',
|
||||
'/spider/i',
|
||||
'/scraper/i',
|
||||
'/curl/i',
|
||||
'/wget/i',
|
||||
'/python/i',
|
||||
'/java/i',
|
||||
'/php/i',
|
||||
'/node/i',
|
||||
'/axios/i',
|
||||
'/postman/i',
|
||||
'/insomnia/i',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if User-Agent indicates a bot
|
||||
*/
|
||||
public static function isBot(string $userAgent): bool
|
||||
{
|
||||
foreach (self::getBotPatterns() as $pattern) {
|
||||
if (preg_match($pattern, $userAgent)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
134
src/Framework/UserAgent/Patterns/EnginePatterns.php
Normal file
134
src/Framework/UserAgent/Patterns/EnginePatterns.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Patterns;
|
||||
|
||||
use App\Framework\UserAgent\Enums\EngineType;
|
||||
|
||||
/**
|
||||
* Browser engine detection patterns
|
||||
*/
|
||||
final readonly class EnginePatterns
|
||||
{
|
||||
/**
|
||||
* Get engine detection patterns in priority order
|
||||
* @return array<array{pattern: string, engine: EngineType, versionPattern: string}>
|
||||
*/
|
||||
public static function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
// Blink (Chrome 28+, Opera 15+, Edge 79+)
|
||||
[
|
||||
'pattern' => '/Chrome\/\d+.*Safari\/\d+/',
|
||||
'engine' => EngineType::BLINK,
|
||||
'versionPattern' => '/Chrome\/([\d.]+)/', // Use Chrome version as proxy
|
||||
],
|
||||
|
||||
// WebKit (Safari, older Chrome)
|
||||
[
|
||||
'pattern' => '/AppleWebKit\/([\d.]+).*Version\/[\d.]+.*Safari/',
|
||||
'engine' => EngineType::WEBKIT,
|
||||
'versionPattern' => '/AppleWebKit\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// Gecko (Firefox)
|
||||
[
|
||||
'pattern' => '/Gecko\/([\d]+).*Firefox/',
|
||||
'engine' => EngineType::GECKO,
|
||||
'versionPattern' => '/Gecko\/([\d]+)/',
|
||||
],
|
||||
|
||||
// Trident (Internet Explorer)
|
||||
[
|
||||
'pattern' => '/Trident\/([\d.]+)/',
|
||||
'engine' => EngineType::TRIDENT,
|
||||
'versionPattern' => '/Trident\/([\d.]+)/',
|
||||
],
|
||||
|
||||
// EdgeHTML (Legacy Edge 12-18)
|
||||
[
|
||||
'pattern' => '/Edge\/([\d.]+)/',
|
||||
'engine' => EngineType::EDGE_HTML,
|
||||
'versionPattern' => '/Edge\/([\d.]+)/',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback engine detection based on browser
|
||||
* @return array<string, EngineType>
|
||||
*/
|
||||
public static function getBrowserEngineMap(): array
|
||||
{
|
||||
return [
|
||||
'chrome' => EngineType::BLINK,
|
||||
'edge' => EngineType::BLINK,
|
||||
'opera' => EngineType::BLINK,
|
||||
'samsung_browser' => EngineType::BLINK,
|
||||
'brave' => EngineType::BLINK,
|
||||
'vivaldi' => EngineType::BLINK,
|
||||
'safari' => EngineType::WEBKIT,
|
||||
'firefox' => EngineType::GECKO,
|
||||
'internet_explorer' => EngineType::TRIDENT,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gecko build numbers to readable versions
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getGeckoVersionMap(): array
|
||||
{
|
||||
return [
|
||||
'20100101' => '2.0', // Firefox 4+
|
||||
'20090715' => '1.9.1', // Firefox 3.5
|
||||
'20081217' => '1.9.0', // Firefox 3.0
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gecko build number to version
|
||||
*/
|
||||
public static function formatGeckoVersion(string $buildNumber): string
|
||||
{
|
||||
$map = self::getGeckoVersionMap();
|
||||
|
||||
// Try exact match first
|
||||
if (isset($map[$buildNumber])) {
|
||||
return $map[$buildNumber];
|
||||
}
|
||||
|
||||
// For modern Firefox, use a simplified approach
|
||||
if ((int) $buildNumber >= 20100101) {
|
||||
return '2.0+';
|
||||
}
|
||||
|
||||
return $buildNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum engine versions for modern web features
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function getFeatureSupport(): array
|
||||
{
|
||||
return [
|
||||
'css-grid' => [
|
||||
'blink' => '57.0',
|
||||
'webkit' => '10.1',
|
||||
'gecko' => '52.0',
|
||||
],
|
||||
'es6' => [
|
||||
'blink' => '51.0',
|
||||
'webkit' => '10.0',
|
||||
'gecko' => '45.0',
|
||||
],
|
||||
'service-worker' => [
|
||||
'blink' => '40.0',
|
||||
'webkit' => '11.1',
|
||||
'gecko' => '44.0',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
147
src/Framework/UserAgent/Patterns/PlatformPatterns.php
Normal file
147
src/Framework/UserAgent/Patterns/PlatformPatterns.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\Patterns;
|
||||
|
||||
use App\Framework\UserAgent\Enums\PlatformType;
|
||||
|
||||
/**
|
||||
* Platform/OS detection patterns
|
||||
*/
|
||||
final readonly class PlatformPatterns
|
||||
{
|
||||
/**
|
||||
* Get platform detection patterns in priority order
|
||||
* @return array<array{pattern: string, platform: PlatformType, versionPattern: string}>
|
||||
*/
|
||||
public static function getPatterns(): array
|
||||
{
|
||||
return [
|
||||
// iOS (check before macOS as iOS UA contains Mac OS X)
|
||||
[
|
||||
'pattern' => '/iPhone OS ([\d_]+)/',
|
||||
'platform' => PlatformType::IOS,
|
||||
'versionPattern' => '/iPhone OS ([\d_]+)/',
|
||||
],
|
||||
[
|
||||
'pattern' => '/OS ([\d_]+) like Mac OS X/',
|
||||
'platform' => PlatformType::IOS,
|
||||
'versionPattern' => '/OS ([\d_]+) like Mac OS X/',
|
||||
],
|
||||
|
||||
// Android
|
||||
[
|
||||
'pattern' => '/Android ([\d.]+)/',
|
||||
'platform' => PlatformType::ANDROID,
|
||||
'versionPattern' => '/Android ([\d.]+)/',
|
||||
],
|
||||
|
||||
// Windows
|
||||
[
|
||||
'pattern' => '/Windows NT ([\d.]+)/',
|
||||
'platform' => PlatformType::WINDOWS,
|
||||
'versionPattern' => '/Windows NT ([\d.]+)/',
|
||||
],
|
||||
|
||||
// macOS (after iOS check)
|
||||
[
|
||||
'pattern' => '/Mac OS X ([\d_]+)/',
|
||||
'platform' => PlatformType::MACOS,
|
||||
'versionPattern' => '/Mac OS X ([\d_]+)/',
|
||||
],
|
||||
[
|
||||
'pattern' => '/Macintosh.*OS X ([\d_]+)/',
|
||||
'platform' => PlatformType::MACOS,
|
||||
'versionPattern' => '/OS X ([\d_]+)/',
|
||||
],
|
||||
|
||||
// Chrome OS
|
||||
[
|
||||
'pattern' => '/CrOS \w+ ([\d.]+)/',
|
||||
'platform' => PlatformType::CHROME_OS,
|
||||
'versionPattern' => '/CrOS \w+ ([\d.]+)/',
|
||||
],
|
||||
|
||||
// Linux (generic, should be last)
|
||||
[
|
||||
'pattern' => '/Linux/',
|
||||
'platform' => PlatformType::LINUX,
|
||||
'versionPattern' => '//', // No version pattern for generic Linux
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Windows NT versions to readable names
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getWindowsVersionMap(): array
|
||||
{
|
||||
return [
|
||||
'10.0' => '10/11',
|
||||
'6.3' => '8.1',
|
||||
'6.2' => '8',
|
||||
'6.1' => '7',
|
||||
'6.0' => 'Vista',
|
||||
'5.2' => 'XP 64-bit',
|
||||
'5.1' => 'XP',
|
||||
'5.0' => '2000',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Windows NT version to readable format
|
||||
*/
|
||||
public static function formatWindowsVersion(string $version): string
|
||||
{
|
||||
$map = self::getWindowsVersionMap();
|
||||
|
||||
return $map[$version] ?? $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert iOS/macOS version from underscore to dot notation
|
||||
*/
|
||||
public static function formatAppleVersion(string $version): string
|
||||
{
|
||||
return str_replace('_', '.', $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns that indicate mobile devices
|
||||
* @return string[]
|
||||
*/
|
||||
public static function getMobilePatterns(): array
|
||||
{
|
||||
return [
|
||||
'/Mobile/',
|
||||
'/Android/',
|
||||
'/iPhone/',
|
||||
'/iPad/',
|
||||
'/iPod/',
|
||||
'/BlackBerry/',
|
||||
'/Windows Phone/',
|
||||
'/Opera Mini/',
|
||||
'/webOS/',
|
||||
'/Kindle/',
|
||||
'/Silk/',
|
||||
'/Mobile Safari/',
|
||||
'/SamsungBrowser/',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if User-Agent indicates a mobile device
|
||||
*/
|
||||
public static function isMobile(string $userAgent): bool
|
||||
{
|
||||
foreach (self::getMobilePatterns() as $pattern) {
|
||||
if (preg_match($pattern, $userAgent)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
42
src/Framework/UserAgent/UserAgent.php
Normal file
42
src/Framework/UserAgent/UserAgent.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent;
|
||||
|
||||
/**
|
||||
* Value Object representing an unparsed User-Agent string
|
||||
* Lightweight, suitable for session storage
|
||||
*/
|
||||
final readonly class UserAgent
|
||||
{
|
||||
private function __construct(
|
||||
public string $value
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method - create from string
|
||||
*/
|
||||
public static function fromString(string $userAgentString): self
|
||||
{
|
||||
return new self($userAgentString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse User-Agent into a rich ParsedUserAgent object
|
||||
* Note: Consider injecting UserAgentParser via DI container for better performance
|
||||
*/
|
||||
public function parse(): ParsedUserAgent
|
||||
{
|
||||
return new UserAgentParser()->parse($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
254
src/Framework/UserAgent/UserAgentParser.php
Normal file
254
src/Framework/UserAgent/UserAgentParser.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\UserAgent\Enums\BrowserType;
|
||||
use App\Framework\UserAgent\Enums\EngineType;
|
||||
use App\Framework\UserAgent\Enums\PlatformType;
|
||||
use App\Framework\UserAgent\Patterns\BrowserPatterns;
|
||||
use App\Framework\UserAgent\Patterns\EnginePatterns;
|
||||
use App\Framework\UserAgent\Patterns\PlatformPatterns;
|
||||
|
||||
/**
|
||||
* High-performance User-Agent parser with caching
|
||||
* Optimized for modern browsers and frameworks
|
||||
*/
|
||||
final class UserAgentParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?Cache $cache = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse User-Agent string into structured ParsedUserAgent object
|
||||
*/
|
||||
public function parse(string $userAgentString): ParsedUserAgent
|
||||
{
|
||||
$normalized = trim($userAgentString);
|
||||
|
||||
// Handle empty User-Agent
|
||||
if ($normalized === '') {
|
||||
return $this->createUnknownUserAgent('');
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = 'useragent:' . md5($normalized);
|
||||
if ($this->cache) {
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if ($cached instanceof ParsedUserAgent) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse components
|
||||
$browser = $this->parseBrowser($normalized);
|
||||
$browserVersion = $this->parseBrowserVersion($normalized, $browser);
|
||||
$platform = $this->parsePlatform($normalized);
|
||||
$platformVersion = $this->parsePlatformVersion($normalized, $platform);
|
||||
$engine = $this->parseEngine($normalized, $browser);
|
||||
$engineVersion = $this->parseEngineVersion($normalized, $engine);
|
||||
$isMobile = PlatformPatterns::isMobile($normalized) || $platform->isMobile();
|
||||
$isBot = BrowserPatterns::isBot($normalized);
|
||||
$isModern = $this->determineModernBrowser($browser, $browserVersion, $isBot);
|
||||
|
||||
$parsedUserAgent = new ParsedUserAgent(
|
||||
raw: $normalized,
|
||||
browser: $browser,
|
||||
browserVersion: $browserVersion,
|
||||
platform: $platform,
|
||||
platformVersion: $platformVersion,
|
||||
engine: $engine,
|
||||
engineVersion: $engineVersion,
|
||||
isMobile: $isMobile,
|
||||
isBot: $isBot,
|
||||
isModern: $isModern
|
||||
);
|
||||
|
||||
// Cache result
|
||||
if ($this->cache) {
|
||||
$this->cache->set($cacheKey, $parsedUserAgent, 3600); // Cache for 1 hour
|
||||
}
|
||||
|
||||
return $parsedUserAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse browser type and version
|
||||
*/
|
||||
private function parseBrowser(string $userAgent): BrowserType
|
||||
{
|
||||
foreach (BrowserPatterns::getPatterns() as $pattern) {
|
||||
if (preg_match($pattern['pattern'], $userAgent)) {
|
||||
return $pattern['browser'];
|
||||
}
|
||||
}
|
||||
|
||||
// Try fallback patterns
|
||||
foreach (BrowserPatterns::getFallbackPatterns() as $fallback) {
|
||||
if (preg_match($fallback['pattern'], $userAgent)) {
|
||||
return $fallback['browser'];
|
||||
}
|
||||
}
|
||||
|
||||
return BrowserType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse browser version
|
||||
*/
|
||||
private function parseBrowserVersion(string $userAgent, BrowserType $browser): string
|
||||
{
|
||||
// Find matching pattern for this browser
|
||||
foreach (BrowserPatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['browser'] === $browser && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
return $matches[1] ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse platform/operating system
|
||||
*/
|
||||
private function parsePlatform(string $userAgent): PlatformType
|
||||
{
|
||||
foreach (PlatformPatterns::getPatterns() as $pattern) {
|
||||
if (preg_match($pattern['pattern'], $userAgent)) {
|
||||
return $pattern['platform'];
|
||||
}
|
||||
}
|
||||
|
||||
return PlatformType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse platform version
|
||||
*/
|
||||
private function parsePlatformVersion(string $userAgent, PlatformType $platform): string
|
||||
{
|
||||
foreach (PlatformPatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['platform'] === $platform &&
|
||||
! empty($pattern['versionPattern']) &&
|
||||
preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
|
||||
$version = $matches[1] ?? 'Unknown';
|
||||
|
||||
// Format version based on platform
|
||||
return match ($platform) {
|
||||
PlatformType::WINDOWS => PlatformPatterns::formatWindowsVersion($version),
|
||||
PlatformType::MACOS, PlatformType::IOS => PlatformPatterns::formatAppleVersion($version),
|
||||
default => $version
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse browser engine
|
||||
*/
|
||||
private function parseEngine(string $userAgent, BrowserType $browser): EngineType
|
||||
{
|
||||
// Try pattern-based detection first
|
||||
foreach (EnginePatterns::getPatterns() as $pattern) {
|
||||
if (preg_match($pattern['pattern'], $userAgent)) {
|
||||
return $pattern['engine'];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to browser-based mapping
|
||||
$engineMap = EnginePatterns::getBrowserEngineMap();
|
||||
|
||||
return $engineMap[$browser->value] ?? EngineType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse engine version
|
||||
*/
|
||||
private function parseEngineVersion(string $userAgent, EngineType $engine): string
|
||||
{
|
||||
foreach (EnginePatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['engine'] === $engine && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
$version = $matches[1] ?? 'Unknown';
|
||||
|
||||
// Special formatting for Gecko
|
||||
if ($engine === EngineType::GECKO) {
|
||||
return EnginePatterns::formatGeckoVersion($version);
|
||||
}
|
||||
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if browser is considered modern
|
||||
*/
|
||||
private function determineModernBrowser(BrowserType $browser, string $version, bool $isBot): bool
|
||||
{
|
||||
if ($isBot || $version === 'Unknown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $browser->isModern()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$threshold = $browser->getModernVersionThreshold();
|
||||
|
||||
return version_compare($version, $threshold, '>=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create unknown ParsedUserAgent object
|
||||
*/
|
||||
private function createUnknownUserAgent(string $raw): ParsedUserAgent
|
||||
{
|
||||
return new ParsedUserAgent(
|
||||
raw: $raw,
|
||||
browser: BrowserType::UNKNOWN,
|
||||
browserVersion: 'Unknown',
|
||||
platform: PlatformType::UNKNOWN,
|
||||
platformVersion: 'Unknown',
|
||||
engine: EngineType::UNKNOWN,
|
||||
engineVersion: 'Unknown',
|
||||
isMobile: false,
|
||||
isBot: false,
|
||||
isModern: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear parser cache
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
if ($this->cache) {
|
||||
// Clear all useragent cache entries
|
||||
// This would need cache implementation that supports key patterns
|
||||
// For now, this is a placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parser statistics (for debugging/monitoring)
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
return [
|
||||
'cacheEnabled' => $this->cache !== null,
|
||||
'supportedBrowsers' => count(BrowserType::cases()),
|
||||
'supportedPlatforms' => count(PlatformType::cases()),
|
||||
'supportedEngines' => count(EngineType::cases()),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user