/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/preview/archive.mjs * Purpose : Browse inside zip/tar/tar.gz (+7z/rar via 7z binary), stream members * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile } from 'node:child_process'; import { createReadStream } from 'node:fs'; import { createGunzip } from 'node:zlib'; import { promisify } from 'node:util'; import { PassThrough } from 'node:stream'; import yauzl from 'yauzl'; import tarStream from 'tar-stream'; import { blobPath } from '../storage/blobs.mjs'; import { hasBin } from './thumbs.mjs'; const execFileP = promisify(execFile); const MAX_ENTRIES = 5000; /** Homebrew's sevenzip ships `7zz`; p7zip ships `7z`. Accept either. */ async function sevenZipBin() { if (await hasBin('7z')) return '7z'; if (await hasBin('7zz')) return '7zz'; return null; } const MAX_MEMBER_BYTES = 200 * 1024 * 1024; /** Archive kind from filename ('zip' | 'tar' | 'tgz' | '7z' | 'rar' | null). */ export function archiveKind(name) { const n = name.toLowerCase(); if (n.endsWith('.zip')) return 'zip'; if (n.endsWith('.tar.gz') || n.endsWith('.tgz')) return 'tgz'; if (n.endsWith('.tar')) return 'tar'; if (n.endsWith('.7z')) return '7z'; if (n.endsWith('.rar')) return 'rar'; return null; } function openZip(file) { return new Promise((resolve, reject) => { yauzl.open(file, { lazyEntries: true, autoClose: false }, (err, zip) => err ? reject(err) : resolve(zip)); }); } async function listZip(file) { const zip = await openZip(file); const entries = []; return new Promise((resolve, reject) => { zip.readEntry(); zip.on('entry', (entry) => { entries.push({ path: entry.fileName, dir: entry.fileName.endsWith('/'), size: entry.uncompressedSize, }); if (entries.length >= MAX_ENTRIES) { zip.close(); resolve({ entries, truncated: true }); return; } zip.readEntry(); }); zip.on('end', () => { zip.close(); resolve({ entries, truncated: false }); }); zip.on('error', reject); }); } function tarSource(file, kind) { const raw = createReadStream(file); return kind === 'tgz' ? raw.pipe(createGunzip()) : raw; } async function listTar(file, kind) { const extract = tarStream.extract(); const entries = []; tarSource(file, kind).pipe(extract); return new Promise((resolve, reject) => { extract.on('entry', (header, stream, next) => { entries.push({ path: header.name, dir: header.type === 'directory', size: header.size ?? 0, }); stream.resume(); if (entries.length >= MAX_ENTRIES) { extract.destroy(); resolve({ entries, truncated: true }); return; } stream.on('end', next); }); extract.on('finish', () => resolve({ entries, truncated: false })); extract.on('error', reject); }); } async function listWith7z(file) { const bin = await sevenZipBin(); if (!bin) return null; const { stdout } = await execFileP(bin, ['l', '-slt', '-ba', file], { timeout: 60_000, maxBuffer: 32 * 1024 * 1024, }); const entries = []; for (const block of stdout.split('\n\n')) { const get = (key) => block.match(new RegExp(`^${key} = (.*)$`, 'm'))?.[1]; const p = get('Path'); if (!p) continue; entries.push({ path: p.replaceAll('\\', '/'), dir: (get('Attributes') ?? '').includes('D') || get('Folder') === '+', size: Number(get('Size') ?? 0), }); if (entries.length >= MAX_ENTRIES) return { entries, truncated: true }; } return { entries, truncated: false }; } /** * List an archive's members. * @returns {Promise<{entries: {path,dir,size}[], truncated: boolean}|null>} * null when the format needs a missing external binary */ export async function listArchive(sha, name) { const kind = archiveKind(name); const file = blobPath(sha); if (kind === 'zip') return listZip(file); if (kind === 'tar' || kind === 'tgz') return listTar(file, kind); if (kind === '7z' || kind === 'rar') return listWith7z(file); return null; } /** * Stream a single member out of an archive (for inner-file preview). * @returns {Promise<{stream: NodeJS.ReadableStream, size: number}|null>} */ export async function extractMember(sha, name, memberPath) { const kind = archiveKind(name); const file = blobPath(sha); if (memberPath.includes('..')) return null; if (kind === 'zip') { const zip = await openZip(file); return new Promise((resolve, reject) => { zip.readEntry(); zip.on('entry', (entry) => { if (entry.fileName === memberPath && !entry.fileName.endsWith('/')) { if (entry.uncompressedSize > MAX_MEMBER_BYTES) { zip.close(); resolve(null); return; } zip.openReadStream(entry, (err, stream) => { if (err) { zip.close(); reject(err); return; } stream.on('end', () => zip.close()); resolve({ stream, size: entry.uncompressedSize }); }); } else { zip.readEntry(); } }); zip.on('end', () => { zip.close(); resolve(null); }); zip.on('error', reject); }); } if (kind === 'tar' || kind === 'tgz') { const extract = tarStream.extract(); tarSource(file, kind).pipe(extract); return new Promise((resolve, reject) => { extract.on('entry', (header, stream, next) => { if (header.name === memberPath && header.type === 'file') { if ((header.size ?? 0) > MAX_MEMBER_BYTES) { extract.destroy(); resolve(null); return; } resolve({ stream, size: header.size ?? 0 }); } else { stream.resume(); stream.on('end', next); } }); extract.on('finish', () => resolve(null)); extract.on('error', reject); }); } if (kind === '7z' || kind === 'rar') { const bin = await sevenZipBin(); if (!bin) return null; const child = execFile(bin, ['x', '-so', file, memberPath], { timeout: 5 * 60_000, maxBuffer: MAX_MEMBER_BYTES, }); const out = new PassThrough(); child.stdout.pipe(out); child.on('error', (err) => out.destroy(err)); return { stream: out, size: 0 }; } return null; }