import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { STATE_DIR } from './config.js'; export const ATTACHMENT_LIMIT = 100 * 1024 * 1024; // A single prompt is capped separately at five files. This lifetime cap keeps // a forgotten session from growing without bound while still leaving room for // many ordinary attachment turns. export const SESSION_ATTACHMENT_LIMIT = 500 * 1024 * 1024; export const SESSION_ATTACHMENT_COUNT_LIMIT = 200; export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/; export const IMAGE_MIMES = Object.freeze([ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', ]); const ATTACHMENTS_DIR = path.join(STATE_DIR, 'attachments'); const IMAGE_EXTENSIONS = Object.freeze({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', }); const MIME_BY_EXTENSION = Object.freeze({ ...Object.fromEntries(Object.entries(IMAGE_EXTENSIONS).map(([mime, extension]) => [extension, mime])), jpeg: 'image/jpeg', pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', odt: 'application/vnd.oasis.opendocument.text', ods: 'application/vnd.oasis.opendocument.spreadsheet', odp: 'application/vnd.oasis.opendocument.presentation', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', svg: 'image/svg+xml', json: 'application/json', yaml: 'application/yaml', yml: 'application/yaml', xml: 'application/xml', rtf: 'application/rtf', zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar', '7z': 'application/x-7z-compressed', epub: 'application/epub+zip', }); const ATTACHMENT_FILE = /^att_[a-f0-9]{24}(?:[-.]|$)/; const uploadWindows = new Map(); const uploadLocks = new Map(); function httpError(statusCode, message) { const error = new Error(message); error.statusCode = statusCode; return error; } function sessionDir(sessionId) { // Session ids are server-generated slugs. Keep this check here as a second // boundary: a future caller must not turn an attachment lookup into a path // join with an arbitrary browser value. if (!/^[a-z0-9][a-z0-9-]{0,100}$/.test(String(sessionId))) { throw httpError(400, 'session cannot accept files'); } return path.join(ATTACHMENTS_DIR, sessionId); } function checkUploadRate(sessionId) { const now = Date.now(); const recent = (uploadWindows.get(sessionId) || []).filter((at) => now - at < 60_000); if (recent.length >= 20) throw httpError(429, 'too many file uploads — try again in a minute'); recent.push(now); uploadWindows.set(sessionId, recent); } // Serialize writes within one session so concurrent uploads cannot each pass a // stale quota check. Different sessions still stream in parallel. async function withUploadLock(sessionId, task) { const previous = uploadLocks.get(sessionId) || Promise.resolve(); let release; const hold = new Promise((resolve) => { release = resolve; }); const tail = previous.catch(() => {}).then(() => hold); uploadLocks.set(sessionId, tail); await previous.catch(() => {}); try { return await task(); } finally { release(); if (uploadLocks.get(sessionId) === tail) uploadLocks.delete(sessionId); } } async function attachmentUsage(dir) { let entries = []; try { entries = await fs.promises.readdir(dir); } catch { return { bytes: 0, count: 0 }; } let bytes = 0; let count = 0; for (const name of entries) { try { const stat = await fs.promises.lstat(path.join(dir, name)); if (!stat.isFile() || stat.isSymbolicLink()) continue; bytes += stat.size; if (ATTACHMENT_FILE.test(name)) count += 1; } catch {} } return { bytes, count }; } export function detectImageMime(bytes) { if (bytes.length >= 8 && bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG' && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) { return 'image/png'; } if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return 'image/jpeg'; } if (bytes.length >= 6 && ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'))) { return 'image/gif'; } if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') { return 'image/webp'; } return null; } function imageEnvelopeIsValid(mime, header, tail, totalBytes) { if (mime === 'image/png') { return totalBytes >= 45 && header.length >= 24 && header.readUInt32BE(8) === 13 && header.subarray(12, 16).toString('ascii') === 'IHDR' && header.readUInt32BE(16) > 0 && header.readUInt32BE(20) > 0 && tail.length >= 12 && tail.readUInt32BE(tail.length - 12) === 0 && tail.subarray(tail.length - 8, tail.length - 4).toString('ascii') === 'IEND'; } if (mime === 'image/jpeg') { return totalBytes >= 6 && tail.length >= 2 && tail[tail.length - 2] === 0xff && tail[tail.length - 1] === 0xd9; } if (mime === 'image/gif') { return totalBytes >= 14 && header.length >= 10 && header.readUInt16LE(6) > 0 && header.readUInt16LE(8) > 0 && tail[tail.length - 1] === 0x3b; } if (mime === 'image/webp') { const chunk = header.subarray(12, 16).toString('ascii'); return totalBytes >= 20 && header.length >= 16 && ['VP8 ', 'VP8L', 'VP8X'].includes(chunk) && header.readUInt32LE(4) + 8 <= totalBytes; } return false; } const normalizedMime = (value) => { const mime = String(value || '').split(';', 1)[0].trim().toLowerCase(); const canonical = mime === 'image/jpg' || mime === 'image/pjpeg' ? 'image/jpeg' : mime; return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(canonical) ? canonical : ''; }; const extensionOf = (name) => path.extname(name).slice(1).toLowerCase(); const mimeForName = (name) => { const known = MIME_BY_EXTENSION[extensionOf(name)]; return (typeof known === 'string' ? known : '') || 'application/octet-stream'; }; function truncateUtf8(value, maxBytes) { let result = value; while (Buffer.byteLength(result) > maxBytes) result = result.slice(0, -1); return result; } function safeFileName(value) { let decoded = String(Array.isArray(value) ? value[0] : value || 'attachment'); try { decoded = decodeURIComponent(decoded); } catch {} let name = path.basename(decoded.replace(/\\/g, '/')) .normalize('NFKC') .replace(/[\u0000-\u001f\u007f/\\]/g, '_') .trim(); if (!name || name === '.' || name === '..') name = 'attachment'; if (Buffer.byteLength(name) > 180) { const candidate = path.extname(name); const extension = Buffer.byteLength(candidate) <= 24 ? candidate : ''; const stem = extension ? name.slice(0, -extension.length) : name; name = `${truncateUtf8(stem, 180 - Buffer.byteLength(extension))}${extension}`; } return name || 'attachment'; } function canonicalImageName(name, mime) { const extension = IMAGE_EXTENSIONS[mime]; const current = path.extname(name); const stem = current ? name.slice(0, -current.length) : name; return `${stem || 'Screenshot'}.${extension}`; } const isImageMime = (mime) => IMAGE_MIMES.includes(mime); const claimsNativeImage = (name, declared) => isImageMime(normalizedMime(declared)) || Object.values(IMAGE_EXTENSIONS).includes(extensionOf(name)) || extensionOf(name) === 'jpeg'; const responseShape = (sessionId, id, mime, bytes, filePath, name = path.basename(filePath)) => { const kind = isImageMime(mime) ? 'image' : 'file'; return { id, kind, name, mime, bytes, path: filePath, previewUrl: `/api/sessions/${encodeURIComponent(sessionId)}/attachments/${id}/raw`, insertText: `${kind === 'image' ? 'Screenshot' : 'File'}: ${filePath} `, }; }; /** Stream one browser file into the session-owned attachment store. */ export async function receiveAttachment(readable, sessionId, { contentType = '', fileName = '' } = {}) { // Image metadata is advisory. Byte detection remains the security boundary // for formats rendered inline or passed to a CLI's native image interface. // Other formats are inert files: they are never executed, and the raw route // forces them to download rather than rendering browser-active content. const originalName = safeFileName(fileName); checkUploadRate(sessionId); return withUploadLock(sessionId, async () => { const dir = sessionDir(sessionId); await fs.promises.mkdir(dir, { recursive: true }); const usage = await attachmentUsage(dir); if (usage.count >= SESSION_ATTACHMENT_COUNT_LIMIT) { throw httpError(413, `this session already has ${SESSION_ATTACHMENT_COUNT_LIMIT} files`); } const id = `att_${crypto.randomBytes(12).toString('hex')}`; const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`); let bytes = 0; const limiter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'file is larger than 100 MB')); if (usage.bytes + bytes > SESSION_ATTACHMENT_LIMIT) { return callback(httpError(413, 'this session has reached its 500 MB attachment limit')); } callback(null, chunk); }, }); try { await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' })); if (bytes === 0) throw httpError(413, 'file is empty'); const handle = await fs.promises.open(temporary, 'r'); const header = Buffer.alloc(32); const tail = Buffer.alloc(Math.min(16, bytes)); let bytesRead = 0; let tailBytesRead = 0; try { ({ bytesRead } = await handle.read(header, 0, header.length, 0)); ({ bytesRead: tailBytesRead } = await handle.read(tail, 0, tail.length, Math.max(0, bytes - tail.length))); } finally { await handle.close(); } const headerBytes = header.subarray(0, bytesRead); const tailBytes = tail.subarray(0, tailBytesRead); const detected = detectImageMime(headerBytes); if (detected && !imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) { throw httpError(415, 'image is truncated or malformed'); } if (!detected && claimsNativeImage(originalName, contentType)) { throw httpError(415, 'file does not contain a valid PNG, JPEG, GIF, or WebP image'); } const storedName = detected ? canonicalImageName(originalName, detected) : originalName; const mime = detected || mimeForName(storedName); const finalPath = path.join(dir, `${id}-${storedName}`); await fs.promises.rename(temporary, finalPath); return responseShape(sessionId, id, mime, bytes, finalPath, storedName); } catch (error) { await fs.promises.unlink(temporary).catch(() => {}); throw error; } }); } /** Resolve an untrusted attachment id within exactly one session. */ export function resolveAttachment(sessionId, attachmentId) { if (!ATTACHMENT_ID.test(String(attachmentId))) throw httpError(404, 'attachment not found'); const dir = sessionDir(sessionId); let names = []; try { names = fs.readdirSync(dir); } catch {} for (const name of names) { const isCurrent = name.startsWith(`${attachmentId}-`); const legacyExtension = name.startsWith(`${attachmentId}.`) ? extensionOf(name) : ''; if (!isCurrent && !Object.values(IMAGE_EXTENSIONS).includes(legacyExtension)) continue; const filePath = path.join(dir, name); try { const stat = fs.lstatSync(filePath); if (!stat.isFile() || stat.isSymbolicLink()) continue; const displayName = isCurrent ? name.slice(attachmentId.length + 1) : name; return responseShape(sessionId, attachmentId, mimeForName(displayName), stat.size, filePath, displayName); } catch {} } throw httpError(404, 'attachment not found'); } export function resolveAttachments(sessionId, attachmentIds) { if (!Array.isArray(attachmentIds)) throw httpError(400, 'attachmentIds must be an array'); if (attachmentIds.length > 5) throw httpError(400, 'at most five files may be attached'); if (new Set(attachmentIds).size !== attachmentIds.length) throw httpError(400, 'duplicate attachment id'); return attachmentIds.map((id) => resolveAttachment(sessionId, id)); } /** Remove one unsent attachment without disturbing files the session still uses. */ export async function removeAttachment(sessionId, attachmentId) { return withUploadLock(sessionId, async () => { const attachment = resolveAttachment(sessionId, attachmentId); await fs.promises.unlink(attachment.path); return attachment; }); } export async function removeSessionAttachments(sessionId) { uploadWindows.delete(sessionId); await withUploadLock(sessionId, () => fs.promises.rm(sessionDir(sessionId), { recursive: true, force: true })); } /** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */ export async function pruneAttachmentDirs(sessionIds, now = Date.now()) { const live = new Set(sessionIds); const cutoff = now - 7 * 24 * 60 * 60 * 1000; let entries = []; try { entries = await fs.promises.readdir(ATTACHMENTS_DIR, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) continue; const dir = path.join(ATTACHMENTS_DIR, entry.name); // A process death can happen between exclusive temp creation and rename. // Normal request failures remove these immediately; this is the crash // backstop, with the same grace period as orphan session directories. try { for (const name of await fs.promises.readdir(dir)) { const part = path.join(dir, name); const stat = await fs.promises.lstat(part); if (name.endsWith('.part') && stat.isFile() && !stat.isSymbolicLink() && stat.mtimeMs < cutoff) { await fs.promises.unlink(part); } } } catch {} if (live.has(entry.name)) continue; let newest = 0; try { const names = await fs.promises.readdir(dir); const stats = await Promise.all(names.map((name) => fs.promises.lstat(path.join(dir, name)))); newest = Math.max((await fs.promises.stat(dir)).mtimeMs, ...stats.map((stat) => stat.mtimeMs)); } catch { continue; } if (newest < cutoff) await fs.promises.rm(dir, { recursive: true, force: true }); } } const quotePath = (filePath) => JSON.stringify(filePath); /** TUI commands that attach native image context before the textual prompt. */ export function formatAttachmentPrelude(cli, attachments) { if (cli !== 'hermes') return []; return attachments .filter((attachment) => attachment.kind === 'image') .map((image) => `/image ${quotePath(image.path)}`); } /** Keep CLI-version-specific formatting out of request handlers and React. */ export function formatAttachmentDelivery(cli, text, attachments) { const onlyImages = attachments.length > 0 && attachments.every((attachment) => attachment.kind === 'image'); const prompt = String(text || '').trim() || (onlyImages ? `Please inspect the attached screenshot${attachments.length === 1 ? '' : 's'}.` : `Please inspect the attached file${attachments.length === 1 ? '' : 's'}.`); if (!attachments.length) return prompt; const paths = attachments.map((attachment) => attachment.path); if (cli === 'gemini') { return `${prompt}\n\n${paths.map((filePath) => `@${quotePath(filePath)}`).join('\n')}`; } return `${prompt}\n\nAttached files:\n${paths.map((filePath) => `- ${quotePath(filePath)}`).join('\n')}`; }