Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a ForceUriScheme filter to ease removal of UriNormalize filter #133

Merged
merged 1 commit into from
Apr 3, 2024
Merged
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
19 changes: 19 additions & 0 deletions docs/book/v2/standard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,25 @@ $decrypted = $filter->filter('encoded_text_normally_unreadable');
print $decrypted;
```

## ForceUriScheme

This filter will ensure that, given a string that looks like a URI with a host-name and scheme, the scheme will be forced to `https` by default, or any other arbitrary scheme provided as an option.

Any value that cannot be identified as an URI to begin with, will be returned un-filtered. Furthermore, URI parsing is rudimentary so for reliable results, you should ensure that the input is a valid URI prior to filtering.

### Supported Options

The only supported option is `scheme` which defaults to `https`

### Example Usage

```php
$filter = new Laminas\Filter\ForceUriScheme(['scheme' => 'ftp']);

$filter->filter('https://example.com/path'); // 'ftp://example.com/path'
$filter->filter('example.com'); // 'example.com' - Unfiltered because it lacks a scheme
```

## HtmlEntities

Returns the string `$value`, converting characters to their corresponding HTML
Expand Down
6 changes: 6 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,12 @@
<code><![CDATA[$container]]></code>
</UnusedClosureParam>
</file>
<file src="test/ForceUriSchemeTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[badSchemeProvider]]></code>
<code><![CDATA[filterDataProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/HtmlEntitiesTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[returnUnfilteredDataProvider]]></code>
Expand Down
1 change: 1 addition & 0 deletions src/FilterPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ class FilterPluginManager extends AbstractPluginManager
File\Rename::class => InvokableFactory::class,
File\RenameUpload::class => InvokableFactory::class,
File\UpperCase::class => InvokableFactory::class,
ForceUriScheme::class => InvokableFactory::class,
HtmlEntities::class => InvokableFactory::class,
Inflector::class => InvokableFactory::class,
ToInt::class => InvokableFactory::class,
Expand Down
77 changes: 77 additions & 0 deletions src/ForceUriScheme.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Laminas\Filter;

use Laminas\Filter\Exception\InvalidArgumentException;

use function is_string;
use function ltrim;
use function parse_url;
use function preg_match;
use function preg_quote;
use function preg_replace;
use function sprintf;

/**
* @psalm-type Options = array{scheme: non-empty-string}
*/
final class ForceUriScheme implements FilterInterface
{
private const DEFAULT_SCHEME = 'https';

/** @var non-empty-string */
private string $scheme;

/** @param Options $options */
public function __construct(array $options = ['scheme' => self::DEFAULT_SCHEME])
{
if (! preg_match('/^[a-z0-9]+$/i', $options['scheme'])) {
throw new InvalidArgumentException(sprintf(
'The `scheme` option should be a string consisting only of letters and numbers. Please omit the :// '
. ' Received "%s"',
$options['scheme'],
));
}

$this->scheme = $options['scheme'];
}

public function __invoke(mixed $value): mixed
{
return $this->filter($value);
}

public function filter(mixed $value): mixed
{
if (! is_string($value) || $value === '') {
return $value;
}

$url = parse_url($value);

if (! isset($url['host']) || $url['host'] === '') {
return $value;
}

if (! isset($url['scheme']) || $url['scheme'] === '') {
return sprintf(
'%s://%s',
$this->scheme,
ltrim($value, ':/'),
);
}

$search = sprintf(
'/^(%s)(.+)/',
preg_quote($url['scheme'], '/'),
);
$replace = sprintf(
'%s$2',
$this->scheme,
);

return preg_replace($search, $replace, $value);
}
}
96 changes: 96 additions & 0 deletions test/ForceUriSchemeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace LaminasTest\Filter;

use Laminas\Filter\Exception\InvalidArgumentException;
use Laminas\Filter\FilterPluginManager;
use Laminas\Filter\ForceUriScheme;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;

#[CoversClass(ForceUriScheme::class)]
class ForceUriSchemeTest extends TestCase
{
/** @return list<array{0: non-empty-string, 1: mixed, 2: mixed}> */
public static function filterDataProvider(): array
{
return [
['https', 'www.example.com/foo', 'www.example.com/foo'],
['https', 'www.example.com', 'www.example.com'],
['https', 'example.com', 'example.com'],
['https', 'http://www.example.com', 'https://www.example.com'],
['ftp', 'https://www.example.com', 'ftp://www.example.com'],
['foobar5', 'https://www.example.com', 'foobar5://www.example.com'],
['https', '//www.example.com', 'https://www.example.com'],
['https', 'http://http.example.com', 'https://http.example.com'],
['https', '42', '42'],
['https', 42, 42],
['https', false, false],
['https', null, null],
['https', (object) [], (object) []],
];
}

/**
* @param non-empty-string $scheme
*/
#[DataProvider('filterDataProvider')]
public function testBasicFiltering(string $scheme, mixed $input, mixed $expect): void
{
$filter = new ForceUriScheme(['scheme' => $scheme]);
self::assertEquals($expect, $filter->filter($input));
}

/**
* @param non-empty-string $scheme
*/
#[DataProvider('filterDataProvider')]
public function testFilterCanBeInvoked(string $scheme, mixed $input, mixed $expect): void
{
$filter = new ForceUriScheme(['scheme' => $scheme]);
self::assertEquals($expect, $filter->__invoke($input));
}

/** @return list<array{0: string}> */
public static function badSchemeProvider(): array
{
return [
[''],
['foo://'],
['mailto:'],
['...'],
];
}

#[DataProvider('badSchemeProvider')]
public function testInvalidScheme(string $scheme): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The `scheme` option should be a string consisting only of letters and numbers');

/** @psalm-suppress ArgumentTypeCoercion */
new ForceUriScheme(['scheme' => $scheme]);
}

public function testThatThePluginManagerWillReturnAnInstance(): void
{
$manager = new FilterPluginManager($this->createMock(ContainerInterface::class));
$filter = $manager->get(ForceUriScheme::class);
self::assertInstanceOf(ForceUriScheme::class, $filter);

self::assertSame('https://example.com', $filter->filter('ftp://example.com'));
}

public function testThatThePluginManagerCanBuildWithOptions(): void
{
$manager = new FilterPluginManager($this->createMock(ContainerInterface::class));
$filter = $manager->build(ForceUriScheme::class, ['scheme' => 'muppets']);
self::assertInstanceOf(ForceUriScheme::class, $filter);

self::assertSame('muppets://example.com', $filter->filter('ftp://example.com'));
}
}