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);
+ }
+}