Files
documents_manager/lib/Transfer/StreamingZip.php
2026-02-10 20:17:04 -05:00

286 lines
10 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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;
}
}