Archives - ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for tar). macOS/Windows clutter and paths that could escape the extraction folder are skipped. - Models inside archives are extracted once per archive to hash and geometry-fingerprint them, so duplicates are found inside packs too. Pictures, PDFs and readmes are listed but never matched. - New routes list an archive's entries and serve/convert a single entry, extracted on first use and cached under extracted/. Viewer - Model detail lists what's inside each archive with view/download links; opening an entry adds a viewer tab. - New 3D formats: FBX (with sibling textures), OBJ with its MTL and textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the WASM readers are served from node_modules, loaded on first use. - Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md) can be uploaded and preview in the viewer; a model without an embedded 3MF preview uses its first picture as the card thumbnail. - Inline file responses send nosniff, and SVGs are sandboxed. - GLB conversions write to a temp file and rename, so a half-written preview is never served; a later tab can't be overdrawn by a slow load. - Better 3MF card image: the project thumbnail or plate 1 render, not a slicer pick map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.7 KiB
JavaScript
133 lines
4.7 KiB
JavaScript
// 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/<file id>/ 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 };
|