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%
27.7 KB · 670 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/api/v1.mjs8 *  Purpose : JSON API /api/v1 — nodes, uploads, shares, tags, search, auth9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { Readable } from 'node:stream';14import QRCode from 'qrcode';15import mime from 'mime';16import archiver from 'archiver';17import { config } from '../config.mjs';18import { getDb, logActivity } from '../db/db.mjs';19import {20  changePassword, checkLockout, clearLoginFailures, recordLoginFailure, verifyPassword,21} from '../auth/password.mjs';22import {23  SESSION_COOKIE, createApiToken, createSession, destroySession, listApiTokens,24  listSessions, revokeAllSessions, revokeApiToken, revokeSession,25} from '../auth/session.mjs';26import { putBlob, storageStats } from '../storage/blobs.mjs';27import {28  ROOT_ID, childByName, childrenOf, copyNode, createFileNode, deleteForever, duplicateNode, getNode,29  listDescendantFiles, listTrash, mkdir, moveNode, mustGetNode, pathOf, pathString,30  recentFiles, renameNode, resolvePath, restoreNode, trashNode,31} from '../storage/nodes.mjs';32import {33  deleteVersion, getVersion, listVersions, restoreVersion, setNodeBlob,34} from '../storage/versions.mjs';35import {36  abortUpload, chunksPresent, completeUpload, getUpload, initUpload, writeChunk,37} from '../storage/upload.mjs';38import {39  createShare, getShareById, listShares, revokeShare, shareEvents, sharesForNode, updateShare,40} from '../shares/shares.mjs';41import {42  closeRequest, createRequest, getRequestById, listRequests,43} from '../shares/requests.mjs';44import { searchNodes } from '../search/search.mjs';45import { extractNodeText } from '../search/extract.mjs';46import { updateFtsTags } from '../search/index-sync.mjs';47import { previewStrategy, thumbKind, iconFamily } from '../preview/router.mjs';48import {49  describePreview, handleArchive, handleArchiveMember, handleExif, handleHeic,50  handleMarkdown, handleOfficePdf, handlePeaks, handleRawText, handleText,51  handleVideoFile, handleVideoStatus,52} from '../preview/handlers.mjs';53import { apiError, makeRateLimiter, sendBlob } from '../web/http-helpers.mjs';5455/** Serialize a node row for API responses. */56export function nodeJson(node) {57  if (!node) return null;58  const tags = getDb()59    .prepare('SELECT t.id, t.name, t.color FROM node_tags nt JOIN tags t ON t.id = nt.tag_id WHERE nt.node_id = ?')60    .all(node.id);61  return {62    id: node.id,63    parentId: node.parent_id,64    name: node.name,65    type: node.type,66    size: node.size,67    mime: node.mime,68    created: node.created,69    modified: node.modified,70    starred: Boolean(node.starred),71    color: node.color,72    emoji: node.emoji,73    trashedAt: node.trashed_at,74    strategy: node.type === 'file' ? previewStrategy(node).strategy : null,75    icon: iconFamily(node),76    hasThumb: node.type === 'file' ? thumbKind(node) !== null : false,77    tags,78  };79}8081function shareJson(share) {82  return {83    id: share.id,84    token: share.token,85    url: `${config.publicUrl}/s/${share.token}`,86    nodeId: share.node_id,87    nodeName: share.node_name,88    nodeType: share.node_type,89    created: share.created,90    expiresAt: share.expires_at,91    hasPassword: Boolean(share.password_hash),92    maxDownloads: share.max_downloads,93    downloads: share.downloads,94    visits: share.visits,95    allowDownload: Boolean(share.allow_download),96    label: share.label,97    revokedAt: share.revoked_at,98  };99}100101/** Register every /api/v1 route. */102export function registerApiV1(app) {103  const loginLimiter = makeRateLimiter({ windowMs: 60_000, max: 20 });104105  // ── Auth (no session required) ─────────────────────────────────────106  app.post('/api/v1/auth/login', async (req, reply) => {107    if (!loginLimiter(req, reply)) return undefined;108    const lock = checkLockout(req.ip);109    if (lock.locked) {110      return apiError(reply, 429, 'locked_out',111        `Too many attempts. Retry in ${Math.ceil((lock.retryAfterMs ?? 0) / 1000)}s`);112    }113    const { password, remember } = req.body ?? {};114    if (!(await verifyPassword(password))) {115      recordLoginFailure(req.ip);116      return apiError(reply, 401, 'bad_password', 'Wrong password');117    }118    clearLoginFailures(req.ip);119    const session = createSession({120      remember: Boolean(remember),121      ip: req.ip,122      ua: req.headers['user-agent'] ?? '',123    });124    reply.setCookie(SESSION_COOKIE, session.token, {125      path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: session.maxAgeSec,126    });127    return { ok: true };128  });129130  // Everything below requires auth (session cookie or Bearer API token) +131  // a CSRF header on state-changing calls when cookie-authenticated.132  app.addHook('preHandler', async (req, reply) => {133    if (req.url.startsWith('/api/v1/auth/login')) return;134    if (!req.url.startsWith('/api/v1/')) return;135    if (!req.authed) return apiError(reply, 401, 'unauthorized', 'Login required');136    if (req.authKind === 'session' && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {137      if (req.headers['x-spbdrive-csrf'] !== '1') {138        return apiError(reply, 403, 'csrf', 'Missing CSRF header');139      }140    }141    return undefined;142  });143144  app.post('/api/v1/auth/logout', async (req, reply) => {145    destroySession(req.cookies?.[SESSION_COOKIE]);146    reply.clearCookie(SESSION_COOKIE, { path: '/' });147    return { ok: true };148  });149150  app.post('/api/v1/auth/password', async (req, reply) => {151    const { current, next } = req.body ?? {};152    const ok = await changePassword(current, next).catch((err) =>153      apiError(reply, 400, 'weak_password', err.message));154    if (ok === false) return apiError(reply, 403, 'bad_password', 'Current password is wrong');155    return { ok: true };156  });157158  app.get('/api/v1/auth/sessions', async () => ({ sessions: listSessions() }));159  app.delete('/api/v1/auth/sessions/:id', async (req) => ({ ok: revokeSession(Number(req.params.id)) }));160  app.delete('/api/v1/auth/sessions', async (req, reply) => {161    revokeAllSessions();162    reply.clearCookie(SESSION_COOKIE, { path: '/' });163    return { ok: true };164  });165166  app.get('/api/v1/auth/api-tokens', async () => ({ tokens: listApiTokens() }));167  app.post('/api/v1/auth/api-tokens', async (req) => createApiToken(req.body?.name ?? 'token'));168  app.delete('/api/v1/auth/api-tokens/:id', async (req) => ({ ok: revokeApiToken(Number(req.params.id)) }));169170  app.get('/api/v1/me', async (req) => ({171    user: 'Simon-Pierre Boucher',172    email: 'contact@spboucher.ai',173    authKind: req.authKind,174    rootId: ROOT_ID,175  }));176177  app.get('/api/v1/stats', async () => storageStats());178179  // ── Nodes ───────────────────────────────────────────────────────────180  app.get('/api/v1/nodes/:id', async (req, reply) => {181    const node = getNode(Number(req.params.id));182    if (!node) return apiError(reply, 404, 'not_found', 'No such node');183    return { node: nodeJson(node), path: pathOf(node.id).map((n) => ({ id: n.id, name: n.name })) };184  });185186  app.get('/api/v1/nodes/:id/children', async (req, reply) => {187    const id = Number(req.params.id);188    const node = getNode(id);189    if (!node) return apiError(reply, 404, 'not_found', 'No such folder');190    const { sort = 'name', dir = 'asc' } = req.query;191    return {192      children: childrenOf(id, { sort, dir }).map(nodeJson),193      path: pathOf(id).map((n) => ({ id: n.id, name: n.name })),194    };195  });196197  // Full folder tree for the sidebar (folders only).198  app.get('/api/v1/tree', async () => {199    const rows = getDb()200      .prepare("SELECT id, parent_id, name, color, emoji FROM nodes WHERE type = 'folder' AND trashed_at IS NULL ORDER BY name COLLATE NOCASE")201      .all();202    return { folders: rows };203  });204205  app.get('/api/v1/resolve', async (req, reply) => {206    const node = resolvePath(String(req.query.path ?? '/'));207    if (!node) return apiError(reply, 404, 'not_found', 'Path not found');208    return { node: nodeJson(node) };209  });210211  app.post('/api/v1/nodes', async (req, reply) => {212    const { parentId, name, type } = req.body ?? {};213    if (type !== 'folder') return apiError(reply, 400, 'bad_type', 'Only folders are created here');214    const node = mkdir(Number(parentId ?? ROOT_ID), String(name ?? ''), { ip: req.ip });215    return { node: nodeJson(node) };216  });217218  app.patch('/api/v1/nodes/:id', async (req, reply) => {219    const id = Number(req.params.id);220    let node = mustGetNode(id);221    const body = req.body ?? {};222    if (body.name !== undefined && body.name !== node.name) {223      node = renameNode(id, String(body.name), { ip: req.ip });224    }225    if (body.parentId !== undefined && Number(body.parentId) !== node.parent_id) {226      const moved = moveNode(id, Number(body.parentId), { conflict: body.conflict ?? 'keep-both', ip: req.ip });227      if (moved === null) return { node: null, skipped: true };228      node = moved;229    }230    const db = getDb();231    if (body.starred !== undefined) {232      db.prepare('UPDATE nodes SET starred = ? WHERE id = ?').run(body.starred ? 1 : 0, id);233      logActivity(body.starred ? 'node.star' : 'node.unstar', { nodeId: id, ip: req.ip });234    }235    if (body.color !== undefined) db.prepare('UPDATE nodes SET color = ? WHERE id = ?').run(body.color, id);236    if (body.emoji !== undefined) db.prepare('UPDATE nodes SET emoji = ? WHERE id = ?').run(body.emoji, id);237    if (Array.isArray(body.tagIds)) {238      db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id);239      const ins = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag_id) VALUES (?, ?)');240      for (const tagId of body.tagIds) ins.run(id, Number(tagId));241      updateFtsTags(id);242    }243    return { node: nodeJson(getNode(id)) };244  });245246  app.delete('/api/v1/nodes/:id', async (req) => {247    const id = Number(req.params.id);248    if (req.query.force === 'true') {249      deleteForever(id, { ip: req.ip });250      return { ok: true, forever: true };251    }252    return { ok: true, node: nodeJson(trashNode(id, { ip: req.ip })) };253  });254255  app.post('/api/v1/nodes/:id/restore', async (req) =>256    ({ node: nodeJson(restoreNode(Number(req.params.id), { ip: req.ip })) }));257258  app.post('/api/v1/nodes/:id/copy', async (req) =>259    ({ node: nodeJson(copyNode(Number(req.params.id), Number(req.body?.parentId ?? ROOT_ID), { ip: req.ip })) }));260261  app.post('/api/v1/nodes/:id/duplicate', async (req) =>262    ({ node: nodeJson(duplicateNode(Number(req.params.id), { ip: req.ip })) }));263264  // ── Version history ─────────────────────────────────────────────────265  app.get('/api/v1/nodes/:id/versions', async (req, reply) => {266    const node = getNode(Number(req.params.id));267    if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file');268    return {269      versions: listVersions(node.id).map((v) => ({270        id: v.id, size: v.size, mime: v.mime, created: v.created,271        replacedAt: v.replaced_at, origin: v.origin,272      })),273    };274  });275276  app.post('/api/v1/nodes/:id/versions/:vid/restore', async (req) => {277    const node = restoreVersion(Number(req.params.id), Number(req.params.vid), { ip: req.ip });278    extractNodeText(node);279    return { node: nodeJson(node) };280  });281282  app.delete('/api/v1/nodes/:id/versions/:vid', async (req, reply) => {283    if (!deleteVersion(Number(req.params.id), Number(req.params.vid))) {284      return apiError(reply, 404, 'not_found', 'No such version');285    }286    return { ok: true };287  });288289  app.get('/api/v1/nodes/:id/versions/:vid/dl', async (req, reply) => {290    const node = getNode(Number(req.params.id));291    const version = node ? getVersion(node.id, Number(req.params.vid)) : null;292    if (!version) return apiError(reply, 404, 'not_found', 'No such version');293    const stamp = new Date(version.replaced_at).toISOString().slice(0, 10);294    const dot = node.name.lastIndexOf('.');295    const versionedName = dot > 0296      ? `${node.name.slice(0, dot)} (v${stamp})${node.name.slice(dot)}`297      : `${node.name} (v${stamp})`;298    return sendBlob(req, reply, {299      sha: version.blob_sha, mime: version.mime, filename: versionedName, download: true,300    });301  });302303  // ── In-browser editor: create + save text files ─────────────────────304  const TEXT_EDIT_LIMIT = 16 * 1024 * 1024;305306  app.post('/api/v1/files', async (req, reply) => {307    const { parentId, name, content = '' } = req.body ?? {};308    if (typeof content !== 'string' || content.length > TEXT_EDIT_LIMIT) {309      return apiError(reply, 413, 'too_large', 'Content too large');310    }311    const buf = Buffer.from(content, 'utf8');312    const { sha, size } = await putBlob(Readable.from([buf]));313    const guessed = mime.getType(String(name ?? '')) || 'text/plain';314    const node = createFileNode(Number(parentId ?? ROOT_ID), String(name ?? ''), {315      sha, size, mime: guessed, conflict: 'keep-both', ip: req.ip,316    });317    if (node) extractNodeText(node);318    return { node: nodeJson(node) };319  });320321  app.put('/api/v1/nodes/:id/content', async (req, reply) => {322    const node = getNode(Number(req.params.id));323    if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file');324    const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''), 'utf8');325    if (body.length > TEXT_EDIT_LIMIT) return apiError(reply, 413, 'too_large', 'Content too large');326    const { sha, size } = await putBlob(Readable.from([body]));327    const updated = setNodeBlob(node.id, { sha, size, mime: node.mime, origin: 'edit', ip: req.ip });328    extractNodeText(updated);329    return { node: nodeJson(updated) };330  });331332  app.get('/api/v1/trash', async () => ({ items: listTrash().map(nodeJson) }));333  app.post('/api/v1/trash/empty', async (req) => {334    const items = listTrash();335    for (const item of items) deleteForever(item.id, { ip: req.ip });336    return { ok: true, removed: items.length };337  });338339  app.get('/api/v1/recent', async () => ({ items: recentFiles(50).map(nodeJson) }));340341  // ── Uploads (chunked + resumable) ───────────────────────────────────342  app.post('/api/v1/upload/init', async (req, reply) => {343    const { parentId, name, size, path: relPath } = req.body ?? {};344    // Folder uploads send a relative path — create intermediate dirs.345    let parent = Number(parentId ?? ROOT_ID);346    if (relPath) {347      const parts = String(relPath).split('/').filter(Boolean).slice(0, -1);348      for (const part of parts) parent = mkdir(parent, part, { ip: req.ip }).id;349    }350    const session = await initUpload({ parentId: parent, name: String(name ?? ''), size });351    // Tell the client about a same-name file so it can offer352    // Keep both / Replace (new version) / Skip before completing.353    const existing = childByName(parent, String(name ?? ''));354    session.conflictsWith = existing?.type === 'file'355      ? { id: existing.id, name: existing.name, size: existing.size, modified: existing.modified }356      : null;357    return session;358  });359360  app.get('/api/v1/upload/:id', async (req) => {361    const up = getUpload(req.params.id);362    return { uploadId: up.id, nChunks: up.n_chunks, chunkSize: up.chunk_size, have: await chunksPresent(up.id) };363  });364365  app.put('/api/v1/upload/:id/chunk/:n', async (req, reply) => {366    await writeChunk(req.params.id, req.params.n, req.raw);367    return { ok: true };368  });369370  app.post('/api/v1/upload/:id/complete', async (req) => {371    const { conflict, mime: bodyMime } = req.body ?? {};372    const up = getUpload(req.params.id);373    const guessed = bodyMime || mime.getType(up.name) || 'application/octet-stream';374    const node = await completeUpload(req.params.id, { conflict, mime: guessed, ip: req.ip });375    if (node) extractNodeText(node); // background FTS extraction376    return { node: nodeJson(node), skipped: node === null };377  });378379  app.delete('/api/v1/upload/:id', async (req) => {380    await abortUpload(req.params.id);381    return { ok: true };382  });383384  // ── Multi-select ZIP download ───────────────────────────────────────385  app.get('/api/v1/zip', async (req, reply) => {386    const ids = String(req.query.ids ?? '').split(',').map(Number).filter(Boolean);387    if (ids.length === 0) return apiError(reply, 400, 'no_ids', 'ids required');388    const name = ids.length === 1 ? `${mustGetNode(ids[0]).name}.zip` : 'spbdrive-selection.zip';389    reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`);390    reply.type('application/zip');391    const archive = archiver('zip', { zlib: { level: 6 } });392    for (const id of ids) {393      const node = mustGetNode(id);394      if (node.type === 'file') {395        archive.file((await import('../storage/blobs.mjs')).blobPath(node.blob_sha), { name: node.name });396      } else {397        for (const { node: file, relPath } of listDescendantFiles(node.id, node.name)) {398          archive.file((await import('../storage/blobs.mjs')).blobPath(file.blob_sha), { name: relPath });399        }400      }401    }402    archive.finalize();403    logActivity('file.download_zip', { detail: `${ids.length} item(s)`, ip: req.ip });404    return reply.send(archive);405  });406407  // ── Tags ────────────────────────────────────────────────────────────408  app.get('/api/v1/tags', async () => ({409    tags: getDb().prepare(410      `SELECT t.*, COUNT(nt.node_id) AS count FROM tags t411       LEFT JOIN node_tags nt ON nt.tag_id = t.id GROUP BY t.id ORDER BY t.name`,412    ).all(),413  }));414415  app.post('/api/v1/tags', async (req, reply) => {416    const name = String(req.body?.name ?? '').trim().slice(0, 40);417    if (!name) return apiError(reply, 400, 'bad_name', 'Tag name required');418    const color = String(req.body?.color ?? '#4f8cff');419    getDb()420      .prepare('INSERT INTO tags (name, color) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET color = excluded.color')421      .run(name, color);422    return { tag: getDb().prepare('SELECT * FROM tags WHERE name = ?').get(name) };423  });424425  app.patch('/api/v1/tags/:id', async (req) => {426    const { name, color } = req.body ?? {};427    const db = getDb();428    if (name) db.prepare('UPDATE tags SET name = ? WHERE id = ?').run(String(name).slice(0, 40), Number(req.params.id));429    if (color) db.prepare('UPDATE tags SET color = ? WHERE id = ?').run(String(color), Number(req.params.id));430    return { tag: db.prepare('SELECT * FROM tags WHERE id = ?').get(Number(req.params.id)) };431  });432433  app.delete('/api/v1/tags/:id', async (req) => {434    getDb().prepare('DELETE FROM tags WHERE id = ?').run(Number(req.params.id));435    return { ok: true };436  });437438  app.get('/api/v1/tags/:id/nodes', async (req) => {439    const rows = getDb()440      .prepare(441        `SELECT n.* FROM node_tags nt JOIN nodes n ON n.id = nt.node_id442         WHERE nt.tag_id = ? AND n.trashed_at IS NULL ORDER BY n.modified DESC`,443      )444      .all(Number(req.params.id));445    return { items: rows.map(nodeJson) };446  });447448  // ── Starred ─────────────────────────────────────────────────────────449  app.get('/api/v1/starred', async () => ({450    items: getDb()451      .prepare('SELECT * FROM nodes WHERE starred = 1 AND trashed_at IS NULL ORDER BY modified DESC')452      .all()453      .map(nodeJson),454  }));455456  // ── Shares ──────────────────────────────────────────────────────────457  app.get('/api/v1/shares', async () => ({ shares: listShares().map(shareJson) }));458459  app.get('/api/v1/nodes/:id/shares', async (req) => ({460    shares: sharesForNode(Number(req.params.id)).map((s) =>461      shareJson({ ...s, node_name: '', node_type: '' })),462  }));463464  app.post('/api/v1/shares', async (req, reply) => {465    const { nodeId, expiresAt, password, maxDownloads, allowDownload, label } = req.body ?? {};466    const share = await createShare(Number(nodeId), {467      expiresAt: expiresAt ?? null,468      password: password || null,469      maxDownloads: maxDownloads ?? null,470      allowDownload,471      label: label ?? null,472      ip: req.ip,473    });474    const full = { ...share, node_name: mustGetNode(share.node_id).name, node_type: mustGetNode(share.node_id).type };475    return { share: shareJson(full) };476  });477478  app.patch('/api/v1/shares/:id', async (req, reply) => {479    const share = await updateShare(Number(req.params.id), req.body ?? {});480    if (!share) return apiError(reply, 404, 'not_found', 'No such share');481    const node = mustGetNode(share.node_id);482    return { share: shareJson({ ...share, node_name: node.name, node_type: node.type }) };483  });484485  app.delete('/api/v1/shares/:id', async (req, reply) => {486    if (!revokeShare(Number(req.params.id), { ip: req.ip })) {487      return apiError(reply, 404, 'not_found', 'No such share');488    }489    return { ok: true };490  });491492  app.get('/api/v1/shares/:id/events', async (req, reply) => {493    const share = getShareById(Number(req.params.id));494    if (!share) return apiError(reply, 404, 'not_found', 'No such share');495    return { events: shareEvents(share.id) };496  });497498  app.get('/api/v1/shares/:id/qr', async (req, reply) => {499    const share = getShareById(Number(req.params.id));500    if (!share) return apiError(reply, 404, 'not_found', 'No such share');501    const svg = await QRCode.toString(`${config.publicUrl}/s/${share.token}`, {502      type: 'svg', margin: 1, width: 240,503      color: { dark: '#0b0e14', light: '#ffffff' },504    });505    reply.type('image/svg+xml');506    return reply.send(svg);507  });508509  // ── File requests (receive files from others) ───────────────────────510  const requestJson = (r) => ({511    id: r.id,512    token: r.token,513    url: `${config.publicUrl}/r/${r.token}`,514    folderId: r.folder_id,515    folderName: r.folder_name ?? getNode(r.folder_id)?.name ?? '',516    label: r.label,517    created: r.created,518    expiresAt: r.expires_at,519    maxFiles: r.max_files,520    received: r.received,521    closedAt: r.closed_at,522  });523524  app.get('/api/v1/requests', async () => ({ requests: listRequests().map(requestJson) }));525526  app.post('/api/v1/requests', async (req) => {527    const { folderId, label, expiresAt, maxFiles } = req.body ?? {};528    const request = createRequest(Number(folderId ?? ROOT_ID), {529      label: label ?? null,530      expiresAt: expiresAt ?? null,531      maxFiles: maxFiles ?? null,532      ip: req.ip,533    });534    return { request: requestJson(request) };535  });536537  app.delete('/api/v1/requests/:id', async (req, reply) => {538    if (!closeRequest(Number(req.params.id), { ip: req.ip })) {539      return apiError(reply, 404, 'not_found', 'No such request');540    }541    return { ok: true };542  });543544  app.get('/api/v1/requests/:id/qr', async (req, reply) => {545    const request = getRequestById(Number(req.params.id));546    if (!request) return apiError(reply, 404, 'not_found', 'No such request');547    const svg = await QRCode.toString(`${config.publicUrl}/r/${request.token}`, {548      type: 'svg', margin: 1, width: 240,549      color: { dark: '#0b0e14', light: '#ffffff' },550    });551    reply.type('image/svg+xml');552    return reply.send(svg);553  });554555  // ── Storage insights: dedup savings, duplicates, largest files ──────556  app.get('/api/v1/stats/detailed', async () => {557    const db = getDb();558    const dupGroups = db559      .prepare(560        `SELECT blob_sha AS sha, COUNT(*) AS copies, MAX(size) AS size561         FROM nodes WHERE type = 'file' AND trashed_at IS NULL AND blob_sha IS NOT NULL562         GROUP BY blob_sha HAVING copies > 1563         ORDER BY (copies - 1) * size DESC LIMIT 50`,564      )565      .all();566    const nodesForSha = db.prepare(567      "SELECT * FROM nodes WHERE blob_sha = ? AND trashed_at IS NULL AND type = 'file' LIMIT 10",568    );569    const duplicates = dupGroups.map((g) => ({570      sha: g.sha,571      copies: g.copies,572      size: g.size,573      savedBytes: (g.copies - 1) * g.size,574      nodes: nodesForSha.all(g.sha).map((n) => ({575        id: n.id, parentId: n.parent_id, name: n.name, path: pathString(n.id),576      })),577    }));578    const largest = db579      .prepare(580        `SELECT * FROM nodes WHERE type = 'file' AND trashed_at IS NULL581         ORDER BY size DESC LIMIT 20`,582      )583      .all()584      .map((n) => ({ ...nodeJson(n), path: pathString(n.id) }));585    const dedupSaved = db586      .prepare('SELECT COALESCE(SUM((refcount - 1) * size), 0) AS bytes FROM blobs WHERE refcount > 1')587      .get().bytes;588    const versions = db589      .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM node_versions')590      .get();591    const trash = db592      .prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM nodes WHERE trashed_at IS NOT NULL AND type = 'file'")593      .get();594    return {595      ...storageStats(),596      dedupSavedBytes: dedupSaved,597      duplicates,598      largest,599      versionCount: versions.n,600      versionBytes: versions.bytes,601      trashCount: trash.n,602      trashBytes: trash.bytes,603    };604  });605606  // ── Search & activity ───────────────────────────────────────────────607  app.get('/api/v1/search', async (req) => {608    const { q = '', type, tag, folderId, starred, shared, after, before, minSize, maxSize } = req.query;609    const rows = searchNodes(String(q), {610      type, tag,611      folderId: folderId ? Number(folderId) : undefined,612      starred: starred === 'true',613      shared: shared === 'true',614      after: after ? Number(after) : undefined,615      before: before ? Number(before) : undefined,616      minSize: minSize ? Number(minSize) : undefined,617      maxSize: maxSize ? Number(maxSize) : undefined,618    });619    return { results: rows.map((r) => ({ ...nodeJson(r), snippet: r.snippet })) };620  });621622  app.get('/api/v1/activity', async (req) => {623    const limit = Math.min(Number(req.query.limit ?? 200), 1000);624    const rows = getDb()625      .prepare(626        `SELECT a.*, n.name AS node_name, n.type AS node_type FROM activity a627         LEFT JOIN nodes n ON n.id = a.node_id ORDER BY a.ts DESC LIMIT ?`,628      )629      .all(limit);630    return { events: rows };631  });632633  // ── Preview endpoints (session/API-token protected) ────────────────634  const previewNode = (req, reply) => {635    const node = getNode(Number(req.params.id));636    if (!node || node.type !== 'file') {637      apiError(reply, 404, 'not_found', 'No such file');638      return null;639    }640    return node;641  };642643  app.get('/api/v1/preview/:id', async (req, reply) => {644    const node = previewNode(req, reply);645    if (!node) return undefined;646    return describePreview(node, `/api/v1/preview/${node.id}`);647  });648649  const previewRoutes = {650    '/text': handleText,651    '/markdown': handleMarkdown,652    '/raw': handleRawText,653    '/archive': handleArchive,654    '/archive/member': handleArchiveMember,655    '/pdf': handleOfficePdf,656    '/video': handleVideoStatus,657    '/video/file': handleVideoFile,658    '/peaks': handlePeaks,659    '/exif': handleExif,660    '/heic': handleHeic,661  };662  for (const [suffix, handler] of Object.entries(previewRoutes)) {663    app.get(`/api/v1/preview/:id${suffix}`, async (req, reply) => {664      const node = previewNode(req, reply);665      if (!node) return undefined;666      return handler(node, req, reply);667    });668  }669}670