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