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/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