/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/http-helpers.mjs * Purpose : Range-aware blob responses, rate limiting, content-type policy * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { blobPath, blobStream } from '../storage/blobs.mjs'; import { stat } from 'node:fs/promises'; /** MIME types that are safe to render inline; everything else downloads. */ const INLINE_SAFE = /^(image\/(?!svg)|video\/|audio\/|application\/pdf|text\/plain$)/; /** RFC7233 single-range parser. Returns null when absent/invalid. */ export function parseRange(header, size) { if (!header || !header.startsWith('bytes=')) return null; const m = header.slice(6).split(',')[0].trim().match(/^(\d*)-(\d*)$/); if (!m || (m[1] === '' && m[2] === '')) return null; let start; let end; if (m[1] === '') { // suffix range: last N bytes const n = Number(m[2]); if (n === 0) return null; start = Math.max(0, size - n); end = size - 1; } else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Number(m[2]); } if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) return null; return { start, end: Math.min(end, size - 1) }; } /** RFC5987 filename* encoding for Content-Disposition. */ function contentDisposition(kind, filename) { const ascii = filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, "'"); return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; } /** * Stream a blob with full Range support (instant video seeking). * @param {{download?: boolean, mime?: string, filename?: string}} opts * download=true forces attachment; inline is only allowed for safe types * (uploaded HTML/SVG never executes same-origin — security checklist). */ export async function sendBlob(req, reply, { sha, mime, filename, download = false }) { const size = (await stat(blobPath(sha))).size; const type = mime || 'application/octet-stream'; const inline = !download && INLINE_SAFE.test(type); const servedType = inline ? type : type === 'text/html' || type === 'image/svg+xml' ? type // still declared, but forced to attachment below : type; reply.header('Accept-Ranges', 'bytes'); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Content-Disposition', contentDisposition(inline ? 'inline' : 'attachment', filename)); reply.header('Cache-Control', 'private, max-age=3600'); reply.type(servedType); const range = parseRange(req.headers.range, size); if (range) { reply.code(206); reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`); reply.header('Content-Length', range.end - range.start + 1); return reply.send(blobStream(sha, range)); } reply.header('Content-Length', size); return reply.send(blobStream(sha)); } /** * Serve an SVG blob inline but fully sandboxed (CSP sandbox, no scripts run). */ export async function sendSandboxedSvg(req, reply, { sha, filename }) { const size = (await stat(blobPath(sha))).size; reply.header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'"); reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Content-Disposition', contentDisposition('inline', filename)); reply.header('Cache-Control', 'private, max-age=3600'); reply.header('Content-Length', size); reply.type('image/svg+xml'); return reply.send(blobStream(sha)); } /** * Fixed-window in-memory rate limiter. * @returns {(req, reply) => boolean} true when the request may proceed */ export function makeRateLimiter({ windowMs = 60_000, max = 100 } = {}) { const hits = new Map(); setInterval(() => hits.clear(), windowMs).unref(); return (req, reply) => { const key = req.ip; const count = (hits.get(key) ?? 0) + 1; hits.set(key, count); if (count > max) { reply.code(429).send({ error: { code: 'rate_limited', message: 'Too many requests' } }); return false; } return true; }; } /** Standard API error payload. */ export function apiError(reply, statusCode, code, message) { return reply.code(statusCode).send({ error: { code, message } }); }