File size: 16,169 Bytes
7e49345
 
 
 
 
 
 
5732e05
 
2fc73bc
5732e05
2fc73bc
 
7e49345
 
 
 
 
 
 
 
 
5732e05
7e49345
 
 
 
 
5732e05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e49345
2fc73bc
7e49345
 
 
 
 
 
 
 
 
 
 
 
5732e05
7e49345
 
 
 
 
 
 
5732e05
7e49345
 
 
 
2fc73bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5732e05
2fc73bc
 
 
 
 
7e49345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5732e05
 
 
 
 
 
7e49345
5732e05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e49345
2fc73bc
 
 
 
 
5732e05
2fc73bc
 
 
 
 
 
 
5732e05
2fc73bc
5732e05
2fc73bc
 
 
 
7e49345
 
2fc73bc
5732e05
7e49345
2fc73bc
 
 
 
 
 
 
 
 
 
 
 
 
 
5732e05
2fc73bc
 
5732e05
 
 
2fc73bc
5732e05
 
 
2fc73bc
5732e05
2fc73bc
 
 
 
 
7e49345
 
 
5732e05
7e49345
 
5732e05
 
 
 
 
 
 
7e49345
 
 
5732e05
 
7e49345
 
 
 
 
5732e05
7e49345
5732e05
7e49345
5732e05
7e49345
 
60395e6
 
 
 
 
 
 
 
 
2fc73bc
7e49345
2fc73bc
7e49345
 
 
2fc73bc
7e49345
 
 
2fc73bc
7e49345
 
 
 
 
 
 
2fc73bc
7e49345
2fc73bc
 
 
7e49345
 
 
 
 
 
2fc73bc
 
 
7e49345
2fc73bc
7e49345
 
 
 
 
2fc73bc
5732e05
2fc73bc
5732e05
 
 
2fc73bc
 
7e49345
5732e05
 
 
 
 
 
 
7e49345
 
 
5732e05
7e49345
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
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')}`;
}