1 |
efrain |
1 |
<?php
|
|
|
2 |
|
|
|
3 |
declare(strict_types=1);
|
|
|
4 |
|
|
|
5 |
namespace ZipStream;
|
|
|
6 |
|
|
|
7 |
use Closure;
|
|
|
8 |
use DateTimeImmutable;
|
|
|
9 |
use DateTimeInterface;
|
|
|
10 |
use GuzzleHttp\Psr7\StreamWrapper;
|
|
|
11 |
use Psr\Http\Message\StreamInterface;
|
|
|
12 |
use RuntimeException;
|
|
|
13 |
use ZipStream\Exception\FileNotFoundException;
|
|
|
14 |
use ZipStream\Exception\FileNotReadableException;
|
|
|
15 |
use ZipStream\Exception\OverflowException;
|
|
|
16 |
use ZipStream\Exception\ResourceActionException;
|
|
|
17 |
|
|
|
18 |
/**
|
|
|
19 |
* Streamed, dynamically generated zip archives.
|
|
|
20 |
*
|
|
|
21 |
* ## Usage
|
|
|
22 |
*
|
|
|
23 |
* Streaming zip archives is a simple, three-step process:
|
|
|
24 |
*
|
|
|
25 |
* 1. Create the zip stream:
|
|
|
26 |
*
|
|
|
27 |
* ```php
|
|
|
28 |
* $zip = new ZipStream(outputName: 'example.zip');
|
|
|
29 |
* ```
|
|
|
30 |
*
|
|
|
31 |
* 2. Add one or more files to the archive:
|
|
|
32 |
*
|
|
|
33 |
* ```php
|
|
|
34 |
* // add first file
|
|
|
35 |
* $zip->addFile(fileName: 'world.txt', data: 'Hello World');
|
|
|
36 |
*
|
|
|
37 |
* // add second file
|
|
|
38 |
* $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon');
|
|
|
39 |
* ```
|
|
|
40 |
*
|
|
|
41 |
* 3. Finish the zip stream:
|
|
|
42 |
*
|
|
|
43 |
* ```php
|
|
|
44 |
* $zip->finish();
|
|
|
45 |
* ```
|
|
|
46 |
*
|
|
|
47 |
* You can also add an archive comment, add comments to individual files,
|
|
|
48 |
* and adjust the timestamp of files. See the API documentation for each
|
|
|
49 |
* method below for additional information.
|
|
|
50 |
*
|
|
|
51 |
* ## Example
|
|
|
52 |
*
|
|
|
53 |
* ```php
|
|
|
54 |
* // create a new zip stream object
|
|
|
55 |
* $zip = new ZipStream(outputName: 'some_files.zip');
|
|
|
56 |
*
|
|
|
57 |
* // list of local files
|
|
|
58 |
* $files = array('foo.txt', 'bar.jpg');
|
|
|
59 |
*
|
|
|
60 |
* // read and add each file to the archive
|
|
|
61 |
* foreach ($files as $path)
|
|
|
62 |
* $zip->addFileFormPath(fileName: $path, $path);
|
|
|
63 |
*
|
|
|
64 |
* // write archive footer to stream
|
|
|
65 |
* $zip->finish();
|
|
|
66 |
* ```
|
|
|
67 |
*/
|
|
|
68 |
class ZipStream
|
|
|
69 |
{
|
|
|
70 |
/**
|
|
|
71 |
* This number corresponds to the ZIP version/OS used (2 bytes)
|
|
|
72 |
* From: https://www.iana.org/assignments/media-types/application/zip
|
|
|
73 |
* The upper byte (leftmost one) indicates the host system (OS) for the
|
|
|
74 |
* file. Software can use this information to determine
|
|
|
75 |
* the line record format for text files etc. The current
|
|
|
76 |
* mappings are:
|
|
|
77 |
*
|
|
|
78 |
* 0 - MS-DOS and OS/2 (F.A.T. file systems)
|
|
|
79 |
* 1 - Amiga 2 - VAX/VMS
|
|
|
80 |
* 3 - *nix 4 - VM/CMS
|
|
|
81 |
* 5 - Atari ST 6 - OS/2 H.P.F.S.
|
|
|
82 |
* 7 - Macintosh 8 - Z-System
|
|
|
83 |
* 9 - CP/M 10 thru 255 - unused
|
|
|
84 |
*
|
|
|
85 |
* The lower byte (rightmost one) indicates the version number of the
|
|
|
86 |
* software used to encode the file. The value/10
|
|
|
87 |
* indicates the major version number, and the value
|
|
|
88 |
* mod 10 is the minor version number.
|
|
|
89 |
* Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
|
|
|
90 |
* to prevent file permissions issues upon extract (see #84)
|
|
|
91 |
* 0x603 is 00000110 00000011 in binary, so 6 and 3
|
|
|
92 |
*
|
|
|
93 |
* @internal
|
|
|
94 |
*/
|
|
|
95 |
public const ZIP_VERSION_MADE_BY = 0x603;
|
|
|
96 |
|
|
|
97 |
private bool $ready = true;
|
|
|
98 |
|
|
|
99 |
private int $offset = 0;
|
|
|
100 |
|
|
|
101 |
/**
|
|
|
102 |
* @var string[]
|
|
|
103 |
*/
|
|
|
104 |
private array $centralDirectoryRecords = [];
|
|
|
105 |
|
|
|
106 |
/**
|
|
|
107 |
* @var resource
|
|
|
108 |
*/
|
|
|
109 |
private $outputStream;
|
|
|
110 |
|
|
|
111 |
private readonly Closure $httpHeaderCallback;
|
|
|
112 |
|
|
|
113 |
/**
|
|
|
114 |
* @var File[]
|
|
|
115 |
*/
|
|
|
116 |
private array $recordedSimulation = [];
|
|
|
117 |
|
|
|
118 |
/**
|
|
|
119 |
* Create a new ZipStream object.
|
|
|
120 |
*
|
|
|
121 |
* ##### Examples
|
|
|
122 |
*
|
|
|
123 |
* ```php
|
|
|
124 |
* // create a new zip file named 'foo.zip'
|
|
|
125 |
* $zip = new ZipStream(outputName: 'foo.zip');
|
|
|
126 |
*
|
|
|
127 |
* // create a new zip file named 'bar.zip' with a comment
|
|
|
128 |
* $zip = new ZipStream(
|
|
|
129 |
* outputName: 'bar.zip',
|
|
|
130 |
* comment: 'this is a comment for the zip file.',
|
|
|
131 |
* );
|
|
|
132 |
* ```
|
|
|
133 |
*
|
|
|
134 |
* @param OperationMode $operationMode
|
|
|
135 |
* The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes.
|
|
|
136 |
* For details see the `OperationMode` documentation.
|
|
|
137 |
*
|
|
|
138 |
* Default to `NORMAL`.
|
|
|
139 |
*
|
|
|
140 |
* @param string $comment
|
|
|
141 |
* Archive Level Comment
|
|
|
142 |
*
|
|
|
143 |
* @param StreamInterface|resource|null $outputStream
|
|
|
144 |
* Override the output of the archive to a different target.
|
|
|
145 |
*
|
|
|
146 |
* By default the archive is sent to `STDOUT`.
|
|
|
147 |
*
|
|
|
148 |
* @param CompressionMethod $defaultCompressionMethod
|
|
|
149 |
* How to handle file compression. Legal values are
|
|
|
150 |
* `CompressionMethod::DEFLATE` (the default), or
|
|
|
151 |
* `CompressionMethod::STORE`. `STORE` sends the file raw and is
|
|
|
152 |
* significantly faster, while `DEFLATE` compresses the file and
|
|
|
153 |
* is much, much slower.
|
|
|
154 |
*
|
|
|
155 |
* @param int $defaultDeflateLevel
|
|
|
156 |
* Default deflation level. Only relevant if `compressionMethod`
|
|
|
157 |
* is `DEFLATE`.
|
|
|
158 |
*
|
|
|
159 |
* See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters)
|
|
|
160 |
*
|
|
|
161 |
* @param bool $enableZip64
|
|
|
162 |
* Enable Zip64 extension, supporting very large
|
|
|
163 |
* archives (any size > 4 GB or file count > 64k)
|
|
|
164 |
*
|
|
|
165 |
* @param bool $defaultEnableZeroHeader
|
|
|
166 |
* Enable streaming files with single read.
|
|
|
167 |
*
|
|
|
168 |
* When the zero header is set, the file is streamed into the output
|
|
|
169 |
* and the size & checksum are added at the end of the file. This is the
|
|
|
170 |
* fastest method and uses the least memory. Unfortunately not all
|
|
|
171 |
* ZIP clients fully support this and can lead to clients reporting
|
|
|
172 |
* the generated ZIP files as corrupted in combination with other
|
|
|
173 |
* circumstances. (Zip64 enabled, using UTF8 in comments / names etc.)
|
|
|
174 |
*
|
|
|
175 |
* When the zero header is not set, the length & checksum need to be
|
|
|
176 |
* defined before the file is actually added. To prevent loading all
|
|
|
177 |
* the data into memory, the data has to be read twice. If the data
|
|
|
178 |
* which is added is not seekable, this call will fail.
|
|
|
179 |
*
|
|
|
180 |
* @param bool $sendHttpHeaders
|
|
|
181 |
* Boolean indicating whether or not to send
|
|
|
182 |
* the HTTP headers for this file.
|
|
|
183 |
*
|
|
|
184 |
* @param ?Closure $httpHeaderCallback
|
|
|
185 |
* The method called to send HTTP headers
|
|
|
186 |
*
|
|
|
187 |
* @param string|null $outputName
|
|
|
188 |
* The name of the created archive.
|
|
|
189 |
*
|
|
|
190 |
* Only relevant if `$sendHttpHeaders = true`.
|
|
|
191 |
*
|
|
|
192 |
* @param string $contentDisposition
|
|
|
193 |
* HTTP Content-Disposition
|
|
|
194 |
*
|
|
|
195 |
* Only relevant if `sendHttpHeaders = true`.
|
|
|
196 |
*
|
|
|
197 |
* @param string $contentType
|
|
|
198 |
* HTTP Content Type
|
|
|
199 |
*
|
|
|
200 |
* Only relevant if `sendHttpHeaders = true`.
|
|
|
201 |
*
|
|
|
202 |
* @param bool $flushOutput
|
|
|
203 |
* Enable flush after every write to output stream.
|
|
|
204 |
*
|
|
|
205 |
* @return self
|
|
|
206 |
*/
|
|
|
207 |
public function __construct(
|
|
|
208 |
private OperationMode $operationMode = OperationMode::NORMAL,
|
|
|
209 |
private readonly string $comment = '',
|
|
|
210 |
$outputStream = null,
|
|
|
211 |
private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE,
|
|
|
212 |
private readonly int $defaultDeflateLevel = 6,
|
|
|
213 |
private readonly bool $enableZip64 = true,
|
|
|
214 |
private readonly bool $defaultEnableZeroHeader = true,
|
|
|
215 |
private bool $sendHttpHeaders = true,
|
|
|
216 |
?Closure $httpHeaderCallback = null,
|
|
|
217 |
private readonly ?string $outputName = null,
|
|
|
218 |
private readonly string $contentDisposition = 'attachment',
|
|
|
219 |
private readonly string $contentType = 'application/x-zip',
|
|
|
220 |
private bool $flushOutput = false,
|
|
|
221 |
) {
|
|
|
222 |
$this->outputStream = self::normalizeStream($outputStream);
|
|
|
223 |
$this->httpHeaderCallback = $httpHeaderCallback ?? header(...);
|
|
|
224 |
}
|
|
|
225 |
|
|
|
226 |
/**
|
|
|
227 |
* Add a file to the archive.
|
|
|
228 |
*
|
|
|
229 |
* ##### File Options
|
|
|
230 |
*
|
|
|
231 |
* See {@see addFileFromPsr7Stream()}
|
|
|
232 |
*
|
|
|
233 |
* ##### Examples
|
|
|
234 |
*
|
|
|
235 |
* ```php
|
|
|
236 |
* // add a file named 'world.txt'
|
|
|
237 |
* $zip->addFile(fileName: 'world.txt', data: 'Hello World!');
|
|
|
238 |
*
|
|
|
239 |
* // add a file named 'bar.jpg' with a comment and a last-modified
|
|
|
240 |
* // time of two hours ago
|
|
|
241 |
* $zip->addFile(
|
|
|
242 |
* fileName: 'bar.jpg',
|
|
|
243 |
* data: $data,
|
|
|
244 |
* comment: 'this is a comment about bar.jpg',
|
|
|
245 |
* lastModificationDateTime: new DateTime('2 hours ago'),
|
|
|
246 |
* );
|
|
|
247 |
* ```
|
|
|
248 |
*
|
|
|
249 |
* @param string $data
|
|
|
250 |
*
|
|
|
251 |
* contents of file
|
|
|
252 |
*/
|
|
|
253 |
public function addFile(
|
|
|
254 |
string $fileName,
|
|
|
255 |
string $data,
|
|
|
256 |
string $comment = '',
|
|
|
257 |
?CompressionMethod $compressionMethod = null,
|
|
|
258 |
?int $deflateLevel = null,
|
|
|
259 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
260 |
?int $maxSize = null,
|
|
|
261 |
?int $exactSize = null,
|
|
|
262 |
?bool $enableZeroHeader = null,
|
|
|
263 |
): void {
|
|
|
264 |
$this->addFileFromCallback(
|
|
|
265 |
fileName: $fileName,
|
|
|
266 |
callback: fn () => $data,
|
|
|
267 |
comment: $comment,
|
|
|
268 |
compressionMethod: $compressionMethod,
|
|
|
269 |
deflateLevel: $deflateLevel,
|
|
|
270 |
lastModificationDateTime: $lastModificationDateTime,
|
|
|
271 |
maxSize: $maxSize,
|
|
|
272 |
exactSize: $exactSize,
|
|
|
273 |
enableZeroHeader: $enableZeroHeader,
|
|
|
274 |
);
|
|
|
275 |
}
|
|
|
276 |
|
|
|
277 |
/**
|
|
|
278 |
* Add a file at path to the archive.
|
|
|
279 |
*
|
|
|
280 |
* ##### File Options
|
|
|
281 |
*
|
|
|
282 |
* See {@see addFileFromPsr7Stream()}
|
|
|
283 |
*
|
|
|
284 |
* ###### Examples
|
|
|
285 |
*
|
|
|
286 |
* ```php
|
|
|
287 |
* // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
|
|
|
288 |
* $zip->addFileFromPath(
|
|
|
289 |
* fileName: 'foo.txt',
|
|
|
290 |
* path: '/tmp/foo.txt',
|
|
|
291 |
* );
|
|
|
292 |
*
|
|
|
293 |
* // add a file named 'bigfile.rar' from the local file
|
|
|
294 |
* // '/usr/share/bigfile.rar' with a comment and a last-modified
|
|
|
295 |
* // time of two hours ago
|
|
|
296 |
* $zip->addFile(
|
|
|
297 |
* fileName: 'bigfile.rar',
|
|
|
298 |
* path: '/usr/share/bigfile.rar',
|
|
|
299 |
* comment: 'this is a comment about bigfile.rar',
|
|
|
300 |
* lastModificationDateTime: new DateTime('2 hours ago'),
|
|
|
301 |
* );
|
|
|
302 |
* ```
|
|
|
303 |
*
|
|
|
304 |
* @throws \ZipStream\Exception\FileNotFoundException
|
|
|
305 |
* @throws \ZipStream\Exception\FileNotReadableException
|
|
|
306 |
*/
|
|
|
307 |
public function addFileFromPath(
|
|
|
308 |
/**
|
|
|
309 |
* name of file in archive (including directory path).
|
|
|
310 |
*/
|
|
|
311 |
string $fileName,
|
|
|
312 |
|
|
|
313 |
/**
|
|
|
314 |
* path to file on disk (note: paths should be encoded using
|
|
|
315 |
* UNIX-style forward slashes -- e.g '/path/to/some/file').
|
|
|
316 |
*/
|
|
|
317 |
string $path,
|
|
|
318 |
string $comment = '',
|
|
|
319 |
?CompressionMethod $compressionMethod = null,
|
|
|
320 |
?int $deflateLevel = null,
|
|
|
321 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
322 |
?int $maxSize = null,
|
|
|
323 |
?int $exactSize = null,
|
|
|
324 |
?bool $enableZeroHeader = null,
|
|
|
325 |
): void {
|
|
|
326 |
if (!is_readable($path)) {
|
|
|
327 |
if (!file_exists($path)) {
|
|
|
328 |
throw new FileNotFoundException($path);
|
|
|
329 |
}
|
|
|
330 |
throw new FileNotReadableException($path);
|
|
|
331 |
}
|
|
|
332 |
|
|
|
333 |
if ($fileTime = filemtime($path)) {
|
|
|
334 |
$lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);
|
|
|
335 |
}
|
|
|
336 |
|
|
|
337 |
$this->addFileFromCallback(
|
|
|
338 |
fileName: $fileName,
|
|
|
339 |
callback: function () use ($path) {
|
|
|
340 |
|
|
|
341 |
$stream = fopen($path, 'rb');
|
|
|
342 |
|
|
|
343 |
if (!$stream) {
|
|
|
344 |
// @codeCoverageIgnoreStart
|
|
|
345 |
throw new ResourceActionException('fopen');
|
|
|
346 |
// @codeCoverageIgnoreEnd
|
|
|
347 |
}
|
|
|
348 |
|
|
|
349 |
return $stream;
|
|
|
350 |
},
|
|
|
351 |
comment: $comment,
|
|
|
352 |
compressionMethod: $compressionMethod,
|
|
|
353 |
deflateLevel: $deflateLevel,
|
|
|
354 |
lastModificationDateTime: $lastModificationDateTime,
|
|
|
355 |
maxSize: $maxSize,
|
|
|
356 |
exactSize: $exactSize,
|
|
|
357 |
enableZeroHeader: $enableZeroHeader,
|
|
|
358 |
);
|
|
|
359 |
}
|
|
|
360 |
|
|
|
361 |
/**
|
|
|
362 |
* Add an open stream (resource) to the archive.
|
|
|
363 |
*
|
|
|
364 |
* ##### File Options
|
|
|
365 |
*
|
|
|
366 |
* See {@see addFileFromPsr7Stream()}
|
|
|
367 |
*
|
|
|
368 |
* ##### Examples
|
|
|
369 |
*
|
|
|
370 |
* ```php
|
|
|
371 |
* // create a temporary file stream and write text to it
|
|
|
372 |
* $filePointer = tmpfile();
|
|
|
373 |
* fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
|
|
|
374 |
*
|
|
|
375 |
* // add a file named 'streamfile.txt' from the content of the stream
|
|
|
376 |
* $archive->addFileFromStream(
|
|
|
377 |
* fileName: 'streamfile.txt',
|
|
|
378 |
* stream: $filePointer,
|
|
|
379 |
* );
|
|
|
380 |
* ```
|
|
|
381 |
*
|
|
|
382 |
* @param resource $stream contents of file as a stream resource
|
|
|
383 |
*/
|
|
|
384 |
public function addFileFromStream(
|
|
|
385 |
string $fileName,
|
|
|
386 |
$stream,
|
|
|
387 |
string $comment = '',
|
|
|
388 |
?CompressionMethod $compressionMethod = null,
|
|
|
389 |
?int $deflateLevel = null,
|
|
|
390 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
391 |
?int $maxSize = null,
|
|
|
392 |
?int $exactSize = null,
|
|
|
393 |
?bool $enableZeroHeader = null,
|
|
|
394 |
): void {
|
|
|
395 |
$this->addFileFromCallback(
|
|
|
396 |
fileName: $fileName,
|
|
|
397 |
callback: fn () => $stream,
|
|
|
398 |
comment: $comment,
|
|
|
399 |
compressionMethod: $compressionMethod,
|
|
|
400 |
deflateLevel: $deflateLevel,
|
|
|
401 |
lastModificationDateTime: $lastModificationDateTime,
|
|
|
402 |
maxSize: $maxSize,
|
|
|
403 |
exactSize: $exactSize,
|
|
|
404 |
enableZeroHeader: $enableZeroHeader,
|
|
|
405 |
);
|
|
|
406 |
}
|
|
|
407 |
|
|
|
408 |
/**
|
|
|
409 |
* Add an open stream to the archive.
|
|
|
410 |
*
|
|
|
411 |
* ##### Examples
|
|
|
412 |
*
|
|
|
413 |
* ```php
|
|
|
414 |
* $stream = $response->getBody();
|
|
|
415 |
* // add a file named 'streamfile.txt' from the content of the stream
|
|
|
416 |
* $archive->addFileFromPsr7Stream(
|
|
|
417 |
* fileName: 'streamfile.txt',
|
|
|
418 |
* stream: $stream,
|
|
|
419 |
* );
|
|
|
420 |
* ```
|
|
|
421 |
*
|
|
|
422 |
* @param string $fileName
|
|
|
423 |
* path of file in archive (including directory)
|
|
|
424 |
*
|
|
|
425 |
* @param StreamInterface $stream
|
|
|
426 |
* contents of file as a stream resource
|
|
|
427 |
*
|
|
|
428 |
* @param string $comment
|
|
|
429 |
* ZIP comment for this file
|
|
|
430 |
*
|
|
|
431 |
* @param ?CompressionMethod $compressionMethod
|
|
|
432 |
* Override `defaultCompressionMethod`
|
|
|
433 |
*
|
|
|
434 |
* See {@see __construct()}
|
|
|
435 |
*
|
|
|
436 |
* @param ?int $deflateLevel
|
|
|
437 |
* Override `defaultDeflateLevel`
|
|
|
438 |
*
|
|
|
439 |
* See {@see __construct()}
|
|
|
440 |
*
|
|
|
441 |
* @param ?DateTimeInterface $lastModificationDateTime
|
|
|
442 |
* Set last modification time of file.
|
|
|
443 |
*
|
|
|
444 |
* Default: `now`
|
|
|
445 |
*
|
|
|
446 |
* @param ?int $maxSize
|
|
|
447 |
* Only read `maxSize` bytes from file.
|
|
|
448 |
*
|
|
|
449 |
* The file is considered done when either reaching `EOF`
|
|
|
450 |
* or the `maxSize`.
|
|
|
451 |
*
|
|
|
452 |
* @param ?int $exactSize
|
|
|
453 |
* Read exactly `exactSize` bytes from file.
|
|
|
454 |
* If `EOF` is reached before reading `exactSize` bytes, an error will be
|
|
|
455 |
* thrown. The parameter allows for faster size calculations if the `stream`
|
|
|
456 |
* does not support `fstat` size or is slow and otherwise known beforehand.
|
|
|
457 |
*
|
|
|
458 |
* @param ?bool $enableZeroHeader
|
|
|
459 |
* Override `defaultEnableZeroHeader`
|
|
|
460 |
*
|
|
|
461 |
* See {@see __construct()}
|
|
|
462 |
*/
|
|
|
463 |
public function addFileFromPsr7Stream(
|
|
|
464 |
string $fileName,
|
|
|
465 |
StreamInterface $stream,
|
|
|
466 |
string $comment = '',
|
|
|
467 |
?CompressionMethod $compressionMethod = null,
|
|
|
468 |
?int $deflateLevel = null,
|
|
|
469 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
470 |
?int $maxSize = null,
|
|
|
471 |
?int $exactSize = null,
|
|
|
472 |
?bool $enableZeroHeader = null,
|
|
|
473 |
): void {
|
|
|
474 |
$this->addFileFromCallback(
|
|
|
475 |
fileName: $fileName,
|
|
|
476 |
callback: fn () => $stream,
|
|
|
477 |
comment: $comment,
|
|
|
478 |
compressionMethod: $compressionMethod,
|
|
|
479 |
deflateLevel: $deflateLevel,
|
|
|
480 |
lastModificationDateTime: $lastModificationDateTime,
|
|
|
481 |
maxSize: $maxSize,
|
|
|
482 |
exactSize: $exactSize,
|
|
|
483 |
enableZeroHeader: $enableZeroHeader,
|
|
|
484 |
);
|
|
|
485 |
}
|
|
|
486 |
|
|
|
487 |
/**
|
|
|
488 |
* Add a file based on a callback.
|
|
|
489 |
*
|
|
|
490 |
* This is useful when you want to simulate a lot of files without keeping
|
|
|
491 |
* all of the file handles open at the same time.
|
|
|
492 |
*
|
|
|
493 |
* ##### Examples
|
|
|
494 |
*
|
|
|
495 |
* ```php
|
|
|
496 |
* foreach($files as $name => $size) {
|
|
|
497 |
* $archive->addFileFromPsr7Stream(
|
|
|
498 |
* fileName: 'streamfile.txt',
|
|
|
499 |
* exactSize: $size,
|
|
|
500 |
* callback: function() use($name): Psr\Http\Message\StreamInterface {
|
|
|
501 |
* $response = download($name);
|
|
|
502 |
* return $response->getBody();
|
|
|
503 |
* }
|
|
|
504 |
* );
|
|
|
505 |
* }
|
|
|
506 |
* ```
|
|
|
507 |
*
|
|
|
508 |
* @param string $fileName
|
|
|
509 |
* path of file in archive (including directory)
|
|
|
510 |
*
|
|
|
511 |
* @param Closure $callback
|
|
|
512 |
* @psalm-param Closure(): (resource|StreamInterface|string) $callback
|
|
|
513 |
* A callback to get the file contents in the shape of a PHP stream,
|
|
|
514 |
* a Psr StreamInterface implementation, or a string.
|
|
|
515 |
*
|
|
|
516 |
* @param string $comment
|
|
|
517 |
* ZIP comment for this file
|
|
|
518 |
*
|
|
|
519 |
* @param ?CompressionMethod $compressionMethod
|
|
|
520 |
* Override `defaultCompressionMethod`
|
|
|
521 |
*
|
|
|
522 |
* See {@see __construct()}
|
|
|
523 |
*
|
|
|
524 |
* @param ?int $deflateLevel
|
|
|
525 |
* Override `defaultDeflateLevel`
|
|
|
526 |
*
|
|
|
527 |
* See {@see __construct()}
|
|
|
528 |
*
|
|
|
529 |
* @param ?DateTimeInterface $lastModificationDateTime
|
|
|
530 |
* Set last modification time of file.
|
|
|
531 |
*
|
|
|
532 |
* Default: `now`
|
|
|
533 |
*
|
|
|
534 |
* @param ?int $maxSize
|
|
|
535 |
* Only read `maxSize` bytes from file.
|
|
|
536 |
*
|
|
|
537 |
* The file is considered done when either reaching `EOF`
|
|
|
538 |
* or the `maxSize`.
|
|
|
539 |
*
|
|
|
540 |
* @param ?int $exactSize
|
|
|
541 |
* Read exactly `exactSize` bytes from file.
|
|
|
542 |
* If `EOF` is reached before reading `exactSize` bytes, an error will be
|
|
|
543 |
* thrown. The parameter allows for faster size calculations if the `stream`
|
|
|
544 |
* does not support `fstat` size or is slow and otherwise known beforehand.
|
|
|
545 |
*
|
|
|
546 |
* @param ?bool $enableZeroHeader
|
|
|
547 |
* Override `defaultEnableZeroHeader`
|
|
|
548 |
*
|
|
|
549 |
* See {@see __construct()}
|
|
|
550 |
*/
|
|
|
551 |
public function addFileFromCallback(
|
|
|
552 |
string $fileName,
|
|
|
553 |
Closure $callback,
|
|
|
554 |
string $comment = '',
|
|
|
555 |
?CompressionMethod $compressionMethod = null,
|
|
|
556 |
?int $deflateLevel = null,
|
|
|
557 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
558 |
?int $maxSize = null,
|
|
|
559 |
?int $exactSize = null,
|
|
|
560 |
?bool $enableZeroHeader = null,
|
|
|
561 |
): void {
|
|
|
562 |
$file = new File(
|
|
|
563 |
dataCallback: function () use ($callback, $maxSize) {
|
|
|
564 |
$data = $callback();
|
|
|
565 |
|
|
|
566 |
if(is_resource($data)) {
|
|
|
567 |
return $data;
|
|
|
568 |
}
|
|
|
569 |
|
|
|
570 |
if($data instanceof StreamInterface) {
|
|
|
571 |
return StreamWrapper::getResource($data);
|
|
|
572 |
}
|
|
|
573 |
|
|
|
574 |
|
|
|
575 |
$stream = fopen('php://memory', 'rw+');
|
|
|
576 |
if ($stream === false) {
|
|
|
577 |
// @codeCoverageIgnoreStart
|
|
|
578 |
throw new ResourceActionException('fopen');
|
|
|
579 |
// @codeCoverageIgnoreEnd
|
|
|
580 |
}
|
|
|
581 |
if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {
|
|
|
582 |
// @codeCoverageIgnoreStart
|
|
|
583 |
throw new ResourceActionException('fwrite', $stream);
|
|
|
584 |
// @codeCoverageIgnoreEnd
|
|
|
585 |
} elseif (fwrite($stream, $data) === false) {
|
|
|
586 |
// @codeCoverageIgnoreStart
|
|
|
587 |
throw new ResourceActionException('fwrite', $stream);
|
|
|
588 |
// @codeCoverageIgnoreEnd
|
|
|
589 |
}
|
|
|
590 |
if (rewind($stream) === false) {
|
|
|
591 |
// @codeCoverageIgnoreStart
|
|
|
592 |
throw new ResourceActionException('rewind', $stream);
|
|
|
593 |
// @codeCoverageIgnoreEnd
|
|
|
594 |
}
|
|
|
595 |
|
|
|
596 |
return $stream;
|
|
|
597 |
|
|
|
598 |
},
|
|
|
599 |
send: $this->send(...),
|
|
|
600 |
recordSentBytes: $this->recordSentBytes(...),
|
|
|
601 |
operationMode: $this->operationMode,
|
|
|
602 |
fileName: $fileName,
|
|
|
603 |
startOffset: $this->offset,
|
|
|
604 |
compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,
|
|
|
605 |
comment: $comment,
|
|
|
606 |
deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,
|
|
|
607 |
lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),
|
|
|
608 |
maxSize: $maxSize,
|
|
|
609 |
exactSize: $exactSize,
|
|
|
610 |
enableZip64: $this->enableZip64,
|
|
|
611 |
enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,
|
|
|
612 |
);
|
|
|
613 |
|
|
|
614 |
if($this->operationMode !== OperationMode::NORMAL) {
|
|
|
615 |
$this->recordedSimulation[] = $file;
|
|
|
616 |
}
|
|
|
617 |
|
|
|
618 |
$this->centralDirectoryRecords[] = $file->process();
|
|
|
619 |
}
|
|
|
620 |
|
|
|
621 |
/**
|
|
|
622 |
* Add a directory to the archive.
|
|
|
623 |
*
|
|
|
624 |
* ##### File Options
|
|
|
625 |
*
|
|
|
626 |
* See {@see addFileFromPsr7Stream()}
|
|
|
627 |
*
|
|
|
628 |
* ##### Examples
|
|
|
629 |
*
|
|
|
630 |
* ```php
|
|
|
631 |
* // add a directory named 'world/'
|
|
|
632 |
* $zip->addFile(fileName: 'world/');
|
|
|
633 |
* ```
|
|
|
634 |
*/
|
|
|
635 |
public function addDirectory(
|
|
|
636 |
string $fileName,
|
|
|
637 |
string $comment = '',
|
|
|
638 |
?DateTimeInterface $lastModificationDateTime = null,
|
|
|
639 |
): void {
|
|
|
640 |
if (!str_ends_with($fileName, '/')) {
|
|
|
641 |
$fileName .= '/';
|
|
|
642 |
}
|
|
|
643 |
|
|
|
644 |
$this->addFile(
|
|
|
645 |
fileName: $fileName,
|
|
|
646 |
data: '',
|
|
|
647 |
comment: $comment,
|
|
|
648 |
compressionMethod: CompressionMethod::STORE,
|
|
|
649 |
deflateLevel: null,
|
|
|
650 |
lastModificationDateTime: $lastModificationDateTime,
|
|
|
651 |
maxSize: 0,
|
|
|
652 |
exactSize: 0,
|
|
|
653 |
enableZeroHeader: false,
|
|
|
654 |
);
|
|
|
655 |
}
|
|
|
656 |
|
|
|
657 |
/**
|
|
|
658 |
* Executes a previously calculated simulation.
|
|
|
659 |
*
|
|
|
660 |
* ##### Example
|
|
|
661 |
*
|
|
|
662 |
* ```php
|
|
|
663 |
* $zip = new ZipStream(
|
|
|
664 |
* outputName: 'foo.zip',
|
|
|
665 |
* operationMode: OperationMode::SIMULATE_STRICT,
|
|
|
666 |
* );
|
|
|
667 |
*
|
|
|
668 |
* $zip->addFile('test.txt', 'Hello World');
|
|
|
669 |
*
|
|
|
670 |
* $size = $zip->finish();
|
|
|
671 |
*
|
|
|
672 |
* header('Content-Length: '. $size);
|
|
|
673 |
*
|
|
|
674 |
* $zip->executeSimulation();
|
|
|
675 |
* ```
|
|
|
676 |
*/
|
|
|
677 |
public function executeSimulation(): void
|
|
|
678 |
{
|
|
|
679 |
if($this->operationMode !== OperationMode::NORMAL) {
|
|
|
680 |
throw new RuntimeException('Zip simulation is not finished.');
|
|
|
681 |
}
|
|
|
682 |
|
|
|
683 |
foreach($this->recordedSimulation as $file) {
|
|
|
684 |
$this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();
|
|
|
685 |
}
|
|
|
686 |
|
|
|
687 |
$this->finish();
|
|
|
688 |
}
|
|
|
689 |
|
|
|
690 |
/**
|
|
|
691 |
* Write zip footer to stream.
|
|
|
692 |
*
|
|
|
693 |
* The clase is left in an unusable state after `finish`.
|
|
|
694 |
*
|
|
|
695 |
* ##### Example
|
|
|
696 |
*
|
|
|
697 |
* ```php
|
|
|
698 |
* // write footer to stream
|
|
|
699 |
* $zip->finish();
|
|
|
700 |
* ```
|
|
|
701 |
*/
|
|
|
702 |
public function finish(): int
|
|
|
703 |
{
|
|
|
704 |
$centralDirectoryStartOffsetOnDisk = $this->offset;
|
|
|
705 |
$sizeOfCentralDirectory = 0;
|
|
|
706 |
|
|
|
707 |
// add trailing cdr file records
|
|
|
708 |
foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) {
|
|
|
709 |
$this->send($centralDirectoryRecord);
|
|
|
710 |
$sizeOfCentralDirectory += strlen($centralDirectoryRecord);
|
|
|
711 |
}
|
|
|
712 |
|
|
|
713 |
// Add 64bit headers (if applicable)
|
|
|
714 |
if (count($this->centralDirectoryRecords) >= 0xFFFF ||
|
|
|
715 |
$centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
|
|
|
716 |
$sizeOfCentralDirectory > 0xFFFFFFFF) {
|
|
|
717 |
if (!$this->enableZip64) {
|
|
|
718 |
throw new OverflowException();
|
|
|
719 |
}
|
|
|
720 |
|
|
|
721 |
$this->send(Zip64\EndOfCentralDirectory::generate(
|
|
|
722 |
versionMadeBy: self::ZIP_VERSION_MADE_BY,
|
|
|
723 |
versionNeededToExtract: Version::ZIP64->value,
|
|
|
724 |
numberOfThisDisk: 0,
|
|
|
725 |
numberOfTheDiskWithCentralDirectoryStart: 0,
|
|
|
726 |
numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),
|
|
|
727 |
numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),
|
|
|
728 |
sizeOfCentralDirectory: $sizeOfCentralDirectory,
|
|
|
729 |
centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,
|
|
|
730 |
extensibleDataSector: '',
|
|
|
731 |
));
|
|
|
732 |
|
|
|
733 |
$this->send(Zip64\EndOfCentralDirectoryLocator::generate(
|
|
|
734 |
numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,
|
|
|
735 |
zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,
|
|
|
736 |
totalNumberOfDisks: 1,
|
|
|
737 |
));
|
|
|
738 |
}
|
|
|
739 |
|
|
|
740 |
// add trailing cdr eof record
|
|
|
741 |
$numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);
|
|
|
742 |
$this->send(EndOfCentralDirectory::generate(
|
|
|
743 |
numberOfThisDisk: 0x00,
|
|
|
744 |
numberOfTheDiskWithCentralDirectoryStart: 0x00,
|
|
|
745 |
numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,
|
|
|
746 |
numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,
|
|
|
747 |
sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),
|
|
|
748 |
centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),
|
|
|
749 |
zipFileComment: $this->comment,
|
|
|
750 |
));
|
|
|
751 |
|
|
|
752 |
$size = $this->offset;
|
|
|
753 |
|
|
|
754 |
// The End
|
|
|
755 |
$this->clear();
|
|
|
756 |
|
|
|
757 |
return $size;
|
|
|
758 |
}
|
|
|
759 |
|
|
|
760 |
/**
|
|
|
761 |
* @param StreamInterface|resource|null $outputStream
|
|
|
762 |
* @return resource
|
|
|
763 |
*/
|
|
|
764 |
private static function normalizeStream($outputStream)
|
|
|
765 |
{
|
|
|
766 |
if ($outputStream instanceof StreamInterface) {
|
|
|
767 |
return StreamWrapper::getResource($outputStream);
|
|
|
768 |
}
|
|
|
769 |
if (is_resource($outputStream)) {
|
|
|
770 |
return $outputStream;
|
|
|
771 |
}
|
|
|
772 |
return fopen('php://output', 'wb');
|
|
|
773 |
}
|
|
|
774 |
|
|
|
775 |
/**
|
|
|
776 |
* Record sent bytes
|
|
|
777 |
*/
|
|
|
778 |
private function recordSentBytes(int $sentBytes): void
|
|
|
779 |
{
|
|
|
780 |
$this->offset += $sentBytes;
|
|
|
781 |
}
|
|
|
782 |
|
|
|
783 |
/**
|
|
|
784 |
* Send string, sending HTTP headers if necessary.
|
|
|
785 |
* Flush output after write if configure option is set.
|
|
|
786 |
*/
|
|
|
787 |
private function send(string $data): void
|
|
|
788 |
{
|
|
|
789 |
if (!$this->ready) {
|
|
|
790 |
throw new RuntimeException('Archive is already finished');
|
|
|
791 |
}
|
|
|
792 |
|
|
|
793 |
if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {
|
|
|
794 |
$this->sendHttpHeaders();
|
|
|
795 |
$this->sendHttpHeaders = false;
|
|
|
796 |
}
|
|
|
797 |
|
|
|
798 |
$this->recordSentBytes(strlen($data));
|
|
|
799 |
|
|
|
800 |
if ($this->operationMode === OperationMode::NORMAL) {
|
|
|
801 |
if (fwrite($this->outputStream, $data) === false) {
|
|
|
802 |
throw new ResourceActionException('fwrite', $this->outputStream);
|
|
|
803 |
}
|
|
|
804 |
|
|
|
805 |
if ($this->flushOutput) {
|
|
|
806 |
// flush output buffer if it is on and flushable
|
|
|
807 |
$status = ob_get_status();
|
|
|
808 |
if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
|
|
|
809 |
ob_flush();
|
|
|
810 |
}
|
|
|
811 |
|
|
|
812 |
// Flush system buffers after flushing userspace output buffer
|
|
|
813 |
flush();
|
|
|
814 |
}
|
|
|
815 |
}
|
|
|
816 |
}
|
|
|
817 |
|
|
|
818 |
/**
|
|
|
819 |
* Send HTTP headers for this stream.
|
|
|
820 |
*/
|
|
|
821 |
private function sendHttpHeaders(): void
|
|
|
822 |
{
|
|
|
823 |
// grab content disposition
|
|
|
824 |
$disposition = $this->contentDisposition;
|
|
|
825 |
|
|
|
826 |
if ($this->outputName) {
|
|
|
827 |
// Various different browsers dislike various characters here. Strip them all for safety.
|
|
|
828 |
$safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));
|
|
|
829 |
|
|
|
830 |
// Check if we need to UTF-8 encode the filename
|
|
|
831 |
$urlencoded = rawurlencode($safeOutput);
|
|
|
832 |
$disposition .= "; filename*=UTF-8''{$urlencoded}";
|
|
|
833 |
}
|
|
|
834 |
|
|
|
835 |
$headers = [
|
|
|
836 |
'Content-Type' => $this->contentType,
|
|
|
837 |
'Content-Disposition' => $disposition,
|
|
|
838 |
'Pragma' => 'public',
|
|
|
839 |
'Cache-Control' => 'public, must-revalidate',
|
|
|
840 |
'Content-Transfer-Encoding' => 'binary',
|
|
|
841 |
];
|
|
|
842 |
|
|
|
843 |
foreach ($headers as $key => $val) {
|
|
|
844 |
($this->httpHeaderCallback)("$key: $val");
|
|
|
845 |
}
|
|
|
846 |
}
|
|
|
847 |
|
|
|
848 |
/**
|
|
|
849 |
* Clear all internal variables. Note that the stream object is not
|
|
|
850 |
* usable after this.
|
|
|
851 |
*/
|
|
|
852 |
private function clear(): void
|
|
|
853 |
{
|
|
|
854 |
$this->centralDirectoryRecords = [];
|
|
|
855 |
$this->offset = 0;
|
|
|
856 |
|
|
|
857 |
if($this->operationMode === OperationMode::NORMAL) {
|
|
|
858 |
$this->ready = false;
|
|
|
859 |
$this->recordedSimulation = [];
|
|
|
860 |
} else {
|
|
|
861 |
$this->operationMode = OperationMode::NORMAL;
|
|
|
862 |
}
|
|
|
863 |
}
|
|
|
864 |
}
|