| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- <?php
- // Pure-PHP ZIP writer for backups. Builds the archive with pack() rather than
- // requiring ext-zip, exec or a temp copy of the whole archive in memory, and
- // reports file count, source size and SHA-256 in the same pass — the backup
- // index and the upload metadata both need those numbers.
- //
- // Deflate compression (method 8) is optional: storing everything uncompressed
- // is fine for the product JPEGs, wasteful for the JSON data files.
- //
- // Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
- declare(strict_types=1);
- function manageZipDosDateTime(int $timestamp): array
- {
- $parts = getdate($timestamp);
- $year = max(1980, (int) $parts['year']);
- return [
- (($year - 1980) << 9) | ((int) $parts['mon'] << 5) | (int) $parts['mday'],
- ((int) $parts['hours'] << 11) |
- ((int) $parts['minutes'] << 5) |
- ((int) floor(((int) $parts['seconds']) / 2)),
- ];
- }
- function manageZipValidateEntryName(string $name): void
- {
- $name = manageClientNormalizePath($name);
- if (
- $name === '' ||
- str_contains($name, "\0") ||
- str_starts_with($name, '/') ||
- preg_match('/^[A-Za-z]:\//', $name) === 1
- ) {
- throw new RuntimeException('Ungültiger Pfad im Backup: ' . $name);
- }
- foreach (explode('/', $name) as $segment) {
- if ($segment === '' || $segment === '.' || $segment === '..') {
- throw new RuntimeException('Ungültiger Pfad im Backup: ' . $name);
- }
- }
- if (strlen($name) > 65535) {
- throw new RuntimeException('Pfad im Backup ist zu lang: ' . $name);
- }
- }
- function manageZipWriteBytes($handle, string $data): void
- {
- $offset = 0;
- $length = strlen($data);
- while ($offset < $length) {
- $written = fwrite($handle, substr($data, $offset));
- if ($written === false || $written === 0) {
- throw new RuntimeException('Backup-ZIP konnte nicht geschrieben werden.');
- }
- $offset += $written;
- }
- }
- // Streams a stored (uncompressed) entry in 1 MiB chunks, so archive size is
- // never bounded by memory_limit.
- function manageZipCopyStored(string $file, $handle): void
- {
- $source = fopen($file, 'rb');
- if ($source === false) {
- throw new RuntimeException('Datei konnte nicht gelesen werden: ' . basename($file));
- }
- try {
- while (!feof($source)) {
- $chunk = fread($source, 1048576);
- if ($chunk === false) {
- throw new RuntimeException('Datei konnte nicht gelesen werden: ' . basename($file));
- }
- if ($chunk !== '') {
- manageZipWriteBytes($handle, $chunk);
- }
- }
- } finally {
- fclose($source);
- }
- }
- // Deflates an entry with an incremental zlib stream, again without ever holding
- // the whole file in memory. Returns the compressed size.
- function manageZipCopyDeflated(string $file, $handle): int
- {
- $source = fopen($file, 'rb');
- if ($source === false) {
- throw new RuntimeException('Datei konnte nicht gelesen werden: ' . basename($file));
- }
- // Raw deflate (window -15) is what a ZIP entry with method 8 expects.
- $deflate = deflate_init(ZLIB_ENCODING_RAW, ['level' => 6]);
- if ($deflate === false) {
- fclose($source);
- throw new RuntimeException('Kompression konnte nicht initialisiert werden.');
- }
- $compressedSize = 0;
- try {
- while (!feof($source)) {
- $chunk = fread($source, 1048576);
- if ($chunk === false) {
- throw new RuntimeException('Datei konnte nicht gelesen werden: ' . basename($file));
- }
- if ($chunk === '') {
- continue;
- }
- $encoded = deflate_add($deflate, $chunk, ZLIB_NO_FLUSH);
- if ($encoded === false) {
- throw new RuntimeException('Kompression fehlgeschlagen: ' . basename($file));
- }
- if ($encoded !== '') {
- manageZipWriteBytes($handle, $encoded);
- $compressedSize += strlen($encoded);
- }
- }
- $encoded = deflate_add($deflate, '', ZLIB_FINISH);
- if ($encoded === false) {
- throw new RuntimeException('Kompression fehlgeschlagen: ' . basename($file));
- }
- if ($encoded !== '') {
- manageZipWriteBytes($handle, $encoded);
- $compressedSize += strlen($encoded);
- }
- } finally {
- fclose($source);
- }
- return $compressedSize;
- }
- function manageZipCompressionAvailable(): bool
- {
- return MANAGE_BACKUP_COMPRESS === true &&
- function_exists('deflate_init') &&
- function_exists('deflate_add');
- }
- /**
- * Writes a ZIP archive.
- *
- * @param string $targetFile absolute path of the archive to create
- * @param array $files list of ['path' => absolute, 'name' => entry name]
- *
- * @return array{file_count: int, source_bytes: int, archive_bytes: int, sha256: string}
- */
- function manageZipWrite(string $targetFile, array $files): array
- {
- if ($files === []) {
- throw new RuntimeException('Keine Dateien für das Backup gefunden.');
- }
- $handle = fopen($targetFile, 'wb');
- if ($handle === false) {
- throw new RuntimeException('Backup-ZIP konnte nicht erstellt werden.');
- }
- $compress = manageZipCompressionAvailable();
- $centralDirectory = '';
- $fileCount = 0;
- $sourceBytes = 0;
- try {
- foreach ($files as $file) {
- $path = (string) ($file['path'] ?? '');
- $name = manageClientNormalizePath((string) ($file['name'] ?? ''));
- manageZipValidateEntryName($name);
- if (!is_file($path) || !is_readable($path)) {
- continue;
- }
- $size = filesize($path);
- if ($size === false) {
- throw new RuntimeException('Dateigröße konnte nicht ermittelt werden: ' . $name);
- }
- if ($size > 0xffffffff) {
- throw new RuntimeException('Datei ist zu groß für dieses Backup-Format: ' . $name);
- }
- $offset = ftell($handle);
- if ($offset === false || $offset > 0xffffffff) {
- throw new RuntimeException('Backup-ZIP ist zu groß für dieses Backup-Format.');
- }
- $crcHex = hash_file('crc32b', $path);
- if (!is_string($crcHex) || preg_match('/^[a-f0-9]{8}$/i', $crcHex) !== 1) {
- throw new RuntimeException('Prüfsumme konnte nicht berechnet werden: ' . $name);
- }
- $crc = (int) hexdec($crcHex);
- [$dosDate, $dosTime] = manageZipDosDateTime((int) (filemtime($path) ?: time()));
- $nameLength = strlen($name);
- // An empty file must stay stored: deflate would emit a 2-byte body
- // for zero input, which some readers reject.
- $useDeflate = $compress && $size > 0;
- $method = $useDeflate ? 8 : 0;
- // The local header needs the compressed size up front, which is not
- // known before compressing. The header is therefore written with a
- // placeholder and patched after the body, exactly like a two-pass
- // writer; seeking is safe because the target is a real file.
- manageZipWriteBytes(
- $handle,
- pack(
- 'VvvvvvVVVvv',
- 0x04034b50,
- $useDeflate ? 20 : 10,
- 0,
- $method,
- $dosTime,
- $dosDate,
- $crc,
- 0,
- $size,
- $nameLength,
- 0
- ) . $name
- );
- if ($useDeflate) {
- $compressedSize = manageZipCopyDeflated($path, $handle);
- } else {
- manageZipCopyStored($path, $handle);
- $compressedSize = $size;
- }
- if ($compressedSize > 0xffffffff) {
- throw new RuntimeException('Datei ist zu groß für dieses Backup-Format: ' . $name);
- }
- if ($useDeflate) {
- $afterEntry = ftell($handle);
- if ($afterEntry === false) {
- throw new RuntimeException('Backup-ZIP konnte nicht geschrieben werden.');
- }
- // Compressed size sits 18 bytes into the local file header.
- if (fseek($handle, $offset + 18) !== 0) {
- throw new RuntimeException('Backup-ZIP konnte nicht aktualisiert werden.');
- }
- manageZipWriteBytes($handle, pack('V', $compressedSize));
- if (fseek($handle, $afterEntry) !== 0) {
- throw new RuntimeException('Backup-ZIP konnte nicht aktualisiert werden.');
- }
- }
- $centralDirectory .=
- pack(
- 'VvvvvvvVVVvvvvvVV',
- 0x02014b50,
- 0x031e,
- $useDeflate ? 20 : 10,
- 0,
- $method,
- $dosTime,
- $dosDate,
- $crc,
- $compressedSize,
- $size,
- $nameLength,
- 0,
- 0,
- 0,
- 0,
- 0,
- $offset
- ) .
- $name;
- $fileCount++;
- $sourceBytes += $size;
- }
- if ($fileCount < 1) {
- throw new RuntimeException('Keine lesbaren Dateien für das Backup gefunden.');
- }
- if ($fileCount > 65535) {
- throw new RuntimeException('Zu viele Dateien für dieses Backup-Format.');
- }
- $centralOffset = ftell($handle);
- $centralSize = strlen($centralDirectory);
- if (
- $centralOffset === false ||
- $centralOffset > 0xffffffff ||
- $centralSize > 0xffffffff
- ) {
- throw new RuntimeException('Backup-ZIP ist zu groß für dieses Backup-Format.');
- }
- manageZipWriteBytes($handle, $centralDirectory);
- manageZipWriteBytes(
- $handle,
- pack(
- 'VvvvvVVv',
- 0x06054b50,
- 0,
- 0,
- $fileCount,
- $fileCount,
- $centralSize,
- $centralOffset,
- 0
- )
- );
- } catch (Throwable $exception) {
- fclose($handle);
- @unlink($targetFile);
- throw $exception;
- }
- fclose($handle);
- @chmod($targetFile, 0660);
- return [
- 'file_count' => $fileCount,
- 'source_bytes' => $sourceBytes,
- 'archive_bytes' => (int) (filesize($targetFile) ?: 0),
- 'sha256' => hash_file('sha256', $targetFile) ?: '',
- ];
- }
|