Skip to content
65 changes: 61 additions & 4 deletions src/FinfoMimeTypeDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,38 @@ class FinfoMimeTypeDetector implements MimeTypeDetector, ExtensionLookup
*/
private $inconclusiveMimetypes;

/**
* @var bool
*/
private $ignoreNonSeekableStreams;

/**
* Buffer size to read from streams if no other bufferSampleSize is defined.
*/
const STREAM_BUFFER_SAMPLE_SIZE_DEFAULT = 4100;

public function __construct(
string $magicFile = '',
ExtensionToMimeTypeMap $extensionMap = null,
?int $bufferSampleSize = null,
array $inconclusiveMimetypes = self::INCONCLUSIVE_MIME_TYPES
array $inconclusiveMimetypes = self::INCONCLUSIVE_MIME_TYPES,
bool $ignoreNonSeekableStreams = true
) {
$this->finfo = new finfo(FILEINFO_MIME_TYPE, $magicFile);
$this->extensionMap = $extensionMap ?: new GeneratedExtensionToMimeTypeMap();
$this->bufferSampleSize = $bufferSampleSize;
$this->inconclusiveMimetypes = $inconclusiveMimetypes;
$this->ignoreNonSeekableStreams = $ignoreNonSeekableStreams;
}

public function detectMimeType(string $path, $contents): ?string
{
$mimeType = is_string($contents)
? (@$this->finfo->buffer($this->takeSample($contents)) ?: null)
: null;
$mimeType = null;
if (is_string($contents)) {
$mimeType = @$this->finfo->buffer($this->takeSample($contents));
} elseif (is_resource($contents)) {
$mimeType = @$this->finfo->buffer($this->takeResourceSample($contents));
}

if ($mimeType !== null && ! in_array($mimeType, $this->inconclusiveMimetypes)) {
return $mimeType;
Expand Down Expand Up @@ -90,6 +105,48 @@ private function takeSample(string $contents): string
return (string) substr($contents, 0, $this->bufferSampleSize);
}

/**
* Fetches a sample of a resource while maintaining its pointer.
*
* Non-seekable resources are skipped by default because their pointer can't
* be maintained.
*/
private function takeResourceSample($contents): string
{
if (is_resource($contents) && get_resource_type($contents) === 'stream') {
$streamMetaData = stream_get_meta_data($contents);
if ($streamMetaData['seekable']) {
$streamPosition = ftell($contents);
} elseif ($this->ignoreNonSeekableStreams) {
return '';
}

// Memory optimization: given a length stream_get_contents()
// immediately allocates an internal buffer.
// However, stream_copy_to_stream() reads up to the defined length
// without pre-allocating any extra buffer.
// Given the relatively large STREAM_BUFFER_SAMPLE_SIZE_DEFAULT this
// avoids unnecessary memory hogging.
$streamContentBuffer = fopen("php://memory", "w+b");
$sampleSize = $this->bufferSampleSize ?? self::STREAM_BUFFER_SAMPLE_SIZE_DEFAULT;
stream_copy_to_stream(
$contents,
$streamContentBuffer,
$sampleSize,
0
);
rewind($contents);
rewind($streamContentBuffer);
$streamSample = stream_get_contents($streamContentBuffer);
fclose($streamContentBuffer);
if (isset($streamPosition)) {
fseek($contents, $streamPosition);
}
return $streamSample;
}
return '';
}

public function lookupExtension(string $mimetype): ?string
{
return $this->extensionMap instanceof ExtensionLookup
Expand Down
62 changes: 61 additions & 1 deletion src/FinfoMimeTypeDetectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public function detecting_from_a_file_location(): void
/**
* @test
*/
public function detecting_uses_extensions_when_a_resource_is_presented(): void
public function detecting_uses_extensions_when_a_invalid_resource_is_presented(): void
{
/** @var resource $handle */
$handle = fopen(__DIR__ . '/../test_files/flysystem.svg', 'r+');
Expand All @@ -111,4 +111,64 @@ public function detecting_uses_extensions_when_a_resource_is_presented(): void

$this->assertEquals('image/svg+xml', $mimeType);
}

/**
* @test
*/
public function detecting_uses_stream_contents_when_a_valid_resource_is_presented(): void
{
/** @var resource $handle */
$handle = fopen(__DIR__ . '/../test_files/flysystem.svg', 'r+');

$mimeType = $this->detector->detectMimeType('flysystem.unknown', $handle);
fclose($handle);

$this->assertEquals('image/svg+xml', $mimeType);
}

/**
* @test
*/
public function detecting_keeps_stream_contents_positions_unchanged(): void
{
/** @var resource $handle */
$handle = fopen(__DIR__ . '/../test_files/flysystem.svg', 'r+');
fseek($handle, 10);
$mimeType = $this->detector->detectMimeType('flysystem.unknown', $handle);
$this->assertEquals('image/svg+xml', $mimeType);
$this->assertEquals(10, ftell($handle));
fclose($handle);
}

/**
* @test
*/
public function detecting_non_seekable_streams(): void
{
/** @var resource $handle */
$handle = fopen('https://github.com/thephpleague/mime-type-detection', 'r');
$mimeType = $this->detector->detectMimeType('flysystem.unknown', $handle);
$this->assertEquals(null, $mimeType);
$this->assertEquals(0, ftell($handle));

$nonSeekableDetector = new FinfoMimeTypeDetector(
'',
null,
null, [
'application/x-empty',
'text/plain',
'text/x-asm',
'application/octet-stream',
'inode/x-empty',
],
false
);
$mimeType = $nonSeekableDetector->detectMimeType('flysystem.unknown', $handle);
$this->assertEquals('text/html', $mimeType);
// Because this stream is non seekable it's position will be where the
// detectors buffer is.
$this->assertEquals(FinfoMimeTypeDetector::STREAM_BUFFER_SAMPLE_SIZE_DEFAULT, ftell($handle));

fclose($handle);
}
}