spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/preview/archive.mjs8 * Purpose : Browse inside zip/tar/tar.gz (+7z/rar via 7z binary), stream members9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { execFile } from 'node:child_process';14import { createReadStream } from 'node:fs';15import { createGunzip } from 'node:zlib';16import { promisify } from 'node:util';17import { PassThrough } from 'node:stream';18import yauzl from 'yauzl';19import tarStream from 'tar-stream';20import { blobPath } from '../storage/blobs.mjs';21import { hasBin } from './thumbs.mjs';2223const execFileP = promisify(execFile);24const MAX_ENTRIES = 5000;2526/** Homebrew's sevenzip ships `7zz`; p7zip ships `7z`. Accept either. */27async function sevenZipBin() {28 if (await hasBin('7z')) return '7z';29 if (await hasBin('7zz')) return '7zz';30 return null;31}32const MAX_MEMBER_BYTES = 200 * 1024 * 1024;3334/** Archive kind from filename ('zip' | 'tar' | 'tgz' | '7z' | 'rar' | null). */35export function archiveKind(name) {36 const n = name.toLowerCase();37 if (n.endsWith('.zip')) return 'zip';38 if (n.endsWith('.tar.gz') || n.endsWith('.tgz')) return 'tgz';39 if (n.endsWith('.tar')) return 'tar';40 if (n.endsWith('.7z')) return '7z';41 if (n.endsWith('.rar')) return 'rar';42 return null;43}4445function openZip(file) {46 return new Promise((resolve, reject) => {47 yauzl.open(file, { lazyEntries: true, autoClose: false }, (err, zip) =>48 err ? reject(err) : resolve(zip));49 });50}5152async function listZip(file) {53 const zip = await openZip(file);54 const entries = [];55 return new Promise((resolve, reject) => {56 zip.readEntry();57 zip.on('entry', (entry) => {58 entries.push({59 path: entry.fileName,60 dir: entry.fileName.endsWith('/'),61 size: entry.uncompressedSize,62 });63 if (entries.length >= MAX_ENTRIES) {64 zip.close();65 resolve({ entries, truncated: true });66 return;67 }68 zip.readEntry();69 });70 zip.on('end', () => { zip.close(); resolve({ entries, truncated: false }); });71 zip.on('error', reject);72 });73}7475function tarSource(file, kind) {76 const raw = createReadStream(file);77 return kind === 'tgz' ? raw.pipe(createGunzip()) : raw;78}7980async function listTar(file, kind) {81 const extract = tarStream.extract();82 const entries = [];83 tarSource(file, kind).pipe(extract);84 return new Promise((resolve, reject) => {85 extract.on('entry', (header, stream, next) => {86 entries.push({87 path: header.name,88 dir: header.type === 'directory',89 size: header.size ?? 0,90 });91 stream.resume();92 if (entries.length >= MAX_ENTRIES) {93 extract.destroy();94 resolve({ entries, truncated: true });95 return;96 }97 stream.on('end', next);98 });99 extract.on('finish', () => resolve({ entries, truncated: false }));100 extract.on('error', reject);101 });102}103104async function listWith7z(file) {105 const bin = await sevenZipBin();106 if (!bin) return null;107 const { stdout } = await execFileP(bin, ['l', '-slt', '-ba', file], {108 timeout: 60_000, maxBuffer: 32 * 1024 * 1024,109 });110 const entries = [];111 for (const block of stdout.split('\n\n')) {112 const get = (key) => block.match(new RegExp(`^${key} = (.*)$`, 'm'))?.[1];113 const p = get('Path');114 if (!p) continue;115 entries.push({116 path: p.replaceAll('\\', '/'),117 dir: (get('Attributes') ?? '').includes('D') || get('Folder') === '+',118 size: Number(get('Size') ?? 0),119 });120 if (entries.length >= MAX_ENTRIES) return { entries, truncated: true };121 }122 return { entries, truncated: false };123}124125/**126 * List an archive's members.127 * @returns {Promise<{entries: {path,dir,size}[], truncated: boolean}|null>}128 * null when the format needs a missing external binary129 */130export async function listArchive(sha, name) {131 const kind = archiveKind(name);132 const file = blobPath(sha);133 if (kind === 'zip') return listZip(file);134 if (kind === 'tar' || kind === 'tgz') return listTar(file, kind);135 if (kind === '7z' || kind === 'rar') return listWith7z(file);136 return null;137}138139/**140 * Stream a single member out of an archive (for inner-file preview).141 * @returns {Promise<{stream: NodeJS.ReadableStream, size: number}|null>}142 */143export async function extractMember(sha, name, memberPath) {144 const kind = archiveKind(name);145 const file = blobPath(sha);146 if (memberPath.includes('..')) return null;147148 if (kind === 'zip') {149 const zip = await openZip(file);150 return new Promise((resolve, reject) => {151 zip.readEntry();152 zip.on('entry', (entry) => {153 if (entry.fileName === memberPath && !entry.fileName.endsWith('/')) {154 if (entry.uncompressedSize > MAX_MEMBER_BYTES) { zip.close(); resolve(null); return; }155 zip.openReadStream(entry, (err, stream) => {156 if (err) { zip.close(); reject(err); return; }157 stream.on('end', () => zip.close());158 resolve({ stream, size: entry.uncompressedSize });159 });160 } else {161 zip.readEntry();162 }163 });164 zip.on('end', () => { zip.close(); resolve(null); });165 zip.on('error', reject);166 });167 }168169 if (kind === 'tar' || kind === 'tgz') {170 const extract = tarStream.extract();171 tarSource(file, kind).pipe(extract);172 return new Promise((resolve, reject) => {173 extract.on('entry', (header, stream, next) => {174 if (header.name === memberPath && header.type === 'file') {175 if ((header.size ?? 0) > MAX_MEMBER_BYTES) { extract.destroy(); resolve(null); return; }176 resolve({ stream, size: header.size ?? 0 });177 } else {178 stream.resume();179 stream.on('end', next);180 }181 });182 extract.on('finish', () => resolve(null));183 extract.on('error', reject);184 });185 }186187 if (kind === '7z' || kind === 'rar') {188 const bin = await sevenZipBin();189 if (!bin) return null;190 const child = execFile(bin, ['x', '-so', file, memberPath], {191 timeout: 5 * 60_000, maxBuffer: MAX_MEMBER_BYTES,192 });193 const out = new PassThrough();194 child.stdout.pipe(out);195 child.on('error', (err) => out.destroy(err));196 return { stream: out, size: 0 };197 }198199 return null;200}201