Skip to content

Commit

Permalink
Merge pull request #133 from gsteel/force-uri-scheme-filter
Browse files Browse the repository at this point in the history
Add a `ForceUriScheme` filter to ease removal of `UriNormalize` filter
  • Loading branch information
gsteel committed Apr 3, 2024
2 parents 31e4b76 + 23e93d5 commit fffc9f2
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 0 deletions.
19 changes: 19 additions & 0 deletions docs/book/v2/standard-filters.md
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
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
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
@@ -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
@@ -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'));
}
}

0 comments on commit fffc9f2

Please sign in to comment.