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/web/routes.mjs8 * Purpose : Web UI routes — login, app shell, media, public share pages9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';14import path from 'node:path';15import { fileURLToPath } from 'node:url';16import nunjucks from 'nunjucks';17import archiver from 'archiver';18import {19 checkLockout, clearLoginFailures, getKeys, recordLoginFailure, verifyPassword,20} from '../auth/password.mjs';21import { SESSION_COOKIE, createSession, destroySession } from '../auth/session.mjs';22import { config } from '../config.mjs';23import { blobPath } from '../storage/blobs.mjs';24import { childrenOf, getNode, isDescendant, listDescendantFiles, pathOf } from '../storage/nodes.mjs';25import {26 getShareByToken, recordShareEvent, shareValidity, verifySharePassword,27} from '../shares/shares.mjs';28import { getRequestByToken, recordReceived, requestValidity } from '../shares/requests.mjs';29import {30 abortUpload, chunksPresent, completeUpload, getUpload, initUpload, markUploadSource, writeChunk,31} from '../storage/upload.mjs';32import { extractNodeText } from '../search/extract.mjs';33import { previewStrategy, thumbKind, iconFamily } from '../preview/router.mjs';34import {35 describePreview, handleArchive, handleArchiveMember, handleExif, handleFileSend,36 handleHeic, handleMarkdown, handleOfficePdf, handlePeaks, handleRawText,37 handleText, handleThumb, handleVideoFile, handleVideoStatus,38} from '../preview/handlers.mjs';39import { makeRateLimiter } from './http-helpers.mjs';4041const HERE = path.dirname(fileURLToPath(import.meta.url));42const CSRF_COOKIE = 'spbdrive_csrf';4344const env = nunjucks.configure(path.join(HERE, 'views'), { autoescape: true, noCache: false });45env.addFilter('filesize', (bytes) => {46 const n = Number(bytes ?? 0);47 if (n < 1024) return `${n} B`;48 const units = ['KB', 'MB', 'GB', 'TB'];49 let v = n / 1024;50 let i = 0;51 while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }52 return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;53});5455const render = (template, ctx = {}) =>56 env.render(template, { publicUrl: config.publicUrl, ...ctx });5758/** Strict CSP for app + share pages (self-hosted everything). */59const PAGE_CSP = [60 "default-src 'self'",61 "script-src 'self'",62 "style-src 'self' 'unsafe-inline'", // shiki + dynamic UI styles63 "img-src 'self' data: blob:",64 "media-src 'self' blob:",65 "font-src 'self'",66 "connect-src 'self'",67 "worker-src 'self' blob:",68 "frame-src 'self'", // pdf viewer iframe69 "object-src 'none'",70 "base-uri 'none'",71 "form-action 'self'",72].join('; ');7374function pageHeaders(reply) {75 reply.header('Content-Security-Policy', PAGE_CSP);76 reply.header('X-Content-Type-Options', 'nosniff');77 reply.header('Referrer-Policy', 'no-referrer');78 reply.header('X-Frame-Options', 'SAMEORIGIN');79 reply.type('text/html; charset=utf-8');80}8182function csrfToken(req, reply) {83 let token = req.cookies?.[CSRF_COOKIE];84 if (!token || !/^[a-f0-9]{32}$/.test(token)) {85 token = randomBytes(16).toString('hex');86 reply.setCookie(CSRF_COOKIE, token, { path: '/', secure: true, sameSite: 'lax' });87 }88 return token;89}9091function checkCsrf(req) {92 const cookie = req.cookies?.[CSRF_COOKIE];93 const field = req.body?._csrf;94 return Boolean(cookie && field && cookie === field);95}9697// Signed cookie proving a visitor passed a share's password gate.98function shareGateValue(share) {99 return createHmac('sha256', getKeys().shareKey)100 .update(`${share.token}:${share.password_hash ?? ''}`)101 .digest('hex');102}103104function shareGatePassed(req, share) {105 const cookie = req.cookies?.[`spbshare_${share.id}`];106 if (!cookie) return false;107 const expected = shareGateValue(share);108 const a = Buffer.from(cookie);109 const b = Buffer.from(expected);110 return a.length === b.length && timingSafeEqual(a, b);111}112113/** Register web + media + share routes. */114export function registerWebRoutes(app) {115 const shareLimiter = makeRateLimiter({ windowMs: 60_000, max: 100 });116117 // ── Login / logout ──────────────────────────────────────────────────118 app.get('/login', async (req, reply) => {119 if (req.authed) return reply.redirect(req.query.next ?? '/app');120 pageHeaders(reply);121 return reply.send(render('login.njk', { csrf: csrfToken(req, reply), error: null, next: req.query.next ?? '/app' }));122 });123124 app.post('/login', async (req, reply) => {125 pageHeaders(reply);126 const fail = (error, code = 401) => reply.code(code).send(127 render('login.njk', { csrf: csrfToken(req, reply), error, next: req.body?.next ?? '/app' }),128 );129 if (!checkCsrf(req)) return fail('Session expired — try again', 403);130 const lock = checkLockout(req.ip);131 if (lock.locked) {132 return fail(`Too many attempts. Locked for ${Math.ceil((lock.retryAfterMs ?? 0) / 60000) || 1} min.`, 429);133 }134 if (!(await verifyPassword(req.body?.password))) {135 recordLoginFailure(req.ip);136 return fail('Wrong password');137 }138 clearLoginFailures(req.ip);139 const session = createSession({140 remember: req.body?.remember === 'on',141 ip: req.ip,142 ua: req.headers['user-agent'] ?? '',143 });144 reply.setCookie(SESSION_COOKIE, session.token, {145 path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: session.maxAgeSec,146 });147 const next = String(req.body?.next ?? '/app');148 return reply.redirect(next.startsWith('/') && !next.startsWith('//') ? next : '/app');149 });150151 app.get('/logout', async (req, reply) => {152 destroySession(req.cookies?.[SESSION_COOKIE]);153 reply.clearCookie(SESSION_COOKIE, { path: '/' });154 return reply.redirect('/login');155 });156157 // ── App shell ───────────────────────────────────────────────────────158 const requireSession = (req, reply) => {159 if (!req.authed) {160 reply.redirect(`/login?next=${encodeURIComponent(req.url)}`);161 return false;162 }163 return true;164 };165166 app.get('/', async (req, reply) => reply.redirect(req.authed ? '/app' : '/login'));167168 app.get('/app', async (req, reply) => {169 if (!requireSession(req, reply)) return undefined;170 pageHeaders(reply);171 return reply.send(render('app.njk', {}));172 });173 app.get('/app/*', async (req, reply) => {174 if (!requireSession(req, reply)) return undefined;175 pageHeaders(reply);176 return reply.send(render('app.njk', {}));177 });178179 // ── Authenticated media: /dl /stream /thumb ─────────────────────────180 const mediaNode = (req, reply) => {181 if (!req.authed) {182 reply.code(401).send({ error: { code: 'unauthorized', message: 'Login required' } });183 return null;184 }185 const node = getNode(Number(req.params.id));186 if (!node || node.type !== 'file' || !node.blob_sha) {187 reply.code(404).send({ error: { code: 'not_found', message: 'No such file' } });188 return null;189 }190 return node;191 };192193 app.get('/dl/:id', async (req, reply) => {194 const node = mediaNode(req, reply);195 if (!node) return undefined;196 return handleFileSend(node, req, reply, { download: true });197 });198199 app.get('/stream/:id', async (req, reply) => {200 const node = mediaNode(req, reply);201 if (!node) return undefined;202 return handleFileSend(node, req, reply, { download: false });203 });204205 app.get('/thumb/:id', async (req, reply) => {206 const node = mediaNode(req, reply);207 if (!node) return undefined;208 const kind = thumbKind(node);209 if (!kind) return reply.code(404).send({ error: { code: 'no_thumb', message: 'No thumbnail' } });210 return handleThumb(node, req, reply, kind);211 });212213 // ── Public share pages ──────────────────────────────────────────────214 const expiredPage = (reply, reason) => {215 pageHeaders(reply);216 // No filename in title — expired links leak nothing.217 return reply.code(reason === 'missing' ? 404 : 410).send(render('share-expired.njk', {}));218 };219220 /**221 * Resolve + validate a share request. Returns null after replying when222 * anything is off (unknown token, expired, gated, rate-limited).223 */224 const shareCtx = async (req, reply, { wantPage = false } = {}) => {225 if (!shareLimiter(req, reply)) return null;226 const share = getShareByToken(String(req.params.token ?? ''));227 const validity = shareValidity(share);228 if (!validity.ok) {229 expiredPage(reply, validity.reason);230 return null;231 }232 const node = getNode(share.node_id);233 if (!node || node.trashed_at) {234 expiredPage(reply, 'missing');235 return null;236 }237 if (share.password_hash && !shareGatePassed(req, share)) {238 if (wantPage) {239 pageHeaders(reply);240 reply.send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: null }));241 } else {242 reply.code(403).send({ error: { code: 'password_required', message: 'Password required' } });243 }244 return null;245 }246 return { share, node };247 };248249 app.post('/s/:token/unlock', async (req, reply) => {250 if (!shareLimiter(req, reply)) return undefined;251 const share = getShareByToken(String(req.params.token ?? ''));252 const validity = shareValidity(share);253 if (!validity.ok) return expiredPage(reply, validity.reason);254 pageHeaders(reply);255 if (!checkCsrf(req)) {256 return reply.code(403).send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: 'Try again' }));257 }258 if (!(await verifySharePassword(share, req.body?.password))) {259 return reply.code(401).send(render('share-gate.njk', { token: share.token, csrf: csrfToken(req, reply), error: 'Wrong password' }));260 }261 reply.setCookie(`spbshare_${share.id}`, shareGateValue(share), {262 path: `/s/${share.token}`, httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86_400,263 });264 return reply.redirect(`/s/${share.token}`);265 });266267 app.get('/s/:token', async (req, reply) => {268 const ctx = await shareCtx(req, reply, { wantPage: true });269 if (!ctx) return undefined;270 const { share, node } = ctx;271 recordShareEvent(share.id, 'visit', { ip: req.ip, ua: req.headers['user-agent'] ?? '' });272 pageHeaders(reply);273 const og = {274 title: node.name,275 url: `${config.publicUrl}/s/${share.token}`,276 image: node.type === 'file' && thumbKind(node)277 ? `${config.publicUrl}/s/${share.token}/thumb?size=512`278 : null,279 };280 if (node.type === 'folder') {281 return reply.send(render('share-folder.njk', { share, node, og, allowDownload: Boolean(share.allow_download) }));282 }283 return reply.send(render('share-file.njk', {284 share, node, og,285 strategy: previewStrategy(node).strategy,286 icon: iconFamily(node),287 allowDownload: Boolean(share.allow_download),288 }));289 });290291 // Child resolution for folder shares — must stay inside the shared subtree.292 const shareChild = (ctx, childId, reply) => {293 if (childId === undefined || Number(childId) === ctx.node.id) return ctx.node;294 const child = getNode(Number(childId));295 if (!child || child.trashed_at || !isDescendant(child.id, ctx.node.id)) {296 reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } });297 return null;298 }299 return child;300 };301302 app.get('/s/:token/api/children', async (req, reply) => {303 const ctx = await shareCtx(req, reply);304 if (!ctx) return undefined;305 const folder = shareChild(ctx, req.query.id, reply);306 if (!folder) return undefined;307 if (folder.type !== 'folder') return reply.code(400).send({ error: { code: 'not_folder', message: 'Not a folder' } });308 const children = childrenOf(folder.id).map((n) => ({309 id: n.id, name: n.name, type: n.type, size: n.size, mime: n.mime, modified: n.modified,310 icon: iconFamily(n),311 hasThumb: n.type === 'file' && thumbKind(n) !== null,312 strategy: n.type === 'file' ? previewStrategy(n).strategy : null,313 }));314 const crumb = pathOf(folder.id);315 const rootIdx = crumb.findIndex((n) => n.id === ctx.node.id);316 return {317 children,318 path: crumb.slice(rootIdx).map((n) => ({ id: n.id, name: n.name || ctx.node.name })),319 allowDownload: Boolean(ctx.share.allow_download),320 };321 });322323 const shareFile = async (req, reply, opts) => {324 const ctx = await shareCtx(req, reply);325 if (!ctx) return null;326 const node = shareChild(ctx, req.params.childId, reply);327 if (!node) return null;328 if (node.type !== 'file') {329 reply.code(400).send({ error: { code: 'not_file', message: 'Not a file' } });330 return null;331 }332 if (opts?.download && !ctx.share.allow_download) {333 reply.code(403).send({ error: { code: 'no_download', message: 'Download disabled for this share' } });334 return null;335 }336 return { ctx, node };337 };338339 for (const [route, download] of [['/s/:token/dl', true], ['/s/:token/dl/:childId', true],340 ['/s/:token/stream', false], ['/s/:token/stream/:childId', false]]) {341 app.get(route, async (req, reply) => {342 const res = await shareFile(req, reply, { download });343 if (!res) return undefined;344 if (download) {345 recordShareEvent(res.ctx.share.id, 'download', { ip: req.ip, ua: req.headers['user-agent'] ?? '' });346 }347 return handleFileSend(res.node, req, reply, { download });348 });349 }350351 for (const route of ['/s/:token/thumb', '/s/:token/thumb/:childId']) {352 app.get(route, async (req, reply) => {353 const res = await shareFile(req, reply);354 if (!res) return undefined;355 const kind = thumbKind(res.node);356 if (!kind) return reply.code(404).send({ error: { code: 'no_thumb', message: 'No thumbnail' } });357 return handleThumb(res.node, req, reply, kind);358 });359 }360361 app.get('/s/:token/zip', async (req, reply) => {362 const ctx = await shareCtx(req, reply);363 if (!ctx) return undefined;364 if (!ctx.share.allow_download) {365 return reply.code(403).send({ error: { code: 'no_download', message: 'Download disabled' } });366 }367 if (ctx.node.type !== 'folder') return reply.code(400).send({ error: { code: 'not_folder', message: 'Not a folder' } });368 recordShareEvent(ctx.share.id, 'download', { ip: req.ip, ua: req.headers['user-agent'] ?? '' });369 reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`${ctx.node.name || 'folder'}.zip`)}`);370 reply.type('application/zip');371 const archive = archiver('zip', { zlib: { level: 6 } });372 for (const { node: file, relPath } of listDescendantFiles(ctx.node.id)) {373 archive.file(blobPath(file.blob_sha), { name: relPath });374 }375 archive.finalize();376 return reply.send(archive);377 });378379 // Share preview endpoints — same handlers as the app API.380 app.get('/s/:token/preview', async (req, reply) => {381 const res = await shareFile(req, reply);382 if (!res) return undefined;383 return describePreview(res.node, `/s/${res.ctx.share.token}/preview`);384 });385 app.get('/s/:token/preview/:childId', async (req, reply) => {386 const res = await shareFile(req, reply);387 if (!res) return undefined;388 return describePreview(res.node, `/s/${res.ctx.share.token}/preview/${res.node.id}`);389 });390391 const sharePreviewRoutes = {392 '/text': handleText,393 '/markdown': handleMarkdown,394 '/raw': handleRawText,395 '/archive': handleArchive,396 '/archive/member': handleArchiveMember,397 '/pdf': handleOfficePdf,398 '/video': handleVideoStatus,399 '/video/file': handleVideoFile,400 '/peaks': handlePeaks,401 '/exif': handleExif,402 '/heic': handleHeic,403 };404 for (const [suffix, handler] of Object.entries(sharePreviewRoutes)) {405 for (const base of ['/s/:token/preview', '/s/:token/preview/:childId']) {406 app.get(`${base}${suffix}`, async (req, reply) => {407 const res = await shareFile(req, reply);408 if (!res) return undefined;409 return handler(res.node, req, reply);410 });411 }412 }413414 // ── Public file-request pages: /r/:token receives uploads ──────────415 const requestLimiter = makeRateLimiter({ windowMs: 60_000, max: 240 });416417 /** Resolve + validate a file request. Replies and returns null when off. */418 const requestCtx = (req, reply, { wantPage = false } = {}) => {419 if (!requestLimiter(req, reply)) return null;420 const request = getRequestByToken(String(req.params.token ?? ''));421 const validity = requestValidity(request);422 if (!validity.ok) {423 if (wantPage) expiredPage(reply, validity.reason);424 else reply.code(410).send({ error: { code: 'unavailable', message: 'This link is no longer accepting files' } });425 return null;426 }427 return request;428 };429430 /** The upload session must belong to THIS request (no cross-tenant reuse). */431 const requestUpload = (request, uploadId, reply) => {432 const up = getUpload(uploadId);433 if (up.source !== `req:${request.id}`) {434 reply.code(404).send({ error: { code: 'not_found', message: 'Unknown upload' } });435 return null;436 }437 return up;438 };439440 app.get('/r/:token', async (req, reply) => {441 const request = requestCtx(req, reply, { wantPage: true });442 if (!request) return undefined;443 pageHeaders(reply);444 return reply.send(render('request.njk', {445 token: request.token,446 label: request.label,447 maxFiles: request.max_files,448 received: request.received,449 expiresAt: request.expires_at,450 }));451 });452453 app.post('/r/:token/upload/init', async (req, reply) => {454 const request = requestCtx(req, reply);455 if (!request) return undefined;456 const { name, size } = req.body ?? {};457 const session = await initUpload({458 parentId: request.folder_id, name: String(name ?? ''), size,459 });460 markUploadSource(session.uploadId, `req:${request.id}`);461 return session;462 });463464 app.get('/r/:token/upload/:id', async (req, reply) => {465 const request = requestCtx(req, reply);466 if (!request) return undefined;467 const up = requestUpload(request, req.params.id, reply);468 if (!up) return undefined;469 return { uploadId: up.id, nChunks: up.n_chunks, chunkSize: up.chunk_size, have: await chunksPresent(up.id) };470 });471472 app.put('/r/:token/upload/:id/chunk/:n', async (req, reply) => {473 const request = requestCtx(req, reply);474 if (!request) return undefined;475 if (!requestUpload(request, req.params.id, reply)) return undefined;476 await writeChunk(req.params.id, req.params.n, req.raw);477 return { ok: true };478 });479480 app.post('/r/:token/upload/:id/complete', async (req, reply) => {481 const request = requestCtx(req, reply);482 if (!request) return undefined;483 if (!requestUpload(request, req.params.id, reply)) return undefined;484 const up = getUpload(req.params.id);485 const guessedMime = req.body?.mime486 || (await import('mime')).default.getType(up.name)487 || 'application/octet-stream';488 // Never overwrite the owner's files from a public link.489 const node = await completeUpload(req.params.id, { conflict: 'keep-both', mime: guessedMime, ip: req.ip });490 recordReceived(request.id, { name: node?.name ?? up.name, ip: req.ip });491 if (node) extractNodeText(node);492 return { ok: true, name: node?.name ?? up.name };493 });494495 app.delete('/r/:token/upload/:id', async (req, reply) => {496 const request = requestCtx(req, reply);497 if (!request) return undefined;498 if (!requestUpload(request, req.params.id, reply)) return undefined;499 await abortUpload(req.params.id);500 return { ok: true };501 });502503 // ── Error pages ─────────────────────────────────────────────────────504 app.setNotFoundHandler((req, reply) => {505 if (req.url.startsWith('/api/') || req.headers.accept?.includes('application/json')) {506 return reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } });507 }508 pageHeaders(reply);509 return reply.code(404).send(render('error.njk', { code: 404, message: 'This page does not exist.' }));510 });511512 app.setErrorHandler((err, req, reply) => {513 const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500;514 if (status >= 500) req.log.error({ err }, 'request failed');515 if (req.url.startsWith('/api/') || req.headers.accept?.includes('application/json')) {516 return reply.code(status).send({517 error: { code: err.code ?? 'internal', message: status >= 500 ? 'Internal error' : err.message },518 });519 }520 pageHeaders(reply);521 return reply.code(status).send(render('error.njk', {522 code: status,523 message: status >= 500 ? 'Something broke on our side.' : err.message,524 }));525 });526}527