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%
4.5 KB · 114 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/http-helpers.mjs8 *  Purpose : Range-aware blob responses, rate limiting, content-type policy9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { blobPath, blobStream } from '../storage/blobs.mjs';14import { stat } from 'node:fs/promises';1516/** MIME types that are safe to render inline; everything else downloads. */17const INLINE_SAFE = /^(image\/(?!svg)|video\/|audio\/|application\/pdf|text\/plain$)/;1819/** RFC7233 single-range parser. Returns null when absent/invalid. */20export function parseRange(header, size) {21  if (!header || !header.startsWith('bytes=')) return null;22  const m = header.slice(6).split(',')[0].trim().match(/^(\d*)-(\d*)$/);23  if (!m || (m[1] === '' && m[2] === '')) return null;24  let start;25  let end;26  if (m[1] === '') {27    // suffix range: last N bytes28    const n = Number(m[2]);29    if (n === 0) return null;30    start = Math.max(0, size - n);31    end = size - 1;32  } else {33    start = Number(m[1]);34    end = m[2] === '' ? size - 1 : Number(m[2]);35  }36  if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) return null;37  return { start, end: Math.min(end, size - 1) };38}3940/** RFC5987 filename* encoding for Content-Disposition. */41function contentDisposition(kind, filename) {42  const ascii = filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, "'");43  return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;44}4546/**47 * Stream a blob with full Range support (instant video seeking).48 * @param {{download?: boolean, mime?: string, filename?: string}} opts49 *   download=true forces attachment; inline is only allowed for safe types50 *   (uploaded HTML/SVG never executes same-origin — security checklist).51 */52export async function sendBlob(req, reply, { sha, mime, filename, download = false }) {53  const size = (await stat(blobPath(sha))).size;54  const type = mime || 'application/octet-stream';55  const inline = !download && INLINE_SAFE.test(type);56  const servedType = inline ? type : type === 'text/html' || type === 'image/svg+xml'57    ? type // still declared, but forced to attachment below58    : type;5960  reply.header('Accept-Ranges', 'bytes');61  reply.header('X-Content-Type-Options', 'nosniff');62  reply.header('Content-Disposition', contentDisposition(inline ? 'inline' : 'attachment', filename));63  reply.header('Cache-Control', 'private, max-age=3600');64  reply.type(servedType);6566  const range = parseRange(req.headers.range, size);67  if (range) {68    reply.code(206);69    reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);70    reply.header('Content-Length', range.end - range.start + 1);71    return reply.send(blobStream(sha, range));72  }73  reply.header('Content-Length', size);74  return reply.send(blobStream(sha));75}7677/**78 * Serve an SVG blob inline but fully sandboxed (CSP sandbox, no scripts run).79 */80export async function sendSandboxedSvg(req, reply, { sha, filename }) {81  const size = (await stat(blobPath(sha))).size;82  reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'");83  reply.header('X-Content-Type-Options', 'nosniff');84  reply.header('Content-Disposition', contentDisposition('inline', filename));85  reply.header('Cache-Control', 'private, max-age=3600');86  reply.header('Content-Length', size);87  reply.type('image/svg+xml');88  return reply.send(blobStream(sha));89}9091/**92 * Fixed-window in-memory rate limiter.93 * @returns {(req, reply) => boolean} true when the request may proceed94 */95export function makeRateLimiter({ windowMs = 60_000, max = 100 } = {}) {96  const hits = new Map();97  setInterval(() => hits.clear(), windowMs).unref();98  return (req, reply) => {99    const key = req.ip;100    const count = (hits.get(key) ?? 0) + 1;101    hits.set(key, count);102    if (count > max) {103      reply.code(429).send({ error: { code: 'rate_limited', message: 'Too many requests' } });104      return false;105    }106    return true;107  };108}109110/** Standard API error payload. */111export function apiError(reply, statusCode, code, message) {112  return reply.code(statusCode).send({ error: { code, message } });113}114