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.3 KB · 156 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : cli/commands/transfer.mjs8 *  Purpose : Upload (chunked, recursive), download, one-way push sync9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { createHash } from 'node:crypto';14import { createWriteStream, createReadStream, existsSync, statSync, readdirSync, readFileSync } from 'node:fs';15import path from 'node:path';16import { pipeline } from 'node:stream/promises';17import { Readable } from 'node:stream';18import { c, EXIT, fail, fmtSize, progressBar, req, resolveRemote, ensureRemoteDir } from '../lib.mjs';1920const CHUNK = 8 * 1024 * 1024;2122function walkLocal(dir, prefix = '') {23  const out = [];24  for (const entry of readdirSync(dir, { withFileTypes: true })) {25    if (entry.name === '.DS_Store') continue;26    const full = path.join(dir, entry.name);27    const rel = prefix ? `${prefix}/${entry.name}` : entry.name;28    if (entry.isDirectory()) out.push(...walkLocal(full, rel));29    else if (entry.isFile()) out.push({ full, rel, size: statSync(full).size });30  }31  return out;32}3334async function uploadFile(cfg, localPath, parentId, relPath, size) {35  const init = await req(cfg, '/api/v1/upload/init', {36    method: 'POST',37    body: {38      parentId,39      name: path.basename(relPath),40      path: relPath.includes('/') ? relPath : undefined,41      size,42    },43  });44  const bar = progressBar(relPath, size);45  const nChunks = init.nChunks;46  for (let n = 0; n < nChunks; n += 1) {47    const start = n * CHUNK;48    const end = Math.min(start + CHUNK, size);49    const chunk = size === 0 ? Buffer.alloc(0) : await readSlice(localPath, start, end);50    await req(cfg, `/api/v1/upload/${init.uploadId}/chunk/${n}`, { method: 'PUT', rawBody: chunk });51    bar.update(end);52  }53  const { node } = await req(cfg, `/api/v1/upload/${init.uploadId}/complete`, {54    method: 'POST', body: { conflict: 'keep-both' },55  });56  bar.done();57  return node;58}5960function readSlice(file, start, end) {61  return new Promise((resolve, reject) => {62    const chunks = [];63    createReadStream(file, { start, end: end - 1 })64      .on('data', (d) => chunks.push(d))65      .on('end', () => resolve(Buffer.concat(chunks)))66      .on('error', reject);67  });68}6970/** spbdrive up <files...> [-d /remote/folder] */71export async function cmdUp(cfg, files, opts) {72  const destNode = await ensureRemoteDir(cfg, opts.dest ?? '/');73  let count = 0; let bytes = 0;74  for (const file of files) {75    if (!existsSync(file)) fail(`No such file: ${file}`, EXIT.USAGE);76    const stat = statSync(file);77    if (stat.isDirectory()) {78      const base = path.basename(path.resolve(file));79      for (const entry of walkLocal(file, base)) {80        await uploadFile(cfg, entry.full, destNode.id, entry.rel, entry.size);81        count += 1; bytes += entry.size;82      }83    } else {84      await uploadFile(cfg, file, destNode.id, path.basename(file), stat.size);85      count += 1; bytes += stat.size;86    }87  }88  console.log(c.green(`✓ Uploaded ${count} file(s), ${fmtSize(bytes)} → ${opts.dest ?? '/'}`));89}9091/** spbdrive down <remote-path> [local] */92export async function cmdDown(cfg, remotePath, local) {93  const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message, EXIT.FAIL));94  const isFolder = node.type === 'folder';95  const target = local ?? (isFolder ? `${node.name || 'drive'}.zip` : node.name);96  const url = isFolder ? `/api/v1/zip?ids=${node.id}` : `/dl/${node.id}`;97  const res = await req(cfg, url, { stream: true });98  if (!res.ok) fail(`Download failed: HTTP ${res.status}`, EXIT.NET);99  const total = Number(res.headers.get('content-length') ?? 0);100  const bar = progressBar(target, total);101  let done = 0;102  await pipeline(103    Readable.fromWeb(res.body),104    async function* (source) { for await (const chunk of source) { done += chunk.length; bar.update(done); yield chunk; } },105    createWriteStream(target),106  );107  bar.done();108  console.log(c.green(`✓ ${target} (${fmtSize(done)})`));109}110111/** spbdrive push <local-dir> <remote-dir> [--delete] — one-way mirror. */112export async function cmdPush(cfg, localDir, remoteDir, opts) {113  if (!existsSync(localDir) || !statSync(localDir).isDirectory()) {114    fail(`Not a directory: ${localDir}`, EXIT.USAGE);115  }116  const destRoot = await ensureRemoteDir(cfg, remoteDir);117  const localFiles = walkLocal(localDir);118  console.log(`${c.bold('push')} ${localDir} → ${remoteDir} (${localFiles.length} local files)`);119120  // Index remote subtree: path → node (size for cheap compare, sha when needed).121  const remoteIndex = new Map();122  const indexRemote = async (folderId, prefix) => {123    const { children } = await req(cfg, `/api/v1/nodes/${folderId}/children`);124    for (const child of children) {125      const rel = prefix ? `${prefix}/${child.name}` : child.name;126      if (child.type === 'folder') await indexRemote(child.id, rel);127      else remoteIndex.set(rel, child);128    }129  };130  await indexRemote(destRoot.id, '');131132  let uploaded = 0; let skipped = 0; let deleted = 0;133  for (const file of localFiles) {134    const remote = remoteIndex.get(file.rel);135    remoteIndex.delete(file.rel);136    if (remote && remote.size === file.size) {137      // Same size → compare content hash before skipping.138      const localSha = createHash('sha256').update(readFileSync(file.full)).digest('hex');139      const desc = await req(cfg, `/api/v1/preview/${remote.id}`);140      if (desc.sha === localSha) { skipped += 1; continue; }141    }142    if (remote) await req(cfg, `/api/v1/nodes/${remote.id}?force=true`, { method: 'DELETE' });143    await uploadFile(cfg, file.full, destRoot.id, file.rel, file.size);144    uploaded += 1;145  }146147  if (opts.delete) {148    for (const [rel, node] of remoteIndex) {149      await req(cfg, `/api/v1/nodes/${node.id}`, { method: 'DELETE' });150      console.log(c.dim(`  − ${rel} (trashed)`));151      deleted += 1;152    }153  }154  console.log(c.green(`✓ push done — ${uploaded} uploaded, ${skipped} unchanged${opts.delete ? `, ${deleted} removed` : ''}`));155}156