Skip to content

Commit f321f00

Browse files
authored
feat: add url component (RSS-Bridge#3684)
* feat: add url library * fix
1 parent 437afd6 commit f321f00

File tree

4 files changed

+207
-9
lines changed

4 files changed

+207
-9
lines changed

bridges/RedditBridge.php

+14-9
Original file line numberDiff line numberDiff line change
@@ -305,25 +305,30 @@ private function link($href, $text)
305305

306306
public function detectParameters($url)
307307
{
308-
$parsed_url = parse_url($url);
308+
try {
309+
$urlObject = Url::fromString($url);
310+
} catch (UrlException $e) {
311+
return null;
312+
}
309313

310-
$host = $parsed_url['host'] ?? null;
314+
$host = $urlObject->getHost();
315+
$path = $urlObject->getPath();
311316

312-
if ($host != 'www.reddit.com' && $host != 'old.reddit.com') {
317+
$pathSegments = explode('/', $path);
318+
319+
if ($host !== 'www.reddit.com' && $host !== 'old.reddit.com') {
313320
return null;
314321
}
315322

316-
$path = explode('/', $parsed_url['path']);
317-
318-
if ($path[1] == 'r') {
323+
if ($pathSegments[1] == 'r') {
319324
return [
320325
'context' => 'single',
321-
'r' => $path[2]
326+
'r' => $pathSegments[2],
322327
];
323-
} elseif ($path[1] == 'user') {
328+
} elseif ($pathSegments[1] == 'user') {
324329
return [
325330
'context' => 'user',
326-
'u' => $path[2]
331+
'u' => $pathSegments[2],
327332
];
328333
} else {
329334
return null;

lib/bootstrap.php

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
__DIR__ . '/../lib/utils.php',
4545
__DIR__ . '/../lib/http.php',
4646
__DIR__ . '/../lib/logger.php',
47+
__DIR__ . '/../lib/url.php',
4748
// Vendor
4849
__DIR__ . '/../vendor/parsedown/Parsedown.php',
4950
__DIR__ . '/../vendor/php-urljoin/src/urljoin.php',

lib/url.php

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
final class UrlException extends \Exception
6+
{
7+
}
8+
9+
/**
10+
* Intentionally restrictive url parser
11+
*/
12+
final class Url
13+
{
14+
private string $scheme;
15+
private string $host;
16+
private int $port;
17+
private string $path;
18+
private ?string $queryString;
19+
20+
private function __construct()
21+
{
22+
}
23+
24+
public static function fromString(string $url): self
25+
{
26+
if (!self::validate($url)) {
27+
throw new UrlException(sprintf('Illegal url: "%s"', $url));
28+
}
29+
30+
$parts = parse_url($url);
31+
if ($parts === false) {
32+
throw new UrlException(sprintf('Invalid url %s', $url));
33+
}
34+
35+
return (new self())
36+
->withScheme($parts['scheme'] ?? '')
37+
->withHost($parts['host'])
38+
->withPort($parts['port'] ?? 80)
39+
->withPath($parts['path'] ?? '/')
40+
->withQueryString($parts['query'] ?? null);
41+
}
42+
43+
public static function validate(string $url): bool
44+
{
45+
if (strlen($url) > 1500) {
46+
return false;
47+
}
48+
$pattern = '#^https?://' // scheme
49+
. '([a-z0-9-]+\.?)+' // one or more domain names
50+
. '(\.[a-z]{1,24})?' // optional global tld
51+
. '(:\d+)?' // optional port
52+
. '($|/|\?)#i'; // end of string or slash or question mark
53+
54+
return preg_match($pattern, $url) === 1;
55+
}
56+
57+
public function getScheme(): string
58+
{
59+
return $this->scheme;
60+
}
61+
62+
public function getHost(): string
63+
{
64+
return $this->host;
65+
}
66+
67+
public function getPort(): int
68+
{
69+
return $this->port;
70+
}
71+
72+
public function getPath(): string
73+
{
74+
return $this->path;
75+
}
76+
77+
public function getQueryString(): string
78+
{
79+
return $this->queryString;
80+
}
81+
82+
public function withScheme(string $scheme): self
83+
{
84+
if (!in_array($scheme, ['http', 'https'])) {
85+
throw new UrlException(sprintf('Invalid scheme %s', $scheme));
86+
}
87+
$clone = clone $this;
88+
$clone->scheme = $scheme;
89+
return $clone;
90+
}
91+
92+
public function withHost(string $host): self
93+
{
94+
$clone = clone $this;
95+
$clone->host = $host;
96+
return $clone;
97+
}
98+
99+
public function withPort(int $port)
100+
{
101+
$clone = clone $this;
102+
$clone->port = $port;
103+
return $clone;
104+
}
105+
106+
public function withPath(string $path): self
107+
{
108+
if (!str_starts_with($path, '/')) {
109+
throw new UrlException(sprintf('Path must start with forward slash: %s', $path));
110+
}
111+
$clone = clone $this;
112+
$clone->path = $path;
113+
return $clone;
114+
}
115+
116+
public function withQueryString(?string $queryString): self
117+
{
118+
$clone = clone $this;
119+
$clone->queryString = $queryString;
120+
return $clone;
121+
}
122+
123+
public function __toString()
124+
{
125+
if ($this->port === 80) {
126+
$port = '';
127+
} else {
128+
$port = ':' . $this->port;
129+
}
130+
if ($this->queryString) {
131+
$queryString = '?' . $this->queryString;
132+
} else {
133+
$queryString = '';
134+
}
135+
136+
return sprintf(
137+
'%s://%s%s%s%s',
138+
$this->scheme,
139+
$this->host,
140+
$port,
141+
$this->path,
142+
$queryString
143+
);
144+
}
145+
}

tests/UrlTest.php

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RssBridge\Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Url;
9+
10+
class UrlTest extends TestCase
11+
{
12+
public function testBasicUsages()
13+
{
14+
$urls = [
15+
'http://example.com/',
16+
'http://example.com:9000/',
17+
'https://example.com/',
18+
'https://example.com/?foo',
19+
'https://example.com/?foo=bar',
20+
];
21+
foreach ($urls as $url) {
22+
$this->assertSame($url, Url::fromString($url)->__toString());
23+
}
24+
}
25+
26+
public function testNormalization()
27+
{
28+
$urls = [
29+
'http://example.com' => 'http://example.com/',
30+
'https://example.com/?' => 'https://example.com/',
31+
'https://example.com/foo?' => 'https://example.com/foo',
32+
'http://example.com:80/' => 'http://example.com/',
33+
];
34+
foreach ($urls as $from => $to) {
35+
$this->assertSame($to, Url::fromString($from)->__toString());
36+
}
37+
}
38+
39+
public function testMutation()
40+
{
41+
$this->assertSame('http://example.com/foo', (Url::fromString('http://example.com/'))->withPath('/foo')->__toString());
42+
$this->assertSame('http://example.com/foo?a=b', (Url::fromString('http://example.com/?a=b'))->withPath('/foo')->__toString());
43+
$this->assertSame('http://example.com/', (Url::fromString('http://example.com/'))->withPath('/')->__toString());
44+
$this->assertSame('http://example.com/qqq?foo=bar', (Url::fromString('http://example.com/qqq'))->withQueryString('foo=bar')->__toString());
45+
$this->assertSame('http://example.net/qqq?foo=bar', (Url::fromString('http://example.com/qqq?foo=bar'))->withHost('example.net')->__toString());
46+
}
47+
}

0 commit comments

Comments
 (0)