// Lists and extracts files inside archives. ZIP/7Z/RAR go through 7-Zip (WASM, // in a worker thread); TAR and .tar.gz go through node-tar. Extracted entries // are cached under EXTRACT_DIR// and can always be re-extracted. const fs = require('fs'); const path = require('path'); const { Worker } = require('worker_threads'); const tar = require('tar'); const EXTRACT_DIR = path.resolve(process.env.EXTRACT_DIR || './extracted'); const TAR_FILE_TYPES = ['File', 'OldFile', 'ContiguousFile']; let worker = null; let nextId = 1; const pending = new Map(); function failPending(err) { for (const p of pending.values()) p.reject(err); pending.clear(); worker = null; } // The worker keeps the process alive only while a request is outstanding, so // an idle worker never stops Node from exiting. function sevenZip(message) { if (!worker) { worker = new Worker(path.join(__dirname, 'archiveWorker.js')); worker.on('message', ({ id, result, error }) => { const p = pending.get(id); pending.delete(id); if (pending.size === 0 && worker) worker.unref(); if (p) error ? p.reject(new Error(error)) : p.resolve(result); }); worker.on('error', failPending); worker.on('exit', () => failPending(new Error('Archive worker stopped'))); } return new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); worker.ref(); worker.postMessage({ id, ...message }); }); } const isTar = (format) => format === 'tar' || format === 'tgz'; const clean = (p) => p.replace(/^\.\//, ''); // Clutter archivers add (macOS resource forks, Finder/Explorer files). function isJunk(entryPath) { const parts = clean(entryPath).split('/'); const name = parts[parts.length - 1]; return parts.includes('__MACOSX') || name.startsWith('._') || name === '.DS_Store' || name === 'Thumbs.db'; } // Paths that could land outside the extraction folder are never used. function isSafe(entryPath) { return Boolean(entryPath) && !path.isAbsolute(entryPath) && !entryPath.split(/[\\/]/).includes('..'); } // file: { file_path, format }. Returns [{ path, size }] of real files. async function listEntries(file) { const abs = path.resolve(file.file_path); let raw; if (isTar(file.format)) { raw = []; await tar.t({ file: abs, onReadEntry: (e) => { if (TAR_FILE_TYPES.includes(e.type)) raw.push({ path: e.path, size: e.size }); }, }); } else { raw = await sevenZip({ op: 'list', archive: abs }); } return raw.filter((e) => isSafe(e.path) && !isJunk(e.path)); } function tempDir() { fs.mkdirSync(EXTRACT_DIR, { recursive: true }); return fs.mkdtempSync(path.join(EXTRACT_DIR, '.tmp-')); } // Extracts entries into destDir. Returns Map(entry path -> absolute path) for // the ones that came out; archive-level failures throw. async function extractMany(file, entryPaths, destDir) { const abs = path.resolve(file.file_path); const wanted = new Set(entryPaths.filter(isSafe)); if (isTar(file.format)) { await tar.x({ file: abs, cwd: destDir, filter: (p) => wanted.has(p) }); } else { const { code, error } = await sevenZip({ op: 'extract', archive: abs, entries: [...wanted], destDir }); if (code !== 0 && error) console.warn(`7-Zip reported a problem extracting from ${abs}: ${error}`); } const out = new Map(); for (const p of wanted) { const target = path.resolve(destDir, p); if (target.startsWith(destDir + path.sep) && fs.existsSync(target)) out.set(p, target); } return out; } function entryCachePath(fileId, entry) { return path.join(EXTRACT_DIR, String(fileId), `${entry.id}${path.extname(entry.path).toLowerCase()}`); } const inflight = new Map(); // file: { id, file_path, format }; entry: { id, path }. Returns the absolute // path of a cached copy, extracting it on first use. async function extractEntry(file, entry) { const dest = entryCachePath(file.id, entry); if (fs.existsSync(dest)) return dest; if (!inflight.has(dest)) { const job = (async () => { fs.mkdirSync(path.dirname(dest), { recursive: true }); const tmp = tempDir(); try { const got = (await extractMany(file, [entry.path], tmp)).get(entry.path); if (!got) throw new Error(`Could not extract ${clean(entry.path)} from the archive`); fs.renameSync(got, dest); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } })().finally(() => inflight.delete(dest)); inflight.set(dest, job); } await inflight.get(dest); return dest; } function removeExtracted(fileId) { fs.rmSync(path.join(EXTRACT_DIR, String(fileId)), { recursive: true, force: true }); } module.exports = { listEntries, extractMany, extractEntry, removeExtracted, tempDir, isJunk };