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
48 changes: 44 additions & 4 deletions framework/IO/Util/TStreamHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@
* TStreamHelper class.
*
* Static utilities over a PSR-7 {@see StreamInterface}, for operations the interface itself
* does not provide: copying one stream into another, hashing a stream's contents, and reading
* a single line. They work on any StreamInterface, not just {@see \Prado\IO\TStream}.
* does not provide: copying a stream into a string or another stream, hashing a stream's
* contents, and reading a single line. They work on any StreamInterface, not just
* {@see \Prado\IO\TStream}.
*
* The copies and the hash move {@see CHUNK_SIZE} bytes per pass, so a body larger than
* memory streams through without materializing. Mapping a file name or extension to its
* media type is {@see \Prado\Web\TMediaType::mimeTypeFromFilename()}, with the media
* types themselves.
*
* @author Brad Anderson <belisoful@icloud.com>
* @since 4.4.0
Expand All @@ -27,12 +33,37 @@ class TStreamHelper
/** @var int The chunk size used when copying and hashing. */
public const CHUNK_SIZE = 8192;

/**
* Reads a stream from its current position into a string, optionally size-bounded.
* @param StreamInterface $stream The stream to read.
* @param int $maxLength The maximum number of bytes to read, or -1 for all remaining. Default -1.
* @return string The bytes read.
*/
public static function copyToString(StreamInterface $stream, int $maxLength = -1): string
{
$buffer = '';
while (!$stream->eof()) {
$want = $maxLength === -1 ? static::CHUNK_SIZE : min(static::CHUNK_SIZE, $maxLength - strlen($buffer));
if ($want <= 0) {
break;
}
$chunk = $stream->read($want);
if ($chunk === '') {
break;
}
$buffer .= $chunk;
}
return $buffer;
}

/**
* Copies bytes from one stream to another, reading from the source's current position and
* writing at the destination's current position.
* writing at the destination's current position. Each chunk is written completely,
* looping over short writes, so the destination never receives a torn copy.
* @param StreamInterface $source The stream to read from.
* @param StreamInterface $dest The stream to write to.
* @param int $maxLength The maximum number of bytes to copy, or -1 for all remaining. Default -1.
* @throws \RuntimeException When the destination stops accepting bytes mid-copy.
* @return int The number of bytes copied.
*/
public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLength = -1): int
Expand All @@ -47,7 +78,16 @@ public static function copyToStream(StreamInterface $source, StreamInterface $de
if ($chunk === '') {
break;
}
$copied += $dest->write($chunk);
$offset = 0;
$length = strlen($chunk);
while ($offset < $length) {
$written = $dest->write($offset === 0 ? $chunk : substr($chunk, $offset));
if ($written <= 0) {
throw new \RuntimeException('copyToStream destination stopped accepting bytes at ' . ($copied + $offset));
}
$offset += $written;
}
$copied += $length;
}
return $copied;
}
Expand Down
149 changes: 144 additions & 5 deletions framework/Web/TMediaType.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
*
* *Application:* {@see JSON}, {@see JSON_LD}, {@see XML}, {@see XHTML},
* {@see FORM}, {@see OCTET_STREAM}, {@see PDF}, {@see ZIP},
* {@see GZIP}, {@see TAR}, {@see BZIP2}, {@see XZ}, {@see RTF}, {@see WASM}
* {@see GZIP}, {@see TAR}, {@see BZIP2}, {@see XZ}, {@see SEVEN_ZIP},
* {@see RAR}, {@see ZSTD}, {@see RTF}, {@see WASM}, {@see EPUB}
*
* *Multipart:* {@see MULTIPART}
*
Expand All @@ -70,18 +71,28 @@
* *CSP / Reporting API:* {@see CSP_REPORT}, {@see REPORTS_JSON}
*
* *Image:* {@see PNG}, {@see JPEG}, {@see GIF}, {@see WEBP}, {@see AVIF},
* {@see SVG}, {@see ICON}, {@see BMP}, {@see TIFF}
* {@see SVG}, {@see ICON}, {@see BMP}, {@see TIFF}, {@see APNG}
*
* *Audio:* {@see AUDIO_MPEG}, {@see AUDIO_OGG}, {@see AUDIO_WAV},
* {@see AUDIO_WEBM}, {@see AUDIO_AAC}
* {@see AUDIO_WEBM}, {@see AUDIO_AAC}, {@see AUDIO_FLAC}
*
* *Video:* {@see VIDEO_MP4}, {@see VIDEO_WEBM}, {@see VIDEO_OGG}
* *Video:* {@see VIDEO_MP4}, {@see VIDEO_WEBM}, {@see VIDEO_OGG},
* {@see VIDEO_MPEG}, {@see VIDEO_QUICKTIME}, {@see VIDEO_AVI}, {@see VIDEO_MATROSKA}
*
* *Font:* {@see WOFF}, {@see WOFF2}, {@see TTF}, {@see OTF}
* *Font:* {@see WOFF}, {@see WOFF2}, {@see TTF}, {@see OTF}, {@see EOT}
*
* *Defaults:* {@see DEFAULT_TYPE} (`'text'`), {@see DEFAULT_SUBTYPE} (`'html'`) —
* override in a subclass to change the no-argument default.
*
* **File extension lookup.** {@see mimeTypeFromFilename()} and
* {@see mimeTypeFromExtension()} map a file name or extension to its media type
* string through {@see EXTENSION_MIME_TYPES}:
*
* ```php
* TMediaType::mimeTypeFromFilename('report.pdf'); // 'application/pdf'
* new TMediaType(TMediaType::mimeTypeFromExtension('json') ?? TMediaType::OCTET_STREAM);
* ```
*
* **ArrayAccess.** Parameters are also accessible via array syntax via
* {@see THeaderParametersTrait}, making `TMediaType` a transparent pipe to
* its parameter map:
Expand Down Expand Up @@ -174,6 +185,15 @@ class TMediaType implements \ArrayAccess
/** `application/x-xz` — XZ/LZMA-compressed data (`.xz`, `.tar.xz`, `.txz`). */
public const XZ = 'application/x-xz';

/** `application/x-7z-compressed` — 7-Zip archive (`.7z`). */
public const SEVEN_ZIP = 'application/x-7z-compressed';

/** `application/vnd.rar` — RAR archive (`.rar`). */
public const RAR = 'application/vnd.rar';

/** `application/zstd` — Zstandard-compressed data (`.zst`, `.tar.zst`). */
public const ZSTD = 'application/zstd';

/** `application/ld+json` — JSON-LD structured data. */
public const JSON_LD = 'application/ld+json';

Expand All @@ -183,6 +203,9 @@ class TMediaType implements \ArrayAccess
/** `application/rtf` — Rich Text Format document. */
public const RTF = 'application/rtf';

/** `application/epub+zip` — EPUB electronic publication (`.epub`). */
public const EPUB = 'application/epub+zip';

// ---- Multipart ----

/** `multipart/form-data` — Multipart form upload (HTML forms with file input). */
Expand Down Expand Up @@ -271,6 +294,9 @@ class TMediaType implements \ArrayAccess
/** `image/tiff` — TIFF image. */
public const TIFF = 'image/tiff';

/** `image/apng` — Animated Portable Network Graphics (`.apng`). */
public const APNG = 'image/apng';

// ---- Audio ----

/** `audio/mpeg` — MP3 and other MPEG audio. */
Expand All @@ -288,6 +314,9 @@ class TMediaType implements \ArrayAccess
/** `audio/aac` — AAC audio. */
public const AUDIO_AAC = 'audio/aac';

/** `audio/flac` — Free Lossless Audio Codec (`.flac`). */
public const AUDIO_FLAC = 'audio/flac';

// ---- Video ----

/** `video/mp4` — MP4 video. */
Expand All @@ -299,6 +328,18 @@ class TMediaType implements \ArrayAccess
/** `video/ogg` — Ogg video. */
public const VIDEO_OGG = 'video/ogg';

/** `video/mpeg` — MPEG-1/2 video (`.mpeg`, `.mpg`). */
public const VIDEO_MPEG = 'video/mpeg';

/** `video/quicktime` — QuickTime video (`.mov`). */
public const VIDEO_QUICKTIME = 'video/quicktime';

/** `video/x-msvideo` — Audio Video Interleave (`.avi`). */
public const VIDEO_AVI = 'video/x-msvideo';

/** `video/x-matroska` — Matroska multimedia container (`.mkv`). */
public const VIDEO_MATROSKA = 'video/x-matroska';

// ---- Font ----

/** `font/woff` — Web Open Font Format. */
Expand All @@ -313,6 +354,9 @@ class TMediaType implements \ArrayAccess
/** `font/otf` — OpenType font. */
public const OTF = 'font/otf';

/** `application/vnd.ms-fontobject` — Embedded OpenType font (`.eot`). */
public const EOT = 'application/vnd.ms-fontobject';

// ---- Defaults ----

/**
Expand All @@ -329,6 +373,77 @@ class TMediaType implements \ArrayAccess
*/
public const DEFAULT_SUBTYPE = 'html';

/**
* The lowercased file extension to media type map, used by
* {@see mimeTypeFromExtension()} and {@see mimeTypeFromFilename()}. Entries reference
* the named constants above where one exists, so each media type string is defined once.
* @var array<string, string>
*/
protected const EXTENSION_MIME_TYPES = [
'7z' => self::SEVEN_ZIP,
'aac' => self::AUDIO_AAC,
'apng' => self::APNG,
'avi' => self::VIDEO_AVI,
'avif' => self::AVIF,
'bmp' => self::BMP,
'bz2' => self::BZIP2,
'css' => self::CSS,
'csv' => self::CSV,
'doc' => self::DOC,
'docx' => self::DOCX,
'eot' => self::EOT,
'epub' => self::EPUB,
'flac' => self::AUDIO_FLAC,
'gif' => self::GIF,
'gz' => self::GZIP,
'htm' => self::HTML,
'html' => self::HTML,
'ico' => self::ICON,
'ics' => self::CALENDAR,
'jpeg' => self::JPEG,
'jpg' => self::JPEG,
'js' => self::JAVASCRIPT,
'json' => self::JSON,
'jsonld' => self::JSON_LD,
'md' => self::MARKDOWN,
'mjs' => self::JAVASCRIPT,
'mkv' => self::VIDEO_MATROSKA,
'mov' => self::VIDEO_QUICKTIME,
'mp3' => self::AUDIO_MPEG,
'mp4' => self::VIDEO_MP4,
'mpeg' => self::VIDEO_MPEG,
'oga' => self::AUDIO_OGG,
'ogg' => self::AUDIO_OGG,
'ogv' => self::VIDEO_OGG,
'otf' => self::OTF,
'pdf' => self::PDF,
'png' => self::PNG,
'ppt' => self::PPT,
'pptx' => self::PPTX,
'rar' => self::RAR,
'rtf' => self::RTF,
'svg' => self::SVG,
'tar' => self::TAR,
'tif' => self::TIFF,
'tiff' => self::TIFF,
'ttf' => self::TTF,
'txt' => self::PLAIN,
'wasm' => self::WASM,
'wav' => self::AUDIO_WAV,
'weba' => self::AUDIO_WEBM,
'webm' => self::VIDEO_WEBM,
'webp' => self::WEBP,
'woff' => self::WOFF,
'woff2' => self::WOFF2,
'xhtml' => self::XHTML,
'xls' => self::XLS,
'xlsx' => self::XLSX,
'xml' => self::XML,
'xz' => self::XZ,
'zip' => self::ZIP,
'zst' => self::ZSTD,
];

// =========================================================================
// Backing fields
// =========================================================================
Expand Down Expand Up @@ -380,6 +495,30 @@ public function __construct(?string $mediaType = null)
}
}

// =========================================================================
// File extension lookup
// =========================================================================

/**
* Maps a file name to its media type string by extension.
* @param string $filename the file name or path.
* @return ?string the media type string, or `null` when the extension is unknown.
*/
public static function mimeTypeFromFilename(string $filename): ?string
{
return static::mimeTypeFromExtension(pathinfo($filename, PATHINFO_EXTENSION));
}

/**
* Maps a file extension to its media type string via {@see EXTENSION_MIME_TYPES}.
* @param string $extension the extension, with or without a leading dot, any case.
* @return ?string the media type string, or `null` when the extension is unknown.
*/
public static function mimeTypeFromExtension(string $extension): ?string
{
return static::EXTENSION_MIME_TYPES[strtolower(ltrim($extension, '.'))] ?? null;
}

// =========================================================================
// Type / Subtype / MimeType
// =========================================================================
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/IO/Util/TStreamHelperTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Prado\IO\Stream\TFnStream;
use Prado\IO\Stream\TPumpStream;
use Prado\IO\TStream;
use Prado\IO\Util\TStreamHelper;

Expand Down Expand Up @@ -66,4 +68,70 @@ public function testReadLineRespectsMaxLength()
$s = TStream::fromString("abcdef\n");
self::assertSame('abc', TStreamHelper::readLine($s, 4), 'Reads up to maxLength - 1 bytes.');
}

public function testReadLineOnANonSeekableStreamDoesNotOvershoot()
{
$pump = new TPumpStream(function (int $n) {
static $data = "line1\nline2\n";
$chunk = substr($data, 0, $n);
$data = substr($data, $n);
return $chunk;
});
self::assertSame("line1\n", TStreamHelper::readLine($pump));
self::assertSame("line2\n", TStreamHelper::readLine($pump), 'Byte-wise reads leave the next line intact.');
}

// ---- copyToString ---------------------------------------------------------

public function testCopyToStringReadsAllAndSpansChunks()
{
$data = random_bytes(3 * TStreamHelper::CHUNK_SIZE + 123);
self::assertSame($data, TStreamHelper::copyToString(TStream::fromString($data)));
}

public function testCopyToStringHonorsMaxLengthAndPosition()
{
$s = TStream::fromString('hello helper world');
self::assertSame('hello', TStreamHelper::copyToString($s, 5));
self::assertSame(' helper', TStreamHelper::copyToString($s, 7), 'The copy resumes at the stream position.');
self::assertSame(' world', TStreamHelper::copyToString($s), 'Unbounded reads the remainder.');
}

// ---- copy/hash robustness -------------------------------------------------

public function testCopyToStreamLoopsOverShortWrites()
{
// A destination accepting one byte per call; the copy must still land completely.
$sink = '';
$dest = new TFnStream([
'isWritable' => fn () => true,
'write' => function (string $bytes) use (&$sink) {
$sink .= $bytes[0];
return 1;
},
]);
self::assertSame(6, TStreamHelper::copyToStream(TStream::fromString('abcdef'), $dest));
self::assertSame('abcdef', $sink, 'Short writes are retried until the chunk lands.');
}

public function testCopyToStreamThrowsWhenTheDestinationStops()
{
$dest = new TFnStream([
'isWritable' => fn () => true,
'write' => fn () => 0,
]);
self::expectException(\RuntimeException::class);
TStreamHelper::copyToStream(TStream::fromString('abc'), $dest);
}

public function testHashNonSeekableFromCurrentPosition()
{
$parts = ['alpha', 'beta', ''];
$i = 0;
$pump = new TPumpStream(function () use (&$parts, &$i) {
return $parts[$i++] ?? '';
});
self::assertSame(hash('crc32b', 'alphabeta'), TStreamHelper::hash($pump, 'crc32b'), 'A non-seekable stream hashes from its current position.');
}

}
Loading
Loading