@gotcos/glasses-server 6.24.5 → 6.26.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.
@@ -2,6 +2,10 @@
2
2
  // content endpoints backed by server/lib/media-store.ts.
3
3
  //
4
4
  // POST /api/media — upload images (base64 JSON batch)
5
+ // POST /api/media/upload/init — open a chunked upload (contract §2)
6
+ // PUT /api/media/upload/:id/:n — append chunk n (raw bytes, in order)
7
+ // GET /api/media/upload/:id — resume probe, safe to poll
8
+ // POST /api/media/upload/:id/finalize — assemble, then the SAME ingest
5
9
  // POST /api/media/reserve — bind staged media to a queue item
6
10
  // POST /api/media/associate — bind media to a run/message (replay-safe)
7
11
  // POST /api/media/release — drop staged/reserved media (cancel path)
@@ -14,9 +18,12 @@
14
18
  // mounted BEFORE the global express.json() so a maximum valid batch
15
19
  // (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
16
20
  // the allowance stays scoped to /api/media.
21
+ //
22
+ // /api/media/file STREAMS to disk rather than buffering: see
23
+ // createMediaBinaryBodyParser below.
17
24
 
18
- import { Router, json, raw, type Request, type Response } from 'express'
19
- import { readFileSync } from 'node:fs'
25
+ import { Router, json, type NextFunction, type Request, type Response } from 'express'
26
+ import { createReadStream, createWriteStream, openSync, statSync } from 'node:fs'
20
27
  import {
21
28
  MAX_ATTACHMENTS_PER_PROMPT,
22
29
  isValidMediaId,
@@ -27,6 +34,7 @@ import {
27
34
  G2_LENS_VARIANT_CAPABILITY,
28
35
  getMediaStore,
29
36
  MediaStoreError,
37
+ type MediaStagingFile,
30
38
  } from '../lib/media-store.js'
31
39
  import { currentMessageEra } from '../lib/message-era.js'
32
40
  import {
@@ -36,17 +44,271 @@ import {
36
44
  strictBase64Decode,
37
45
  } from '../lib/image-safety.js'
38
46
  import {
39
- MAX_RICH_MEDIA_BYTES,
47
+ MAX_OTHER_MEDIA_BYTES,
48
+ MAX_VIDEO_MEDIA_BYTES,
49
+ MEDIA_CHUNK_BYTES,
50
+ MEDIA_SNIFF_BYTES,
40
51
  RichMediaSafetyError,
52
+ isVideoUploadHead,
41
53
  } from '../lib/rich-media-safety.js'
54
+ import {
55
+ UploadSessionError,
56
+ getUploadSessions,
57
+ isValidUploadId,
58
+ type UploadSessionErrorCode,
59
+ } from '../lib/upload-session.js'
42
60
 
43
61
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
44
62
  // overhead. Mounted only for /api/media in server/index.ts — the global
45
63
  // server limit is unchanged.
46
64
  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 })
65
+
66
+ /** Chunked upload (contract §2) is registered on this router below, so health may
67
+ * advertise it. The flag lives beside the routes it describes precisely so the
68
+ * two cannot drift: flipping it without mounting them sends clients to 404s,
69
+ * and mounting them without flipping it advertises nothing. */
70
+ export const MEDIA_CHUNKED_UPLOAD_ENABLED = true
71
+
72
+ /** One streamed upload body, staged on disk. Held in a WeakMap keyed by the
73
+ * request so the handoff stays private to this module instead of widening the
74
+ * global express Request type. */
75
+ interface StagedUploadBody extends MediaStagingFile {
76
+ bytes: number
77
+ }
78
+ const stagedUploadBodies = new WeakMap<Request, StagedUploadBody>()
79
+
80
+ export interface MediaBinaryParserOptions {
81
+ /** Ceiling for ISO-BMFF video bodies. Test seam. */
82
+ videoMaxBytes?: number
83
+ /** Ceiling for everything else — images, PDFs, text. Test seam. */
84
+ otherMaxBytes?: number
85
+ }
86
+
87
+ /** Streams a binary upload into the media store's tmp/ staging dir.
88
+ *
89
+ * express.raw() buffered the whole body: at the 100 MB video cap that is a
90
+ * 100 MB allocation per concurrent upload, and ffprobe/ffmpeg want a file on
91
+ * disk anyway, so the Buffer was pure overhead. The byte ceiling is enforced
92
+ * DURING streaming — an oversized body is refused about one chunk past the
93
+ * limit instead of after landing in full.
94
+ *
95
+ * Video and other media now have DIFFERENT caps, so the ceiling starts at the
96
+ * larger of the two and tightens as soon as the leading magic bytes identify
97
+ * the kind. Classification is byte-based: a declared Content-Type must not be
98
+ * able to buy the bigger ceiling. */
99
+ export function createMediaBinaryBodyParser(options: MediaBinaryParserOptions = {}) {
100
+ const videoMaxBytes = options.videoMaxBytes ?? MAX_VIDEO_MEDIA_BYTES
101
+ const otherMaxBytes = options.otherMaxBytes ?? MAX_OTHER_MEDIA_BYTES
102
+ const hardCeiling = Math.max(videoMaxBytes, otherMaxBytes)
103
+
104
+ return function mediaBinaryUploadParser(req: Request, res: Response, next: NextFunction): void {
105
+ // Mounted with app.use(), so it also sees verbs that carry no body.
106
+ if (req.method !== 'POST' && req.method !== 'PUT') {
107
+ next()
108
+ return
109
+ }
110
+
111
+ // Answer, then stop listening. The client is mid-upload, so the request is
112
+ // closed only once the response has flushed — otherwise it reads a
113
+ // connection reset instead of the status.
114
+ const refuse = (status: number, body: Record<string, unknown>): void => {
115
+ res.status(status).json(body)
116
+ res.once('finish', () => req.destroy())
117
+ }
118
+
119
+ // Content-Length is authoritative for body size when present, so an
120
+ // obviously oversized upload is refused before a staging file even exists.
121
+ const declaredLength = Number(req.header('content-length'))
122
+ if (Number.isFinite(declaredLength) && declaredLength > hardCeiling) {
123
+ refuse(413, { error: 'attachment_too_large', maxBytes: hardCeiling })
124
+ return
125
+ }
126
+
127
+ const staged = getMediaStore().createStagingFile()
128
+ let fd: number
129
+ try {
130
+ // Opened synchronously so the file provably exists before any bytes are
131
+ // accepted. fs.createWriteStream opens LAZILY: an early reject that
132
+ // destroys the stream and unlinks the path can lose that race, the
133
+ // pending open then creates the file, and the staging file leaks.
134
+ fd = openSync(staged.path, 'w', 0o600)
135
+ } catch (err) {
136
+ console.error('[media] could not open upload staging file:', err)
137
+ staged.dispose()
138
+ res.status(500).json({ error: 'attachment_staging_failed' })
139
+ return
140
+ }
141
+ const sink = createWriteStream(staged.path, { fd, autoClose: true })
142
+ const headChunks: Buffer[] = []
143
+ let headBytes = 0
144
+ let received = 0
145
+ let ceiling = hardCeiling
146
+ let settled = false
147
+
148
+ const fail = (status: number, body: Record<string, unknown>): void => {
149
+ if (settled) return
150
+ settled = true
151
+ req.unpipe(sink)
152
+ sink.destroy()
153
+ staged.dispose()
154
+ refuse(status, body)
155
+ }
156
+
157
+ req.on('data', (chunk: Buffer) => {
158
+ if (settled) return
159
+ received += chunk.length
160
+ if (headBytes < MEDIA_SNIFF_BYTES) {
161
+ headChunks.push(chunk.subarray(0, MEDIA_SNIFF_BYTES - headBytes))
162
+ headBytes = Math.min(MEDIA_SNIFF_BYTES, headBytes + chunk.length)
163
+ if (headBytes >= MEDIA_SNIFF_BYTES) {
164
+ ceiling = isVideoUploadHead(Buffer.concat(headChunks)) ? videoMaxBytes : otherMaxBytes
165
+ }
166
+ }
167
+ if (received > ceiling) fail(413, { error: 'attachment_too_large', maxBytes: ceiling })
168
+ })
169
+
170
+ // Client hung up mid-upload: drop the partial file, answer nothing.
171
+ const abandon = (): void => {
172
+ if (settled) return
173
+ settled = true
174
+ sink.destroy()
175
+ staged.dispose()
176
+ }
177
+ req.once('aborted', abandon)
178
+ req.once('error', abandon)
179
+
180
+ sink.once('error', (err) => {
181
+ console.error('[media] upload staging write failed:', err)
182
+ fail(500, { error: 'attachment_staging_failed' })
183
+ })
184
+ sink.once('finish', () => {
185
+ if (settled) return
186
+ settled = true
187
+ stagedUploadBodies.set(req, { ...staged, bytes: received })
188
+ // Handing off only after the body is fully drained is what keeps the
189
+ // downstream JSON parsers off it: body-parser 2.x opens with
190
+ // `onFinished.isFinished(req)`, true once `complete && !readable`, and
191
+ // returns without reading. That matters because a .json attachment is a
192
+ // supported format, so express.json() genuinely matches these uploads.
193
+ // (The old `req._body = true` convention is body-parser 1.x and is not
194
+ // consulted by the version shipped with Express 5 — verified in
195
+ // node_modules/body-parser/lib/read.js.)
196
+ next()
197
+ })
198
+
199
+ req.pipe(sink)
200
+ }
201
+ }
202
+
203
+ /** Binary parser mounted only on /api/media/file, before global JSON. */
204
+ export const mediaBinaryBodyParser = createMediaBinaryBodyParser()
205
+
206
+ /** Claim the staged body for this request. Callers own disposal. */
207
+ function takeStagedUploadBody(req: Request): StagedUploadBody | undefined {
208
+ const staged = stagedUploadBodies.get(req)
209
+ if (staged) stagedUploadBodies.delete(req)
210
+ return staged
211
+ }
212
+
213
+ /** One chunk's raw bytes, held per-request like the streamed single-shot body. */
214
+ const chunkBodies = new WeakMap<Request, Buffer>()
215
+
216
+ export interface MediaChunkParserOptions {
217
+ /** Per-chunk ceiling — the contract's advertised `chunkBytes`. Test seam. */
218
+ maxChunkBytes?: number
219
+ }
220
+
221
+ /** Raw-bytes parser for PUT /api/media/upload/:uploadId/:index.
222
+ *
223
+ * This one BUFFERS where the single-shot parser streams, and the difference is
224
+ * deliberate: a chunk is bounded by the advertised chunkBytes (8 MiB), and
225
+ * holding the whole chunk before a single byte reaches the staging file is what
226
+ * makes the append atomic per chunk. A dropped connection therefore cannot
227
+ * leave a half-written chunk on disk, which is what lets the client re-send the
228
+ * chunk at `nextIndex` safely (§6). Streaming straight to the file would trade
229
+ * 8 MiB of memory for a partial-write recovery problem on every reconnect.
230
+ *
231
+ * Mounted as route-level middleware on the PUT below, so init/finalize keep the
232
+ * JSON parser and this can never see them. It is also safe to mount with
233
+ * app.use('/api/media/upload', …) ahead of the JSON parsers — the guard below
234
+ * makes a second pass a no-op. */
235
+ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}) {
236
+ const maxChunkBytes = options.maxChunkBytes ?? MEDIA_CHUNK_BYTES
237
+
238
+ return function mediaChunkUploadParser(req: Request, res: Response, next: NextFunction): void {
239
+ if (req.method !== 'PUT') {
240
+ next()
241
+ return
242
+ }
243
+ if (chunkBodies.has(req)) {
244
+ next()
245
+ return
246
+ }
247
+
248
+ const refuse = (status: number, body: Record<string, unknown>): void => {
249
+ res.status(status).json(body)
250
+ res.once('finish', () => req.destroy())
251
+ }
252
+
253
+ // An upstream JSON parser already drained this body — verified: a PUT sent as
254
+ // application/json arrives here with req.body set and readableEnded true.
255
+ // Attaching 'end' to a finished stream would HANG the request, so convert it
256
+ // into a legible refusal instead. Chunks must be raw bytes.
257
+ if (req.readableEnded || req.body !== undefined) {
258
+ refuse(400, {
259
+ error: 'chunk_bytes_required',
260
+ detail: 'chunk body must be sent as raw bytes (application/octet-stream)',
261
+ })
262
+ return
263
+ }
264
+
265
+ const declaredLength = Number(req.header('content-length'))
266
+ if (Number.isFinite(declaredLength) && declaredLength > maxChunkBytes) {
267
+ refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
268
+ return
269
+ }
270
+
271
+ const parts: Buffer[] = []
272
+ let received = 0
273
+ let settled = false
274
+
275
+ req.on('data', (chunk: Buffer) => {
276
+ if (settled) return
277
+ received += chunk.length
278
+ if (received > maxChunkBytes) {
279
+ settled = true
280
+ parts.length = 0
281
+ refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
282
+ return
283
+ }
284
+ parts.push(chunk)
285
+ })
286
+ const abandon = (): void => {
287
+ if (settled) return
288
+ settled = true
289
+ parts.length = 0
290
+ }
291
+ req.once('aborted', abandon)
292
+ req.once('error', abandon)
293
+ req.once('end', () => {
294
+ if (settled) return
295
+ settled = true
296
+ chunkBodies.set(req, Buffer.concat(parts))
297
+ next()
298
+ })
299
+ }
300
+ }
301
+
302
+ /** Chunk parser at the advertised chunk size. */
303
+ export const mediaChunkBodyParser = createMediaChunkBodyParser()
304
+
305
+ function takeChunkBody(req: Request): Buffer | undefined {
306
+ const bytes = chunkBodies.get(req)
307
+ // `!== undefined`, not truthiness: a zero-length chunk is a real (rejected)
308
+ // body, and the registry owns that refusal.
309
+ if (bytes !== undefined) chunkBodies.delete(req)
310
+ return bytes
311
+ }
50
312
 
51
313
  export const mediaRouter = Router()
52
314
 
@@ -73,7 +335,25 @@ const SAFETY_ERROR_STATUS: Record<string, number> = {
73
335
  video_too_long: 400,
74
336
  }
75
337
 
338
+ const UPLOAD_ERROR_STATUS: Record<UploadSessionErrorCode, number> = {
339
+ upload_not_found: 404,
340
+ chunk_out_of_order: 409,
341
+ attachment_too_large: 413,
342
+ incomplete_upload: 400,
343
+ upload_size_mismatch: 400,
344
+ invalid_total_bytes: 400,
345
+ chunk_bytes_required: 400,
346
+ chunked_upload_unavailable: 503,
347
+ upload_staging_failed: 500,
348
+ }
349
+
76
350
  function sendMediaError(res: Response, err: unknown): void {
351
+ // Chunked-upload failures go through the SAME funnel as every other media
352
+ // error, so there is one place that decides the error body's shape.
353
+ if (err instanceof UploadSessionError) {
354
+ res.status(UPLOAD_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, ...err.detail })
355
+ return
356
+ }
77
357
  if (err instanceof MediaStoreError) {
78
358
  res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
79
359
  return
@@ -162,11 +442,12 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
162
442
 
163
443
  /** One document or video per binary request. The client may issue several
164
444
  * 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. */
445
+ * prompt cap. Filename and MIME are hints only; the store sniffs and validates
446
+ * the actual staged bytes. */
167
447
  mediaRouter.post('/media/file', async (req: Request, res: Response) => {
448
+ const staged = takeStagedUploadBody(req)
168
449
  try {
169
- if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
450
+ if (!staged || staged.bytes === 0) {
170
451
  res.status(400).json({ error: 'attachment_bytes_required' })
171
452
  return
172
453
  }
@@ -175,8 +456,9 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
175
456
  if (rawLabel) {
176
457
  try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
177
458
  }
178
- const attachment = await getMediaStore().ingestRichMedia({
179
- bytes: req.body,
459
+ const attachment = await getMediaStore().ingestRichMediaFromFile({
460
+ sourcePath: staged.path,
461
+ byteLength: staged.bytes,
180
462
  label,
181
463
  declaredMime: safeString(req.header('content-type'), 120),
182
464
  capturedAt: safeString(req.header('x-cos-captured-at'), 40),
@@ -185,6 +467,156 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
185
467
  res.json({ attachment })
186
468
  } catch (err) {
187
469
  sendMediaError(res, err)
470
+ } finally {
471
+ // No-op once ingest moved the file into the asset dir; the guard that
472
+ // matters is the rejected-upload path, which must not leak a staged file.
473
+ staged?.dispose()
474
+ }
475
+ })
476
+
477
+ // ── Chunked upload (contract §2) ──────────────────────────────────────────────
478
+ //
479
+ // Registered ahead of the GET /media/:id reads below so a future single-segment
480
+ // param route can never shadow /media/upload/…. Compression does not unlock
481
+ // length — only this does: a 3-minute 4K original is ~570 MB and cannot pass any
482
+ // sane single-shot cap.
483
+
484
+ /** Read the label the same way POST /media/file does. The two paths must agree,
485
+ * or the same phone video gets a different label depending on its size. */
486
+ function uploadLabelFrom(req: Request): string | undefined {
487
+ const rawLabel = safeString(req.header('x-cos-filename'), 360)
488
+ if (!rawLabel) return undefined
489
+ try { return decodeURIComponent(rawLabel).slice(0, 120) } catch { return rawLabel.slice(0, 120) }
490
+ }
491
+
492
+ mediaRouter.post('/media/upload/init', (req: Request, res: Response) => {
493
+ try {
494
+ const body = req.body ?? {}
495
+ const session = getUploadSessions().create({
496
+ totalBytes: body.totalBytes,
497
+ // MIME arrives in the BODY, not Content-Type: this request's Content-Type
498
+ // is application/json. It stays a hint either way — finalize sniffs the
499
+ // assembled bytes.
500
+ mime: safeString(body.mime, 120),
501
+ label: uploadLabelFrom(req),
502
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
503
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
504
+ })
505
+ res.json({
506
+ uploadId: session.uploadId,
507
+ chunkBytes: MEDIA_CHUNK_BYTES,
508
+ receivedBytes: session.receivedBytes,
509
+ })
510
+ } catch (err) {
511
+ sendMediaError(res, err)
512
+ }
513
+ })
514
+
515
+ mediaRouter.put('/media/upload/:uploadId/:index', mediaChunkBodyParser, (req: Request, res: Response) => {
516
+ const bytes = takeChunkBody(req)
517
+ try {
518
+ if (!isValidUploadId(req.params.uploadId)) {
519
+ // A malformed id cannot name a session this server minted, so it is the
520
+ // same answer as an expired one — never a partial success (§6).
521
+ throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
522
+ }
523
+ if (bytes === undefined) {
524
+ throw new UploadSessionError('chunk_bytes_required', 'chunk body was not read as raw bytes')
525
+ }
526
+ // Parsed strictly, then compared: a non-numeric segment is definitionally
527
+ // not the expected index, so it answers 409 with the index to send.
528
+ const rawIndex = req.params.index
529
+ const index = typeof rawIndex === 'string' && /^\d{1,9}$/.test(rawIndex)
530
+ ? Number(rawIndex)
531
+ : Number.NaN
532
+ const session = getUploadSessions().appendChunk(req.params.uploadId, index, bytes)
533
+ res.json({ receivedBytes: session.receivedBytes, nextIndex: session.nextIndex })
534
+ } catch (err) {
535
+ sendMediaError(res, err)
536
+ }
537
+ })
538
+
539
+ mediaRouter.get('/media/upload/:uploadId', (req: Request, res: Response) => {
540
+ const sessions = getUploadSessions()
541
+ const session = isValidUploadId(req.params.uploadId)
542
+ ? sessions.peek(req.params.uploadId)
543
+ : undefined
544
+ if (!session) {
545
+ res.status(404).json({ error: 'upload_not_found' })
546
+ return
547
+ }
548
+ res.json(sessions.progressOf(session))
549
+ })
550
+
551
+ /**
552
+ * Abandon an upload and release its resources NOW.
553
+ *
554
+ * WHY THIS EXISTS. Without it an abandoned session held one of only
555
+ * MAX_CONCURRENT_CHUNKED_UPLOADS (8) slots plus up to chunkedMaxBytes of tmp/ for the
556
+ * full 4-hour TTL, with no way to release it early. The client's recovery ladder fires
557
+ * on a flaky phone link BY DESIGN, so give-ups are expected traffic rather than an edge
558
+ * case: eight of them in one bad session — plausible on a large file — then made init
559
+ * answer 503 for up to four hours, with the user seeing a feature that simply stopped
560
+ * working and no way to clear it.
561
+ *
562
+ * IDEMPOTENT BY CONSTRUCTION. An unknown, already-cancelled, or expired id answers 200,
563
+ * not 404. The client calls this best-effort while giving up on something that already
564
+ * failed; making it fail again would invite a retry loop over a request whose only job
565
+ * is to release resources. `dropped` reports whether this call was the one that freed
566
+ * it, for logs — never as a signal the client must act on.
567
+ *
568
+ * A malformed id is still 400: that is a client bug worth surfacing, not a resource to
569
+ * release.
570
+ */
571
+ mediaRouter.delete('/media/upload/:uploadId', (req: Request, res: Response) => {
572
+ if (!isValidUploadId(req.params.uploadId)) {
573
+ res.status(400).json({ error: 'invalid_upload_id' })
574
+ return
575
+ }
576
+ // drop() forgets the session AND disposes its staging file, so the slot and the disk
577
+ // are both released here rather than waiting on the TTL sweep.
578
+ const dropped = getUploadSessions().drop(req.params.uploadId)
579
+ res.json({ ok: true, dropped })
580
+ })
581
+
582
+ mediaRouter.post('/media/upload/:uploadId/finalize', async (req: Request, res: Response) => {
583
+ let claimed: { stagingPath: string; dispose: () => void } | null = null
584
+ try {
585
+ if (!isValidUploadId(req.params.uploadId)) {
586
+ throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
587
+ }
588
+ // Throws `incomplete_upload` WITHOUT consuming the session, so a client that
589
+ // finalized early can keep sending chunks. Anything it returns is a session
590
+ // this request now owns.
591
+ const session = getUploadSessions().finalize(req.params.uploadId)
592
+ claimed = session
593
+ // The SAME ingest as POST /media/file — validation, the per-kind cap from
594
+ // magic bytes, the atomic rename, and compression scheduling all stay in one
595
+ // place. A second ingest path would be a second place for those to rot.
596
+ const attachment = await getMediaStore().ingestRichMediaFromFile({
597
+ sourcePath: session.stagingPath,
598
+ byteLength: session.totalBytes,
599
+ label: session.label,
600
+ declaredMime: session.mime,
601
+ capturedAt: session.capturedAt,
602
+ sessionId: session.sessionId,
603
+ // The one line that makes chunked upload actually work. Without it this
604
+ // inherits the SINGLE-SHOT per-kind cap, so every chunked video transferred
605
+ // in full and was then refused at finalize with a raw 413 — strictly worse
606
+ // than the pre-flight refusal it replaced. Only video gets the chunked
607
+ // ceiling; documents stay at the single-shot cap, because the text path reads
608
+ // the whole file into a Buffer and a JS string.
609
+ transfer: 'chunked',
610
+ })
611
+ res.json({ attachment })
612
+ } catch (err) {
613
+ sendMediaError(res, err)
614
+ } finally {
615
+ // No-op once ingest moved the file into the asset dir. The case that matters
616
+ // is a REJECTED assembly (unsupported format, over the per-kind cap): ingest
617
+ // is atomic, so nothing is half-published, and this is what stops the
618
+ // assembled body leaking in tmp/.
619
+ claimed?.dispose()
188
620
  }
189
621
  })
190
622
 
@@ -295,9 +727,18 @@ mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
295
727
  return
296
728
  }
297
729
  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)
730
+ // STREAMED, not buffered (2026-08-11). This was readFileSync + res.end(bytes),
731
+ // which allocated the entire asset per request. The UPLOAD path was rewritten
732
+ // specifically to stop holding a video in memory, and raising the video cap to
733
+ // 100 MiB silently raised THIS route's peak allocation from 64 MiB to 100 MiB per
734
+ // concurrent download — the same defect, on the way out.
735
+ //
736
+ // Content-Length comes from statSync, NOT rec.bytes: background compression
737
+ // rewrites this file in place, so the record's size and the file's size can
738
+ // disagree in the window between the rename and the index update. A wrong
739
+ // Content-Length truncates or hangs the client, so the only safe source is the
740
+ // file actually being sent.
741
+ const size = statSync(content.path).size
301
742
  res.status(200)
302
743
  res.setHeader('Content-Type', content.mime)
303
744
  res.setHeader('Cache-Control', 'private, no-store')
@@ -308,9 +749,23 @@ mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
308
749
  // header, browser fetch can receive this value but cannot read it.
309
750
  res.setHeader('Access-Control-Expose-Headers', 'X-COS-G2-Variant')
310
751
  }
311
- res.setHeader('Content-Length', String(bytes.length))
312
- res.end(bytes)
752
+ res.setHeader('Content-Length', String(size))
753
+
754
+ const stream = createReadStream(content.path)
755
+ // Once bytes are on the wire the status line is spent, so a failure here CANNOT
756
+ // become a JSON error body — appending one would corrupt a partial asset.
757
+ // Destroy the socket instead: a truncated response is an unambiguous failure to
758
+ // the client, a silently corrupted one is not.
759
+ stream.on('error', (streamErr) => {
760
+ console.error(`[media] content stream failed: ${streamErr instanceof Error ? streamErr.message : streamErr}`)
761
+ res.destroy()
762
+ })
763
+ // Client hung up mid-download: stop reading rather than pumping a dead socket.
764
+ res.on('close', () => stream.destroy())
765
+ stream.pipe(res)
313
766
  } catch (err) {
767
+ // statSync/open failures land here, BEFORE any byte is written, so a structured
768
+ // error response is still legal.
314
769
  sendMediaError(res, err)
315
770
  }
316
771
  })