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%
21.0 KB · 527 lines javascript
Raw Blame History
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