/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/commands/transfer.mjs * Purpose : Upload (chunked, recursive), download, one-way push sync * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createHash } from 'node:crypto'; import { createWriteStream, createReadStream, existsSync, statSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { Readable } from 'node:stream'; import { c, EXIT, fail, fmtSize, progressBar, req, resolveRemote, ensureRemoteDir } from '../lib.mjs'; const CHUNK = 8 * 1024 * 1024; function walkLocal(dir, prefix = '') { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === '.DS_Store') continue; const full = path.join(dir, entry.name); const rel = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) out.push(...walkLocal(full, rel)); else if (entry.isFile()) out.push({ full, rel, size: statSync(full).size }); } return out; } async function uploadFile(cfg, localPath, parentId, relPath, size) { const init = await req(cfg, '/api/v1/upload/init', { method: 'POST', body: { parentId, name: path.basename(relPath), path: relPath.includes('/') ? relPath : undefined, size, }, }); const bar = progressBar(relPath, size); const nChunks = init.nChunks; for (let n = 0; n < nChunks; n += 1) { const start = n * CHUNK; const end = Math.min(start + CHUNK, size); const chunk = size === 0 ? Buffer.alloc(0) : await readSlice(localPath, start, end); await req(cfg, `/api/v1/upload/${init.uploadId}/chunk/${n}`, { method: 'PUT', rawBody: chunk }); bar.update(end); } const { node } = await req(cfg, `/api/v1/upload/${init.uploadId}/complete`, { method: 'POST', body: { conflict: 'keep-both' }, }); bar.done(); return node; } function readSlice(file, start, end) { return new Promise((resolve, reject) => { const chunks = []; createReadStream(file, { start, end: end - 1 }) .on('data', (d) => chunks.push(d)) .on('end', () => resolve(Buffer.concat(chunks))) .on('error', reject); }); } /** spbdrive up [-d /remote/folder] */ export async function cmdUp(cfg, files, opts) { const destNode = await ensureRemoteDir(cfg, opts.dest ?? '/'); let count = 0; let bytes = 0; for (const file of files) { if (!existsSync(file)) fail(`No such file: ${file}`, EXIT.USAGE); const stat = statSync(file); if (stat.isDirectory()) { const base = path.basename(path.resolve(file)); for (const entry of walkLocal(file, base)) { await uploadFile(cfg, entry.full, destNode.id, entry.rel, entry.size); count += 1; bytes += entry.size; } } else { await uploadFile(cfg, file, destNode.id, path.basename(file), stat.size); count += 1; bytes += stat.size; } } console.log(c.green(`✓ Uploaded ${count} file(s), ${fmtSize(bytes)} → ${opts.dest ?? '/'}`)); } /** spbdrive down [local] */ export async function cmdDown(cfg, remotePath, local) { const node = await resolveRemote(cfg, remotePath).catch((e) => fail(e.message, EXIT.FAIL)); const isFolder = node.type === 'folder'; const target = local ?? (isFolder ? `${node.name || 'drive'}.zip` : node.name); const url = isFolder ? `/api/v1/zip?ids=${node.id}` : `/dl/${node.id}`; const res = await req(cfg, url, { stream: true }); if (!res.ok) fail(`Download failed: HTTP ${res.status}`, EXIT.NET); const total = Number(res.headers.get('content-length') ?? 0); const bar = progressBar(target, total); let done = 0; await pipeline( Readable.fromWeb(res.body), async function* (source) { for await (const chunk of source) { done += chunk.length; bar.update(done); yield chunk; } }, createWriteStream(target), ); bar.done(); console.log(c.green(`✓ ${target} (${fmtSize(done)})`)); } /** spbdrive push [--delete] — one-way mirror. */ export async function cmdPush(cfg, localDir, remoteDir, opts) { if (!existsSync(localDir) || !statSync(localDir).isDirectory()) { fail(`Not a directory: ${localDir}`, EXIT.USAGE); } const destRoot = await ensureRemoteDir(cfg, remoteDir); const localFiles = walkLocal(localDir); console.log(`${c.bold('push')} ${localDir} → ${remoteDir} (${localFiles.length} local files)`); // Index remote subtree: path → node (size for cheap compare, sha when needed). const remoteIndex = new Map(); const indexRemote = async (folderId, prefix) => { const { children } = await req(cfg, `/api/v1/nodes/${folderId}/children`); for (const child of children) { const rel = prefix ? `${prefix}/${child.name}` : child.name; if (child.type === 'folder') await indexRemote(child.id, rel); else remoteIndex.set(rel, child); } }; await indexRemote(destRoot.id, ''); let uploaded = 0; let skipped = 0; let deleted = 0; for (const file of localFiles) { const remote = remoteIndex.get(file.rel); remoteIndex.delete(file.rel); if (remote && remote.size === file.size) { // Same size → compare content hash before skipping. const localSha = createHash('sha256').update(readFileSync(file.full)).digest('hex'); const desc = await req(cfg, `/api/v1/preview/${remote.id}`); if (desc.sha === localSha) { skipped += 1; continue; } } if (remote) await req(cfg, `/api/v1/nodes/${remote.id}?force=true`, { method: 'DELETE' }); await uploadFile(cfg, file.full, destRoot.id, file.rel, file.size); uploaded += 1; } if (opts.delete) { for (const [rel, node] of remoteIndex) { await req(cfg, `/api/v1/nodes/${node.id}`, { method: 'DELETE' }); console.log(c.dim(` − ${rel} (trashed)`)); deleted += 1; } } console.log(c.green(`✓ push done — ${uploaded} uploaded, ${skipped} unchanged${opts.delete ? `, ${deleted} removed` : ''}`)); }