printhive/lib/archives.js
dlawler489 d4b7e29348 Browse archives, preview companion files, and view FBX/STEP/3DM/OBJ+MTL
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>
2026-09-15 07:36:47 +10:00

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 };