/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/routes.mjs * Purpose : Web UI routes — login, app shell, media, public share pages * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import nunjucks from 'nunjucks'; import archiver from 'archiver'; import { checkLockout, clearLoginFailures, getKeys, recordLoginFailure, verifyPassword, } from '../auth/password.mjs'; import { SESSION_COOKIE, createSession, destroySession } from '../auth/session.mjs'; import { config } from '../config.mjs'; import { blobPath } from '../storage/blobs.mjs'; import { childrenOf, getNode, isDescendant, listDescendantFiles, pathOf } from '../storage/nodes.mjs'; import { getShareByToken, recordShareEvent, shareValidity, verifySharePassword, } from '../shares/shares.mjs'; import { getRequestByToken, recordReceived, requestValidity } from '../shares/requests.mjs'; import { abortUpload, chunksPresent, completeUpload, getUpload, initUpload, markUploadSource, writeChunk, } from '../storage/upload.mjs'; import { extractNodeText } from '../search/extract.mjs'; import { previewStrategy, thumbKind, iconFamily } from '../preview/router.mjs'; import { describePreview, handleArchive, handleArchiveMember, handleExif, handleFileSend, handleHeic, handleMarkdown, handleOfficePdf, handlePeaks, handleRawText, handleText, handleThumb, handleVideoFile, handleVideoStatus, } from '../preview/handlers.mjs'; import { makeRateLimiter } from './http-helpers.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const CSRF_COOKIE = 'spbdrive_csrf'; const env = nunjucks.configure(path.join(HERE, 'views'), { autoescape: true, noCache: false }); env.addFilter('filesize', (bytes) => { const n = Number(bytes ?? 0); if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; }); const render = (template, ctx = {}) => env.render(template, { publicUrl: config.publicUrl, ...ctx }); /** Strict CSP for app + share pages (self-hosted everything). */ const PAGE_CSP = [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", // shiki + dynamic UI styles "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", "connect-src 'self'", "worker-src 'self' blob:", "frame-src 'self'", // pdf viewer iframe "object-src 'none'", "base-uri 'none'", "form-action 'self'", ].join('; '); function pageHeaders(reply) { reply.header('Content-Security-Policy', PAGE_CSP); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Referrer-Policy', 'no-referrer'); reply.header('X-Frame-Options', 'SAMEORIGIN'); reply.type('text/html; charset=utf-8'); } function csrfToken(req, reply) { let token = req.cookies?.[CSRF_COOKIE]; if (!token || !/^[a-f0-9]{32}$/.test(token)) { token = randomBytes(16).toString('hex'); reply.setCookie(CSRF_COOKIE, token, { path: '/', secure: true, sameSite: 'lax' }); } return token; } function checkCsrf(req) { const cookie = req.cookies?.[CSRF_COOKIE]; const field = req.body?._csrf; return Boolean(cookie && field && cookie === field); } // Signed cookie proving a visitor passed a share's password gate. function shareGateValue(share) { return createHmac('sha256', getKeys().shareKey) .update(`${share.token}:${share.password_hash ?? ''}`) .digest('hex'); } function shareGatePassed(req, share) { const cookie = req.cookies?.[`spbshare_${share.id}`]; if (!cookie) return false; const expected = shareGateValue(share); const a = Buffer.from(cookie); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); } /** Register web + media + share routes. */ export function registerWebRoutes(app) { const shareLimiter = makeRateLimiter({ windowMs: 60_000, max: 100 }); // ── Login / logout ────────────────────────────────────────────────── app.get('/login', async (req, reply) => { if (req.authed) return reply.redirect(req.query.next ?? '/app'); pageHeaders(reply); return reply.send(render('login.njk', { csrf: csrfToken(req, reply), error: null, next: req.query.next ?? '/app' })); }); app.post('/login', async (req, reply) => { pageHeaders(reply); const fail = (error, code = 401) => reply.code(code).send( render('login.njk', { csrf: csrfToken(req, reply), error, next: req.body?.next ?? '/app' }), ); if (!checkCsrf(req)) return fail('Session expired — try again', 403); const lock = checkLockout(req.ip); if (lock.locked) { return fail(`Too many attempts. Locked for ${Math.ceil((lock.retryAfterMs ?? 0) / 60000) || 1} min.`, 429); } if (!(await verifyPassword(req.body?.password))) { recordLoginFailure(req.ip); return fail('Wrong password'); } clearLoginFailures(req.ip); const session = createSession({ remember: req.body?.remember === 'on', ip: req.ip, ua: req.headers['user-agent'] ?? '', }); reply.setCookie(SESSION_COOKIE, session.token, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: session.maxAgeSec, }); const next = String(req.body?.next ?? '/app'); return reply.redirect(next.startsWith('/') && !next.startsWith('//') ? next : '/app'); }); app.get('/logout', async (req, reply) => { destroySession(req.cookies?.[SESSION_COOKIE]); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return reply.redirect('/login'); }); // ── App shell ─────────────────────────────────────────────────────── const requireSession = (req, reply) => { if (!req.authed) { reply.redirect(`/login?next=${encodeURIComponent(req.url)}`); return false; } return true; }; app.get('/', async (req, reply) => reply.redirect(req.authed ? '/app' : '/login')); app.get('/app', async (req, reply) => { if (!requireSession(req, reply)) return undefined; pageHeaders(reply); return reply.send(render('app.njk', {})); }); app.get('/app/*', async (req, reply) => { if (!requireSession(req, reply)) return undefined; pageHeaders(reply); return reply.send(render('app.njk', {})); }); // ── Authenticated media: /dl /stream /thumb ───────────────────────── const mediaNode = (req, reply) => { if (!req.authed) { reply.code(401).send({ error: { code: 'unauthorized', message: 'Login required' } }); return null; } const node = getNode(Number(req.params.id)); if (!node || node.type !== 'file' || !node.blob_sha) { reply.code(404).send({ error: { code: 'not_found', message: 'No such file' } }); return null; } return node; }; app.get('/dl/:id', async (req, reply) => { const node = mediaNode(req, reply); if (!node) return undefined; return handleFileSend(node, req, reply, { download: true }); }); app.get('/stream/:id', async (req, reply) => { const node = mediaNode(req, reply); if (!node) return undefined; return handleFileSend(node, req, reply, { download: false }); }); app.get('/thumb/:id', async (req, reply) => { const node = mediaNode(req, reply); if (!node) return undefined; const kind = thumbKind(node); if (!kind) return reply.code(404).send({ error: { code: 'no_thumb', message: 'No thumbnail' } }); return handleThumb(node, req, reply, kind); }); // ── Public share pages ────────────────────────────────────────────── const expiredPage = (reply, reason) => { pageHeaders(reply); // No filename in title — expired links leak nothing. return reply.code(reason === 'missing' ? 404 : 410).send(render('share-expired.njk', {})); }; /** * Resolve + validate a share request. Returns null after replying when * anything is off (unknown token, expired, gated, rate-limited). */ const shareCtx = async (req, reply, { wantPage = false } = {}) => { if (!shareLimiter(req, reply)) return null; const share = getShareByToken(String(req.params.token ?? '')); const validity = shareValidity(share); if (!validity.ok) { expiredPage(reply, validity.reason); return null; } const node = getNode(share.node_id); if (!node || node.trashed_at) { expiredPage(reply, 'missing'); return null; } if (share.password_hash && !shareGatePassed(req, share)) { if (wantPage) { pageHeaders(reply); reply.send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: null })); } else { reply.code(403).send({ error: { code: 'password_required', message: 'Password required' } }); } return null; } return { share, node }; }; app.post('/s/:token/unlock', async (req, reply) => { if (!shareLimiter(req, reply)) return undefined; const share = getShareByToken(String(req.params.token ?? '')); const validity = shareValidity(share); if (!validity.ok) return expiredPage(reply, validity.reason); pageHeaders(reply); if (!checkCsrf(req)) { return reply.code(403).send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: 'Try again' })); } if (!(await verifySharePassword(share, req.body?.password))) { return reply.code(401).send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: 'Wrong password' })); } reply.setCookie(`spbshare_${share.id}`, shareGateValue(share), { path: `/s/${share.token}`, httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86_400, }); return reply.redirect(`/s/${share.token}`); }); app.get('/s/:token', async (req, reply) => { const ctx = await shareCtx(req, reply, { wantPage: true }); if (!ctx) return undefined; const { share, node } = ctx; recordShareEvent(share.id, 'visit', { ip: req.ip, ua: req.headers['user-agent'] ?? '' }); pageHeaders(reply); const og = { title: node.name, url: `${config.publicUrl}/s/${share.token}`, image: node.type === 'file' && thumbKind(node) ? `${config.publicUrl}/s/${share.token}/thumb?size=512` : null, }; if (node.type === 'folder') { return reply.send(render('share-folder.njk', { share, node, og, allowDownload: Boolean(share.allow_download) })); } return reply.send(render('share-file.njk', { share, node, og, strategy: previewStrategy(node).strategy, icon: iconFamily(node), allowDownload: Boolean(share.allow_download), })); }); // Child resolution for folder shares — must stay inside the shared subtree. const shareChild = (ctx, childId, reply) => { if (childId === undefined || Number(childId) === ctx.node.id) return ctx.node; const child = getNode(Number(childId)); if (!child || child.trashed_at || !isDescendant(child.id, ctx.node.id)) { reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } }); return null; } return child; }; app.get('/s/:token/api/children', async (req, reply) => { const ctx = await shareCtx(req, reply); if (!ctx) return undefined; const folder = shareChild(ctx, req.query.id, reply); if (!folder) return undefined; if (folder.type !== 'folder') return reply.code(400).send({ error: { code: 'not_folder', message: 'Not a folder' } }); const children = childrenOf(folder.id).map((n) => ({ id: n.id, name: n.name, type: n.type, size: n.size, mime: n.mime, modified: n.modified, icon: iconFamily(n), hasThumb: n.type === 'file' && thumbKind(n) !== null, strategy: n.type === 'file' ? previewStrategy(n).strategy : null, })); const crumb = pathOf(folder.id); const rootIdx = crumb.findIndex((n) => n.id === ctx.node.id); return { children, path: crumb.slice(rootIdx).map((n) => ({ id: n.id, name: n.name || ctx.node.name })), allowDownload: Boolean(ctx.share.allow_download), }; }); const shareFile = async (req, reply, opts) => { const ctx = await shareCtx(req, reply); if (!ctx) return null; const node = shareChild(ctx, req.params.childId, reply); if (!node) return null; if (node.type !== 'file') { reply.code(400).send({ error: { code: 'not_file', message: 'Not a file' } }); return null; } if (opts?.download && !ctx.share.allow_download) { reply.code(403).send({ error: { code: 'no_download', message: 'Download disabled for this share' } }); return null; } return { ctx, node }; }; for (const [route, download] of [['/s/:token/dl', true], ['/s/:token/dl/:childId', true], ['/s/:token/stream', false], ['/s/:token/stream/:childId', false]]) { app.get(route, async (req, reply) => { const res = await shareFile(req, reply, { download }); if (!res) return undefined; if (download) { recordShareEvent(res.ctx.share.id, 'download', { ip: req.ip, ua: req.headers['user-agent'] ?? '' }); } return handleFileSend(res.node, req, reply, { download }); }); } for (const route of ['/s/:token/thumb', '/s/:token/thumb/:childId']) { app.get(route, async (req, reply) => { const res = await shareFile(req, reply); if (!res) return undefined; const kind = thumbKind(res.node); if (!kind) return reply.code(404).send({ error: { code: 'no_thumb', message: 'No thumbnail' } }); return handleThumb(res.node, req, reply, kind); }); } app.get('/s/:token/zip', async (req, reply) => { const ctx = await shareCtx(req, reply); if (!ctx) return undefined; if (!ctx.share.allow_download) { return reply.code(403).send({ error: { code: 'no_download', message: 'Download disabled' } }); } if (ctx.node.type !== 'folder') return reply.code(400).send({ error: { code: 'not_folder', message: 'Not a folder' } }); recordShareEvent(ctx.share.id, 'download', { ip: req.ip, ua: req.headers['user-agent'] ?? '' }); reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`${ctx.node.name || 'folder'}.zip`)}`); reply.type('application/zip'); const archive = archiver('zip', { zlib: { level: 6 } }); for (const { node: file, relPath } of listDescendantFiles(ctx.node.id)) { archive.file(blobPath(file.blob_sha), { name: relPath }); } archive.finalize(); return reply.send(archive); }); // Share preview endpoints — same handlers as the app API. app.get('/s/:token/preview', async (req, reply) => { const res = await shareFile(req, reply); if (!res) return undefined; return describePreview(res.node, `/s/${res.ctx.share.token}/preview`); }); app.get('/s/:token/preview/:childId', async (req, reply) => { const res = await shareFile(req, reply); if (!res) return undefined; return describePreview(res.node, `/s/${res.ctx.share.token}/preview/${res.node.id}`); }); const sharePreviewRoutes = { '/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(sharePreviewRoutes)) { for (const base of ['/s/:token/preview', '/s/:token/preview/:childId']) { app.get(`${base}${suffix}`, async (req, reply) => { const res = await shareFile(req, reply); if (!res) return undefined; return handler(res.node, req, reply); }); } } // ── Public file-request pages: /r/:token receives uploads ────────── const requestLimiter = makeRateLimiter({ windowMs: 60_000, max: 240 }); /** Resolve + validate a file request. Replies and returns null when off. */ const requestCtx = (req, reply, { wantPage = false } = {}) => { if (!requestLimiter(req, reply)) return null; const request = getRequestByToken(String(req.params.token ?? '')); const validity = requestValidity(request); if (!validity.ok) { if (wantPage) expiredPage(reply, validity.reason); else reply.code(410).send({ error: { code: 'unavailable', message: 'This link is no longer accepting files' } }); return null; } return request; }; /** The upload session must belong to THIS request (no cross-tenant reuse). */ const requestUpload = (request, uploadId, reply) => { const up = getUpload(uploadId); if (up.source !== `req:${request.id}`) { reply.code(404).send({ error: { code: 'not_found', message: 'Unknown upload' } }); return null; } return up; }; app.get('/r/:token', async (req, reply) => { const request = requestCtx(req, reply, { wantPage: true }); if (!request) return undefined; pageHeaders(reply); return reply.send(render('request.njk', { token: request.token, label: request.label, maxFiles: request.max_files, received: request.received, expiresAt: request.expires_at, })); }); app.post('/r/:token/upload/init', async (req, reply) => { const request = requestCtx(req, reply); if (!request) return undefined; const { name, size } = req.body ?? {}; const session = await initUpload({ parentId: request.folder_id, name: String(name ?? ''), size, }); markUploadSource(session.uploadId, `req:${request.id}`); return session; }); app.get('/r/:token/upload/:id', async (req, reply) => { const request = requestCtx(req, reply); if (!request) return undefined; const up = requestUpload(request, req.params.id, reply); if (!up) return undefined; return { uploadId: up.id, nChunks: up.n_chunks, chunkSize: up.chunk_size, have: await chunksPresent(up.id) }; }); app.put('/r/:token/upload/:id/chunk/:n', async (req, reply) => { const request = requestCtx(req, reply); if (!request) return undefined; if (!requestUpload(request, req.params.id, reply)) return undefined; await writeChunk(req.params.id, req.params.n, req.raw); return { ok: true }; }); app.post('/r/:token/upload/:id/complete', async (req, reply) => { const request = requestCtx(req, reply); if (!request) return undefined; if (!requestUpload(request, req.params.id, reply)) return undefined; const up = getUpload(req.params.id); const guessedMime = req.body?.mime || (await import('mime')).default.getType(up.name) || 'application/octet-stream'; // Never overwrite the owner's files from a public link. const node = await completeUpload(req.params.id, { conflict: 'keep-both', mime: guessedMime, ip: req.ip }); recordReceived(request.id, { name: node?.name ?? up.name, ip: req.ip }); if (node) extractNodeText(node); return { ok: true, name: node?.name ?? up.name }; }); app.delete('/r/:token/upload/:id', async (req, reply) => { const request = requestCtx(req, reply); if (!request) return undefined; if (!requestUpload(request, req.params.id, reply)) return undefined; await abortUpload(req.params.id); return { ok: true }; }); // ── Error pages ───────────────────────────────────────────────────── app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.headers.accept?.includes('application/json')) { return reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } }); } pageHeaders(reply); return reply.code(404).send(render('error.njk', { code: 404, message: 'This page does not exist.' })); }); app.setErrorHandler((err, req, reply) => { const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500; if (status >= 500) req.log.error({ err }, 'request failed'); if (req.url.startsWith('/api/') || req.headers.accept?.includes('application/json')) { return reply.code(status).send({ error: { code: err.code ?? 'internal', message: status >= 500 ? 'Internal error' : err.message }, }); } pageHeaders(reply); return reply.code(status).send(render('error.njk', { code: status, message: status >= 500 ? 'Something broke on our side.' : err.message, })); }); }