diff --git a/downloads.php b/downloads.php index 21325269b9..8e0cc1a418 100644 --- a/downloads.php +++ b/downloads.php @@ -1,4 +1,6 @@ [ + 'name' => 'Linux', + 'variants' => [ + 'linux-debian' => 'Debian', + 'linux-fedora' => 'Fedora', + 'linux-redhat' => 'RedHat', + 'linux-ubuntu' => 'Ubuntu', + 'linux-docker-cli' => 'Docker (Command Line Interface)', + 'linux-docker-web' => 'Docker (Web Development)', + ], + ], + 'osx' => [ + 'name' => 'macOS', + 'variants' => [ + 'osx-homebrew' => 'Homebrew', + 'osx-homebrew-php' => 'Homebrew-PHP', + 'osx-docker-cli' => 'Docker (Command Line Interface)', + 'osx-docker-web' => 'Docker (Web Development)', + 'osx-macports' => 'MacPorts', + ], + ], + 'windows' => [ + 'name' => 'Windows', + 'variants' => [ + 'windows-downloads' => 'ZIP Downloads', + 'windows-native' => 'Single Line Installer', + 'windows-chocolatey' => 'Chocolatey', + 'windows-scoop' => 'Scoop', + 'windows-winget' => 'Winget', + 'windows-docker-cli' => 'Docker (Command Line Interface)', + 'windows-docker-web' => 'Docker (Web Development)', + 'windows-wsl-debian' => 'WSL/Debian', + 'windows-wsl-ubuntu' => 'WSL/Ubuntu', + ], + ], +]; + +// An invalid ?os= redirects to the auto-detected results before any output. +$resolution = (new OptionResolver($os))->resolve( + $_GET, + $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? '', + $_SERVER['HTTP_USER_AGENT'] ?? '', +); + +if ($resolution->redirectQuery !== null) { + header('Location: /downloads.php?' . $resolution->redirectQuery, true, 302); + exit; +} + +$options = $resolution->options; + $SIDEBAR_DATA = '
Supported Versions @@ -52,44 +106,6 @@ function option(string $value, string $desc, $attributes = []): string return ''; } -$os = [ - 'linux' => [ - 'name' => 'Linux', - 'variants' => [ - 'linux-debian' => 'Debian', - 'linux-fedora' => 'Fedora', - 'linux-redhat' => 'RedHat', - 'linux-ubuntu' => 'Ubuntu', - 'linux-docker-cli' => 'Docker (Command Line Interface)', - 'linux-docker-web' => 'Docker (Web Development)', - ], - ], - 'osx' => [ - 'name' => 'macOS', - 'variants' => [ - 'osx-homebrew' => 'Homebrew', - 'osx-homebrew-php' => 'Homebrew-PHP', - 'osx-docker-cli' => 'Docker (Command Line Interface)', - 'osx-docker-web' => 'Docker (Web Development)', - 'osx-macports' => 'MacPorts', - ], - ], - 'windows' => [ - 'name' => 'Windows', - 'variants' => [ - 'windows-downloads' => 'ZIP Downloads', - 'windows-native' => 'Single Line Installer', - 'windows-chocolatey' => 'Chocolatey', - 'windows-scoop' => 'Scoop', - 'windows-winget' => 'Winget', - 'windows-docker-cli' => 'Docker (Command Line Interface)', - 'windows-docker-web' => 'Docker (Web Development)', - 'windows-wsl-debian' => 'WSL/Debian', - 'windows-wsl-ubuntu' => 'WSL/Ubuntu', - ], - ], -]; - $versions = [ '8.5' => 'version 8.5', '8.4' => 'version 8.4', @@ -98,44 +114,6 @@ function option(string $value, string $desc, $attributes = []): string 'default' => 'default PHP version for OS', ]; - -$platform = $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? ''; -$ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; -$auto_os = null; -$auto_osvariant = null; - -if (!empty($platform) || !empty($ua)) { - $platform = strtolower(trim($platform, '"')); - if ($platform === 'windows' || stripos($ua, 'Windows') !== false) { - $auto_os = 'windows'; - } elseif ($platform === 'macos' || stripos($ua, 'Mac') !== false) { - $auto_os = 'osx'; - } elseif ($platform === 'linux' || stripos($ua, 'Linux') !== false) { - $auto_os = 'linux'; - if (stripos($ua, 'Ubuntu') !== false) { - $auto_osvariant = 'linux-ubuntu'; - } elseif (stripos($ua, 'Debian') !== false) { - $auto_osvariant = 'linux-debian'; - } elseif (stripos($ua, 'Fedora') !== false) { - $auto_osvariant = 'linux-fedora'; - } elseif (stripos($ua, 'Red Hat') !== false || stripos($ua, 'RedHat') !== false) { - $auto_osvariant = 'linux-redhat'; - } - } -} - -$defaults = [ - 'os' => $auto_os ?? 'linux', - 'version' => 'default', -]; - -$options = array_merge($defaults, $_GET); - -if ($auto_osvariant && (!array_key_exists('osvariant', $options) || !array_key_exists($options['osvariant'], $os[$options['os']]['variants']))) { - $options['osvariant'] = $auto_osvariant; -} elseif (!array_key_exists('osvariant', $options) || !array_key_exists($options['osvariant'], $os[$options['os']]['variants'])) { - $options['osvariant'] = array_key_first($os[$options['os']]['variants']); -} ?>

Downloads & Installation Instructions

diff --git a/src/Downloads/OptionResolver.php b/src/Downloads/OptionResolver.php new file mode 100644 index 0000000000..8c946b85e4 --- /dev/null +++ b/src/Downloads/OptionResolver.php @@ -0,0 +1,109 @@ +}> $osList + */ + public function __construct(private readonly array $osList) + { + } + + /** + * Resolve the selected download options from the request, auto-detecting the + * operating system from client hints / user agent when not explicitly chosen. + * + * When the client supplies an invalid os parameter (e.g. bots requesting + * ?os= or ?os[]=x, which previously crashed while indexing the os + * list), the returned Resolution carries a redirect query pointing at the + * auto-detected results instead of silently rendering a default. + * + * @param array $get GET parameters (os, osvariant, version, ...) + */ + public function resolve(array $get, string $platformHeader, string $uaHeader): Resolution + { + [$autoOs, $autoOsVariant] = $this->autoDetect($platformHeader, $uaHeader); + + $defaults = [ + 'os' => $autoOs ?? 'linux', + 'version' => 'default', + ]; + + $options = array_merge($defaults, $get); + + $invalidOs = array_key_exists('os', $get) + && (!is_string($get['os']) || !array_key_exists($get['os'], $this->osList)); + + if ($invalidOs) { + $options['os'] = $defaults['os']; + } + + if ($autoOsVariant && (!array_key_exists('osvariant', $options) || !array_key_exists($options['osvariant'], $this->osList[$options['os']]['variants']))) { + $options['osvariant'] = $autoOsVariant; + } elseif (!array_key_exists('osvariant', $options) || !array_key_exists($options['osvariant'], $this->osList[$options['os']]['variants'])) { + $options['osvariant'] = array_key_first($this->osList[$options['os']]['variants']); + } + + return new Resolution( + $options, + $invalidOs ? $this->redirectQuery($options) : null, + ); + } + + /** + * Build the canonical query string for the auto-detected results, preserving + * the resolved os/variant/version so the redirect lands on a valid page. + * + * @param array $options + */ + private function redirectQuery(array $options): string + { + $query = []; + + foreach (['os', 'osvariant', 'version'] as $key) { + if (array_key_exists($key, $options) && is_string($options[$key])) { + $query[$key] = $options[$key]; + } + } + + return http_build_query($query); + } + + /** + * @return array{0: ?string, 1: ?string} [auto os, auto os variant] + */ + private function autoDetect(string $platformHeader, string $uaHeader): array + { + $autoOs = null; + $autoOsVariant = null; + + if ($platformHeader === '' && $uaHeader === '') { + return [$autoOs, $autoOsVariant]; + } + + $platform = strtolower(trim($platformHeader, '"')); + + if ($platform === 'windows' || stripos($uaHeader, 'Windows') !== false) { + $autoOs = 'windows'; + } elseif ($platform === 'macos' || stripos($uaHeader, 'Mac') !== false) { + $autoOs = 'osx'; + } elseif ($platform === 'linux' || stripos($uaHeader, 'Linux') !== false) { + $autoOs = 'linux'; + if (stripos($uaHeader, 'Ubuntu') !== false) { + $autoOsVariant = 'linux-ubuntu'; + } elseif (stripos($uaHeader, 'Debian') !== false) { + $autoOsVariant = 'linux-debian'; + } elseif (stripos($uaHeader, 'Fedora') !== false) { + $autoOsVariant = 'linux-fedora'; + } elseif (stripos($uaHeader, 'Red Hat') !== false || stripos($uaHeader, 'RedHat') !== false) { + $autoOsVariant = 'linux-redhat'; + } + } + + return [$autoOs, $autoOsVariant]; + } +} diff --git a/src/Downloads/Resolution.php b/src/Downloads/Resolution.php new file mode 100644 index 0000000000..0638ecbb0f --- /dev/null +++ b/src/Downloads/Resolution.php @@ -0,0 +1,19 @@ + $options resolved download options for rendering + * @param ?string $redirectQuery query string to redirect to (without leading + * '?'), or null when the request should render + */ + public function __construct( + public readonly array $options, + public readonly ?string $redirectQuery = null, + ) { + } +} diff --git a/tests/Unit/Downloads/OptionResolverTest.php b/tests/Unit/Downloads/OptionResolverTest.php new file mode 100644 index 0000000000..7d379e4cc3 --- /dev/null +++ b/tests/Unit/Downloads/OptionResolverTest.php @@ -0,0 +1,177 @@ +}> + */ + private const OS_LIST = [ + 'linux' => [ + 'name' => 'Linux', + 'variants' => [ + 'linux-debian' => 'Debian', + 'linux-ubuntu' => 'Ubuntu', + ], + ], + 'osx' => [ + 'name' => 'macOS', + 'variants' => [ + 'osx-homebrew' => 'Homebrew', + 'osx-macports' => 'MacPorts', + ], + ], + 'windows' => [ + 'name' => 'Windows', + 'variants' => [ + 'windows-downloads' => 'ZIP Downloads', + 'windows-native' => 'Single Line Installer', + ], + ], + ]; + + /** + * Reproduces the production crash: a bot requesting downloads.php?os= + * previously made $os[$options['os']] null and threw + * "array_key_exists(): Argument #2 ($array) must be of type array, null given". + * It now redirects to the auto-detected results instead. + */ + public function testInvalidOsParameterRedirectsToDefault(): void + { + $resolution = $this->resolver()->resolve(['os' => 'not-a-real-os'], '', ''); + + self::assertSame('linux', $resolution->options['os']); + self::assertSame('linux-debian', $resolution->options['osvariant']); + self::assertSame('os=linux&osvariant=linux-debian&version=default', $resolution->redirectQuery); + } + + public function testArrayOsParameterRedirectsToDefault(): void + { + $resolution = $this->resolver()->resolve(['os' => ['linux']], '', ''); + + self::assertSame('linux', $resolution->options['os']); + self::assertSame('os=linux&osvariant=linux-debian&version=default', $resolution->redirectQuery); + } + + public function testInvalidOsRedirectsToAutoDetectedResults(): void + { + $resolution = $this->resolver()->resolve( + ['os' => 'error'], + '', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + ); + + self::assertSame('windows', $resolution->options['os']); + self::assertSame('os=windows&osvariant=windows-downloads&version=default', $resolution->redirectQuery); + } + + public function testInvalidOsRedirectPreservesRequestedVersion(): void + { + $resolution = $this->resolver()->resolve(['os' => 'error', 'version' => '8.4'], '', ''); + + self::assertSame('os=linux&osvariant=linux-debian&version=8.4', $resolution->redirectQuery); + } + + public function testValidOsParameterDoesNotRedirect(): void + { + $resolution = $this->resolver()->resolve(['os' => 'windows'], '', ''); + + self::assertNull($resolution->redirectQuery); + self::assertSame('windows', $resolution->options['os']); + self::assertSame('windows-downloads', $resolution->options['osvariant']); + } + + public function testAbsentOsParameterDoesNotRedirect(): void + { + $resolution = $this->resolver()->resolve([], '', ''); + + self::assertNull($resolution->redirectQuery); + self::assertSame('linux', $resolution->options['os']); + } + + public function testValidOsAndVariantAreHonored(): void + { + $resolution = $this->resolver()->resolve( + ['os' => 'osx', 'osvariant' => 'osx-macports'], + '', + '', + ); + + self::assertNull($resolution->redirectQuery); + self::assertSame('osx', $resolution->options['os']); + self::assertSame('osx-macports', $resolution->options['osvariant']); + } + + public function testInvalidVariantFallsBackToFirstForThatOs(): void + { + $resolution = $this->resolver()->resolve( + ['os' => 'windows', 'osvariant' => 'linux-debian'], + '', + '', + ); + + self::assertNull($resolution->redirectQuery); + self::assertSame('windows', $resolution->options['os']); + self::assertSame('windows-downloads', $resolution->options['osvariant']); + } + + public function testVersionDefaultsWhenNotSupplied(): void + { + $resolution = $this->resolver()->resolve([], '', ''); + + self::assertSame('default', $resolution->options['version']); + } + + public function testGetParametersArePreserved(): void + { + $resolution = $this->resolver()->resolve( + ['os' => 'linux', 'version' => '8.4', 'source' => 'Y'], + '', + '', + ); + + self::assertSame('8.4', $resolution->options['version']); + self::assertSame('Y', $resolution->options['source']); + } + + public function testDetectsWindowsFromUserAgent(): void + { + $resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + + self::assertSame('windows', $resolution->options['os']); + } + + public function testDetectsUbuntuVariantFromUserAgent(): void + { + $resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64)'); + + self::assertSame('linux', $resolution->options['os']); + self::assertSame('linux-ubuntu', $resolution->options['osvariant']); + } + + public function testExplicitOsParameterOverridesAutoDetection(): void + { + $resolution = $this->resolver()->resolve( + ['os' => 'osx'], + '', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + ); + + self::assertNull($resolution->redirectQuery); + self::assertSame('osx', $resolution->options['os']); + } + + private function resolver(): OptionResolver + { + return new OptionResolver(self::OS_LIST); + } +}