Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Phaseolies\Auth\Authable;
use ParagonIE\ConstantTime\Base32;
use OTPHP\TOTP;
use Uri\Rfc3986\Uri;

trait InteractsWithTwoFactorAuth
{
Expand Down Expand Up @@ -49,7 +50,8 @@ public function enableTwoFactorAuth(): array
$this->getClock()
);

$host = parse_url(config('app.url'), PHP_URL_HOST);
$appUri = Uri::parse((string) config('app.url'));
$host = $appUri?->getHost() ?? '';
$issuer = preg_replace('/[^a-zA-Z0-9.\-_]/', '', $host);

$totp->setLabel(strtolower(trim(config('app.name'))));
Expand Down
22 changes: 13 additions & 9 deletions src/Phaseolies/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Phaseolies\Http\ParameterBag;
use Phaseolies\Http\InputBag;
use Phaseolies\Http\HeaderBag;
use Uri\Rfc3986\Uri;

/**
* @phpstan-consistent-constructor
Expand Down Expand Up @@ -629,9 +630,9 @@ public function merge(array $input): self
*/
public function getPath(): string
{
return urldecode(
parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH)
);
$path = parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH);

return urldecode(is_string($path) ? $path : '/');
}

/**
Expand Down Expand Up @@ -1171,14 +1172,17 @@ protected function prepareRequestUri(): string
} else {
// HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path,
// only use URL path.
$uriComponents = parse_url($requestUri);
$uriComponents = Uri::parse($requestUri);

if (isset($uriComponents['path'])) {
$requestUri = $uriComponents['path'];
}
if ($uriComponents !== null) {
$requestUri = $uriComponents->getRawPath();

if (isset($uriComponents['query'])) {
$requestUri .= '?' . $uriComponents['query'];
if ($uriComponents->getRawQuery() !== null) {
$requestUri .= '?' . $uriComponents->getRawQuery();
}
} elseif (($uriComponents = parse_url($requestUri)) !== false) {
$requestUri = ($uriComponents['path'] ?? '')
. (isset($uriComponents['query']) ? '?' . $uriComponents['query'] : '');
}
}
} elseif ($this->server->has('ORIG_PATH_INFO')) {
Expand Down
33 changes: 6 additions & 27 deletions src/Phaseolies/Http/Response/RedirectResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Phaseolies\Session\MessageBag;
use Phaseolies\Http\Response;
use Phaseolies\Support\Facades\Str;
use Uri\Rfc3986\Uri;

class RedirectResponse extends Response
{
Expand Down Expand Up @@ -139,35 +140,13 @@ protected function ensureScheme(string $url, bool $secure): string
{
// If the URL is already absolute (contains ://), parse it
if (strpos($url, '://') !== false) {
$parsedUrl = parse_url($url);

// Rebuild the URL with the new scheme
$parsedUrl = Uri::parse($url);
$scheme = $secure ? 'https' : 'http';
$url = $scheme . '://';

// Add the host if it exists
if (isset($parsedUrl['host'])) {
$url .= $parsedUrl['host'];
}

// Add the port if it exists
if (isset($parsedUrl['port'])) {
$url .= ':' . $parsedUrl['port'];
}

// Add the path if it exists
if (isset($parsedUrl['path'])) {
$url .= $parsedUrl['path'];
}

// Add the query string if it exists
if (isset($parsedUrl['query'])) {
$url .= '?' . $parsedUrl['query'];
}

// Add the fragment if it exists
if (isset($parsedUrl['fragment'])) {
$url .= '#' . $parsedUrl['fragment'];
if ($parsedUrl !== null) {
$url = $parsedUrl->withScheme($scheme)->toRawString();
} elseif (parse_url($url) !== false) {
$url = preg_replace('#^[a-z][a-z0-9+.\\-]*://#i', $scheme . '://', $url, 1) ?? $url;
}
} else {
// For relative URLs, prepend the current host and scheme
Expand Down
12 changes: 7 additions & 5 deletions src/Phaseolies/Launchers/CacheLauncher.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Phaseolies\Launchers;

use Uri\Rfc3986\Uri;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
Expand Down Expand Up @@ -71,12 +72,13 @@ protected function createRedisAdapter(array $config): RedisAdapter
$redis = new \Redis();

$dsn = $config['connection'] ?? 'redis://127.0.0.1:6379';
$parsed = parse_url($dsn);
$parsed = Uri::parse($dsn);

$host = $parsed['host'] ?? '127.0.0.1';
$port = $parsed['port'] ?? 6379;
$password = $parsed['pass'] ?? null;
$database = isset($parsed['path']) ? (int) substr($parsed['path'], 1) : 0;
$host = $parsed?->getHost() ?: '127.0.0.1';
$port = $parsed?->getPort() ?? 6379;
$password = $parsed?->getPassword();
$path = $parsed?->getRawPath() ?? '';
$database = $path !== '' ? (int) substr($path, 1) : 0;

if (!$redis->connect($host, $port, 2.5)) {
throw new \RuntimeException("Could not connect to Redis at {$host}:{$port}");
Expand Down
4 changes: 1 addition & 3 deletions src/Phaseolies/Launchers/RouteLauncher.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ class RouteLauncher extends ServiceLauncher
*/
public function register()
{
$path = urldecode(
parse_url(request()->server->get("REQUEST_URI", "/"), PHP_URL_PATH)
);
$path = request()->getPath();

if ($path !== '/' && str_ends_with(request()->server->get('REQUEST_URI'), '/')) {
header('Location: ' . rtrim(request()->server->get('REQUEST_URI'), '/'), true, 301);
Expand Down
11 changes: 6 additions & 5 deletions src/Phaseolies/Support/ViteManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Phaseolies\Support;

use RuntimeException;
use Uri\Rfc3986\Uri;

class ViteManager
{
Expand Down Expand Up @@ -128,15 +129,15 @@ protected function hotUrl(): string
*/
protected function hotServerIsReachable(string $url): bool
{
$parts = parse_url($url);
$parts = Uri::parse($url);
$host = $parts?->getHost();

if (!is_array($parts) || empty($parts['host'])) {
if ($host === null || $host === '') {
return false;
}

$scheme = strtolower($parts['scheme'] ?? 'http');
$host = $parts['host'];
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
$scheme = strtolower($parts->getScheme() ?? 'http');
$port = $parts->getPort() ?? ($scheme === 'https' ? 443 : 80);
$transport = $scheme === 'https' ? 'ssl' : 'tcp';

$connection = @stream_socket_client(
Expand Down
43 changes: 29 additions & 14 deletions src/Phaseolies/Utilities/Paginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Phaseolies\Utilities;

use Uri\Rfc3986\Uri;

class Paginator
{
/**
Expand Down Expand Up @@ -337,29 +339,42 @@ public function links(): ?string
*/
protected function appendQueryParameters(?string $url, array $queryParams): string
{
if (!$url) {
if ($url === null || $url === '') {
return '';
}

// Parse the URL to get its components
$parsedUrl = parse_url($url);
$parsedUrl = Uri::parse($url);

if ($parsedUrl !== null) {
$scheme = $parsedUrl->getRawScheme() !== null ? $parsedUrl->getRawScheme() . '://' : '';
$host = $parsedUrl->getRawHost() ?? '';
$port = $parsedUrl->getPort() !== null ? ':' . $parsedUrl->getPort() : '';
$path = $parsedUrl->getRawPath();
$rawQuery = $parsedUrl->getRawQuery();
} else {
$parsedUrl = parse_url($url);

if ($parsedUrl === false) {
$separator = str_contains($url, '?') ? '&' : '?';

return $url . $separator . http_build_query($queryParams);
}

$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
$host = $parsedUrl['host'] ?? '';
$port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
$path = $parsedUrl['path'] ?? '';
$rawQuery = $parsedUrl['query'] ?? null;
}

$existingParams = [];

// Get existing query parameters from the URL
if (isset($parsedUrl['query'])) {
parse_str($parsedUrl['query'], $existingParams);
if ($rawQuery !== null) {
parse_str($rawQuery, $existingParams);
}

// Merge with new parameters
// New ones take precedence
// Existing parameters take precedence over newly added parameters.
$mergedParams = array_merge($queryParams, $existingParams);

// Rebuild the URL without modifying the base URL
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
$host = $parsedUrl['host'] ?? '';
$port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
$path = $parsedUrl['path'] ?? '';
$query = http_build_query($mergedParams);

return $scheme . $host . $port . $path . '?' . $query;
Expand Down
50 changes: 50 additions & 0 deletions tests/PaginatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,56 @@ public function testUrlGeneration()
$this->assertEquals('http://example.com?page=3', $this->paginator->url(3));
}

public function testAppendQueryParametersPreservesExistingUriComponents(): void
{
$method = new \ReflectionMethod($this->paginator, 'appendQueryParameters');

$result = $method->invoke(
$this->paginator,
'https://example.com/items?filter=old&sort=asc',
['page' => 3, 'filter' => 'new']
);

$this->assertSame('https://example.com/items?page=3&filter=old&sort=asc', $result);
}

public function testAppendQueryParametersMergesWhenQueryContainsBrackets(): void
{
$method = new \ReflectionMethod($this->paginator, 'appendQueryParameters');

$this->assertSame(
'http://x.test/items?page=2&sort=id&filter%5Bstatus%5D=a',
$method->invoke(
$this->paginator,
'http://x.test/items?filter[status]=a&page=2',
['page' => 3, 'sort' => 'id']
)
);
}

public function testAppendQueryParametersMergesWhenQueryContainsSpaces(): void
{
$method = new \ReflectionMethod($this->paginator, 'appendQueryParameters');

$this->assertSame(
'http://x.test/items?page=2&sort=id&q=a+b',
$method->invoke(
$this->paginator,
'http://x.test/items?q=a b&page=2',
['page' => 3, 'sort' => 'id']
)
);
}

public function testAppendQueryParametersFallsBackForInvalidUri(): void
{
$method = new \ReflectionMethod($this->paginator, 'appendQueryParameters');

$result = $method->invoke($this->paginator, 'http://[invalid]:99999', ['page' => 2]);

$this->assertSame('http://[invalid]:99999?page=2', $result);
}

public function testJumpMethod()
{
$expected = [1, '...', 3, 4, 5, 6, 7, '...', 10];
Expand Down
32 changes: 32 additions & 0 deletions tests/RedirectResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,38 @@ public function testToMethodWithSecureFalse()
$this->assertEquals('http://example.com/profile', $location);
}

public function testToMethodWithSecureTruePreservesEncodedComponents(): void
{
$url = 'http://example.com/path%2Fitem?token=a%2Fb#section%2Fone';

$this->redirect->to($url, 302, [], true);

$this->assertSame(
'https://example.com/path%2Fitem?token=a%2Fb#section%2Fone',
$this->redirect->headers->get('Location')
);
}

public function testSecureTrueStillUpgradesUrlsWithBracketsAndSpaces(): void
{
$this->redirect->to('http://example.com/a?x[]=1', 302, [], true);

$this->assertSame('https://example.com/a?x[]=1', $this->redirect->headers->get('Location'));

$this->redirect->to('http://example.com/a b', 302, [], true);

$this->assertSame('https://example.com/a b', $this->redirect->headers->get('Location'));
}

public function testToMethodPreservesInvalidAbsoluteUrlWhenForcingScheme(): void
{
$url = 'http://[invalid]:99999';

$this->redirect->to($url, 302, [], true);

$this->assertSame($url, $this->redirect->headers->get('Location'));
}

public function testBackMethodWithReferer()
{
$referer = 'https://example.com/previous';
Expand Down
Loading
Loading