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/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