* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\FileManager\Transfer; /** * Native PHP streaming ZIP archive generator * * Creates ZIP files on-the-fly without requiring external libraries. * Streams directly to output, minimizing memory usage. * * Supports: * - Store method (no compression) for maximum streaming efficiency * - Deflate compression for smaller archives * - Data descriptors for streaming without knowing file size upfront * - Files up to 4GB (ZIP64 not implemented) */ class StreamingZip { /** @var resource Output stream */ private $output; /** @var array Central directory entries */ private array $centralDirectory = []; /** @var int Current byte offset in the archive */ private int $offset = 0; /** @var bool Whether to use deflate compression */ private bool $compress; /** @var int Compression level (1-9) */ private int $compressionLevel; /** * @param resource|null $output Output stream (defaults to php://output) * @param bool $compress Whether to compress files (deflate) * @param int $compressionLevel Compression level 1-9 (only used if compress=true) */ public function __construct($output = null, bool $compress = false, int $compressionLevel = 6) { $this->output = $output ?? fopen('php://output', 'wb'); $this->compress = $compress; $this->compressionLevel = $compressionLevel; } /** * Add a file from a stream resource * * @param string $path Path/name within the ZIP archive * @param resource $stream File content stream * @param int|null $modTime Unix timestamp for modification time (null = now) */ public function addFileFromStream(string $path, $stream, ?int $modTime = null): void { $modTime = $modTime ?? time(); $dosTime = $this->unixToDosTime($modTime); // Read entire file content first (needed for accurate CRC) $content = ''; while (!feof($stream)) { $chunk = fread($stream, 65536); if ($chunk === false || $chunk === '') { break; } $content .= $chunk; } $uncompressedSize = strlen($content); $crcValue = crc32($content); // Compress if needed if ($this->compress && $uncompressedSize > 0) { $compressed = gzdeflate($content, $this->compressionLevel); $compressedSize = strlen($compressed); $method = 0x0008; // Deflate } else { $compressed = $content; $compressedSize = $uncompressedSize; $method = 0x0000; // Store } // General purpose flags $gpFlags = 0x0000; // UTF-8 filename flag if (preg_match('//u', $path)) { $gpFlags |= 0x0800; // Bit 11: UTF-8 filename } $pathBytes = $path; // Local file header $localHeader = pack('V', 0x04034b50); // Local file header signature $localHeader .= pack('v', 20); // Version needed to extract (2.0) $localHeader .= pack('v', $gpFlags); // General purpose bit flag $localHeader .= pack('v', $method); // Compression method $localHeader .= pack('V', $dosTime); // Last mod time & date $localHeader .= pack('V', $crcValue); // CRC-32 $localHeader .= pack('V', $compressedSize); // Compressed size $localHeader .= pack('V', $uncompressedSize); // Uncompressed size $localHeader .= pack('v', strlen($pathBytes)); // File name length $localHeader .= pack('v', 0); // Extra field length $localHeader .= $pathBytes; // File name fwrite($this->output, $localHeader); fwrite($this->output, $compressed); $headerLen = strlen($localHeader); // Store entry for central directory $this->centralDirectory[] = [ 'path' => $pathBytes, 'crc' => $crcValue, 'compressedSize' => $compressedSize, 'uncompressedSize' => $uncompressedSize, 'method' => $method, 'dosTime' => $dosTime, 'gpFlags' => $gpFlags, 'offset' => $this->offset, ]; $this->offset += $headerLen + $compressedSize; // Free memory unset($content, $compressed); @flush(); } /** * Add a file from a string * * @param string $path Path/name within the ZIP archive * @param string $content File content * @param int|null $modTime Unix timestamp for modification time */ public function addFileFromString(string $path, string $content, ?int $modTime = null): void { $stream = fopen('php://temp', 'r+b'); fwrite($stream, $content); rewind($stream); $this->addFileFromStream($path, $stream, $modTime); fclose($stream); } /** * Add an empty directory entry * * @param string $path Directory path (will have / appended if missing) * @param int|null $modTime Unix timestamp */ public function addDirectory(string $path, ?int $modTime = null): void { if (!str_ends_with($path, '/')) { $path .= '/'; } $modTime = $modTime ?? time(); $dosTime = $this->unixToDosTime($modTime); $gpFlags = 0x0000; if (preg_match('//u', $path)) { $gpFlags |= 0x0800; } $pathBytes = $path; // Local file header for directory $localHeader = pack('V', 0x04034b50); $localHeader .= pack('v', 20); $localHeader .= pack('v', $gpFlags); $localHeader .= pack('v', 0); // Store method $localHeader .= pack('V', $dosTime); $localHeader .= pack('V', 0); // CRC $localHeader .= pack('V', 0); // Compressed size $localHeader .= pack('V', 0); // Uncompressed size $localHeader .= pack('v', strlen($pathBytes)); $localHeader .= pack('v', 0); $localHeader .= $pathBytes; fwrite($this->output, $localHeader); $headerLen = strlen($localHeader); $this->centralDirectory[] = [ 'path' => $pathBytes, 'crc' => 0, 'compressedSize' => 0, 'uncompressedSize' => 0, 'method' => 0, 'dosTime' => $dosTime, 'gpFlags' => $gpFlags, 'offset' => $this->offset, 'externalAttr' => 0x10, // Directory attribute ]; $this->offset += $headerLen; @flush(); } /** * Finalize and close the ZIP archive */ public function finish(): void { $cdOffset = $this->offset; $cdSize = 0; // Write central directory foreach ($this->centralDirectory as $entry) { $record = $this->buildCentralDirectoryEntry($entry); fwrite($this->output, $record); $cdSize += strlen($record); } // End of central directory record $eocd = pack('V', 0x06054b50); // EOCD signature $eocd .= pack('v', 0); // Disk number $eocd .= pack('v', 0); // Disk with CD $eocd .= pack('v', count($this->centralDirectory)); // Entries on this disk $eocd .= pack('v', count($this->centralDirectory)); // Total entries $eocd .= pack('V', $cdSize); // Central directory size $eocd .= pack('V', $cdOffset); // CD offset $eocd .= pack('v', 0); // Comment length fwrite($this->output, $eocd); @flush(); } /** * Build a central directory file header entry */ private function buildCentralDirectoryEntry(array $entry): string { $externalAttr = $entry['externalAttr'] ?? 0; $record = pack('V', 0x02014b50); // Central directory signature $record .= pack('v', 0x033F); // Version made by (Unix, 6.3) $record .= pack('v', 20); // Version needed $record .= pack('v', $entry['gpFlags']); // General purpose flags $record .= pack('v', $entry['method']); // Compression method $record .= pack('V', $entry['dosTime']); // Last mod time & date $record .= pack('V', $entry['crc']); // CRC-32 $record .= pack('V', $entry['compressedSize']); // Compressed size $record .= pack('V', $entry['uncompressedSize']); // Uncompressed size $record .= pack('v', strlen($entry['path'])); // File name length $record .= pack('v', 0); // Extra field length $record .= pack('v', 0); // Comment length $record .= pack('v', 0); // Disk number start $record .= pack('v', 0); // Internal file attributes $record .= pack('V', $externalAttr); // External file attributes $record .= pack('V', $entry['offset']); // Relative offset of local header $record .= $entry['path']; // File name return $record; } /** * Convert Unix timestamp to DOS date/time format */ private function unixToDosTime(int $timestamp): int { $date = getdate($timestamp); $year = max(0, $date['year'] - 1980); if ($year > 127) { $year = 127; } $dosDate = ($year << 9) | ($date['mon'] << 5) | $date['mday']; $dosTime = ($date['hours'] << 11) | ($date['minutes'] << 5) | (int)($date['seconds'] / 2); return ($dosDate << 16) | $dosTime; } }