diff --git a/framework/IO/Util/TStreamHelper.php b/framework/IO/Util/TStreamHelper.php index 3adab446a..e5f687866 100644 --- a/framework/IO/Util/TStreamHelper.php +++ b/framework/IO/Util/TStreamHelper.php @@ -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 * @since 4.4.0 @@ -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 @@ -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; } diff --git a/framework/Web/TMediaType.php b/framework/Web/TMediaType.php index bcb7e71ef..9bf3e944e 100644 --- a/framework/Web/TMediaType.php +++ b/framework/Web/TMediaType.php @@ -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} * @@ -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: @@ -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'; @@ -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). */ @@ -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. */ @@ -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. */ @@ -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. */ @@ -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 ---- /** @@ -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 + */ + 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 // ========================================================================= @@ -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 // ========================================================================= diff --git a/tests/unit/IO/Util/TStreamHelperTest.php b/tests/unit/IO/Util/TStreamHelperTest.php index fdcd822cf..a69df0dce 100644 --- a/tests/unit/IO/Util/TStreamHelperTest.php +++ b/tests/unit/IO/Util/TStreamHelperTest.php @@ -1,5 +1,7 @@ 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.'); + } + } diff --git a/tests/unit/Web/TMediaTypeTest.php b/tests/unit/Web/TMediaTypeTest.php index 4dc52baa6..50ddb4093 100644 --- a/tests/unit/Web/TMediaTypeTest.php +++ b/tests/unit/Web/TMediaTypeTest.php @@ -366,7 +366,7 @@ public function testSubclassDefaultsUsedWhenNoArgConstructed(): void { // A subclass that overrides both DEFAULT_TYPE and DEFAULT_SUBTYPE must have // its constants picked up via static:: in the constructor. - $sub = new class extends TMediaType { + $sub = new class () extends TMediaType { public const DEFAULT_TYPE = 'application'; public const DEFAULT_SUBTYPE = 'json'; }; @@ -378,7 +378,7 @@ public function testSubclassDefaultsUsedWhenNoArgConstructed(): void public function testSubclassDefaultTypeOverrideAlone(): void { // Only DEFAULT_TYPE overridden — DEFAULT_SUBTYPE falls back to the base value. - $sub = new class extends TMediaType { + $sub = new class () extends TMediaType { public const DEFAULT_TYPE = 'image'; }; $this->assertSame('image', $sub->getType()); @@ -388,7 +388,7 @@ public function testSubclassDefaultTypeOverrideAlone(): void public function testSubclassExplicitArgOverridesDefaults(): void { // When an explicit media type is passed, it must win over the subclass defaults. - $sub = new class('text/plain') extends TMediaType { + $sub = new class ('text/plain') extends TMediaType { public const DEFAULT_TYPE = 'application'; public const DEFAULT_SUBTYPE = 'json'; }; @@ -398,7 +398,7 @@ public function testSubclassExplicitArgOverridesDefaults(): void public function testSubclassNoArgConstructorHasNoParameters(): void { - $sub = new class extends TMediaType { + $sub = new class () extends TMediaType { public const DEFAULT_TYPE = 'application'; public const DEFAULT_SUBTYPE = 'xml'; }; @@ -1439,4 +1439,90 @@ public function testOffsetSetCoercesIntegerValueToString(): void $mt['charset'] = 42; $this->assertSame('42', $mt->getCharset()); } + + // ----------------------------------------------------------------------- + // File extension lookup + // ----------------------------------------------------------------------- + + public function testMimeTypeFromExtension(): void + { + $this->assertSame(TMediaType::PDF, TMediaType::mimeTypeFromExtension('pdf')); + $this->assertSame(TMediaType::JPEG, TMediaType::mimeTypeFromExtension('.JPG'), 'A leading dot and any case are accepted.'); + $this->assertSame(TMediaType::GZIP, TMediaType::mimeTypeFromExtension('gz')); + $this->assertNull(TMediaType::mimeTypeFromExtension('nope')); + $this->assertNull(TMediaType::mimeTypeFromExtension('')); + } + + public function testMimeTypeFromFilename(): void + { + $this->assertSame(TMediaType::HTML, TMediaType::mimeTypeFromFilename('/var/www/index.html')); + $this->assertSame(TMediaType::ZIP, TMediaType::mimeTypeFromFilename('archive.tar.zip'), 'The last extension decides.'); + $this->assertNull(TMediaType::mimeTypeFromFilename('README')); + } + + public function testExtensionLookupConstructsAMediaType(): void + { + $mt = new TMediaType(TMediaType::mimeTypeFromFilename('data.json')); + $this->assertSame('application', $mt->getType()); + $this->assertSame('json', $mt->getSubtype()); + } + + // ----------------------------------------------------------------------- + // Named constants — extension-lookup additions + // ----------------------------------------------------------------------- + + public function testConstantSevenZip(): void + { + $this->assertSame('application/x-7z-compressed', TMediaType::SEVEN_ZIP); + } + + public function testConstantRar(): void + { + $this->assertSame('application/vnd.rar', TMediaType::RAR); + } + + public function testConstantZstd(): void + { + $this->assertSame('application/zstd', TMediaType::ZSTD); + } + + public function testConstantEpub(): void + { + $this->assertSame('application/epub+zip', TMediaType::EPUB); + } + + public function testConstantApng(): void + { + $this->assertSame('image/apng', TMediaType::APNG); + } + + public function testConstantAudioFlac(): void + { + $this->assertSame('audio/flac', TMediaType::AUDIO_FLAC); + } + + public function testConstantVideoMpeg(): void + { + $this->assertSame('video/mpeg', TMediaType::VIDEO_MPEG); + } + + public function testConstantVideoQuicktime(): void + { + $this->assertSame('video/quicktime', TMediaType::VIDEO_QUICKTIME); + } + + public function testConstantVideoAvi(): void + { + $this->assertSame('video/x-msvideo', TMediaType::VIDEO_AVI); + } + + public function testConstantVideoMatroska(): void + { + $this->assertSame('video/x-matroska', TMediaType::VIDEO_MATROSKA); + } + + public function testConstantEot(): void + { + $this->assertSame('application/vnd.ms-fontobject', TMediaType::EOT); + } }