printhive/lib/archiveWorker.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

82 lines
2.8 KiB
JavaScript

// Worker thread running 7-Zip (compiled to WASM) for ZIP/7Z/RAR archives.
// 7-Zip calls are synchronous, so they run here to keep the server responsive.
const { parentPort } = require('worker_threads');
const SevenZip = require('7z-wasm');
let sevenZip = null;
const output = [];
// The host filesystem is mounted at /host inside the WASM filesystem.
const host = (p) => `/host${p}`;
async function instance() {
if (!sevenZip) {
sevenZip = await SevenZip({ print: (l) => output.push(l), printErr: (l) => output.push(l) });
sevenZip.FS.mkdir('/host');
sevenZip.FS.mount(sevenZip.NODEFS, { root: '/' }, '/host');
}
return sevenZip;
}
async function run(args) {
const sz = await instance();
output.length = 0;
let code;
try {
code = sz.callMain(args);
} catch (err) {
code = err && typeof err.status === 'number' ? err.status : 2;
}
return { code, lines: output.slice() };
}
function errorText(lines) {
const line = lines.find((l) => /error|wrong password|can ?not open|unsupported|headers/i.test(l));
return line ? line.trim() : null;
}
// Parses `7zz l -slt` into [{ path, size }] for files (folders are dropped).
function parseListing(lines) {
const start = lines.findIndex((l) => l.startsWith('----------'));
if (start === -1) return null;
const entries = [];
let cur = {};
const flush = () => {
const isFolder = cur.Folder === '+' || (cur.Attributes || '').startsWith('D');
if (cur.Path !== undefined && !isFolder) entries.push({ path: cur.Path, size: Number(cur.Size) || 0 });
cur = {};
};
for (const line of lines.slice(start + 1)) {
if (!line.trim()) { flush(); continue; }
const i = line.indexOf(' = ');
if (i > 0) cur[line.slice(0, i)] = line.slice(i + 3);
}
flush();
return entries;
}
async function list(archive) {
// A dummy password makes encrypted archives fail fast instead of prompting.
const { code, lines } = await run(['l', '-slt', '-pprinthive', host(archive)]);
const entries = parseListing(lines);
if (code !== 0 || !entries) throw new Error(errorText(lines) || `7-Zip could not read the archive (exit ${code})`);
return entries;
}
// Extracts the named entries (keeping their folders) under destDir. Callers
// check which files actually arrived.
async function extract(archive, entries, destDir) {
const { code, lines } = await run(
['x', host(archive), `-o${host(destDir)}`, '-y', '-spd', '-bsp0', '-pprinthive', '--', ...entries]);
return { code, error: code === 0 ? null : errorText(lines) };
}
parentPort.on('message', async ({ id, op, archive, entries, destDir }) => {
try {
const result = op === 'list' ? await list(archive) : await extract(archive, entries, destDir);
parentPort.postMessage({ id, result });
} catch (err) {
parentPort.postMessage({ id, error: err.message });
}
});