129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
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 };
|
|
}
|