@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.
- package/CHANGELOG.md +56 -0
- package/package.json +1 -1
- package/server/index.ts +45 -3
- package/server/lib/media-store.ts +241 -12
- package/server/lib/rich-media-safety.ts +206 -44
- package/server/lib/upload-session.ts +409 -0
- package/server/lib/video-compression.ts +376 -0
- package/server/routes/health.ts +20 -2
- package/server/routes/media.ts +471 -16
package/server/routes/media.ts
CHANGED
|
@@ -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,
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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;
|
|
166
|
-
*
|
|
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 (!
|
|
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().
|
|
179
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
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(
|
|
312
|
-
|
|
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
|
})
|