Skip to content

Commit 509adb4

Browse files
committed
Redirect downloads.php to auto-detected OS on invalid os param
1 parent 70b6307 commit 509adb4

4 files changed

Lines changed: 169 additions & 79 deletions

File tree

downloads.php

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,58 @@
99
// Try to make this page non-cached
1010
header_nocache();
1111

12+
$os = [
13+
'linux' => [
14+
'name' => 'Linux',
15+
'variants' => [
16+
'linux-debian' => 'Debian',
17+
'linux-fedora' => 'Fedora',
18+
'linux-redhat' => 'RedHat',
19+
'linux-ubuntu' => 'Ubuntu',
20+
'linux-docker-cli' => 'Docker (Command Line Interface)',
21+
'linux-docker-web' => 'Docker (Web Development)',
22+
],
23+
],
24+
'osx' => [
25+
'name' => 'macOS',
26+
'variants' => [
27+
'osx-homebrew' => 'Homebrew',
28+
'osx-homebrew-php' => 'Homebrew-PHP',
29+
'osx-docker-cli' => 'Docker (Command Line Interface)',
30+
'osx-docker-web' => 'Docker (Web Development)',
31+
'osx-macports' => 'MacPorts',
32+
],
33+
],
34+
'windows' => [
35+
'name' => 'Windows',
36+
'variants' => [
37+
'windows-downloads' => 'ZIP Downloads',
38+
'windows-native' => 'Single Line Installer',
39+
'windows-chocolatey' => 'Chocolatey',
40+
'windows-scoop' => 'Scoop',
41+
'windows-winget' => 'Winget',
42+
'windows-docker-cli' => 'Docker (Command Line Interface)',
43+
'windows-docker-web' => 'Docker (Web Development)',
44+
'windows-wsl-debian' => 'WSL/Debian',
45+
'windows-wsl-ubuntu' => 'WSL/Ubuntu',
46+
],
47+
],
48+
];
49+
50+
// An invalid ?os= redirects to the auto-detected results before any output.
51+
$resolution = (new OptionResolver($os))->resolve(
52+
$_GET,
53+
$_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? '',
54+
$_SERVER['HTTP_USER_AGENT'] ?? '',
55+
);
56+
57+
if ($resolution->redirectQuery !== null) {
58+
header('Location: /downloads.php?' . $resolution->redirectQuery, true, 302);
59+
exit;
60+
}
61+
62+
$options = $resolution->options;
63+
1264
$SIDEBAR_DATA = '
1365
<div class="panel">
1466
<a href="/supported-versions.php">Supported Versions</a>
@@ -54,44 +106,6 @@ function option(string $value, string $desc, $attributes = []): string
54106
return '<option value="' . $value . '"' . implode(' ', array_keys(array_filter($attributes))) . '>' . $desc . '</option>';
55107
}
56108

57-
$os = [
58-
'linux' => [
59-
'name' => 'Linux',
60-
'variants' => [
61-
'linux-debian' => 'Debian',
62-
'linux-fedora' => 'Fedora',
63-
'linux-redhat' => 'RedHat',
64-
'linux-ubuntu' => 'Ubuntu',
65-
'linux-docker-cli' => 'Docker (Command Line Interface)',
66-
'linux-docker-web' => 'Docker (Web Development)',
67-
],
68-
],
69-
'osx' => [
70-
'name' => 'macOS',
71-
'variants' => [
72-
'osx-homebrew' => 'Homebrew',
73-
'osx-homebrew-php' => 'Homebrew-PHP',
74-
'osx-docker-cli' => 'Docker (Command Line Interface)',
75-
'osx-docker-web' => 'Docker (Web Development)',
76-
'osx-macports' => 'MacPorts',
77-
],
78-
],
79-
'windows' => [
80-
'name' => 'Windows',
81-
'variants' => [
82-
'windows-downloads' => 'ZIP Downloads',
83-
'windows-native' => 'Single Line Installer',
84-
'windows-chocolatey' => 'Chocolatey',
85-
'windows-scoop' => 'Scoop',
86-
'windows-winget' => 'Winget',
87-
'windows-docker-cli' => 'Docker (Command Line Interface)',
88-
'windows-docker-web' => 'Docker (Web Development)',
89-
'windows-wsl-debian' => 'WSL/Debian',
90-
'windows-wsl-ubuntu' => 'WSL/Ubuntu',
91-
],
92-
],
93-
];
94-
95109
$versions = [
96110
'8.5' => 'version 8.5',
97111
'8.4' => 'version 8.4',
@@ -100,12 +114,6 @@ function option(string $value, string $desc, $attributes = []): string
100114
'default' => 'default PHP version for OS',
101115
];
102116

103-
104-
$options = (new OptionResolver($os))->resolve(
105-
$_GET,
106-
$_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? '',
107-
$_SERVER['HTTP_USER_AGENT'] ?? '',
108-
);
109117
?>
110118
<h1>Downloads &amp; Installation Instructions</h1>
111119

src/Downloads/OptionResolver.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ public function __construct(private readonly array $osList)
1717
* Resolve the selected download options from the request, auto-detecting the
1818
* operating system from client hints / user agent when not explicitly chosen.
1919
*
20+
* When the client supplies an invalid os parameter (e.g. bots requesting
21+
* ?os=<garbage> or ?os[]=x, which previously crashed while indexing the os
22+
* list), the returned Resolution carries a redirect query pointing at the
23+
* auto-detected results instead of silently rendering a default.
24+
*
2025
* @param array<string, mixed> $get GET parameters (os, osvariant, version, ...)
21-
* @return array<string, mixed>
2226
*/
23-
public function resolve(array $get, string $platformHeader, string $uaHeader): array
27+
public function resolve(array $get, string $platformHeader, string $uaHeader): Resolution
2428
{
2529
[$autoOs, $autoOsVariant] = $this->autoDetect($platformHeader, $uaHeader);
2630

@@ -31,9 +35,10 @@ public function resolve(array $get, string $platformHeader, string $uaHeader): a
3135

3236
$options = array_merge($defaults, $get);
3337

34-
// Ignore an invalid or non-string os parameter (e.g. bots requesting
35-
// ?os=<garbage> or ?os[]=x) so indexing $osList below stays safe.
36-
if (!is_string($options['os']) || !array_key_exists($options['os'], $this->osList)) {
38+
$invalidOs = array_key_exists('os', $get)
39+
&& (!is_string($get['os']) || !array_key_exists($get['os'], $this->osList));
40+
41+
if ($invalidOs) {
3742
$options['os'] = $defaults['os'];
3843
}
3944

@@ -43,7 +48,29 @@ public function resolve(array $get, string $platformHeader, string $uaHeader): a
4348
$options['osvariant'] = array_key_first($this->osList[$options['os']]['variants']);
4449
}
4550

46-
return $options;
51+
return new Resolution(
52+
$options,
53+
$invalidOs ? $this->redirectQuery($options) : null,
54+
);
55+
}
56+
57+
/**
58+
* Build the canonical query string for the auto-detected results, preserving
59+
* the resolved os/variant/version so the redirect lands on a valid page.
60+
*
61+
* @param array<string, mixed> $options
62+
*/
63+
private function redirectQuery(array $options): string
64+
{
65+
$query = [];
66+
67+
foreach (['os', 'osvariant', 'version'] as $key) {
68+
if (array_key_exists($key, $options) && is_string($options[$key])) {
69+
$query[$key] = $options[$key];
70+
}
71+
}
72+
73+
return http_build_query($query);
4774
}
4875

4976
/**

src/Downloads/Resolution.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace phpweb\Downloads;
6+
7+
final class Resolution
8+
{
9+
/**
10+
* @param array<string, mixed> $options resolved download options for rendering
11+
* @param ?string $redirectQuery query string to redirect to (without leading
12+
* '?'), or null when the request should render
13+
*/
14+
public function __construct(
15+
public readonly array $options,
16+
public readonly ?string $redirectQuery = null,
17+
) {
18+
}
19+
}

tests/Unit/Downloads/OptionResolverTest.php

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
namespace phpweb\Test\Unit\Downloads;
66

77
use phpweb\Downloads\OptionResolver;
8+
use phpweb\Downloads\Resolution;
89
use PHPUnit\Framework;
910

1011
#[Framework\Attributes\CoversClass(OptionResolver::class)]
12+
#[Framework\Attributes\CoversClass(Resolution::class)]
1113
class OptionResolverTest extends Framework\TestCase
1214
{
1315
/**
@@ -41,97 +43,131 @@ class OptionResolverTest extends Framework\TestCase
4143
* Reproduces the production crash: a bot requesting downloads.php?os=<garbage>
4244
* previously made $os[$options['os']] null and threw
4345
* "array_key_exists(): Argument #2 ($array) must be of type array, null given".
46+
* It now redirects to the auto-detected results instead.
4447
*/
45-
public function testInvalidOsParameterFallsBackToDefault(): void
48+
public function testInvalidOsParameterRedirectsToDefault(): void
4649
{
47-
$options = $this->resolver()->resolve(['os' => 'not-a-real-os'], '', '');
50+
$resolution = $this->resolver()->resolve(['os' => 'not-a-real-os'], '', '');
4851

49-
self::assertSame('linux', $options['os']);
50-
self::assertSame('linux-debian', $options['osvariant']);
52+
self::assertSame('linux', $resolution->options['os']);
53+
self::assertSame('linux-debian', $resolution->options['osvariant']);
54+
self::assertSame('os=linux&osvariant=linux-debian&version=default', $resolution->redirectQuery);
5155
}
5256

53-
public function testArrayOsParameterFallsBackToDefault(): void
57+
public function testArrayOsParameterRedirectsToDefault(): void
5458
{
55-
$options = $this->resolver()->resolve(['os' => ['linux']], '', '');
59+
$resolution = $this->resolver()->resolve(['os' => ['linux']], '', '');
5660

57-
self::assertSame('linux', $options['os']);
61+
self::assertSame('linux', $resolution->options['os']);
62+
self::assertSame('os=linux&osvariant=linux-debian&version=default', $resolution->redirectQuery);
5863
}
5964

60-
public function testValidOsParameterSelectsFirstVariant(): void
65+
public function testInvalidOsRedirectsToAutoDetectedResults(): void
6166
{
62-
$options = $this->resolver()->resolve(['os' => 'windows'], '', '');
67+
$resolution = $this->resolver()->resolve(
68+
['os' => 'error'],
69+
'',
70+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
71+
);
72+
73+
self::assertSame('windows', $resolution->options['os']);
74+
self::assertSame('os=windows&osvariant=windows-downloads&version=default', $resolution->redirectQuery);
75+
}
76+
77+
public function testInvalidOsRedirectPreservesRequestedVersion(): void
78+
{
79+
$resolution = $this->resolver()->resolve(['os' => 'error', 'version' => '8.4'], '', '');
80+
81+
self::assertSame('os=linux&osvariant=linux-debian&version=8.4', $resolution->redirectQuery);
82+
}
83+
84+
public function testValidOsParameterDoesNotRedirect(): void
85+
{
86+
$resolution = $this->resolver()->resolve(['os' => 'windows'], '', '');
87+
88+
self::assertNull($resolution->redirectQuery);
89+
self::assertSame('windows', $resolution->options['os']);
90+
self::assertSame('windows-downloads', $resolution->options['osvariant']);
91+
}
92+
93+
public function testAbsentOsParameterDoesNotRedirect(): void
94+
{
95+
$resolution = $this->resolver()->resolve([], '', '');
6396

64-
self::assertSame('windows', $options['os']);
65-
self::assertSame('windows-downloads', $options['osvariant']);
97+
self::assertNull($resolution->redirectQuery);
98+
self::assertSame('linux', $resolution->options['os']);
6699
}
67100

68101
public function testValidOsAndVariantAreHonored(): void
69102
{
70-
$options = $this->resolver()->resolve(
103+
$resolution = $this->resolver()->resolve(
71104
['os' => 'osx', 'osvariant' => 'osx-macports'],
72105
'',
73106
'',
74107
);
75108

76-
self::assertSame('osx', $options['os']);
77-
self::assertSame('osx-macports', $options['osvariant']);
109+
self::assertNull($resolution->redirectQuery);
110+
self::assertSame('osx', $resolution->options['os']);
111+
self::assertSame('osx-macports', $resolution->options['osvariant']);
78112
}
79113

80114
public function testInvalidVariantFallsBackToFirstForThatOs(): void
81115
{
82-
$options = $this->resolver()->resolve(
116+
$resolution = $this->resolver()->resolve(
83117
['os' => 'windows', 'osvariant' => 'linux-debian'],
84118
'',
85119
'',
86120
);
87121

88-
self::assertSame('windows', $options['os']);
89-
self::assertSame('windows-downloads', $options['osvariant']);
122+
self::assertNull($resolution->redirectQuery);
123+
self::assertSame('windows', $resolution->options['os']);
124+
self::assertSame('windows-downloads', $resolution->options['osvariant']);
90125
}
91126

92127
public function testVersionDefaultsWhenNotSupplied(): void
93128
{
94-
$options = $this->resolver()->resolve([], '', '');
129+
$resolution = $this->resolver()->resolve([], '', '');
95130

96-
self::assertSame('default', $options['version']);
131+
self::assertSame('default', $resolution->options['version']);
97132
}
98133

99134
public function testGetParametersArePreserved(): void
100135
{
101-
$options = $this->resolver()->resolve(
136+
$resolution = $this->resolver()->resolve(
102137
['os' => 'linux', 'version' => '8.4', 'source' => 'Y'],
103138
'',
104139
'',
105140
);
106141

107-
self::assertSame('8.4', $options['version']);
108-
self::assertSame('Y', $options['source']);
142+
self::assertSame('8.4', $resolution->options['version']);
143+
self::assertSame('Y', $resolution->options['source']);
109144
}
110145

111146
public function testDetectsWindowsFromUserAgent(): void
112147
{
113-
$options = $this->resolver()->resolve([], '', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
148+
$resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
114149

115-
self::assertSame('windows', $options['os']);
150+
self::assertSame('windows', $resolution->options['os']);
116151
}
117152

118153
public function testDetectsUbuntuVariantFromUserAgent(): void
119154
{
120-
$options = $this->resolver()->resolve([], '', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64)');
155+
$resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64)');
121156

122-
self::assertSame('linux', $options['os']);
123-
self::assertSame('linux-ubuntu', $options['osvariant']);
157+
self::assertSame('linux', $resolution->options['os']);
158+
self::assertSame('linux-ubuntu', $resolution->options['osvariant']);
124159
}
125160

126161
public function testExplicitOsParameterOverridesAutoDetection(): void
127162
{
128-
$options = $this->resolver()->resolve(
163+
$resolution = $this->resolver()->resolve(
129164
['os' => 'osx'],
130165
'',
131166
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
132167
);
133168

134-
self::assertSame('osx', $options['os']);
169+
self::assertNull($resolution->redirectQuery);
170+
self::assertSame('osx', $resolution->options['os']);
135171
}
136172

137173
private function resolver(): OptionResolver

0 commit comments

Comments
 (0)