| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- <?php
- /**
- * Minimal ZIP writer in plain PHP — no ZipArchive, no Composer.
- *
- * Only what a gallery archive needs: **stored** entries (method 0, no
- * compression) and ZIP64. Photos are JPEG/RAW and do not compress, so deflating
- * them would burn CPU for nothing; storing them means an entry's bytes are a
- * byte-for-byte copy of the S3 object, which is what lets the builder stream
- * each photo straight through (see app/archive.php).
- *
- * The functions here only produce byte strings — they never touch S3, and the
- * only file handle they see is the one zip_patch_local_header() writes back to.
- *
- * Layout produced:
- *
- * [local header][file bytes] … [central directory][ZIP64 EOCD + locator][EOCD]
- *
- * Local headers are written **before** the file's bytes are known, so their CRC
- * and size fields start as placeholders and are patched afterwards
- * (zip_patch_local_header). That is why every local header carries a ZIP64 extra
- * field unconditionally: the layout has to be fixed before the size is known,
- * and a >4 GB entry must not require a different one. Central directory entries
- * are built at the end, when the real values are known, so those use ZIP64 only
- * where a field actually overflows.
- *
- * No data descriptors are used (the patch makes them unnecessary), which keeps
- * the output readable by the widest range of tools.
- */
- declare(strict_types=1);
- /** Fixed part of a local file header, before filename and extra field. */
- const ZIP_LOCAL_FIXED = 30;
- /** ZIP64 extra field in a local header: id + size + 8-byte sizes. */
- const ZIP_ZIP64_EXTRA = 20;
- /** Value meaning "look in the ZIP64 extra field for the real number". */
- const ZIP_MAX32 = 0xFFFFFFFF;
- /** Convert a Unix timestamp to the DOS date/time pair ZIP stores. */
- function zip_dos_time(int $timestamp): array
- {
- $t = getdate($timestamp);
- // DOS time cannot represent anything before 1980; clamp rather than wrap.
- if ($t['year'] < 1980) {
- return [0, 0x21]; // 1980-01-01 00:00:00
- }
- $time = ($t['hours'] << 11) | ($t['minutes'] << 5) | (int)($t['seconds'] / 2);
- $date = (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
- return [$time, $date];
- }
- /**
- * Total length of the local header for $name, i.e. how far the file's bytes sit
- * from the start of the entry. Callers need this to track archive offsets.
- */
- function zip_local_header_size(string $name): int
- {
- return ZIP_LOCAL_FIXED + strlen($name) + ZIP_ZIP64_EXTRA;
- }
- /**
- * Local file header with placeholder CRC and sizes, to be patched once the
- * file's bytes have been written (zip_patch_local_header).
- *
- * Flag bit 11 marks the filename as UTF-8 so "Straße.jpg" survives; without it
- * readers fall back to CP437 and mangle anything non-ASCII.
- */
- function zip_local_header(string $name, int $mtime): string
- {
- [$time, $date] = zip_dos_time($mtime);
- return pack('V', 0x04034b50) // local file header signature
- . pack('v', 45) // version needed to extract: 4.5 (ZIP64)
- . pack('v', 0x0800) // flags: UTF-8 filename
- . pack('v', 0) // method: stored
- . pack('v', $time)
- . pack('v', $date)
- . pack('V', 0) // CRC-32 — patched later
- . pack('V', ZIP_MAX32) // compressed size → ZIP64 extra
- . pack('V', ZIP_MAX32) // uncompressed size → ZIP64 extra
- . pack('v', strlen($name))
- . pack('v', ZIP_ZIP64_EXTRA)
- . $name
- . pack('v', 0x0001) // ZIP64 extended information extra field
- . pack('v', 16) // ... holding two 8-byte sizes
- . pack('P', 0) // uncompressed size — patched later
- . pack('P', 0); // compressed size — patched later
- }
- /**
- * Fill in the CRC and size a local header was written without.
- *
- * $entryOffset is the position of the header's signature within $fh. The handle
- * is left at end-of-file so the caller can carry on appending.
- *
- * @param resource $fh
- */
- function zip_patch_local_header($fh, int $entryOffset, string $name, int $crc, int $size): void
- {
- // CRC-32 sits 14 bytes into the fixed header; the two ZIP64 sizes sit 4
- // bytes into the extra field, which follows the filename.
- fseek($fh, $entryOffset + 14);
- fwrite($fh, pack('V', $crc));
- fseek($fh, $entryOffset + ZIP_LOCAL_FIXED + strlen($name) + 4);
- fwrite($fh, pack('P', $size) . pack('P', $size));
- fseek($fh, 0, SEEK_END);
- }
- /**
- * One central directory entry.
- *
- * $entry: ['name' => string, 'crc' => int, 'size' => int, 'offset' => int,
- * 'mtime' => int] — offset being the entry's local header position.
- *
- * ZIP64 fields appear only when a value genuinely overflows 32 bits, and the
- * spec requires them in a fixed order (uncompressed, compressed, offset), each
- * present only if its fixed-record counterpart was set to 0xFFFFFFFF.
- */
- function zip_central_entry(array $entry): string
- {
- [$time, $date] = zip_dos_time($entry['mtime']);
- $name = $entry['name'];
- $size = $entry['size'];
- $offset = $entry['offset'];
- $bigSize = $size >= ZIP_MAX32;
- $bigOffset = $offset >= ZIP_MAX32;
- $extra = '';
- if ($bigSize) {
- $extra .= pack('P', $size) . pack('P', $size);
- }
- if ($bigOffset) {
- $extra .= pack('P', $offset);
- }
- if ($extra !== '') {
- $extra = pack('v', 0x0001) . pack('v', strlen($extra)) . $extra;
- }
- return pack('V', 0x02014b50) // central file header signature
- . pack('v', 45) // version made by: 4.5, MS-DOS
- . pack('v', 45) // version needed to extract
- . pack('v', 0x0800) // flags: UTF-8 filename
- . pack('v', 0) // method: stored
- . pack('v', $time)
- . pack('v', $date)
- . pack('V', $entry['crc'])
- . pack('V', $bigSize ? ZIP_MAX32 : $size)
- . pack('V', $bigSize ? ZIP_MAX32 : $size)
- . pack('v', strlen($name))
- . pack('v', strlen($extra))
- . pack('v', 0) // file comment length
- . pack('v', 0) // disk number start
- . pack('v', 0) // internal file attributes
- . pack('V', 0) // external file attributes
- . pack('V', $bigOffset ? ZIP_MAX32 : $offset)
- . $name
- . $extra;
- }
- /**
- * The archive trailer: ZIP64 end-of-central-directory record, its locator, and
- * the classic EOCD.
- *
- * The ZIP64 pair is always emitted. A reader that predates ZIP64 skips straight
- * to the classic EOCD at the end of the file and works as long as nothing
- * overflows; one that supports ZIP64 finds the locator immediately before it and
- * gets the wide values. Emitting both is what lets a 300 MB archive and a 40 GB
- * archive share a single code path.
- */
- function zip_end_of_central_directory(int $count, int $cdSize, int $cdOffset): string
- {
- $zip64Eocd = pack('V', 0x06064b50) // ZIP64 EOCD signature
- . pack('P', 44) // size of the rest of this record
- . pack('v', 45) // version made by
- . pack('v', 45) // version needed
- . pack('V', 0) // this disk
- . pack('V', 0) // disk with start of CD
- . pack('P', $count) // entries on this disk
- . pack('P', $count) // entries total
- . pack('P', $cdSize)
- . pack('P', $cdOffset);
- $locator = pack('V', 0x07064b50) // ZIP64 EOCD locator signature
- . pack('V', 0) // disk with the ZIP64 EOCD
- . pack('P', $cdOffset + $cdSize) // its offset: right after the CD
- . pack('V', 1); // total number of disks
- $eocd = pack('V', 0x06054b50) // EOCD signature
- . pack('v', 0) // this disk
- . pack('v', 0) // disk with start of CD
- . pack('v', min($count, 0xFFFF))
- . pack('v', min($count, 0xFFFF))
- . pack('V', min($cdSize, ZIP_MAX32))
- . pack('V', min($cdOffset, ZIP_MAX32))
- . pack('v', 0); // archive comment length
- return $zip64Eocd . $locator . $eocd;
- }
- /**
- * Make $name unique within the archive, remembering what has been used.
- *
- * Gallery images keep their original filename while their S3 key gets a random
- * token, so two cameras both producing DSC_0001.jpg is entirely normal — and a
- * ZIP with duplicate names silently loses files on extraction.
- */
- function zip_dedupe_name(string $name, array &$seen): string
- {
- $key = strtolower($name);
- if (!isset($seen[$key])) {
- $seen[$key] = 1;
- return $name;
- }
- $ext = pathinfo($name, PATHINFO_EXTENSION);
- $base = $ext !== '' ? substr($name, 0, -(strlen($ext) + 1)) : $name;
- do {
- $candidate = $base . ' (' . (++$seen[$key]) . ')' . ($ext !== '' ? '.' . $ext : '');
- } while (isset($seen[strtolower($candidate)]));
- $seen[strtolower($candidate)] = 1;
- return $candidate;
- }
|