SPB Git

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%
12.4 KB · 350 lines javascript
Raw Blame History
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