SPB Git

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%
6.6 KB · 201 lines javascript
Raw Blame History
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