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 : src/storage/nodes.mjs8 * Purpose : Virtual tree operations — mkdir, move, copy, rename, trash, purge9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { getDb, logActivity } from '../db/db.mjs';14import { refBlob, unrefBlob } from './blobs.mjs';15import { setNodeBlob, dropVersionsForNode } from './versions.mjs';16import { upsertFtsName, deleteFtsEntry } from '../search/index-sync.mjs';1718export const ROOT_ID = 1;19const NAME_RE = /^[^/\\\0]{1,255}$/;2021/** Throw unless a client-supplied name is safe (no slashes, no dot-dirs). */22export function validateName(name) {23 if (typeof name !== 'string' || !NAME_RE.test(name) || name === '.' || name === '..') {24 const err = new Error('Invalid name');25 err.statusCode = 400;26 throw err;27 }28 return name;29}3031/** Fetch a node row by id (null if absent). */32export function getNode(id) {33 return getDb().prepare('SELECT * FROM nodes WHERE id = ?').get(id) ?? null;34}3536/** Fetch a node or throw 404. */37export function mustGetNode(id) {38 const node = getNode(id);39 if (!node) {40 const err = new Error('Not found');41 err.statusCode = 404;42 throw err;43 }44 return node;45}4647const SORTS = {48 name: 'name COLLATE NOCASE',49 size: 'size',50 modified: 'modified',51 type: 'mime',52};5354/** Non-trashed children of a folder, folders always first. */55export function childrenOf(parentId, { sort = 'name', dir = 'asc' } = {}) {56 const col = SORTS[sort] ?? SORTS.name;57 const d = dir === 'desc' ? 'DESC' : 'ASC';58 return getDb()59 .prepare(60 `SELECT * FROM nodes WHERE parent_id = ? AND trashed_at IS NULL61 ORDER BY (type = 'folder') DESC, ${col} ${d}`,62 )63 .all(parentId);64}6566/** Breadcrumb chain from root to the node (inclusive). */67export function pathOf(id) {68 const chain = [];69 let node = getNode(id);70 while (node) {71 chain.unshift(node);72 node = node.parent_id ? getNode(node.parent_id) : null;73 }74 return chain;75}7677/** Human path string like /Photos/2026/img.jpg */78export function pathString(id) {79 return `/${pathOf(id).slice(1).map((n) => n.name).join('/')}`;80}8182/** Resolve an absolute virtual path to a node (null if missing). */83export function resolvePath(p) {84 const parts = String(p ?? '/').split('/').filter(Boolean);85 let node = mustGetNode(ROOT_ID);86 for (const part of parts) {87 node = getDb()88 .prepare('SELECT * FROM nodes WHERE parent_id = ? AND name = ? AND trashed_at IS NULL')89 .get(node.id, part);90 if (!node) return null;91 }92 return node;93}9495/** Existing non-trashed child by name (case-sensitive). */96export function childByName(parentId, name) {97 return getDb()98 .prepare('SELECT * FROM nodes WHERE parent_id = ? AND name = ? AND trashed_at IS NULL')99 .get(parentId, name) ?? null;100}101102/** "photo.jpg" → "photo-2.jpg" (first free suffix) for keep-both conflicts. */103export function uniqueName(parentId, name) {104 if (!childByName(parentId, name)) return name;105 const dot = name.lastIndexOf('.');106 const stem = dot > 0 ? name.slice(0, dot) : name;107 const ext = dot > 0 ? name.slice(dot) : '';108 for (let i = 2; i < 10_000; i += 1) {109 const candidate = `${stem}-${i}${ext}`;110 if (!childByName(parentId, candidate)) return candidate;111 }112 throw new Error('Could not find a free name');113}114115/**116 * Create a folder. Returns the existing folder if one with that name exists117 * (mkdir -p semantics).118 */119export function mkdir(parentId, name, { ip = '' } = {}) {120 validateName(name);121 const parent = mustGetNode(parentId);122 if (parent.type !== 'folder') throw Object.assign(new Error('Parent is not a folder'), { statusCode: 400 });123 const existing = childByName(parentId, name);124 if (existing?.type === 'folder') return existing;125 if (existing) throw Object.assign(new Error('A file with that name exists'), { statusCode: 409 });126 const now = Date.now();127 const info = getDb()128 .prepare(129 `INSERT INTO nodes (parent_id, name, type, created, modified) VALUES (?, ?, 'folder', ?, ?)`,130 )131 .run(parentId, name, now, now);132 const id = Number(info.lastInsertRowid);133 upsertFtsName(id, name);134 logActivity('folder.create', { nodeId: id, detail: name, ip });135 return getNode(id);136}137138/**139 * Create a file node pointing at a stored blob.140 * Replacing an existing file keeps the node (and its shares/tags/id) and141 * archives the previous content as a version.142 * @param {'keep-both'|'replace'|'skip'} conflict143 * @returns {object|null} the node, or null when skipped144 */145export function createFileNode(parentId, name, { sha, size, mime, conflict = 'keep-both', ip = '' }) {146 validateName(name);147 mustGetNode(parentId);148 const existing = childByName(parentId, name);149 let finalName = name;150 if (existing) {151 if (conflict === 'skip') return null;152 if (conflict === 'replace' && existing.type === 'file') {153 return setNodeBlob(existing.id, { sha, size, mime, origin: 'replace', ip });154 }155 finalName = uniqueName(parentId, name);156 }157 const now = Date.now();158 const info = getDb()159 .prepare(160 `INSERT INTO nodes (parent_id, name, type, blob_sha, size, mime, created, modified)161 VALUES (?, ?, 'file', ?, ?, ?, ?, ?)`,162 )163 .run(parentId, finalName, sha, size, mime ?? null, now, now);164 refBlob(sha);165 const id = Number(info.lastInsertRowid);166 upsertFtsName(id, finalName);167 logActivity('file.upload', { nodeId: id, detail: finalName, ip });168 return getNode(id);169}170171/** Rename a node in place. */172export function renameNode(id, newName, { ip = '' } = {}) {173 validateName(newName);174 const node = mustGetNode(id);175 if (id === ROOT_ID) throw Object.assign(new Error('Cannot rename root'), { statusCode: 400 });176 if (childByName(node.parent_id, newName) && newName !== node.name) {177 throw Object.assign(new Error('Name already taken'), { statusCode: 409 });178 }179 getDb().prepare('UPDATE nodes SET name = ?, modified = ? WHERE id = ?').run(newName, Date.now(), id);180 upsertFtsName(id, newName);181 logActivity('node.rename', { nodeId: id, detail: `${node.name} → ${newName}`, ip });182 return getNode(id);183}184185/** True if `maybeDescendant` sits under `ancestorId` (or equals it). */186export function isDescendant(maybeDescendant, ancestorId) {187 let node = getNode(maybeDescendant);188 while (node) {189 if (node.id === ancestorId) return true;190 node = node.parent_id ? getNode(node.parent_id) : null;191 }192 return false;193}194195/** Move a node under a new parent (with cycle + conflict handling). */196export function moveNode(id, newParentId, { conflict = 'keep-both', ip = '' } = {}) {197 const node = mustGetNode(id);198 const parent = mustGetNode(newParentId);199 if (id === ROOT_ID) throw Object.assign(new Error('Cannot move root'), { statusCode: 400 });200 if (parent.type !== 'folder') throw Object.assign(new Error('Target is not a folder'), { statusCode: 400 });201 if (isDescendant(newParentId, id)) {202 throw Object.assign(new Error('Cannot move a folder into itself'), { statusCode: 400 });203 }204 if (node.parent_id === newParentId) return node;205 let name = node.name;206 if (childByName(newParentId, name)) {207 if (conflict === 'skip') return null;208 if (conflict === 'replace') {209 const clash = childByName(newParentId, name);210 if (clash) trashNode(clash.id, { ip });211 } else {212 name = uniqueName(newParentId, name);213 }214 }215 getDb()216 .prepare('UPDATE nodes SET parent_id = ?, name = ?, modified = ? WHERE id = ?')217 .run(newParentId, name, Date.now(), id);218 logActivity('node.move', { nodeId: id, detail: pathString(id), ip });219 return getNode(id);220}221222/** Deep-copy a node (files share blobs — refcounted, no bytes copied). */223export function copyNode(id, destParentId, { ip = '' } = {}) {224 const node = mustGetNode(id);225 mustGetNode(destParentId);226 if (node.type === 'folder' && isDescendant(destParentId, id)) {227 throw Object.assign(new Error('Cannot copy a folder into itself'), { statusCode: 400 });228 }229 const db = getDb();230 const now = Date.now();231232 const cloneInto = (src, parentId, name) => {233 const info = db234 .prepare(235 `INSERT INTO nodes (parent_id, name, type, blob_sha, size, mime, created, modified, starred, color, emoji)236 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,237 )238 .run(parentId, name, src.type, src.blob_sha, src.size, src.mime, now, now, src.starred, src.color, src.emoji);239 const newId = Number(info.lastInsertRowid);240 if (src.blob_sha) refBlob(src.blob_sha);241 upsertFtsName(newId, name);242 if (src.type === 'folder') {243 for (const child of childrenOf(src.id)) cloneInto(child, newId, child.name);244 }245 return newId;246 };247248 const name = uniqueName(destParentId, node.name);249 const newId = cloneInto(node, destParentId, name);250 logActivity('node.copy', { nodeId: newId, detail: `${node.name} → ${pathString(destParentId)}`, ip });251 return getNode(newId);252}253254/** Duplicate next to the original ("photo.jpg" → "photo-2.jpg"). */255export function duplicateNode(id, opts = {}) {256 const node = mustGetNode(id);257 return copyNode(id, node.parent_id, opts);258}259260/** Soft delete — remembers the original parent for restore. */261export function trashNode(id, { ip = '' } = {}) {262 const node = mustGetNode(id);263 if (id === ROOT_ID) throw Object.assign(new Error('Cannot trash root'), { statusCode: 400 });264 if (node.trashed_at) return node;265 getDb()266 .prepare('UPDATE nodes SET trashed_at = ?, trash_orig_parent = parent_id WHERE id = ?')267 .run(Date.now(), id);268 logActivity('node.trash', { nodeId: id, detail: node.name, ip });269 return getNode(id);270}271272/** Restore from trash to its original folder (root if that folder is gone). */273export function restoreNode(id, { ip = '' } = {}) {274 const node = mustGetNode(id);275 if (!node.trashed_at) return node;276 let parentId = node.trash_orig_parent ?? ROOT_ID;277 const parent = getNode(parentId);278 if (!parent || parent.trashed_at) parentId = ROOT_ID;279 const name = uniqueName(parentId, node.name);280 getDb()281 .prepare('UPDATE nodes SET trashed_at = NULL, trash_orig_parent = NULL, parent_id = ?, name = ? WHERE id = ?')282 .run(parentId, name, id);283 logActivity('node.restore', { nodeId: id, detail: node.name, ip });284 return getNode(id);285}286287/** Hard delete — recursively unrefs blobs and removes rows + FTS entries. */288export function deleteForever(id, { ip = '' } = {}) {289 const node = mustGetNode(id);290 if (id === ROOT_ID) throw Object.assign(new Error('Cannot delete root'), { statusCode: 400 });291 const db = getDb();292293 const wipe = (n) => {294 const kids = db.prepare('SELECT * FROM nodes WHERE parent_id = ?').all(n.id);295 for (const kid of kids) wipe(kid);296 if (n.blob_sha) unrefBlob(n.blob_sha);297 dropVersionsForNode(n.id);298 deleteFtsEntry(n.id);299 db.prepare('DELETE FROM nodes WHERE id = ?').run(n.id);300 };301302 wipe(node);303 logActivity('node.delete_forever', { detail: node.name, ip });304}305306/** Top-level items sitting in the trash. */307export function listTrash() {308 return getDb()309 .prepare(310 `SELECT n.* FROM nodes n311 LEFT JOIN nodes p ON p.id = n.parent_id312 WHERE n.trashed_at IS NOT NULL AND (p.trashed_at IS NULL OR p.id IS NULL)313 ORDER BY n.trashed_at DESC`,314 )315 .all();316}317318/** Hard-delete trash entries older than `days` (daily job). */319export function purgeTrash(days) {320 const cutoff = Date.now() - days * 86_400_000;321 const old = getDb()322 .prepare('SELECT id FROM nodes WHERE trashed_at IS NOT NULL AND trashed_at < ?')323 .all(cutoff);324 for (const { id } of old) {325 if (getNode(id)) deleteForever(id);326 }327 return old.length;328}329330/** Every non-trashed file underneath a folder, with subtree-relative paths. */331export function listDescendantFiles(folderId, prefix = '') {332 const out = [];333 for (const child of childrenOf(folderId)) {334 const rel = prefix ? `${prefix}/${child.name}` : child.name;335 if (child.type === 'folder') out.push(...listDescendantFiles(child.id, rel));336 else out.push({ node: child, relPath: rel });337 }338 return out;339}340341/** Last-touched files for the Recent view. */342export function recentFiles(limit = 50) {343 return getDb()344 .prepare(345 `SELECT * FROM nodes WHERE type = 'file' AND trashed_at IS NULL346 ORDER BY modified DESC LIMIT ?`,347 )348 .all(limit);349}350