/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/api/v1.mjs * Purpose : JSON API /api/v1 — nodes, uploads, shares, tags, search, auth * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { Readable } from 'node:stream'; import QRCode from 'qrcode'; import mime from 'mime'; import archiver from 'archiver'; import { config } from '../config.mjs'; import { getDb, logActivity } from '../db/db.mjs'; import { changePassword, checkLockout, clearLoginFailures, recordLoginFailure, verifyPassword, } from '../auth/password.mjs'; import { SESSION_COOKIE, createApiToken, createSession, destroySession, listApiTokens, listSessions, revokeAllSessions, revokeApiToken, revokeSession, } from '../auth/session.mjs'; import { putBlob, storageStats } from '../storage/blobs.mjs'; import { ROOT_ID, childByName, childrenOf, copyNode, createFileNode, deleteForever, duplicateNode, getNode, listDescendantFiles, listTrash, mkdir, moveNode, mustGetNode, pathOf, pathString, recentFiles, renameNode, resolvePath, restoreNode, trashNode, } from '../storage/nodes.mjs'; import { deleteVersion, getVersion, listVersions, restoreVersion, setNodeBlob, } from '../storage/versions.mjs'; import { abortUpload, chunksPresent, completeUpload, getUpload, initUpload, writeChunk, } from '../storage/upload.mjs'; import { createShare, getShareById, listShares, revokeShare, shareEvents, sharesForNode, updateShare, } from '../shares/shares.mjs'; import { closeRequest, createRequest, getRequestById, listRequests, } from '../shares/requests.mjs'; import { searchNodes } from '../search/search.mjs'; import { extractNodeText } from '../search/extract.mjs'; import { updateFtsTags } from '../search/index-sync.mjs'; import { previewStrategy, thumbKind, iconFamily } from '../preview/router.mjs'; import { describePreview, handleArchive, handleArchiveMember, handleExif, handleHeic, handleMarkdown, handleOfficePdf, handlePeaks, handleRawText, handleText, handleVideoFile, handleVideoStatus, } from '../preview/handlers.mjs'; import { apiError, makeRateLimiter, sendBlob } from '../web/http-helpers.mjs'; /** Serialize a node row for API responses. */ export function nodeJson(node) { if (!node) return null; const tags = getDb() .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 = ?') .all(node.id); return { id: node.id, parentId: node.parent_id, name: node.name, type: node.type, size: node.size, mime: node.mime, created: node.created, modified: node.modified, starred: Boolean(node.starred), color: node.color, emoji: node.emoji, trashedAt: node.trashed_at, strategy: node.type === 'file' ? previewStrategy(node).strategy : null, icon: iconFamily(node), hasThumb: node.type === 'file' ? thumbKind(node) !== null : false, tags, }; } function shareJson(share) { return { id: share.id, token: share.token, url: `${config.publicUrl}/s/${share.token}`, nodeId: share.node_id, nodeName: share.node_name, nodeType: share.node_type, created: share.created, expiresAt: share.expires_at, hasPassword: Boolean(share.password_hash), maxDownloads: share.max_downloads, downloads: share.downloads, visits: share.visits, allowDownload: Boolean(share.allow_download), label: share.label, revokedAt: share.revoked_at, }; } /** Register every /api/v1 route. */ export function registerApiV1(app) { const loginLimiter = makeRateLimiter({ windowMs: 60_000, max: 20 }); // ── Auth (no session required) ───────────────────────────────────── app.post('/api/v1/auth/login', async (req, reply) => { if (!loginLimiter(req, reply)) return undefined; const lock = checkLockout(req.ip); if (lock.locked) { return apiError(reply, 429, 'locked_out', `Too many attempts. Retry in ${Math.ceil((lock.retryAfterMs ?? 0) / 1000)}s`); } const { password, remember } = req.body ?? {}; if (!(await verifyPassword(password))) { recordLoginFailure(req.ip); return apiError(reply, 401, 'bad_password', 'Wrong password'); } clearLoginFailures(req.ip); const session = createSession({ remember: Boolean(remember), ip: req.ip, ua: req.headers['user-agent'] ?? '', }); reply.setCookie(SESSION_COOKIE, session.token, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: session.maxAgeSec, }); return { ok: true }; }); // Everything below requires auth (session cookie or Bearer API token) + // a CSRF header on state-changing calls when cookie-authenticated. app.addHook('preHandler', async (req, reply) => { if (req.url.startsWith('/api/v1/auth/login')) return; if (!req.url.startsWith('/api/v1/')) return; if (!req.authed) return apiError(reply, 401, 'unauthorized', 'Login required'); if (req.authKind === 'session' && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { if (req.headers['x-spbdrive-csrf'] !== '1') { return apiError(reply, 403, 'csrf', 'Missing CSRF header'); } } return undefined; }); app.post('/api/v1/auth/logout', async (req, reply) => { destroySession(req.cookies?.[SESSION_COOKIE]); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); app.post('/api/v1/auth/password', async (req, reply) => { const { current, next } = req.body ?? {}; const ok = await changePassword(current, next).catch((err) => apiError(reply, 400, 'weak_password', err.message)); if (ok === false) return apiError(reply, 403, 'bad_password', 'Current password is wrong'); return { ok: true }; }); app.get('/api/v1/auth/sessions', async () => ({ sessions: listSessions() })); app.delete('/api/v1/auth/sessions/:id', async (req) => ({ ok: revokeSession(Number(req.params.id)) })); app.delete('/api/v1/auth/sessions', async (req, reply) => { revokeAllSessions(); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); app.get('/api/v1/auth/api-tokens', async () => ({ tokens: listApiTokens() })); app.post('/api/v1/auth/api-tokens', async (req) => createApiToken(req.body?.name ?? 'token')); app.delete('/api/v1/auth/api-tokens/:id', async (req) => ({ ok: revokeApiToken(Number(req.params.id)) })); app.get('/api/v1/me', async (req) => ({ user: 'Simon-Pierre Boucher', email: 'contact@spboucher.ai', authKind: req.authKind, rootId: ROOT_ID, })); app.get('/api/v1/stats', async () => storageStats()); // ── Nodes ─────────────────────────────────────────────────────────── app.get('/api/v1/nodes/:id', async (req, reply) => { const node = getNode(Number(req.params.id)); if (!node) return apiError(reply, 404, 'not_found', 'No such node'); return { node: nodeJson(node), path: pathOf(node.id).map((n) => ({ id: n.id, name: n.name })) }; }); app.get('/api/v1/nodes/:id/children', async (req, reply) => { const id = Number(req.params.id); const node = getNode(id); if (!node) return apiError(reply, 404, 'not_found', 'No such folder'); const { sort = 'name', dir = 'asc' } = req.query; return { children: childrenOf(id, { sort, dir }).map(nodeJson), path: pathOf(id).map((n) => ({ id: n.id, name: n.name })), }; }); // Full folder tree for the sidebar (folders only). app.get('/api/v1/tree', async () => { const rows = getDb() .prepare("SELECT id, parent_id, name, color, emoji FROM nodes WHERE type = 'folder' AND trashed_at IS NULL ORDER BY name COLLATE NOCASE") .all(); return { folders: rows }; }); app.get('/api/v1/resolve', async (req, reply) => { const node = resolvePath(String(req.query.path ?? '/')); if (!node) return apiError(reply, 404, 'not_found', 'Path not found'); return { node: nodeJson(node) }; }); app.post('/api/v1/nodes', async (req, reply) => { const { parentId, name, type } = req.body ?? {}; if (type !== 'folder') return apiError(reply, 400, 'bad_type', 'Only folders are created here'); const node = mkdir(Number(parentId ?? ROOT_ID), String(name ?? ''), { ip: req.ip }); return { node: nodeJson(node) }; }); app.patch('/api/v1/nodes/:id', async (req, reply) => { const id = Number(req.params.id); let node = mustGetNode(id); const body = req.body ?? {}; if (body.name !== undefined && body.name !== node.name) { node = renameNode(id, String(body.name), { ip: req.ip }); } if (body.parentId !== undefined && Number(body.parentId) !== node.parent_id) { const moved = moveNode(id, Number(body.parentId), { conflict: body.conflict ?? 'keep-both', ip: req.ip }); if (moved === null) return { node: null, skipped: true }; node = moved; } const db = getDb(); if (body.starred !== undefined) { db.prepare('UPDATE nodes SET starred = ? WHERE id = ?').run(body.starred ? 1 : 0, id); logActivity(body.starred ? 'node.star' : 'node.unstar', { nodeId: id, ip: req.ip }); } if (body.color !== undefined) db.prepare('UPDATE nodes SET color = ? WHERE id = ?').run(body.color, id); if (body.emoji !== undefined) db.prepare('UPDATE nodes SET emoji = ? WHERE id = ?').run(body.emoji, id); if (Array.isArray(body.tagIds)) { db.prepare('DELETE FROM node_tags WHERE node_id = ?').run(id); const ins = db.prepare('INSERT OR IGNORE INTO node_tags (node_id, tag_id) VALUES (?, ?)'); for (const tagId of body.tagIds) ins.run(id, Number(tagId)); updateFtsTags(id); } return { node: nodeJson(getNode(id)) }; }); app.delete('/api/v1/nodes/:id', async (req) => { const id = Number(req.params.id); if (req.query.force === 'true') { deleteForever(id, { ip: req.ip }); return { ok: true, forever: true }; } return { ok: true, node: nodeJson(trashNode(id, { ip: req.ip })) }; }); app.post('/api/v1/nodes/:id/restore', async (req) => ({ node: nodeJson(restoreNode(Number(req.params.id), { ip: req.ip })) })); app.post('/api/v1/nodes/:id/copy', async (req) => ({ node: nodeJson(copyNode(Number(req.params.id), Number(req.body?.parentId ?? ROOT_ID), { ip: req.ip })) })); app.post('/api/v1/nodes/:id/duplicate', async (req) => ({ node: nodeJson(duplicateNode(Number(req.params.id), { ip: req.ip })) })); // ── Version history ───────────────────────────────────────────────── app.get('/api/v1/nodes/:id/versions', async (req, reply) => { const node = getNode(Number(req.params.id)); if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file'); return { versions: listVersions(node.id).map((v) => ({ id: v.id, size: v.size, mime: v.mime, created: v.created, replacedAt: v.replaced_at, origin: v.origin, })), }; }); app.post('/api/v1/nodes/:id/versions/:vid/restore', async (req) => { const node = restoreVersion(Number(req.params.id), Number(req.params.vid), { ip: req.ip }); extractNodeText(node); return { node: nodeJson(node) }; }); app.delete('/api/v1/nodes/:id/versions/:vid', async (req, reply) => { if (!deleteVersion(Number(req.params.id), Number(req.params.vid))) { return apiError(reply, 404, 'not_found', 'No such version'); } return { ok: true }; }); app.get('/api/v1/nodes/:id/versions/:vid/dl', async (req, reply) => { const node = getNode(Number(req.params.id)); const version = node ? getVersion(node.id, Number(req.params.vid)) : null; if (!version) return apiError(reply, 404, 'not_found', 'No such version'); const stamp = new Date(version.replaced_at).toISOString().slice(0, 10); const dot = node.name.lastIndexOf('.'); const versionedName = dot > 0 ? `${node.name.slice(0, dot)} (v${stamp})${node.name.slice(dot)}` : `${node.name} (v${stamp})`; return sendBlob(req, reply, { sha: version.blob_sha, mime: version.mime, filename: versionedName, download: true, }); }); // ── In-browser editor: create + save text files ───────────────────── const TEXT_EDIT_LIMIT = 16 * 1024 * 1024; app.post('/api/v1/files', async (req, reply) => { const { parentId, name, content = '' } = req.body ?? {}; if (typeof content !== 'string' || content.length > TEXT_EDIT_LIMIT) { return apiError(reply, 413, 'too_large', 'Content too large'); } const buf = Buffer.from(content, 'utf8'); const { sha, size } = await putBlob(Readable.from([buf])); const guessed = mime.getType(String(name ?? '')) || 'text/plain'; const node = createFileNode(Number(parentId ?? ROOT_ID), String(name ?? ''), { sha, size, mime: guessed, conflict: 'keep-both', ip: req.ip, }); if (node) extractNodeText(node); return { node: nodeJson(node) }; }); app.put('/api/v1/nodes/:id/content', async (req, reply) => { const node = getNode(Number(req.params.id)); if (!node || node.type !== 'file') return apiError(reply, 404, 'not_found', 'No such file'); const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''), 'utf8'); if (body.length > TEXT_EDIT_LIMIT) return apiError(reply, 413, 'too_large', 'Content too large'); const { sha, size } = await putBlob(Readable.from([body])); const updated = setNodeBlob(node.id, { sha, size, mime: node.mime, origin: 'edit', ip: req.ip }); extractNodeText(updated); return { node: nodeJson(updated) }; }); app.get('/api/v1/trash', async () => ({ items: listTrash().map(nodeJson) })); app.post('/api/v1/trash/empty', async (req) => { const items = listTrash(); for (const item of items) deleteForever(item.id, { ip: req.ip }); return { ok: true, removed: items.length }; }); app.get('/api/v1/recent', async () => ({ items: recentFiles(50).map(nodeJson) })); // ── Uploads (chunked + resumable) ─────────────────────────────────── app.post('/api/v1/upload/init', async (req, reply) => { const { parentId, name, size, path: relPath } = req.body ?? {}; // Folder uploads send a relative path — create intermediate dirs. let parent = Number(parentId ?? ROOT_ID); if (relPath) { const parts = String(relPath).split('/').filter(Boolean).slice(0, -1); for (const part of parts) parent = mkdir(parent, part, { ip: req.ip }).id; } const session = await initUpload({ parentId: parent, name: String(name ?? ''), size }); // Tell the client about a same-name file so it can offer // Keep both / Replace (new version) / Skip before completing. const existing = childByName(parent, String(name ?? '')); session.conflictsWith = existing?.type === 'file' ? { id: existing.id, name: existing.name, size: existing.size, modified: existing.modified } : null; return session; }); app.get('/api/v1/upload/:id', async (req) => { const up = getUpload(req.params.id); return { uploadId: up.id, nChunks: up.n_chunks, chunkSize: up.chunk_size, have: await chunksPresent(up.id) }; }); app.put('/api/v1/upload/:id/chunk/:n', async (req, reply) => { await writeChunk(req.params.id, req.params.n, req.raw); return { ok: true }; }); app.post('/api/v1/upload/:id/complete', async (req) => { const { conflict, mime: bodyMime } = req.body ?? {}; const up = getUpload(req.params.id); const guessed = bodyMime || mime.getType(up.name) || 'application/octet-stream'; const node = await completeUpload(req.params.id, { conflict, mime: guessed, ip: req.ip }); if (node) extractNodeText(node); // background FTS extraction return { node: nodeJson(node), skipped: node === null }; }); app.delete('/api/v1/upload/:id', async (req) => { await abortUpload(req.params.id); return { ok: true }; }); // ── Multi-select ZIP download ─────────────────────────────────────── app.get('/api/v1/zip', async (req, reply) => { const ids = String(req.query.ids ?? '').split(',').map(Number).filter(Boolean); if (ids.length === 0) return apiError(reply, 400, 'no_ids', 'ids required'); const name = ids.length === 1 ? `${mustGetNode(ids[0]).name}.zip` : 'spbdrive-selection.zip'; reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`); reply.type('application/zip'); const archive = archiver('zip', { zlib: { level: 6 } }); for (const id of ids) { const node = mustGetNode(id); if (node.type === 'file') { archive.file((await import('../storage/blobs.mjs')).blobPath(node.blob_sha), { name: node.name }); } else { for (const { node: file, relPath } of listDescendantFiles(node.id, node.name)) { archive.file((await import('../storage/blobs.mjs')).blobPath(file.blob_sha), { name: relPath }); } } } archive.finalize(); logActivity('file.download_zip', { detail: `${ids.length} item(s)`, ip: req.ip }); return reply.send(archive); }); // ── Tags ──────────────────────────────────────────────────────────── app.get('/api/v1/tags', async () => ({ tags: getDb().prepare( `SELECT t.*, COUNT(nt.node_id) AS count FROM tags t LEFT JOIN node_tags nt ON nt.tag_id = t.id GROUP BY t.id ORDER BY t.name`, ).all(), })); app.post('/api/v1/tags', async (req, reply) => { const name = String(req.body?.name ?? '').trim().slice(0, 40); if (!name) return apiError(reply, 400, 'bad_name', 'Tag name required'); const color = String(req.body?.color ?? '#4f8cff'); getDb() .prepare('INSERT INTO tags (name, color) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET color = excluded.color') .run(name, color); return { tag: getDb().prepare('SELECT * FROM tags WHERE name = ?').get(name) }; }); app.patch('/api/v1/tags/:id', async (req) => { const { name, color } = req.body ?? {}; const db = getDb(); if (name) db.prepare('UPDATE tags SET name = ? WHERE id = ?').run(String(name).slice(0, 40), Number(req.params.id)); if (color) db.prepare('UPDATE tags SET color = ? WHERE id = ?').run(String(color), Number(req.params.id)); return { tag: db.prepare('SELECT * FROM tags WHERE id = ?').get(Number(req.params.id)) }; }); app.delete('/api/v1/tags/:id', async (req) => { getDb().prepare('DELETE FROM tags WHERE id = ?').run(Number(req.params.id)); return { ok: true }; }); app.get('/api/v1/tags/:id/nodes', async (req) => { const rows = getDb() .prepare( `SELECT n.* FROM node_tags nt JOIN nodes n ON n.id = nt.node_id WHERE nt.tag_id = ? AND n.trashed_at IS NULL ORDER BY n.modified DESC`, ) .all(Number(req.params.id)); return { items: rows.map(nodeJson) }; }); // ── Starred ───────────────────────────────────────────────────────── app.get('/api/v1/starred', async () => ({ items: getDb() .prepare('SELECT * FROM nodes WHERE starred = 1 AND trashed_at IS NULL ORDER BY modified DESC') .all() .map(nodeJson), })); // ── Shares ────────────────────────────────────────────────────────── app.get('/api/v1/shares', async () => ({ shares: listShares().map(shareJson) })); app.get('/api/v1/nodes/:id/shares', async (req) => ({ shares: sharesForNode(Number(req.params.id)).map((s) => shareJson({ ...s, node_name: '', node_type: '' })), })); app.post('/api/v1/shares', async (req, reply) => { const { nodeId, expiresAt, password, maxDownloads, allowDownload, label } = req.body ?? {}; const share = await createShare(Number(nodeId), { expiresAt: expiresAt ?? null, password: password || null, maxDownloads: maxDownloads ?? null, allowDownload, label: label ?? null, ip: req.ip, }); const full = { ...share, node_name: mustGetNode(share.node_id).name, node_type: mustGetNode(share.node_id).type }; return { share: shareJson(full) }; }); app.patch('/api/v1/shares/:id', async (req, reply) => { const share = await updateShare(Number(req.params.id), req.body ?? {}); if (!share) return apiError(reply, 404, 'not_found', 'No such share'); const node = mustGetNode(share.node_id); return { share: shareJson({ ...share, node_name: node.name, node_type: node.type }) }; }); app.delete('/api/v1/shares/:id', async (req, reply) => { if (!revokeShare(Number(req.params.id), { ip: req.ip })) { return apiError(reply, 404, 'not_found', 'No such share'); } return { ok: true }; }); app.get('/api/v1/shares/:id/events', async (req, reply) => { const share = getShareById(Number(req.params.id)); if (!share) return apiError(reply, 404, 'not_found', 'No such share'); return { events: shareEvents(share.id) }; }); app.get('/api/v1/shares/:id/qr', async (req, reply) => { const share = getShareById(Number(req.params.id)); if (!share) return apiError(reply, 404, 'not_found', 'No such share'); const svg = await QRCode.toString(`${config.publicUrl}/s/${share.token}`, { type: 'svg', margin: 1, width: 240, color: { dark: '#0b0e14', light: '#ffffff' }, }); reply.type('image/svg+xml'); return reply.send(svg); }); // ── File requests (receive files from others) ─────────────────────── const requestJson = (r) => ({ id: r.id, token: r.token, url: `${config.publicUrl}/r/${r.token}`, folderId: r.folder_id, folderName: r.folder_name ?? getNode(r.folder_id)?.name ?? '', label: r.label, created: r.created, expiresAt: r.expires_at, maxFiles: r.max_files, received: r.received, closedAt: r.closed_at, }); app.get('/api/v1/requests', async () => ({ requests: listRequests().map(requestJson) })); app.post('/api/v1/requests', async (req) => { const { folderId, label, expiresAt, maxFiles } = req.body ?? {}; const request = createRequest(Number(folderId ?? ROOT_ID), { label: label ?? null, expiresAt: expiresAt ?? null, maxFiles: maxFiles ?? null, ip: req.ip, }); return { request: requestJson(request) }; }); app.delete('/api/v1/requests/:id', async (req, reply) => { if (!closeRequest(Number(req.params.id), { ip: req.ip })) { return apiError(reply, 404, 'not_found', 'No such request'); } return { ok: true }; }); app.get('/api/v1/requests/:id/qr', async (req, reply) => { const request = getRequestById(Number(req.params.id)); if (!request) return apiError(reply, 404, 'not_found', 'No such request'); const svg = await QRCode.toString(`${config.publicUrl}/r/${request.token}`, { type: 'svg', margin: 1, width: 240, color: { dark: '#0b0e14', light: '#ffffff' }, }); reply.type('image/svg+xml'); return reply.send(svg); }); // ── Storage insights: dedup savings, duplicates, largest files ────── app.get('/api/v1/stats/detailed', async () => { const db = getDb(); const dupGroups = db .prepare( `SELECT blob_sha AS sha, COUNT(*) AS copies, MAX(size) AS size FROM nodes WHERE type = 'file' AND trashed_at IS NULL AND blob_sha IS NOT NULL GROUP BY blob_sha HAVING copies > 1 ORDER BY (copies - 1) * size DESC LIMIT 50`, ) .all(); const nodesForSha = db.prepare( "SELECT * FROM nodes WHERE blob_sha = ? AND trashed_at IS NULL AND type = 'file' LIMIT 10", ); const duplicates = dupGroups.map((g) => ({ sha: g.sha, copies: g.copies, size: g.size, savedBytes: (g.copies - 1) * g.size, nodes: nodesForSha.all(g.sha).map((n) => ({ id: n.id, parentId: n.parent_id, name: n.name, path: pathString(n.id), })), })); const largest = db .prepare( `SELECT * FROM nodes WHERE type = 'file' AND trashed_at IS NULL ORDER BY size DESC LIMIT 20`, ) .all() .map((n) => ({ ...nodeJson(n), path: pathString(n.id) })); const dedupSaved = db .prepare('SELECT COALESCE(SUM((refcount - 1) * size), 0) AS bytes FROM blobs WHERE refcount > 1') .get().bytes; const versions = db .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM node_versions') .get(); const trash = db .prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS bytes FROM nodes WHERE trashed_at IS NOT NULL AND type = 'file'") .get(); return { ...storageStats(), dedupSavedBytes: dedupSaved, duplicates, largest, versionCount: versions.n, versionBytes: versions.bytes, trashCount: trash.n, trashBytes: trash.bytes, }; }); // ── Search & activity ─────────────────────────────────────────────── app.get('/api/v1/search', async (req) => { const { q = '', type, tag, folderId, starred, shared, after, before, minSize, maxSize } = req.query; const rows = searchNodes(String(q), { type, tag, folderId: folderId ? Number(folderId) : undefined, starred: starred === 'true', shared: shared === 'true', after: after ? Number(after) : undefined, before: before ? Number(before) : undefined, minSize: minSize ? Number(minSize) : undefined, maxSize: maxSize ? Number(maxSize) : undefined, }); return { results: rows.map((r) => ({ ...nodeJson(r), snippet: r.snippet })) }; }); app.get('/api/v1/activity', async (req) => { const limit = Math.min(Number(req.query.limit ?? 200), 1000); const rows = getDb() .prepare( `SELECT a.*, n.name AS node_name, n.type AS node_type FROM activity a LEFT JOIN nodes n ON n.id = a.node_id ORDER BY a.ts DESC LIMIT ?`, ) .all(limit); return { events: rows }; }); // ── Preview endpoints (session/API-token protected) ──────────────── const previewNode = (req, reply) => { const node = getNode(Number(req.params.id)); if (!node || node.type !== 'file') { apiError(reply, 404, 'not_found', 'No such file'); return null; } return node; }; app.get('/api/v1/preview/:id', async (req, reply) => { const node = previewNode(req, reply); if (!node) return undefined; return describePreview(node, `/api/v1/preview/${node.id}`); }); const previewRoutes = { '/text': handleText, '/markdown': handleMarkdown, '/raw': handleRawText, '/archive': handleArchive, '/archive/member': handleArchiveMember, '/pdf': handleOfficePdf, '/video': handleVideoStatus, '/video/file': handleVideoFile, '/peaks': handlePeaks, '/exif': handleExif, '/heic': handleHeic, }; for (const [suffix, handler] of Object.entries(previewRoutes)) { app.get(`/api/v1/preview/:id${suffix}`, async (req, reply) => { const node = previewNode(req, reply); if (!node) return undefined; return handler(node, req, reply); }); } }