/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/storage/upload.mjs * Purpose : Chunked + resumable uploads (init / chunk / complete / status) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { randomBytes } from 'node:crypto'; import { createReadStream, createWriteStream, existsSync } from 'node:fs'; import { mkdir, readdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { config } from '../config.mjs'; import { getDb } from '../db/db.mjs'; import { putBlob } from './blobs.mjs'; import { createFileNode, mustGetNode, validateName } from './nodes.mjs'; const uploadDir = (id) => path.join(config.uploadsDir, id); /** * Start (or describe) an upload session. * @returns {{uploadId: string, chunkSize: number, nChunks: number, have: number[]}} */ export async function initUpload({ parentId, name, size }) { validateName(name); mustGetNode(parentId); const sz = Number(size); if (!Number.isFinite(sz) || sz < 0) throw Object.assign(new Error('Bad size'), { statusCode: 400 }); const id = randomBytes(16).toString('hex'); const nChunks = Math.max(1, Math.ceil(sz / config.chunkSize)); getDb() .prepare( `INSERT INTO uploads (id, parent_id, name, size, chunk_size, n_chunks, created) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(id, parentId, name, sz, config.chunkSize, nChunks, Date.now()); await mkdir(uploadDir(id), { recursive: true, mode: 0o700 }); return { uploadId: id, chunkSize: config.chunkSize, nChunks, have: [] }; } /** * Tag an upload session with its origin (e.g. 'req:') so * public file-request endpoints can only touch their own sessions. */ export function markUploadSource(id, source) { getDb().prepare('UPDATE uploads SET source = ? WHERE id = ?').run(source, String(id)); } /** Look up an upload session row or throw 404. */ export function getUpload(id) { const row = getDb().prepare('SELECT * FROM uploads WHERE id = ?').get(String(id)); if (!row) throw Object.assign(new Error('Unknown upload'), { statusCode: 404 }); return row; } /** Which chunk indexes are already on disk (for resume). */ export async function chunksPresent(id) { getUpload(id); try { const entries = await readdir(uploadDir(id)); return entries .filter((e) => /^\d+$/.test(e)) .map(Number) .sort((a, b) => a - b); } catch { return []; } } /** Persist one chunk from a request stream. */ export async function writeChunk(id, n, stream) { const up = getUpload(id); const idx = Number(n); if (!Number.isInteger(idx) || idx < 0 || idx >= up.n_chunks) { throw Object.assign(new Error('Chunk index out of range'), { statusCode: 400 }); } await mkdir(uploadDir(id), { recursive: true, mode: 0o700 }); const tmp = path.join(uploadDir(id), `${idx}.part`); await pipeline(stream, createWriteStream(tmp, { mode: 0o600 })); const { rename } = await import('node:fs/promises'); await rename(tmp, path.join(uploadDir(id), String(idx))); } /** * Assemble chunks → CAS blob → file node. Cleans up the session. * @param {'keep-both'|'replace'|'skip'} conflict */ export async function completeUpload(id, { conflict = 'keep-both', mime = null, ip = '' } = {}) { const up = getUpload(id); const have = await chunksPresent(id); if (have.length !== up.n_chunks) { throw Object.assign( new Error(`Missing chunks: have ${have.length}/${up.n_chunks}`), { statusCode: 409 }, ); } async function* concat() { for (let i = 0; i < up.n_chunks; i += 1) { const chunkFile = path.join(uploadDir(id), String(i)); if (!existsSync(chunkFile)) throw new Error(`Chunk ${i} vanished`); yield* createReadStream(chunkFile); } } const { sha, size } = await putBlob(concat()); if (size !== up.size) { throw Object.assign( new Error(`Size mismatch: expected ${up.size}, got ${size}`), { statusCode: 409 }, ); } const node = createFileNode(up.parent_id, up.name, { sha, size, mime, conflict, ip }); await abortUpload(id); return node; } /** Drop an upload session and its chunk files. */ export async function abortUpload(id) { getDb().prepare('DELETE FROM uploads WHERE id = ?').run(String(id)); await rm(uploadDir(String(id)), { recursive: true, force: true }); } /** Remove upload sessions older than 48 h (daily job). */ export async function pruneUploads() { const stale = getDb() .prepare('SELECT id FROM uploads WHERE created < ?') .all(Date.now() - 48 * 3_600_000); for (const { id } of stale) await abortUpload(id); return stale.length; }