Skip to content
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ $rule = new Enjoys\Upload\Rule\MediaType();
$rule->allow('image/*') // All image types
->allow('application/pdf') // PDF files
->allow('*/vnd.openxmlformats-officedocument.*'); // Office documents
$rule->allow('*'); // All types
```
### Event System

Expand Down
143 changes: 47 additions & 96 deletions src/Rule/MediaType.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,26 @@
* 3. Subtype wildcards: "*\/subtype" (e.g. "*\/png" allows png in any type)
*
* Note:
* - The special pattern "*" is NOT supported (will throw RuleException)
* - The special pattern "*" is supported and automatically converted to "*\/*"
* - Patterns must strictly follow "type/subtype" format:
* - No spaces around slash
* - No missing parts
* - No standalone wildcards
* - Exactly one slash separating type and subtype
* - Validation is case-sensitive
*/
final class MediaType implements RuleInterface
{
/**
* @var array Allowed media types in format:
* [
* 'type' => ['subtype1', 'subtype2'], // Specific subtypes
* 'type' => '*', // All subtypes for type
* ]
* or empty array if none allowed
* @var string[] Array of compiled regex patterns
* @psalm-var non-empty-string[]
*/
private array $allowedMediaType = [];
private array $allowPatterns = [];

/**
* @var string Error message template (uses %s placeholder for invalid type)
*/
private string $errorMessage;

/**
* @var bool Flag to allow all media types (when '*' is set as type)
*/
private bool $allowedAllMediaType = false;

/**
* @param string|null $errorMessage Custom error message when validation fails.
* The %s placeholder will be replaced with the rejected media type.
Expand All @@ -66,29 +57,15 @@ public function __construct(string $errorMessage = null)
#[\Override]
public function check(UploadedFileInterface $file): void
{
$mediaType = $file->getClientMediaType() ?? throw new RuleException('Media Type ins null');

if ($this->allowedAllMediaType) {
return;
}

list($type, $subType) = $this->explode($mediaType);

if (!array_key_exists($type, $this->allowedMediaType)) {
throw new RuleException(sprintf($this->errorMessage, sprintf('%s/*', $type)));
}

/** @var string|string[] $allowed */
$allowed = $this->allowedMediaType[$type];
$mediaType = $file->getClientMediaType() ?? throw new RuleException('Media type is null');

if ($allowed === '*') {
return;
foreach ($this->allowPatterns as $pattern) {
if (preg_match($pattern, $mediaType)) {
return;
}
}

/** @var string[] $allowed */
if (!in_array($subType, $allowed, true)) {
throw new RuleException(sprintf($this->errorMessage, $mediaType));
}
throw new RuleException(sprintf($this->errorMessage, $mediaType));
}

/**
Expand All @@ -99,10 +76,10 @@ public function check(UploadedFileInterface $file): void
* - All subtypes for type ("image/*")
* - Specific subtype across all types ("*\/png")
*
* @param string $string Media type pattern to allow. Must contain exactly one '/'
* @param string $pattern Media type pattern to allow. Must contain exactly one '/'
* with no surrounding spaces. Examples:
* - Valid: "image/jpeg", "image/*", "*\/png"
* - Invalid: "*", "image/", "/png", "image /*", "image/ png"
* - Valid: "*", "image/jpeg", "image/*", "*\/png"
* - Invalid: "image/", "/png", "image /*", "image/ png"
*
* @return self For method chaining
* @throws RuleException When:
Expand All @@ -113,77 +90,51 @@ public function check(UploadedFileInterface $file): void
*
* @see MediaTypeTest::dataForAllowFailed() For all invalid cases
*/
public function allow(string $string): MediaType
public function allow(string $pattern): MediaType
{
if (!str_contains($string, '/')) {
throw new RuleException(sprintf('Media Type is wrong: %s', $string));
if ($pattern === '*') {
$pattern = '*/*';
}

list($type, $subType) = $this->explode($string);

if ($type === '*') {
$this->allowedAllMediaType = true;
return $this;
}

/** @var string[]|string $allowType */
$allowType = $this->allowedMediaType[$type] ?? [];


if ($allowType === '*') {
return $this;
}

if ($subType === '*') {
$allowType = $subType;
} else {
$allowType[] = $subType;
}

if (is_array($allowType)) {
$allowType = array_unique($allowType);
}

$this->allowedMediaType[$type] = $allowType;
$this->validateMediaTypePattern($pattern);
$this->allowPatterns[] = $this->createRegexPattern($pattern);
return $this;
}


/**
* Gets currently allowed media types
*
* @return array|string Allowed media types configuration
* @psalm-return non-empty-string
*/
public function getAllowedMediaType(): array|string
private function createRegexPattern(string $string): string
{
return $this->allowedMediaType;
$escaped = preg_quote($string, '/');
$regex = str_replace('\*', '.*', $escaped);
return '/^' . $regex . '$/i';
}

/**
* Validates and splits media type into type/subtype components
*
* @param string $string Media type to validate and split (e.g. "image/jpeg")
* @return string[] Array with exactly two elements: [type, subtype]
* @throws RuleException When:
* - Input doesn't contain exactly one '/' character
* - Either type or subtype is empty
* - There are spaces around the '/' separator
* - The format is invalid (e.g. "image/", "/png", "image /*")
*
* @see MediaTypeTest::dataForAllowFailed() For all invalid format cases
*/
private function explode(string $string): array

public function validateMediaTypePattern(string $pattern): void
{
list($type, $subType) = explode('/', trim($string));

if (empty($type)
|| empty($subType)
|| str_ends_with($type, ' ')
|| str_starts_with($subType, ' ')
) {
throw new RuleException(sprintf('Media Type is wrong: `%s`', $string));
if (substr_count($pattern, '/') !== 1) {
throw new RuleException(sprintf(
'Media type pattern must contain exactly one "/": %s',
$pattern
));
}

[$type, $subType] = explode('/', $pattern);

if ($type === '' || $subType === '') {
throw new RuleException(sprintf(
'Media type pattern parts cannot be empty: %s',
$pattern
));
}

return array($type, $subType);
if (str_contains($type, ' ') || str_contains($subType, ' ')) {
throw new RuleException(sprintf(
'Media type pattern contains spaces: %s',
$pattern
));
}
}

}
104 changes: 40 additions & 64 deletions tests/Rule/MediaTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,48 @@ public function testCheckSuccessIfAllTypeIsAllowed()
);

$rule = new MediaType();
$rule->allow('*/example');
$rule->allow('*/png');
$rule->check($file);
// Tests without assertions does not generate coverage #3016
// https://github.com/sebastianbergmann/phpunit/issues/3016
$this->assertTrue(true);
}

#[DataProvider('successTestData')]
public function testSuccessCheck(string $mediaType, array $allows)
{
$file = new UploadedFile(
$this->tmpFile,
null,
UPLOAD_ERR_OK,
clientMediaType: $mediaType
);

$rule = new MediaType();
foreach ($allows as $allow) {
$rule->allow($allow);
}
$rule->check($file);
// Tests without assertions does not generate coverage #3016
// https://github.com/sebastianbergmann/phpunit/issues/3016
$this->assertTrue(true);
}

public static function successTestData(): array
{
return [
['image/png', ['*/png']],
['image/png', ['image/*']],
['image/png', ['*/*']],
['image/png', ['*/png', 'image/*']],
['image/png', ['image/*', '*/png']],
['image/png', ['image/*', '*/png', '*/*']],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['*/*']],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['*/vnd.openxmlformats-officedocument.*']],

];
}


public function testCheckSuccessIfManyAllowed()
{
Expand All @@ -77,7 +111,7 @@ public function testCheckSuccessIfManyAllowed()

$rule = new MediaType();
$rule->allow('image/jpg')
->allow('image/png');
->allow('image/png');
$rule->check($file);

// Tests without assertions does not generate coverage #3016
Expand All @@ -87,7 +121,7 @@ public function testCheckSuccessIfManyAllowed()

public function testCheckFailedIfManyAllowedButTypeNotSet()
{
$this->expectExceptionMessage('Media type is disallowed: `plain/*`');
$this->expectExceptionMessage('Media type is disallowed: `plain/jpg`');
$file = new UploadedFile(
$this->tmpFile,
null,
Expand Down Expand Up @@ -117,68 +151,9 @@ public function testCheckFailedIfManyAllowedButTypeAndSubTypeNotSet()
$rule->check($file);
}

public function testAllowSuccess()
{
$rule = new MediaType();
$rule->allow('example/example');

$this->assertSame([
'example' => ['example']
], $rule->getAllowedMediaType());

$rule->allow('image/jpg');
$this->assertSame([
'example' => ['example'],
'image' => ['jpg']
], $rule->getAllowedMediaType());

$rule->allow('application/json');
$this->assertSame([
'example' => ['example'],
'image' => ['jpg'],
'application' => ['json']
], $rule->getAllowedMediaType());

$rule->allow('image/png ');
$rule->allow('image/png');
$this->assertSame([
'example' => ['example'],
'image' => ['jpg', 'png'],
'application' => ['json']
], $rule->getAllowedMediaType());

$rule->allow('image/*');
$this->assertSame([
'example' => ['example'],
'image' => '*',
'application' => ['json']
], $rule->getAllowedMediaType());

$rule->allow('image/bmp');
$this->assertSame([
'example' => ['example'],
'image' => '*',
'application' => ['json']
], $rule->getAllowedMediaType());

$rule->allow('image/bmp');
$this->assertSame([
'example' => ['example'],
'image' => '*',
'application' => ['json']
], $rule->getAllowedMediaType());

$rule->allow('*/bmp');
$this->assertSame([
'example' => ['example'],
'image' => '*',
'application' => ['json']
], $rule->getAllowedMediaType());
}

public function testCheckFailedIfMediaTypeIsNull()
{
$this->expectExceptionMessage('Media Type ins null');
$this->expectExceptionMessage('Media type is null');
$file = new UploadedFile(
$this->tmpFile,
null,
Expand All @@ -198,7 +173,6 @@ public static function dataForAllowFailed(): array
['image/'],
['/png'],
['image'],
['*'],
['*/'],
['/*'],
];
Expand All @@ -209,7 +183,9 @@ public function testAllowFailed(string $mediaType)
{
$this->expectException(RuleException::class);
$rule = new MediaType();

$rule->allow($mediaType);
dump($rule);
}


Expand Down