286 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|