@gotcos/glasses-server 6.24.5 → 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 +28 -0
- package/package.json +1 -1
- package/server/index.ts +45 -3
- package/server/lib/media-store.ts +232 -12
- 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
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Strict document/video validation and derivative generation for user uploads.
|
|
2
|
-
// Paths and filenames never enter the public attachment contract.
|
|
3
|
-
//
|
|
2
|
+
// Paths and filenames never enter the public attachment contract. The upload
|
|
3
|
+
// body arrives as a STAGED FILE, not a Buffer: at the 100 MB video cap an
|
|
4
|
+
// in-memory body would be a 100 MB allocation per concurrent upload, and both
|
|
5
|
+
// ffprobe and pdftotext want a path anyway. No URL ingestion; the only paths
|
|
6
|
+
// accepted are ones this server staged itself.
|
|
4
7
|
|
|
5
8
|
import { spawn } from 'node:child_process'
|
|
6
9
|
import {
|
|
10
|
+
closeSync,
|
|
7
11
|
mkdtempSync,
|
|
12
|
+
openSync,
|
|
8
13
|
readFileSync,
|
|
14
|
+
readSync,
|
|
9
15
|
readdirSync,
|
|
10
16
|
rmSync,
|
|
11
17
|
writeFileSync,
|
|
@@ -14,7 +20,27 @@ import { tmpdir } from 'node:os'
|
|
|
14
20
|
import { extname, join } from 'node:path'
|
|
15
21
|
import type { MediaMime } from '../../shared/media-attachment.js'
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
/** Images and documents — the contract's `otherMaxBytes`. Unchanged. */
|
|
24
|
+
export const MAX_OTHER_MEDIA_BYTES = 64 * 1024 * 1024 // 67108864
|
|
25
|
+
/** Video — the contract's `videoMaxBytes`. Measured: a real phone upload runs
|
|
26
|
+
* 3840x2160/30fps at 25.5 Mbps, so 64 MiB buys ~21s and 100 MB buys ~31s.
|
|
27
|
+
* This does NOT unlock long video (a 3-minute 4K original is ~570 MB — only
|
|
28
|
+
* chunked upload unlocks length); it stops an ordinary clip being refused. */
|
|
29
|
+
export const MAX_VIDEO_MEDIA_BYTES = 100 * 1024 * 1024 // 104857600
|
|
30
|
+
/** Hard ceiling for any single-shot body, applied before its kind is known. */
|
|
31
|
+
export const MAX_SINGLE_SHOT_MEDIA_BYTES = Math.max(MAX_OTHER_MEDIA_BYTES, MAX_VIDEO_MEDIA_BYTES)
|
|
32
|
+
/** Advertised chunked-upload geometry (contract §1). Published on health so the
|
|
33
|
+
* client never hardcodes a cap; the two repos have already diverged once. */
|
|
34
|
+
export const MEDIA_CHUNK_BYTES = 8 * 1024 * 1024 // 8388608
|
|
35
|
+
export const MAX_CHUNKED_MEDIA_BYTES = 2 * 1024 * 1024 * 1024 // 2147483648
|
|
36
|
+
/** Mirrors VIDEO_COMPRESSION_LABEL in server/lib/video-compression.ts. It has
|
|
37
|
+
* to be a copy: that module imports getRichMediaProcessingCapabilities from
|
|
38
|
+
* THIS file, so importing its label back would be a circular import. The two
|
|
39
|
+
* are pinned together by a test instead of by the compiler. */
|
|
40
|
+
export const ADVERTISED_VIDEO_COMPRESSION_LABEL = 'x265-crf30'
|
|
41
|
+
/** Bytes needed to classify an upload from its magic numbers. ISO-BMFF needs
|
|
42
|
+
* 12 ('ftyp' at offset 4); every other sniff here needs fewer. */
|
|
43
|
+
export const MEDIA_SNIFF_BYTES = 12
|
|
18
44
|
export const MAX_DOCUMENT_TEXT_CHARS = 100_000
|
|
19
45
|
export const MAX_VIDEO_DURATION_MS = 20 * 60_000
|
|
20
46
|
export const MAX_DERIVATIVE_IMAGES = 8
|
|
@@ -38,27 +64,63 @@ export class RichMediaSafetyError extends Error {
|
|
|
38
64
|
}
|
|
39
65
|
}
|
|
40
66
|
|
|
41
|
-
|
|
67
|
+
/** The original stays on disk. `originalPath` is the staged file the caller
|
|
68
|
+
* handed in — the caller still owns it and MOVES it into place on publish. */
|
|
69
|
+
interface PreparedOriginalFile {
|
|
70
|
+
originalPath: string
|
|
71
|
+
originalBytes: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PreparedDocumentFile = PreparedOriginalFile & {
|
|
42
75
|
category: 'document'
|
|
43
76
|
mime: Extract<MediaMime, 'text/plain' | 'text/markdown' | 'text/csv' | 'application/json' | 'application/pdf'>
|
|
44
|
-
original: Buffer
|
|
45
77
|
extractedText: string
|
|
46
78
|
textTruncated: boolean
|
|
47
79
|
pageImages: Buffer[]
|
|
48
80
|
}
|
|
49
81
|
|
|
50
|
-
export
|
|
82
|
+
export type PreparedVideoFile = PreparedOriginalFile & {
|
|
51
83
|
category: 'video'
|
|
52
84
|
mime: Extract<MediaMime, 'video/mp4' | 'video/quicktime'>
|
|
53
|
-
original: Buffer
|
|
54
85
|
width: number
|
|
55
86
|
height: number
|
|
56
87
|
durationMs: number
|
|
57
88
|
frames: Buffer[]
|
|
58
89
|
}
|
|
59
90
|
|
|
91
|
+
export type PreparedRichMediaFile = PreparedDocumentFile | PreparedVideoFile
|
|
92
|
+
|
|
93
|
+
export type PreparedDocument = Omit<PreparedDocumentFile, keyof PreparedOriginalFile> & { original: Buffer }
|
|
94
|
+
export type PreparedVideo = Omit<PreparedVideoFile, keyof PreparedOriginalFile> & { original: Buffer }
|
|
60
95
|
export type PreparedRichMedia = PreparedDocument | PreparedVideo
|
|
61
96
|
|
|
97
|
+
export interface MediaLimits {
|
|
98
|
+
videoMaxBytes: number
|
|
99
|
+
otherMaxBytes: number
|
|
100
|
+
chunkedUploadEnabled: boolean
|
|
101
|
+
chunkBytes: number
|
|
102
|
+
chunkedMaxBytes: number
|
|
103
|
+
videoCompression: string | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The limits block published on GET /api/health. The server is the single
|
|
107
|
+
* authority: the client must read these rather than carry its own constants.
|
|
108
|
+
* `videoCompression` is null — not false — when ffmpeg/ffprobe are missing,
|
|
109
|
+
* because the field's value is the encode label the client displays. */
|
|
110
|
+
export async function getMediaLimits(
|
|
111
|
+
options: { chunkedUploadEnabled: boolean },
|
|
112
|
+
): Promise<MediaLimits> {
|
|
113
|
+
const capabilities = await getRichMediaProcessingCapabilities()
|
|
114
|
+
return {
|
|
115
|
+
videoMaxBytes: MAX_VIDEO_MEDIA_BYTES,
|
|
116
|
+
otherMaxBytes: MAX_OTHER_MEDIA_BYTES,
|
|
117
|
+
chunkedUploadEnabled: options.chunkedUploadEnabled,
|
|
118
|
+
chunkBytes: MEDIA_CHUNK_BYTES,
|
|
119
|
+
chunkedMaxBytes: MAX_CHUNKED_MEDIA_BYTES,
|
|
120
|
+
videoCompression: capabilities.video ? ADVERTISED_VIDEO_COMPRESSION_LABEL : null,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
62
124
|
async function executableReady(command: string, args: string[]): Promise<boolean> {
|
|
63
125
|
return new Promise(resolve => {
|
|
64
126
|
let settled = false
|
|
@@ -115,8 +177,30 @@ function isPdf(bytes: Buffer): boolean {
|
|
|
115
177
|
return bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-'
|
|
116
178
|
}
|
|
117
179
|
|
|
118
|
-
|
|
119
|
-
|
|
180
|
+
/** True when the leading bytes are ISO-BMFF (MP4/MOV). Decided from the magic
|
|
181
|
+
* numbers and never from the declared Content-Type, because this predicate
|
|
182
|
+
* also picks the byte cap: a text file claiming video/mp4 must not buy the
|
|
183
|
+
* 100 MB ceiling. */
|
|
184
|
+
export function isVideoUploadHead(head: Buffer): boolean {
|
|
185
|
+
return head.length >= 12 && head.subarray(4, 8).toString('ascii') === 'ftyp'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Read only the leading bytes of a staged upload. Classification must not
|
|
189
|
+
* require loading a 100 MB body. */
|
|
190
|
+
function readHead(path: string, length = MEDIA_SNIFF_BYTES): Buffer {
|
|
191
|
+
const buffer = Buffer.alloc(length)
|
|
192
|
+
let fd: number | null = null
|
|
193
|
+
try {
|
|
194
|
+
fd = openSync(path, 'r')
|
|
195
|
+
const read = readSync(fd, buffer, 0, length, 0)
|
|
196
|
+
return buffer.subarray(0, read)
|
|
197
|
+
} catch {
|
|
198
|
+
throw new RichMediaSafetyError('corrupt_attachment', 'staged attachment could not be read')
|
|
199
|
+
} finally {
|
|
200
|
+
if (fd !== null) {
|
|
201
|
+
try { closeSync(fd) } catch { /* fd already gone */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
120
204
|
}
|
|
121
205
|
|
|
122
206
|
function decodeStrictUtf8(bytes: Buffer): string {
|
|
@@ -178,21 +262,22 @@ async function runProcess(command: string, args: string[], timeoutMs = PROCESS_T
|
|
|
178
262
|
})
|
|
179
263
|
}
|
|
180
264
|
|
|
181
|
-
async function processPdf(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
265
|
+
async function processPdf(sourcePath: string, byteLength: number): Promise<PreparedDocumentFile> {
|
|
266
|
+
// Derivatives go to a PRIVATE work dir, never beside the source: the staging
|
|
267
|
+
// directory holds other concurrent uploads, and the page-image scan below is
|
|
268
|
+
// a directory listing.
|
|
269
|
+
const work = mkdtempSync(join(tmpdir(), 'cos-pdf-'))
|
|
270
|
+
const output = join(work, 'content.txt')
|
|
271
|
+
const pagesPrefix = join(work, 'page')
|
|
186
272
|
try {
|
|
187
|
-
|
|
188
|
-
await runProcess('pdftotext', ['-layout', '-enc', 'UTF-8', input, output])
|
|
273
|
+
await runProcess('pdftotext', ['-layout', '-enc', 'UTF-8', sourcePath, output])
|
|
189
274
|
const rawText = readFileSync(output, 'utf8')
|
|
190
275
|
const capped = capText(rawText)
|
|
191
276
|
const pageImages: Buffer[] = []
|
|
192
277
|
try {
|
|
193
|
-
await runProcess('pdftoppm', ['-jpeg', '-r', '120', '-f', '1', '-l', String(MAX_DERIVATIVE_IMAGES),
|
|
194
|
-
for (const name of readdirSync(
|
|
195
|
-
pageImages.push(readFileSync(join(
|
|
278
|
+
await runProcess('pdftoppm', ['-jpeg', '-r', '120', '-f', '1', '-l', String(MAX_DERIVATIVE_IMAGES), sourcePath, pagesPrefix])
|
|
279
|
+
for (const name of readdirSync(work).filter(name => /^page-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)) {
|
|
280
|
+
pageImages.push(readFileSync(join(work, name)))
|
|
196
281
|
}
|
|
197
282
|
} catch (error) {
|
|
198
283
|
if (capped.text.length === 0) throw error
|
|
@@ -201,11 +286,12 @@ async function processPdf(bytes: Buffer): Promise<PreparedDocument> {
|
|
|
201
286
|
throw new RichMediaSafetyError('corrupt_attachment', 'PDF contains no extractable text or pages')
|
|
202
287
|
}
|
|
203
288
|
return {
|
|
204
|
-
category: 'document', mime: 'application/pdf',
|
|
289
|
+
category: 'document', mime: 'application/pdf',
|
|
290
|
+
originalPath: sourcePath, originalBytes: byteLength,
|
|
205
291
|
extractedText: capped.text, textTruncated: capped.truncated, pageImages,
|
|
206
292
|
}
|
|
207
293
|
} finally {
|
|
208
|
-
try { rmSync(
|
|
294
|
+
try { rmSync(work, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
|
|
209
295
|
}
|
|
210
296
|
}
|
|
211
297
|
|
|
@@ -214,13 +300,20 @@ interface ProbePayload {
|
|
|
214
300
|
streams?: Array<{ codec_type?: string; width?: number; height?: number }>
|
|
215
301
|
}
|
|
216
302
|
|
|
217
|
-
async function processVideo(
|
|
218
|
-
|
|
303
|
+
async function processVideo(
|
|
304
|
+
sourcePath: string,
|
|
305
|
+
byteLength: number,
|
|
306
|
+
label: string | undefined,
|
|
307
|
+
declaredMime: string | undefined,
|
|
308
|
+
): Promise<PreparedVideoFile> {
|
|
309
|
+
// Frames go to a private work dir; the source is read in place. ffprobe and
|
|
310
|
+
// ffmpeg both identify ISO-BMFF by probing content, so the staged file needs
|
|
311
|
+
// no .mp4/.mov extension — only the reported MIME depends on the label.
|
|
312
|
+
const work = mkdtempSync(join(tmpdir(), 'cos-video-'))
|
|
219
313
|
const ext = fileExtension(label) === '.mov' ? '.mov' : '.mp4'
|
|
220
|
-
const input =
|
|
221
|
-
const probePath = join(
|
|
314
|
+
const input = sourcePath
|
|
315
|
+
const probePath = join(work, 'probe.json')
|
|
222
316
|
try {
|
|
223
|
-
writeFileSync(input, bytes, { mode: 0o600 })
|
|
224
317
|
await new Promise<void>((resolve, reject) => {
|
|
225
318
|
let settled = false
|
|
226
319
|
let stdout = ''
|
|
@@ -276,37 +369,46 @@ async function processVideo(bytes: Buffer, label: string | undefined, declaredMi
|
|
|
276
369
|
await runProcess('ffmpeg', [
|
|
277
370
|
'-nostdin', '-v', 'error', '-i', input,
|
|
278
371
|
'-vf', `fps=${fps.toFixed(6)},scale=1280:-2:force_original_aspect_ratio=decrease`,
|
|
279
|
-
'-frames:v', String(frameCount), '-q:v', '3', join(
|
|
372
|
+
'-frames:v', String(frameCount), '-q:v', '3', join(work, 'frame-%02d.jpg'),
|
|
280
373
|
])
|
|
281
|
-
const frames = readdirSync(
|
|
374
|
+
const frames = readdirSync(work)
|
|
282
375
|
.filter(name => /^frame-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)
|
|
283
|
-
.map(name => readFileSync(join(
|
|
376
|
+
.map(name => readFileSync(join(work, name)))
|
|
284
377
|
if (frames.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'video produced no review frames')
|
|
285
|
-
const mime:
|
|
378
|
+
const mime: PreparedVideoFile['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
|
|
286
379
|
? 'video/quicktime' : 'video/mp4'
|
|
287
380
|
return {
|
|
288
|
-
category: 'video', mime,
|
|
381
|
+
category: 'video', mime,
|
|
382
|
+
originalPath: sourcePath, originalBytes: byteLength,
|
|
289
383
|
width: Math.floor(stream.width!), height: Math.floor(stream.height!), durationMs, frames,
|
|
290
384
|
}
|
|
291
385
|
} finally {
|
|
292
|
-
try { rmSync(
|
|
386
|
+
try { rmSync(work, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
|
|
293
387
|
}
|
|
294
388
|
}
|
|
295
389
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
390
|
+
/** Production entry point: validate an already-staged upload file in place.
|
|
391
|
+
* The returned `originalPath` is the caller's own staged file — this module
|
|
392
|
+
* never moves, renames, or deletes it. */
|
|
393
|
+
export async function prepareRichMediaFromFile(
|
|
394
|
+
sourcePath: string,
|
|
395
|
+
options: { label?: string; declaredMime?: string; byteLength: number },
|
|
396
|
+
): Promise<PreparedRichMediaFile> {
|
|
397
|
+
if (options.byteLength === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
|
|
398
|
+
const head = readHead(sourcePath)
|
|
399
|
+
// Two caps now, so the cap check has to know WHAT it is looking at. The
|
|
400
|
+
// classification is byte-authoritative for exactly that reason.
|
|
401
|
+
const isVideo = isVideoUploadHead(head)
|
|
402
|
+
const cap = isVideo ? MAX_VIDEO_MEDIA_BYTES : MAX_OTHER_MEDIA_BYTES
|
|
403
|
+
if (options.byteLength > cap) {
|
|
404
|
+
throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${cap} byte limit`)
|
|
303
405
|
}
|
|
304
|
-
if (isPdf(
|
|
305
|
-
if (
|
|
406
|
+
if (isPdf(head)) return processPdf(sourcePath, options.byteLength)
|
|
407
|
+
if (isVideo) return processVideo(sourcePath, options.byteLength, options.label, options.declaredMime)
|
|
306
408
|
|
|
307
409
|
const ext = fileExtension(options.label)
|
|
308
410
|
const declared = (options.declaredMime ?? '').toLowerCase().split(';', 1)[0]
|
|
309
|
-
const textMime:
|
|
411
|
+
const textMime: PreparedDocumentFile['mime'] | null = ext === '.md' || ext === '.markdown' || declared === 'text/markdown'
|
|
310
412
|
? 'text/markdown'
|
|
311
413
|
: ext === '.csv' || declared === 'text/csv'
|
|
312
414
|
? 'text/csv'
|
|
@@ -316,9 +418,38 @@ export async function prepareRichMedia(
|
|
|
316
418
|
? 'text/plain'
|
|
317
419
|
: null
|
|
318
420
|
if (!textMime) throw new RichMediaSafetyError('unsupported_attachment_format', 'supported files: TXT, MD, CSV, JSON, PDF, MP4, MOV')
|
|
319
|
-
|
|
421
|
+
// Text has to be decoded to be validated at all, and it is bounded by the
|
|
422
|
+
// 64 MiB `otherMaxBytes` cap enforced above.
|
|
423
|
+
const capped = capText(decodeStrictUtf8(readFileSync(sourcePath)))
|
|
320
424
|
return {
|
|
321
|
-
category: 'document', mime: textMime,
|
|
425
|
+
category: 'document', mime: textMime,
|
|
426
|
+
originalPath: sourcePath, originalBytes: options.byteLength,
|
|
322
427
|
extractedText: capped.text, textTruncated: capped.truncated, pageImages: [],
|
|
323
428
|
}
|
|
324
429
|
}
|
|
430
|
+
|
|
431
|
+
/** In-memory entry point for callers that already hold the bytes. Stages them
|
|
432
|
+
* to a private temp file and delegates, so there is exactly ONE validation
|
|
433
|
+
* path — a second one is a second place for the safety rules to rot. */
|
|
434
|
+
export async function prepareRichMedia(
|
|
435
|
+
bytes: Buffer,
|
|
436
|
+
options: { label?: string; declaredMime?: string },
|
|
437
|
+
): Promise<PreparedRichMedia> {
|
|
438
|
+
if (bytes.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
|
|
439
|
+
const work = mkdtempSync(join(tmpdir(), 'cos-attachment-'))
|
|
440
|
+
const sourcePath = join(work, 'source.bin')
|
|
441
|
+
try {
|
|
442
|
+
writeFileSync(sourcePath, bytes, { mode: 0o600 })
|
|
443
|
+
const prepared = await prepareRichMediaFromFile(sourcePath, {
|
|
444
|
+
label: options.label,
|
|
445
|
+
declaredMime: options.declaredMime,
|
|
446
|
+
byteLength: bytes.length,
|
|
447
|
+
})
|
|
448
|
+
// The caller's Buffer IS the original — swap it in rather than reading the
|
|
449
|
+
// staged copy back off disk.
|
|
450
|
+
const { originalPath: _path, originalBytes: _bytes, ...rest } = prepared
|
|
451
|
+
return { ...rest, original: bytes } as PreparedRichMedia
|
|
452
|
+
} finally {
|
|
453
|
+
try { rmSync(work, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
|
|
454
|
+
}
|
|
455
|
+
}
|