| import { inflateRawSync } from "node:zlib"; |
| import path from "node:path"; |
| import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; |
|
|
| const textDecoder = new TextDecoder(); |
|
|
| export const binaryContentTypeByExtension: Record<string, string> = { |
| ".gif": "image/gif", |
| ".jpeg": "image/jpeg", |
| ".jpg": "image/jpeg", |
| ".png": "image/png", |
| ".svg": "image/svg+xml", |
| ".webp": "image/webp", |
| }; |
|
|
| function normalizeArchivePath(pathValue: string) { |
| return pathValue |
| .replace(/\\/g, "/") |
| .split("/") |
| .filter(Boolean) |
| .join("/"); |
| } |
|
|
| function readUint16(source: Uint8Array, offset: number) { |
| return source[offset]! | (source[offset + 1]! << 8); |
| } |
|
|
| function readUint32(source: Uint8Array, offset: number) { |
| return ( |
| source[offset]! | |
| (source[offset + 1]! << 8) | |
| (source[offset + 2]! << 16) | |
| (source[offset + 3]! << 24) |
| ) >>> 0; |
| } |
|
|
| function sharedArchiveRoot(paths: string[]) { |
| if (paths.length === 0) return null; |
| const firstSegments = paths |
| .map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean)) |
| .filter((parts) => parts.length > 0); |
| if (firstSegments.length === 0) return null; |
| const candidate = firstSegments[0]![0]!; |
| return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate) |
| ? candidate |
| : null; |
| } |
|
|
| function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry { |
| const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()]; |
| if (!contentType) return textDecoder.decode(bytes); |
| return { |
| encoding: "base64", |
| data: Buffer.from(bytes).toString("base64"), |
| contentType, |
| }; |
| } |
|
|
| async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) { |
| if (compressionMethod === 0) return bytes; |
| if (compressionMethod !== 8) { |
| throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported."); |
| } |
| return new Uint8Array(inflateRawSync(bytes)); |
| } |
|
|
| export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{ |
| rootPath: string | null; |
| files: Record<string, CompanyPortabilityFileEntry>; |
| }> { |
| const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); |
| const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = []; |
| let offset = 0; |
|
|
| while (offset + 4 <= bytes.length) { |
| const signature = readUint32(bytes, offset); |
| if (signature === 0x02014b50 || signature === 0x06054b50) break; |
| if (signature !== 0x04034b50) { |
| throw new Error("Invalid zip archive: unsupported local file header."); |
| } |
|
|
| if (offset + 30 > bytes.length) { |
| throw new Error("Invalid zip archive: truncated local file header."); |
| } |
|
|
| const generalPurposeFlag = readUint16(bytes, offset + 6); |
| const compressionMethod = readUint16(bytes, offset + 8); |
| const compressedSize = readUint32(bytes, offset + 18); |
| const fileNameLength = readUint16(bytes, offset + 26); |
| const extraFieldLength = readUint16(bytes, offset + 28); |
|
|
| if ((generalPurposeFlag & 0x0008) !== 0) { |
| throw new Error("Unsupported zip archive: data descriptors are not supported."); |
| } |
|
|
| const nameOffset = offset + 30; |
| const bodyOffset = nameOffset + fileNameLength + extraFieldLength; |
| const bodyEnd = bodyOffset + compressedSize; |
| if (bodyEnd > bytes.length) { |
| throw new Error("Invalid zip archive: truncated file contents."); |
| } |
|
|
| const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)); |
| const archivePath = normalizeArchivePath(rawArchivePath); |
| const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/")); |
| if (archivePath && !isDirectoryEntry) { |
| const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd)); |
| entries.push({ |
| path: archivePath, |
| body: bytesToPortableFileEntry(archivePath, entryBytes), |
| }); |
| } |
|
|
| offset = bodyEnd; |
| } |
|
|
| const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path)); |
| const files: Record<string, CompanyPortabilityFileEntry> = {}; |
| for (const entry of entries) { |
| const normalizedPath = |
| rootPath && entry.path.startsWith(`${rootPath}/`) |
| ? entry.path.slice(rootPath.length + 1) |
| : entry.path; |
| if (!normalizedPath) continue; |
| files[normalizedPath] = entry.body; |
| } |
|
|
| return { rootPath, files }; |
| } |
|
|