zip.php 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. <?php
  2. /**
  3. * Minimal ZIP writer in plain PHP — no ZipArchive, no Composer.
  4. *
  5. * Only what a gallery archive needs: **stored** entries (method 0, no
  6. * compression) and ZIP64. Photos are JPEG/RAW and do not compress, so deflating
  7. * them would burn CPU for nothing; storing them means an entry's bytes are a
  8. * byte-for-byte copy of the S3 object, which is what lets the builder stream
  9. * each photo straight through (see app/archive.php).
  10. *
  11. * The functions here only produce byte strings — they never touch S3, and the
  12. * only file handle they see is the one zip_patch_local_header() writes back to.
  13. *
  14. * Layout produced:
  15. *
  16. * [local header][file bytes] … [central directory][ZIP64 EOCD + locator][EOCD]
  17. *
  18. * Local headers are written **before** the file's bytes are known, so their CRC
  19. * and size fields start as placeholders and are patched afterwards
  20. * (zip_patch_local_header). That is why every local header carries a ZIP64 extra
  21. * field unconditionally: the layout has to be fixed before the size is known,
  22. * and a >4 GB entry must not require a different one. Central directory entries
  23. * are built at the end, when the real values are known, so those use ZIP64 only
  24. * where a field actually overflows.
  25. *
  26. * No data descriptors are used (the patch makes them unnecessary), which keeps
  27. * the output readable by the widest range of tools.
  28. */
  29. declare(strict_types=1);
  30. /** Fixed part of a local file header, before filename and extra field. */
  31. const ZIP_LOCAL_FIXED = 30;
  32. /** ZIP64 extra field in a local header: id + size + 8-byte sizes. */
  33. const ZIP_ZIP64_EXTRA = 20;
  34. /** Value meaning "look in the ZIP64 extra field for the real number". */
  35. const ZIP_MAX32 = 0xFFFFFFFF;
  36. /** Convert a Unix timestamp to the DOS date/time pair ZIP stores. */
  37. function zip_dos_time(int $timestamp): array
  38. {
  39. $t = getdate($timestamp);
  40. // DOS time cannot represent anything before 1980; clamp rather than wrap.
  41. if ($t['year'] < 1980) {
  42. return [0, 0x21]; // 1980-01-01 00:00:00
  43. }
  44. $time = ($t['hours'] << 11) | ($t['minutes'] << 5) | (int)($t['seconds'] / 2);
  45. $date = (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday'];
  46. return [$time, $date];
  47. }
  48. /**
  49. * Total length of the local header for $name, i.e. how far the file's bytes sit
  50. * from the start of the entry. Callers need this to track archive offsets.
  51. */
  52. function zip_local_header_size(string $name): int
  53. {
  54. return ZIP_LOCAL_FIXED + strlen($name) + ZIP_ZIP64_EXTRA;
  55. }
  56. /**
  57. * Local file header with placeholder CRC and sizes, to be patched once the
  58. * file's bytes have been written (zip_patch_local_header).
  59. *
  60. * Flag bit 11 marks the filename as UTF-8 so "Straße.jpg" survives; without it
  61. * readers fall back to CP437 and mangle anything non-ASCII.
  62. */
  63. function zip_local_header(string $name, int $mtime): string
  64. {
  65. [$time, $date] = zip_dos_time($mtime);
  66. return pack('V', 0x04034b50) // local file header signature
  67. . pack('v', 45) // version needed to extract: 4.5 (ZIP64)
  68. . pack('v', 0x0800) // flags: UTF-8 filename
  69. . pack('v', 0) // method: stored
  70. . pack('v', $time)
  71. . pack('v', $date)
  72. . pack('V', 0) // CRC-32 — patched later
  73. . pack('V', ZIP_MAX32) // compressed size → ZIP64 extra
  74. . pack('V', ZIP_MAX32) // uncompressed size → ZIP64 extra
  75. . pack('v', strlen($name))
  76. . pack('v', ZIP_ZIP64_EXTRA)
  77. . $name
  78. . pack('v', 0x0001) // ZIP64 extended information extra field
  79. . pack('v', 16) // ... holding two 8-byte sizes
  80. . pack('P', 0) // uncompressed size — patched later
  81. . pack('P', 0); // compressed size — patched later
  82. }
  83. /**
  84. * Fill in the CRC and size a local header was written without.
  85. *
  86. * $entryOffset is the position of the header's signature within $fh. The handle
  87. * is left at end-of-file so the caller can carry on appending.
  88. *
  89. * @param resource $fh
  90. */
  91. function zip_patch_local_header($fh, int $entryOffset, string $name, int $crc, int $size): void
  92. {
  93. // CRC-32 sits 14 bytes into the fixed header; the two ZIP64 sizes sit 4
  94. // bytes into the extra field, which follows the filename.
  95. fseek($fh, $entryOffset + 14);
  96. fwrite($fh, pack('V', $crc));
  97. fseek($fh, $entryOffset + ZIP_LOCAL_FIXED + strlen($name) + 4);
  98. fwrite($fh, pack('P', $size) . pack('P', $size));
  99. fseek($fh, 0, SEEK_END);
  100. }
  101. /**
  102. * One central directory entry.
  103. *
  104. * $entry: ['name' => string, 'crc' => int, 'size' => int, 'offset' => int,
  105. * 'mtime' => int] — offset being the entry's local header position.
  106. *
  107. * ZIP64 fields appear only when a value genuinely overflows 32 bits, and the
  108. * spec requires them in a fixed order (uncompressed, compressed, offset), each
  109. * present only if its fixed-record counterpart was set to 0xFFFFFFFF.
  110. */
  111. function zip_central_entry(array $entry): string
  112. {
  113. [$time, $date] = zip_dos_time($entry['mtime']);
  114. $name = $entry['name'];
  115. $size = $entry['size'];
  116. $offset = $entry['offset'];
  117. $bigSize = $size >= ZIP_MAX32;
  118. $bigOffset = $offset >= ZIP_MAX32;
  119. $extra = '';
  120. if ($bigSize) {
  121. $extra .= pack('P', $size) . pack('P', $size);
  122. }
  123. if ($bigOffset) {
  124. $extra .= pack('P', $offset);
  125. }
  126. if ($extra !== '') {
  127. $extra = pack('v', 0x0001) . pack('v', strlen($extra)) . $extra;
  128. }
  129. return pack('V', 0x02014b50) // central file header signature
  130. . pack('v', 45) // version made by: 4.5, MS-DOS
  131. . pack('v', 45) // version needed to extract
  132. . pack('v', 0x0800) // flags: UTF-8 filename
  133. . pack('v', 0) // method: stored
  134. . pack('v', $time)
  135. . pack('v', $date)
  136. . pack('V', $entry['crc'])
  137. . pack('V', $bigSize ? ZIP_MAX32 : $size)
  138. . pack('V', $bigSize ? ZIP_MAX32 : $size)
  139. . pack('v', strlen($name))
  140. . pack('v', strlen($extra))
  141. . pack('v', 0) // file comment length
  142. . pack('v', 0) // disk number start
  143. . pack('v', 0) // internal file attributes
  144. . pack('V', 0) // external file attributes
  145. . pack('V', $bigOffset ? ZIP_MAX32 : $offset)
  146. . $name
  147. . $extra;
  148. }
  149. /**
  150. * The archive trailer: ZIP64 end-of-central-directory record, its locator, and
  151. * the classic EOCD.
  152. *
  153. * The ZIP64 pair is always emitted. A reader that predates ZIP64 skips straight
  154. * to the classic EOCD at the end of the file and works as long as nothing
  155. * overflows; one that supports ZIP64 finds the locator immediately before it and
  156. * gets the wide values. Emitting both is what lets a 300 MB archive and a 40 GB
  157. * archive share a single code path.
  158. */
  159. function zip_end_of_central_directory(int $count, int $cdSize, int $cdOffset): string
  160. {
  161. $zip64Eocd = pack('V', 0x06064b50) // ZIP64 EOCD signature
  162. . pack('P', 44) // size of the rest of this record
  163. . pack('v', 45) // version made by
  164. . pack('v', 45) // version needed
  165. . pack('V', 0) // this disk
  166. . pack('V', 0) // disk with start of CD
  167. . pack('P', $count) // entries on this disk
  168. . pack('P', $count) // entries total
  169. . pack('P', $cdSize)
  170. . pack('P', $cdOffset);
  171. $locator = pack('V', 0x07064b50) // ZIP64 EOCD locator signature
  172. . pack('V', 0) // disk with the ZIP64 EOCD
  173. . pack('P', $cdOffset + $cdSize) // its offset: right after the CD
  174. . pack('V', 1); // total number of disks
  175. $eocd = pack('V', 0x06054b50) // EOCD signature
  176. . pack('v', 0) // this disk
  177. . pack('v', 0) // disk with start of CD
  178. . pack('v', min($count, 0xFFFF))
  179. . pack('v', min($count, 0xFFFF))
  180. . pack('V', min($cdSize, ZIP_MAX32))
  181. . pack('V', min($cdOffset, ZIP_MAX32))
  182. . pack('v', 0); // archive comment length
  183. return $zip64Eocd . $locator . $eocd;
  184. }
  185. /**
  186. * Make $name unique within the archive, remembering what has been used.
  187. *
  188. * Gallery images keep their original filename while their S3 key gets a random
  189. * token, so two cameras both producing DSC_0001.jpg is entirely normal — and a
  190. * ZIP with duplicate names silently loses files on extraction.
  191. */
  192. function zip_dedupe_name(string $name, array &$seen): string
  193. {
  194. $key = strtolower($name);
  195. if (!isset($seen[$key])) {
  196. $seen[$key] = 1;
  197. return $name;
  198. }
  199. $ext = pathinfo($name, PATHINFO_EXTENSION);
  200. $base = $ext !== '' ? substr($name, 0, -(strlen($ext) + 1)) : $name;
  201. do {
  202. $candidate = $base . ' (' . (++$seen[$key]) . ')' . ($ext !== '' ? '.' . $ext : '');
  203. } while (isset($seen[strtolower($candidate)]));
  204. $seen[strtolower($candidate)] = 1;
  205. return $candidate;
  206. }