@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.
@@ -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. Inputs are
3
- // bounded bytes from the authenticated binary route; no URL/path ingestion.
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
- export const MAX_RICH_MEDIA_BYTES = 64 * 1024 * 1024
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
- export interface PreparedDocument {
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 interface PreparedVideo {
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
- function isIsoBmff(bytes: Buffer): boolean {
119
- return bytes.length >= 12 && bytes.subarray(4, 8).toString('ascii') === 'ftyp'
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(bytes: Buffer): Promise<PreparedDocument> {
182
- const root = mkdtempSync(join(tmpdir(), 'cos-pdf-'))
183
- const input = join(root, 'input.pdf')
184
- const output = join(root, 'content.txt')
185
- const pagesPrefix = join(root, 'page')
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
- writeFileSync(input, bytes, { mode: 0o600 })
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), input, pagesPrefix])
194
- for (const name of readdirSync(root).filter(name => /^page-\d+\.jpg$/i.test(name)).sort().slice(0, MAX_DERIVATIVE_IMAGES)) {
195
- pageImages.push(readFileSync(join(root, name)))
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', original: bytes,
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(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
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(bytes: Buffer, label: string | undefined, declaredMime: string | undefined): Promise<PreparedVideo> {
218
- const root = mkdtempSync(join(tmpdir(), 'cos-video-'))
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 = join(root, `input${ext}`)
221
- const probePath = join(root, 'probe.json')
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(root, 'frame-%02d.jpg'),
372
+ '-frames:v', String(frameCount), '-q:v', '3', join(work, 'frame-%02d.jpg'),
280
373
  ])
281
- const frames = readdirSync(root)
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(root, name)))
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: PreparedVideo['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
378
+ const mime: PreparedVideoFile['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
286
379
  ? 'video/quicktime' : 'video/mp4'
287
380
  return {
288
- category: 'video', mime, original: bytes,
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(root, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
386
+ try { rmSync(work, { recursive: true, force: true }) } catch { /* private tmp cleanup */ }
293
387
  }
294
388
  }
295
389
 
296
- export async function prepareRichMedia(
297
- bytes: Buffer,
298
- options: { label?: string; declaredMime?: string },
299
- ): Promise<PreparedRichMedia> {
300
- if (bytes.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
301
- if (bytes.length > MAX_RICH_MEDIA_BYTES) {
302
- throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${MAX_RICH_MEDIA_BYTES} byte limit`)
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(bytes)) return processPdf(bytes)
305
- if (isIsoBmff(bytes)) return processVideo(bytes, options.label, options.declaredMime)
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: PreparedDocument['mime'] | null = ext === '.md' || ext === '.markdown' || declared === 'text/markdown'
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
- const capped = capText(decodeStrictUtf8(bytes))
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, original: bytes,
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
+ }