'use strict'; // Uploaded files are served from the SAME ORIGIN as the dashboard, so the extension they // land on disk with decides how a browser will interpret them. That decision must come // from the file's CONTENT, never from the caller. // // Previously multer named the file `` — a caller-chosen // extension — while the only type check read `file.mimetype`, a caller-supplied header. // A caller could therefore choose to have their bytes served as text/html or // application/javascript from the app origin. // // multer's diskStorage picks the filename BEFORE any bytes are written, so the sniff // cannot happen there. Instead multer writes a neutral `.part` and finalizeUpload() // below sniffs the written file, renames it to a content-derived extension, and reports // the content-derived mime. Both ingest paths (lib/content-ingest.js and the /replace // route) call this one function so they cannot drift. const fs = require('fs'); const path = require('path'); // The ONLY extensions an upload may land on. Keyed by the mime the sniffer reports. // Anything not here is refused — an allowlist, so a new container format is a deliberate // addition rather than something that slips through. const MIME_TO_EXT = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/bmp': '.bmp', 'image/webp': '.webp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/svg+xml': '.svg', 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/ogg': '.ogv', 'video/x-msvideo': '.avi', 'video/quicktime': '.mov', }; // Extensions served INLINE with their real media type. Anything outside this set is // forced to download as opaque bytes. // // SVG is included DELIBERATELY. It has to be: white-label branding stores logos as SVG, // and serving them as application/octet-stream with nosniff makes fail to render. // It is safe here because scripts inside an SVG never execute in an /image context, // and the `Content-Security-Policy: sandbox` the serving layer adds neutralises the only // case where they would — a direct navigation, which is a document. So the logo renders // and the script does not run. Dropping SVG from this set silently breaks white-label. const INLINE_SAFE_EXTS = new Set([ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.avif', '.heic', '.jfif', '.svg', '.mp4', '.webm', '.ogv', '.avi', '.mov', '.mkv', '.vtt', ]); const SNIFF_BYTES = 4096; // enough for every magic below, plus a leading XML prolog // Return a mime string from magic bytes, or null when nothing matches. // Mirrors routes/media.js sniffMedia (same magic set) and adds the containers this // product actually accepts on upload. function sniffMime(buf) { if (!buf || buf.length < 4) return null; const b = buf; const a4 = b.toString('latin1', 0, 4); if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'; if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'; if (a4 === 'GIF8') return 'image/gif'; if (b[0] === 0x42 && b[1] === 0x4d) return 'image/bmp'; if (a4 === 'RIFF' && b.length >= 12) { const kind = b.toString('latin1', 8, 12); if (kind === 'WEBP') return 'image/webp'; if (kind === 'AVI ') return 'video/x-msvideo'; } // ISO-BMFF: '....ftyp' — mp4 / mov / avif / heic all share this container. if (b.length >= 12 && b.toString('latin1', 4, 8) === 'ftyp') { const brand = b.toString('latin1', 8, 12); if (brand === 'avif' || brand === 'avis') return 'image/avif'; if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic'; if (brand === 'qt ') return 'video/quicktime'; return 'video/mp4'; } if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'video/webm'; // EBML (webm/mkv) if (a4 === 'OggS') return 'video/ogg'; // SVG has no magic number — it is XML. Accept only when the document really opens with // an XML prolog or an root, so arbitrary text/HTML cannot masquerade as one. const head = b.toString('utf8', 0, Math.min(b.length, 1024)).replace(/^/, '').trimStart(); if (/^<\?xml[\s>]/i.test(head) || /^]/i.test(b.toString('utf8', 0, Math.min(b.length, SNIFF_BYTES)))) return 'image/svg+xml'; return null; } if (/^]/i.test(head)) return 'image/svg+xml'; return null; } function readHead(filePath) { const fd = fs.openSync(filePath, 'r'); try { const buf = Buffer.alloc(SNIFF_BYTES); const n = fs.readSync(fd, buf, 0, SNIFF_BYTES, 0); return buf.subarray(0, n); } finally { fs.closeSync(fd); } } class UnsupportedUploadError extends Error { constructor(msg) { super(msg); this.name = 'UnsupportedUploadError'; this.status = 400; } } // Sniff a multer-written temp file, rename it to a content-derived name, and report what // it actually is. Throws UnsupportedUploadError (and removes the file) when the bytes are // not a supported media type — the caller maps that to a 400. // // Returns { filepath, mime, ext } where filepath is the basename now on disk. function finalizeUpload(file) { const mime = (() => { try { return sniffMime(readHead(file.path)); } catch { return null; } })(); const ext = mime ? MIME_TO_EXT[mime] : null; if (!mime || !ext) { try { fs.unlinkSync(file.path); } catch { /* best effort */ } throw new UnsupportedUploadError('Unsupported file type — only image and video files are accepted'); } // `.part` -> ``; keep the uuid multer already generated. const base = path.basename(file.path).replace(/\.part$/i, ''); const finalName = base + ext; const finalPath = path.join(path.dirname(file.path), finalName); fs.renameSync(file.path, finalPath); // Keep the multer object consistent for any downstream reader. file.path = finalPath; file.filename = finalName; file.mimetype = mime; return { filepath: finalName, mime, ext }; } module.exports = { finalizeUpload, sniffMime, UnsupportedUploadError, MIME_TO_EXT, INLINE_SAFE_EXTS, SNIFF_BYTES, };