/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/storage/nodes.mjs * Purpose : Virtual tree operations — mkdir, move, copy, rename, trash, purge * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { getDb, logActivity } from '../db/db.mjs'; import { refBlob, unrefBlob } from './blobs.mjs'; import { setNodeBlob, dropVersionsForNode } from './versions.mjs'; import { upsertFtsName, deleteFtsEntry } from '../search/index-sync.mjs'; export const ROOT_ID = 1; const NAME_RE = /^[^/\\\0]{1,255}$/; /** Throw unless a client-supplied name is safe (no slashes, no dot-dirs). */ export function validateName(name) { if (typeof name !== 'string' || !NAME_RE.test(name) || name === '.' || name === '..') { const err = new Error('Invalid name'); err.statusCode = 400; throw err; } return name; } /** Fetch a node row by id (null if absent). */ export function getNode(id) { return getDb().prepare('SELECT * FROM nodes WHERE id = ?').get(id) ?? null; } /** Fetch a node or throw 404. */ export function mustGetNode(id) { const node = getNode(id); if (!node) { const err = new Error('Not found'); err.statusCode = 404; throw err; } return node; } const SORTS = { name: 'name COLLATE NOCASE', size: 'size', modified: 'modified', type: 'mime', }; /** Non-trashed children of a folder, folders always first. */ export function childrenOf(parentId, { sort = 'name', dir = 'asc' } = {}) { const col = SORTS[sort] ?? SORTS.name; const d = dir === 'desc' ? 'DESC' : 'ASC'; return getDb() .prepare( `SELECT * FROM nodes WHERE parent_id = ? AND trashed_at IS NULL ORDER BY (type = 'folder') DESC, ${col} ${d}`, ) .all(parentId); } /** Breadcrumb chain from root to the node (inclusive). */ export function pathOf(id) { const chain = []; let node = getNode(id); while (node) { chain.unshift(node); node = node.parent_id ? getNode(node.parent_id) : null; } return chain; } /** Human path string like /Photos/2026/img.jpg */ export function pathString(id) { return `/${pathOf(id).slice(1).map((n) => n.name).join('/')}`; } /** Resolve an absolute virtual path to a node (null if missing). */ export function resolvePath(p) { const parts = String(p ?? '/').split('/').filter(Boolean); let node = mustGetNode(ROOT_ID); for (const part of parts) { node = getDb() .prepare('SELECT * FROM nodes WHERE parent_id = ? AND name = ? AND trashed_at IS NULL') .get(node.id, part); if (!node) return null; } return node; } /** Existing non-trashed child by name (case-sensitive). */ export function childByName(parentId, name) { return getDb() .prepare('SELECT * FROM nodes WHERE parent_id = ? AND name = ? AND trashed_at IS NULL') .get(parentId, name) ?? null; } /** "photo.jpg" → "photo-2.jpg" (first free suffix) for keep-both conflicts. */ export function uniqueName(parentId, name) { if (!childByName(parentId, name)) return name; const dot = name.lastIndexOf('.'); const stem = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : ''; for (let i = 2; i < 10_000; i += 1) { const candidate = `${stem}-${i}${ext}`; if (!childByName(parentId, candidate)) return candidate; } throw new Error('Could not find a free name'); } /** * Create a folder. Returns the existing folder if one with that name exists * (mkdir -p semantics). */ export function mkdir(parentId, name, { ip = '' } = {}) { validateName(name); const parent = mustGetNode(parentId); if (parent.type !== 'folder') throw Object.assign(new Error('Parent is not a folder'), { statusCode: 400 }); const existing = childByName(parentId, name); if (existing?.type === 'folder') return existing; if (existing) throw Object.assign(new Error('A file with that name exists'), { statusCode: 409 }); const now = Date.now(); const info = getDb() .prepare( `INSERT INTO nodes (parent_id, name, type, created, modified) VALUES (?, ?, 'folder', ?, ?)`, ) .run(parentId, name, now, now); const id = Number(info.lastInsertRowid); upsertFtsName(id, name); logActivity('folder.create', { nodeId: id, detail: name, ip }); return getNode(id); } /** * Create a file node pointing at a stored blob. * Replacing an existing file keeps the node (and its shares/tags/id) and * archives the previous content as a version. * @param {'keep-both'|'replace'|'skip'} conflict * @returns {object|null} the node, or null when skipped */ export function createFileNode(parentId, name, { sha, size, mime, conflict = 'keep-both', ip = '' }) { validateName(name); mustGetNode(parentId); const existing = childByName(parentId, name); let finalName = name; if (existing) { if (conflict === 'skip') return null; if (conflict === 'replace' && existing.type === 'file') { return setNodeBlob(existing.id, { sha, size, mime, origin: 'replace', ip }); } finalName = uniqueName(parentId, name); } const now = Date.now(); const info = getDb() .prepare( `INSERT INTO nodes (parent_id, name, type, blob_sha, size, mime, created, modified) VALUES (?, ?, 'file', ?, ?, ?, ?, ?)`, ) .run(parentId, finalName, sha, size, mime ?? null, now, now); refBlob(sha); const id = Number(info.lastInsertRowid); upsertFtsName(id, finalName); logActivity('file.upload', { nodeId: id, detail: finalName, ip }); return getNode(id); } /** Rename a node in place. */ export function renameNode(id, newName, { ip = '' } = {}) { validateName(newName); const node = mustGetNode(id); if (id === ROOT_ID) throw Object.assign(new Error('Cannot rename root'), { statusCode: 400 }); if (childByName(node.parent_id, newName) && newName !== node.name) { throw Object.assign(new Error('Name already taken'), { statusCode: 409 }); } getDb().prepare('UPDATE nodes SET name = ?, modified = ? WHERE id = ?').run(newName, Date.now(), id); upsertFtsName(id, newName); logActivity('node.rename', { nodeId: id, detail: `${node.name} → ${newName}`, ip }); return getNode(id); } /** True if `maybeDescendant` sits under `ancestorId` (or equals it). */ export function isDescendant(maybeDescendant, ancestorId) { let node = getNode(maybeDescendant); while (node) { if (node.id === ancestorId) return true; node = node.parent_id ? getNode(node.parent_id) : null; } return false; } /** Move a node under a new parent (with cycle + conflict handling). */ export function moveNode(id, newParentId, { conflict = 'keep-both', ip = '' } = {}) { const node = mustGetNode(id); const parent = mustGetNode(newParentId); if (id === ROOT_ID) throw Object.assign(new Error('Cannot move root'), { statusCode: 400 }); if (parent.type !== 'folder') throw Object.assign(new Error('Target is not a folder'), { statusCode: 400 }); if (isDescendant(newParentId, id)) { throw Object.assign(new Error('Cannot move a folder into itself'), { statusCode: 400 }); } if (node.parent_id === newParentId) return node; let name = node.name; if (childByName(newParentId, name)) { if (conflict === 'skip') return null; if (conflict === 'replace') { const clash = childByName(newParentId, name); if (clash) trashNode(clash.id, { ip }); } else { name = uniqueName(newParentId, name); } } getDb() .prepare('UPDATE nodes SET parent_id = ?, name = ?, modified = ? WHERE id = ?') .run(newParentId, name, Date.now(), id); logActivity('node.move', { nodeId: id, detail: pathString(id), ip }); return getNode(id); } /** Deep-copy a node (files share blobs — refcounted, no bytes copied). */ export function copyNode(id, destParentId, { ip = '' } = {}) { const node = mustGetNode(id); mustGetNode(destParentId); if (node.type === 'folder' && isDescendant(destParentId, id)) { throw Object.assign(new Error('Cannot copy a folder into itself'), { statusCode: 400 }); } const db = getDb(); const now = Date.now(); const cloneInto = (src, parentId, name) => { const info = db .prepare( `INSERT INTO nodes (parent_id, name, type, blob_sha, size, mime, created, modified, starred, color, emoji) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run(parentId, name, src.type, src.blob_sha, src.size, src.mime, now, now, src.starred, src.color, src.emoji); const newId = Number(info.lastInsertRowid); if (src.blob_sha) refBlob(src.blob_sha); upsertFtsName(newId, name); if (src.type === 'folder') { for (const child of childrenOf(src.id)) cloneInto(child, newId, child.name); } return newId; }; const name = uniqueName(destParentId, node.name); const newId = cloneInto(node, destParentId, name); logActivity('node.copy', { nodeId: newId, detail: `${node.name} → ${pathString(destParentId)}`, ip }); return getNode(newId); } /** Duplicate next to the original ("photo.jpg" → "photo-2.jpg"). */ export function duplicateNode(id, opts = {}) { const node = mustGetNode(id); return copyNode(id, node.parent_id, opts); } /** Soft delete — remembers the original parent for restore. */ export function trashNode(id, { ip = '' } = {}) { const node = mustGetNode(id); if (id === ROOT_ID) throw Object.assign(new Error('Cannot trash root'), { statusCode: 400 }); if (node.trashed_at) return node; getDb() .prepare('UPDATE nodes SET trashed_at = ?, trash_orig_parent = parent_id WHERE id = ?') .run(Date.now(), id); logActivity('node.trash', { nodeId: id, detail: node.name, ip }); return getNode(id); } /** Restore from trash to its original folder (root if that folder is gone). */ export function restoreNode(id, { ip = '' } = {}) { const node = mustGetNode(id); if (!node.trashed_at) return node; let parentId = node.trash_orig_parent ?? ROOT_ID; const parent = getNode(parentId); if (!parent || parent.trashed_at) parentId = ROOT_ID; const name = uniqueName(parentId, node.name); getDb() .prepare('UPDATE nodes SET trashed_at = NULL, trash_orig_parent = NULL, parent_id = ?, name = ? WHERE id = ?') .run(parentId, name, id); logActivity('node.restore', { nodeId: id, detail: node.name, ip }); return getNode(id); } /** Hard delete — recursively unrefs blobs and removes rows + FTS entries. */ export function deleteForever(id, { ip = '' } = {}) { const node = mustGetNode(id); if (id === ROOT_ID) throw Object.assign(new Error('Cannot delete root'), { statusCode: 400 }); const db = getDb(); const wipe = (n) => { const kids = db.prepare('SELECT * FROM nodes WHERE parent_id = ?').all(n.id); for (const kid of kids) wipe(kid); if (n.blob_sha) unrefBlob(n.blob_sha); dropVersionsForNode(n.id); deleteFtsEntry(n.id); db.prepare('DELETE FROM nodes WHERE id = ?').run(n.id); }; wipe(node); logActivity('node.delete_forever', { detail: node.name, ip }); } /** Top-level items sitting in the trash. */ export function listTrash() { return getDb() .prepare( `SELECT n.* FROM nodes n LEFT JOIN nodes p ON p.id = n.parent_id WHERE n.trashed_at IS NOT NULL AND (p.trashed_at IS NULL OR p.id IS NULL) ORDER BY n.trashed_at DESC`, ) .all(); } /** Hard-delete trash entries older than `days` (daily job). */ export function purgeTrash(days) { const cutoff = Date.now() - days * 86_400_000; const old = getDb() .prepare('SELECT id FROM nodes WHERE trashed_at IS NOT NULL AND trashed_at < ?') .all(cutoff); for (const { id } of old) { if (getNode(id)) deleteForever(id); } return old.length; } /** Every non-trashed file underneath a folder, with subtree-relative paths. */ export function listDescendantFiles(folderId, prefix = '') { const out = []; for (const child of childrenOf(folderId)) { const rel = prefix ? `${prefix}/${child.name}` : child.name; if (child.type === 'folder') out.push(...listDescendantFiles(child.id, rel)); else out.push({ node: child, relPath: rel }); } return out; } /** Last-touched files for the Recent view. */ export function recentFiles(limit = 50) { return getDb() .prepare( `SELECT * FROM nodes WHERE type = 'file' AND trashed_at IS NULL ORDER BY modified DESC LIMIT ?`, ) .all(limit); }