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 : 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