@gotcos/glasses-server 6.24.4 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,9 +14,12 @@
14
14
  // mounted BEFORE the global express.json() so a maximum valid batch
15
15
  // (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
16
16
  // the allowance stays scoped to /api/media.
17
+ //
18
+ // /api/media/file STREAMS to disk rather than buffering: see
19
+ // createMediaBinaryBodyParser below.
17
20
 
18
- import { Router, json, raw, type Request, type Response } from 'express'
19
- import { readFileSync } from 'node:fs'
21
+ import { Router, json, type NextFunction, type Request, type Response } from 'express'
22
+ import { createReadStream, createWriteStream, openSync, statSync } from 'node:fs'
20
23
  import {
21
24
  MAX_ATTACHMENTS_PER_PROMPT,
22
25
  isValidMediaId,
@@ -27,6 +30,7 @@ import {
27
30
  G2_LENS_VARIANT_CAPABILITY,
28
31
  getMediaStore,
29
32
  MediaStoreError,
33
+ type MediaStagingFile,
30
34
  } from '../lib/media-store.js'
31
35
  import { currentMessageEra } from '../lib/message-era.js'
32
36
  import {
@@ -36,17 +40,163 @@ import {
36
40
  strictBase64Decode,
37
41
  } from '../lib/image-safety.js'
38
42
  import {
39
- MAX_RICH_MEDIA_BYTES,
43
+ MAX_OTHER_MEDIA_BYTES,
44
+ MAX_VIDEO_MEDIA_BYTES,
45
+ MEDIA_SNIFF_BYTES,
40
46
  RichMediaSafetyError,
47
+ isVideoUploadHead,
41
48
  } from '../lib/rich-media-safety.js'
42
49
 
43
50
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
44
51
  // overhead. Mounted only for /api/media in server/index.ts — the global
45
52
  // server limit is unchanged.
46
53
  export const mediaBodyParser = json({ limit: '16mb' })
47
- /** Binary parser is mounted only on /api/media/file before global JSON. It
48
- * avoids base64 amplification while retaining a hard route-local cap. */
49
- export const mediaBinaryBodyParser = raw({ type: () => true, limit: MAX_RICH_MEDIA_BYTES })
54
+
55
+ /** Chunked upload (contract §2) is not registered on this router yet, so health
56
+ * must advertise it as unavailable a client told `true` would try endpoints
57
+ * that 404. Flip this in the SAME change that adds the routes below. */
58
+ export const MEDIA_CHUNKED_UPLOAD_ENABLED = false
59
+
60
+ /** One streamed upload body, staged on disk. Held in a WeakMap keyed by the
61
+ * request so the handoff stays private to this module instead of widening the
62
+ * global express Request type. */
63
+ interface StagedUploadBody extends MediaStagingFile {
64
+ bytes: number
65
+ }
66
+ const stagedUploadBodies = new WeakMap<Request, StagedUploadBody>()
67
+
68
+ export interface MediaBinaryParserOptions {
69
+ /** Ceiling for ISO-BMFF video bodies. Test seam. */
70
+ videoMaxBytes?: number
71
+ /** Ceiling for everything else — images, PDFs, text. Test seam. */
72
+ otherMaxBytes?: number
73
+ }
74
+
75
+ /** Streams a binary upload into the media store's tmp/ staging dir.
76
+ *
77
+ * express.raw() buffered the whole body: at the 100 MB video cap that is a
78
+ * 100 MB allocation per concurrent upload, and ffprobe/ffmpeg want a file on
79
+ * disk anyway, so the Buffer was pure overhead. The byte ceiling is enforced
80
+ * DURING streaming — an oversized body is refused about one chunk past the
81
+ * limit instead of after landing in full.
82
+ *
83
+ * Video and other media now have DIFFERENT caps, so the ceiling starts at the
84
+ * larger of the two and tightens as soon as the leading magic bytes identify
85
+ * the kind. Classification is byte-based: a declared Content-Type must not be
86
+ * able to buy the bigger ceiling. */
87
+ export function createMediaBinaryBodyParser(options: MediaBinaryParserOptions = {}) {
88
+ const videoMaxBytes = options.videoMaxBytes ?? MAX_VIDEO_MEDIA_BYTES
89
+ const otherMaxBytes = options.otherMaxBytes ?? MAX_OTHER_MEDIA_BYTES
90
+ const hardCeiling = Math.max(videoMaxBytes, otherMaxBytes)
91
+
92
+ return function mediaBinaryUploadParser(req: Request, res: Response, next: NextFunction): void {
93
+ // Mounted with app.use(), so it also sees verbs that carry no body.
94
+ if (req.method !== 'POST' && req.method !== 'PUT') {
95
+ next()
96
+ return
97
+ }
98
+
99
+ // Answer, then stop listening. The client is mid-upload, so the request is
100
+ // closed only once the response has flushed — otherwise it reads a
101
+ // connection reset instead of the status.
102
+ const refuse = (status: number, body: Record<string, unknown>): void => {
103
+ res.status(status).json(body)
104
+ res.once('finish', () => req.destroy())
105
+ }
106
+
107
+ // Content-Length is authoritative for body size when present, so an
108
+ // obviously oversized upload is refused before a staging file even exists.
109
+ const declaredLength = Number(req.header('content-length'))
110
+ if (Number.isFinite(declaredLength) && declaredLength > hardCeiling) {
111
+ refuse(413, { error: 'attachment_too_large', maxBytes: hardCeiling })
112
+ return
113
+ }
114
+
115
+ const staged = getMediaStore().createStagingFile()
116
+ let fd: number
117
+ try {
118
+ // Opened synchronously so the file provably exists before any bytes are
119
+ // accepted. fs.createWriteStream opens LAZILY: an early reject that
120
+ // destroys the stream and unlinks the path can lose that race, the
121
+ // pending open then creates the file, and the staging file leaks.
122
+ fd = openSync(staged.path, 'w', 0o600)
123
+ } catch (err) {
124
+ console.error('[media] could not open upload staging file:', err)
125
+ staged.dispose()
126
+ res.status(500).json({ error: 'attachment_staging_failed' })
127
+ return
128
+ }
129
+ const sink = createWriteStream(staged.path, { fd, autoClose: true })
130
+ const headChunks: Buffer[] = []
131
+ let headBytes = 0
132
+ let received = 0
133
+ let ceiling = hardCeiling
134
+ let settled = false
135
+
136
+ const fail = (status: number, body: Record<string, unknown>): void => {
137
+ if (settled) return
138
+ settled = true
139
+ req.unpipe(sink)
140
+ sink.destroy()
141
+ staged.dispose()
142
+ refuse(status, body)
143
+ }
144
+
145
+ req.on('data', (chunk: Buffer) => {
146
+ if (settled) return
147
+ received += chunk.length
148
+ if (headBytes < MEDIA_SNIFF_BYTES) {
149
+ headChunks.push(chunk.subarray(0, MEDIA_SNIFF_BYTES - headBytes))
150
+ headBytes = Math.min(MEDIA_SNIFF_BYTES, headBytes + chunk.length)
151
+ if (headBytes >= MEDIA_SNIFF_BYTES) {
152
+ ceiling = isVideoUploadHead(Buffer.concat(headChunks)) ? videoMaxBytes : otherMaxBytes
153
+ }
154
+ }
155
+ if (received > ceiling) fail(413, { error: 'attachment_too_large', maxBytes: ceiling })
156
+ })
157
+
158
+ // Client hung up mid-upload: drop the partial file, answer nothing.
159
+ const abandon = (): void => {
160
+ if (settled) return
161
+ settled = true
162
+ sink.destroy()
163
+ staged.dispose()
164
+ }
165
+ req.once('aborted', abandon)
166
+ req.once('error', abandon)
167
+
168
+ sink.once('error', (err) => {
169
+ console.error('[media] upload staging write failed:', err)
170
+ fail(500, { error: 'attachment_staging_failed' })
171
+ })
172
+ sink.once('finish', () => {
173
+ if (settled) return
174
+ settled = true
175
+ stagedUploadBodies.set(req, { ...staged, bytes: received })
176
+ // Handing off only after the body is fully drained is what keeps the
177
+ // downstream JSON parsers off it: body-parser 2.x opens with
178
+ // `onFinished.isFinished(req)`, true once `complete && !readable`, and
179
+ // returns without reading. That matters because a .json attachment is a
180
+ // supported format, so express.json() genuinely matches these uploads.
181
+ // (The old `req._body = true` convention is body-parser 1.x and is not
182
+ // consulted by the version shipped with Express 5 — verified in
183
+ // node_modules/body-parser/lib/read.js.)
184
+ next()
185
+ })
186
+
187
+ req.pipe(sink)
188
+ }
189
+ }
190
+
191
+ /** Binary parser mounted only on /api/media/file, before global JSON. */
192
+ export const mediaBinaryBodyParser = createMediaBinaryBodyParser()
193
+
194
+ /** Claim the staged body for this request. Callers own disposal. */
195
+ function takeStagedUploadBody(req: Request): StagedUploadBody | undefined {
196
+ const staged = stagedUploadBodies.get(req)
197
+ if (staged) stagedUploadBodies.delete(req)
198
+ return staged
199
+ }
50
200
 
51
201
  export const mediaRouter = Router()
52
202
 
@@ -162,11 +312,12 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
162
312
 
163
313
  /** One document or video per binary request. The client may issue several
164
314
  * bounded requests, but composer admission still owns the shared five-item
165
- * prompt cap. Filename and MIME are hints only; prepareRichMedia sniffs and
166
- * validates the actual bytes. */
315
+ * prompt cap. Filename and MIME are hints only; the store sniffs and validates
316
+ * the actual staged bytes. */
167
317
  mediaRouter.post('/media/file', async (req: Request, res: Response) => {
318
+ const staged = takeStagedUploadBody(req)
168
319
  try {
169
- if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
320
+ if (!staged || staged.bytes === 0) {
170
321
  res.status(400).json({ error: 'attachment_bytes_required' })
171
322
  return
172
323
  }
@@ -175,8 +326,9 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
175
326
  if (rawLabel) {
176
327
  try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
177
328
  }
178
- const attachment = await getMediaStore().ingestRichMedia({
179
- bytes: req.body,
329
+ const attachment = await getMediaStore().ingestRichMediaFromFile({
330
+ sourcePath: staged.path,
331
+ byteLength: staged.bytes,
180
332
  label,
181
333
  declaredMime: safeString(req.header('content-type'), 120),
182
334
  capturedAt: safeString(req.header('x-cos-captured-at'), 40),
@@ -185,6 +337,10 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
185
337
  res.json({ attachment })
186
338
  } catch (err) {
187
339
  sendMediaError(res, err)
340
+ } finally {
341
+ // No-op once ingest moved the file into the asset dir; the guard that
342
+ // matters is the rejected-upload path, which must not leak a staged file.
343
+ staged?.dispose()
188
344
  }
189
345
  })
190
346
 
@@ -295,9 +451,18 @@ mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
295
451
  return
296
452
  }
297
453
  try {
298
- // Read + send the buffer directly: content-length is exact and no
299
- // filesystem path semantics leak into the response.
300
- const bytes = readFileSync(content.path)
454
+ // STREAMED, not buffered (2026-08-11). This was readFileSync + res.end(bytes),
455
+ // which allocated the entire asset per request. The UPLOAD path was rewritten
456
+ // specifically to stop holding a video in memory, and raising the video cap to
457
+ // 100 MiB silently raised THIS route's peak allocation from 64 MiB to 100 MiB per
458
+ // concurrent download — the same defect, on the way out.
459
+ //
460
+ // Content-Length comes from statSync, NOT rec.bytes: background compression
461
+ // rewrites this file in place, so the record's size and the file's size can
462
+ // disagree in the window between the rename and the index update. A wrong
463
+ // Content-Length truncates or hangs the client, so the only safe source is the
464
+ // file actually being sent.
465
+ const size = statSync(content.path).size
301
466
  res.status(200)
302
467
  res.setHeader('Content-Type', content.mime)
303
468
  res.setHeader('Cache-Control', 'private, no-store')
@@ -308,9 +473,23 @@ mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
308
473
  // header, browser fetch can receive this value but cannot read it.
309
474
  res.setHeader('Access-Control-Expose-Headers', 'X-COS-G2-Variant')
310
475
  }
311
- res.setHeader('Content-Length', String(bytes.length))
312
- res.end(bytes)
476
+ res.setHeader('Content-Length', String(size))
477
+
478
+ const stream = createReadStream(content.path)
479
+ // Once bytes are on the wire the status line is spent, so a failure here CANNOT
480
+ // become a JSON error body — appending one would corrupt a partial asset.
481
+ // Destroy the socket instead: a truncated response is an unambiguous failure to
482
+ // the client, a silently corrupted one is not.
483
+ stream.on('error', (streamErr) => {
484
+ console.error(`[media] content stream failed: ${streamErr instanceof Error ? streamErr.message : streamErr}`)
485
+ res.destroy()
486
+ })
487
+ // Client hung up mid-download: stop reading rather than pumping a dead socket.
488
+ res.on('close', () => stream.destroy())
489
+ stream.pipe(res)
313
490
  } catch (err) {
491
+ // statSync/open failures land here, BEFORE any byte is written, so a structured
492
+ // error response is still legal.
314
493
  sendMediaError(res, err)
315
494
  }
316
495
  })