@gotcos/glasses-server 6.25.0 → 6.27.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 +51 -0
- package/package.json +1 -1
- package/server/lib/media-store.ts +9 -0
- package/server/lib/rich-media-safety.ts +120 -8
- package/server/lib/upload-session.ts +409 -0
- package/server/routes/media.ts +280 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
## 6.27.0
|
|
2
|
+
|
|
3
|
+
### Video review frames: 8-16 stills instead of 1-3
|
|
4
|
+
|
|
5
|
+
A video attachment is summarized from stills. The old rule was one still per 15
|
|
6
|
+
seconds, floored at 1 and capped at 8, which gave a 12 second clip **one** frame
|
|
7
|
+
and a 44 second clip **three** - not enough to tell what a video contains. Miles,
|
|
8
|
+
on a fridge sweep: "it only selects three chunks from the video."
|
|
9
|
+
|
|
10
|
+
- Frame count is now `clamp(round(seconds / 6), 8, 16)`. A 12s clip and a 44s
|
|
11
|
+
clip both get 8; a 72s clip gets 12; anything past 96s gets 16.
|
|
12
|
+
- Each frame is the **sharpest** of 5 candidates sampled around its position,
|
|
13
|
+
ranked by encoded JPEG size at fixed quality. Measured on real footage the
|
|
14
|
+
spread within one second was 1.45x, and the large frame read product label
|
|
15
|
+
text that the small one rendered as smear.
|
|
16
|
+
- Candidate count is `frames * 5`, so a 20 minute recording costs the same temp
|
|
17
|
+
I/O as a 12 second one - the sampling rate adapts, the work does not grow.
|
|
18
|
+
- Fixed an upscale: `scale=1280:-2` was enlarging a 480x360 source to 1280x960,
|
|
19
|
+
paying roughly 7x the image tokens for detail that was never captured. Now
|
|
20
|
+
`scale='min(1280,iw)':-2`, so small sources pass through at native size.
|
|
21
|
+
- `MAX_DERIVATIVE_IMAGES` (8) is untouched, so PDF page extraction is unchanged.
|
|
22
|
+
Video no longer shares that constant.
|
|
23
|
+
|
|
24
|
+
## 6.26.0
|
|
25
|
+
|
|
26
|
+
Chunked, resumable upload: video is no longer limited by what fits in one request.
|
|
27
|
+
|
|
28
|
+
- A video can now be uploaded in pieces, so length is bounded by storage rather than by a
|
|
29
|
+
single request. A 3-minute 4K clip is roughly 570 MB and could never fit a one-shot
|
|
30
|
+
limit; it now transfers as a sequence of 8 MiB chunks, losslessly, and is compressed
|
|
31
|
+
afterwards for storage.
|
|
32
|
+
- Interrupted uploads resume. The phone asks the server what it actually received and
|
|
33
|
+
continues from there rather than trusting its own count, because a chunk whose
|
|
34
|
+
acknowledgement was lost makes the client's number wrong. Resume covers network drops,
|
|
35
|
+
which is the common case; a server restart clears in-flight uploads and the phone starts
|
|
36
|
+
over cleanly rather than resuming onto nothing.
|
|
37
|
+
- Cancelling or giving up releases the server's slot and staging disk immediately instead
|
|
38
|
+
of holding them for four hours. Without this, a handful of give-ups on a poor connection
|
|
39
|
+
could make new uploads unavailable until the sessions expired.
|
|
40
|
+
- Assembly is verified before anything is published: the reassembled size must match what
|
|
41
|
+
the phone declared, a chunk that arrives out of order is refused rather than appended,
|
|
42
|
+
and a partial write is rejected rather than producing a correctly-sized file with a hole
|
|
43
|
+
in it.
|
|
44
|
+
- Finalizing a chunked upload runs the same validation, size cap, atomic publish and
|
|
45
|
+
background compression as a single-shot upload — one path, so the safety rules cannot
|
|
46
|
+
drift between them. Only video is allowed the larger chunked ceiling; documents and
|
|
47
|
+
images keep the existing limit, because reading a multi-gigabyte text file into memory
|
|
48
|
+
would fail in a far worse way than refusing it.
|
|
49
|
+
- `GET /api/health` advertises chunked availability and the chunk size, so the phone
|
|
50
|
+
decides from what this server actually supports rather than from a built-in assumption.
|
|
51
|
+
|
|
1
52
|
## 6.25.0
|
|
2
53
|
|
|
3
54
|
Large video uploads: a 100 MiB cap, streamed to disk, compressed in the background.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.27.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from './image-safety.js'
|
|
56
56
|
import {
|
|
57
57
|
prepareRichMediaFromFile,
|
|
58
|
+
type MediaTransferMode,
|
|
58
59
|
type PreparedRichMediaFile,
|
|
59
60
|
} from './rich-media-safety.js'
|
|
60
61
|
import { VIDEO_COMPRESSION_LABEL, compressVideoFile } from './video-compression.js'
|
|
@@ -230,6 +231,10 @@ export interface IngestRichMediaFileInput {
|
|
|
230
231
|
declaredMime?: string
|
|
231
232
|
capturedAt?: string
|
|
232
233
|
sessionId?: string
|
|
234
|
+
/** How the bytes arrived. Omitted means single_shot, so /api/media/file keeps its
|
|
235
|
+
* existing ceiling; chunked finalize passes 'chunked' so a multi-hundred-MB video
|
|
236
|
+
* is judged against the chunked cap instead of being refused AFTER transfer. */
|
|
237
|
+
transfer?: MediaTransferMode
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
/** Handle for a streaming upload's staging file. `dispose()` is idempotent and
|
|
@@ -520,6 +525,10 @@ export class MediaStore {
|
|
|
520
525
|
label: input.label,
|
|
521
526
|
declaredMime: input.declaredMime,
|
|
522
527
|
byteLength: input.byteLength,
|
|
528
|
+
// Chunked finalize must be judged against the chunked ceiling, not the
|
|
529
|
+
// single-shot one it used to inherit. Defaults to single_shot, so
|
|
530
|
+
// /api/media/file is unchanged.
|
|
531
|
+
transfer: input.transfer,
|
|
523
532
|
})
|
|
524
533
|
return this.publishPreparedRichMedia(input, prepared)
|
|
525
534
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readSync,
|
|
15
15
|
readdirSync,
|
|
16
16
|
rmSync,
|
|
17
|
+
statSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from 'node:fs'
|
|
19
20
|
import { tmpdir } from 'node:os'
|
|
@@ -44,6 +45,76 @@ export const MEDIA_SNIFF_BYTES = 12
|
|
|
44
45
|
export const MAX_DOCUMENT_TEXT_CHARS = 100_000
|
|
45
46
|
export const MAX_VIDEO_DURATION_MS = 20 * 60_000
|
|
46
47
|
export const MAX_DERIVATIVE_IMAGES = 8
|
|
48
|
+
|
|
49
|
+
// ── Video summary frames ────────────────────────────────────────────────────────
|
|
50
|
+
// DELIBERATELY SEPARATE from MAX_DERIVATIVE_IMAGES, which the PDF path also uses
|
|
51
|
+
// (pdftoppm -l): raising that shared constant would silently give every PDF 16 pages.
|
|
52
|
+
//
|
|
53
|
+
// What was here: min(8, max(1, ceil(durationMs / 15_000))) — one frame per 15 seconds,
|
|
54
|
+
// capped at 8. Measured against real assets that produced:
|
|
55
|
+
// 11.7s 4K clip -> 1 frame
|
|
56
|
+
// 43.6s fridge sweep -> 3 frames (Msg 796: "I only got 3 samples")
|
|
57
|
+
// 20 min (the cap) -> 8 frames, one per 2.5 MINUTES
|
|
58
|
+
// A 12-second video summarised by a single still is not a summary, and 3 frames missed
|
|
59
|
+
// the entire contents of a refrigerator the user was asking about.
|
|
60
|
+
export const VIDEO_SUMMARY_FRAMES_MIN = 8
|
|
61
|
+
export const VIDEO_SUMMARY_FRAMES_MAX = 16
|
|
62
|
+
/** One frame per ~6s of footage, between the floor and the ceiling. */
|
|
63
|
+
const VIDEO_SUMMARY_SECONDS_PER_FRAME = 6
|
|
64
|
+
/** Candidates sampled per window; the sharpest one is kept. More candidates cost
|
|
65
|
+
* only temp I/O, but the search is pointless beyond a handful per window. */
|
|
66
|
+
export const VIDEO_SUMMARY_CANDIDATES_PER_FRAME = 5
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many stills represent a video.
|
|
70
|
+
*
|
|
71
|
+
* Uniform intervals, not random: random sampling clusters and leaves gaps, while a
|
|
72
|
+
* uniform grid provably covers start to finish and is reproducible across runs — which
|
|
73
|
+
* matters when the same video is asked about twice.
|
|
74
|
+
*
|
|
75
|
+
* 12 frames is the target for a typical clip: roughly one per 8% of the runtime, about
|
|
76
|
+
* 15K tokens at 1280px wide, leaving room for a transcript and the question in one turn.
|
|
77
|
+
* Below the floor of 8 whole segments go unseen; above the ceiling of 16 the marginal
|
|
78
|
+
* still adds little to a SUMMARY and that budget is better spent on a targeted
|
|
79
|
+
* native-resolution pass over a named time window.
|
|
80
|
+
*/
|
|
81
|
+
export function videoSummaryFrameCount(durationMs: number): number {
|
|
82
|
+
const seconds = Number.isFinite(durationMs) ? Math.max(0, durationMs) / 1000 : 0
|
|
83
|
+
const scaled = Math.round(seconds / VIDEO_SUMMARY_SECONDS_PER_FRAME)
|
|
84
|
+
return Math.min(VIDEO_SUMMARY_FRAMES_MAX, Math.max(VIDEO_SUMMARY_FRAMES_MIN, scaled))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pick the sharpest candidate in each window, by encoded JPEG size.
|
|
89
|
+
*
|
|
90
|
+
* At a FIXED quality setting, a sharper frame carries more high-frequency detail and
|
|
91
|
+
* therefore encodes larger; a motion-blurred one compresses down. Measured on the real
|
|
92
|
+
* 480x360 fridge sweep: within one second the spread was 1.45x (27,311 vs 18,852 bytes),
|
|
93
|
+
* and inspecting both ends confirmed it — the largest frame reads "Mootopia WHOLE / 13g /
|
|
94
|
+
* LACTOSE FREE" and the smallest is unreadable smear. Those are the exact labels that
|
|
95
|
+
* previously required a manual frame-by-frame pass to recover.
|
|
96
|
+
*
|
|
97
|
+
* This is a proxy, not a Laplacian: it needs no image library, no new dependency, and one
|
|
98
|
+
* ffmpeg pass. It can be fooled by a window where the sharp frames are also the emptiest,
|
|
99
|
+
* which costs a slightly worse still rather than a wrong answer.
|
|
100
|
+
*/
|
|
101
|
+
export function pickSharpestPerWindow(
|
|
102
|
+
candidates: readonly { name: string; bytes: number }[],
|
|
103
|
+
windows: number,
|
|
104
|
+
): string[] {
|
|
105
|
+
if (candidates.length === 0 || windows <= 0) return []
|
|
106
|
+
const ordered = [...candidates].sort((a, b) => a.name.localeCompare(b.name))
|
|
107
|
+
const perWindow = Math.max(1, Math.floor(ordered.length / windows))
|
|
108
|
+
const picked: string[] = []
|
|
109
|
+
for (let w = 0; w < windows; w++) {
|
|
110
|
+
const start = w * perWindow
|
|
111
|
+
// The final window absorbs any remainder, so no candidate is silently dropped.
|
|
112
|
+
const slice = w === windows - 1 ? ordered.slice(start) : ordered.slice(start, start + perWindow)
|
|
113
|
+
if (slice.length === 0) continue
|
|
114
|
+
picked.push(slice.reduce((best, c) => (c.bytes > best.bytes ? c : best), slice[0]).name)
|
|
115
|
+
}
|
|
116
|
+
return picked
|
|
117
|
+
}
|
|
47
118
|
const PROCESS_STDERR_MAX = 8_192
|
|
48
119
|
const PROCESS_TIMEOUT_MS = 30_000
|
|
49
120
|
|
|
@@ -364,15 +435,25 @@ async function processVideo(
|
|
|
364
435
|
if (durationMs > MAX_VIDEO_DURATION_MS) {
|
|
365
436
|
throw new RichMediaSafetyError('video_too_long', `video exceeds ${MAX_VIDEO_DURATION_MS / 60_000} minute limit`)
|
|
366
437
|
}
|
|
367
|
-
|
|
368
|
-
|
|
438
|
+
// Uniform windows across the whole runtime, then the SHARPEST candidate in each.
|
|
439
|
+
// Sampling candidates at a duration-derived rate keeps the total constant no matter
|
|
440
|
+
// how long the video is: a 20-minute recording costs the same temp I/O as a 12-second
|
|
441
|
+
// one, in ONE ffmpeg pass.
|
|
442
|
+
const frameCount = videoSummaryFrameCount(durationMs)
|
|
443
|
+
const candidateCount = frameCount * VIDEO_SUMMARY_CANDIDATES_PER_FRAME
|
|
444
|
+
const fps = Math.max(0.001, candidateCount / (durationMs / 1000))
|
|
369
445
|
await runProcess('ffmpeg', [
|
|
370
446
|
'-nostdin', '-v', 'error', '-i', input,
|
|
371
|
-
|
|
372
|
-
|
|
447
|
+
// min(1280,iw) rather than a bare 1280: the old filter UPSCALED a 480x360 source to
|
|
448
|
+
// 1280x960, paying ~7x the image tokens for detail that was never captured. It only
|
|
449
|
+
// ever downscales now.
|
|
450
|
+
'-vf', `fps=${fps.toFixed(6)},scale='min(1280,iw)':-2`,
|
|
451
|
+
'-frames:v', String(candidateCount), '-q:v', '3', join(work, 'cand-%04d.jpg'),
|
|
373
452
|
])
|
|
374
|
-
const
|
|
375
|
-
.filter(name => /^
|
|
453
|
+
const candidates = readdirSync(work)
|
|
454
|
+
.filter(name => /^cand-\d+\.jpg$/i.test(name))
|
|
455
|
+
.map(name => ({ name, bytes: statSync(join(work, name)).size }))
|
|
456
|
+
const frames = pickSharpestPerWindow(candidates, frameCount)
|
|
376
457
|
.map(name => readFileSync(join(work, name)))
|
|
377
458
|
if (frames.length === 0) throw new RichMediaSafetyError('corrupt_attachment', 'video produced no review frames')
|
|
378
459
|
const mime: PreparedVideoFile['mime'] = declaredMime === 'video/quicktime' || ext === '.mov'
|
|
@@ -390,16 +471,47 @@ async function processVideo(
|
|
|
390
471
|
/** Production entry point: validate an already-staged upload file in place.
|
|
391
472
|
* The returned `originalPath` is the caller's own staged file — this module
|
|
392
473
|
* never moves, renames, or deletes it. */
|
|
474
|
+
/**
|
|
475
|
+
* How the bytes arrived. This decides the ceiling, and it has to be threaded in
|
|
476
|
+
* rather than assumed.
|
|
477
|
+
*
|
|
478
|
+
* THE DEFECT THIS FIXES. finalize for a chunked upload calls this same function —
|
|
479
|
+
* deliberately, so validation, the cap, the atomic rename and compression are not
|
|
480
|
+
* forked. But it inherited the SINGLE-SHOT cap, so a 200 MB video transferred all
|
|
481
|
+
* 25 chunks and was then refused at finalize with a raw 413. That is worse than the
|
|
482
|
+
* feature not existing: before it, the same file was refused in milliseconds with a
|
|
483
|
+
* readable message. Two independent reviewers found this, and the suite already
|
|
484
|
+
* contained the proof (prepareRichMediaFromFile rejects above the cap) without ever
|
|
485
|
+
* wiring it to the chunked route.
|
|
486
|
+
*/
|
|
487
|
+
export type MediaTransferMode = 'single_shot' | 'chunked'
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The applicable ceiling. Per-kind on purpose, and NOT a flat chunked number.
|
|
491
|
+
*
|
|
492
|
+
* Only VIDEO gets the chunked ceiling. Documents and images stay at the single-shot
|
|
493
|
+
* cap even when chunked, because the text path does
|
|
494
|
+
* `capText(decodeStrictUtf8(readFileSync(sourcePath)))` — a whole-file Buffer plus a
|
|
495
|
+
* whole-file JS string. A 2 GiB .txt would allocate 2 GiB and then throw
|
|
496
|
+
* ERR_STRING_TOO_LONG at V8's ~512 MB string limit, inside the request, in the
|
|
497
|
+
* process that also owns the G2 bridge and whisper. Streaming the upload only to
|
|
498
|
+
* blow up reading it back would defeat the entire point of the rewrite.
|
|
499
|
+
*/
|
|
500
|
+
export function mediaCeilingBytes(isVideo: boolean, transfer: MediaTransferMode = 'single_shot'): number {
|
|
501
|
+
if (!isVideo) return MAX_OTHER_MEDIA_BYTES
|
|
502
|
+
return transfer === 'chunked' ? MAX_CHUNKED_MEDIA_BYTES : MAX_VIDEO_MEDIA_BYTES
|
|
503
|
+
}
|
|
504
|
+
|
|
393
505
|
export async function prepareRichMediaFromFile(
|
|
394
506
|
sourcePath: string,
|
|
395
|
-
options: { label?: string; declaredMime?: string; byteLength: number },
|
|
507
|
+
options: { label?: string; declaredMime?: string; byteLength: number; transfer?: MediaTransferMode },
|
|
396
508
|
): Promise<PreparedRichMediaFile> {
|
|
397
509
|
if (options.byteLength === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
|
|
398
510
|
const head = readHead(sourcePath)
|
|
399
511
|
// Two caps now, so the cap check has to know WHAT it is looking at. The
|
|
400
512
|
// classification is byte-authoritative for exactly that reason.
|
|
401
513
|
const isVideo = isVideoUploadHead(head)
|
|
402
|
-
const cap = isVideo
|
|
514
|
+
const cap = mediaCeilingBytes(isVideo, options.transfer)
|
|
403
515
|
if (options.byteLength > cap) {
|
|
404
516
|
throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${cap} byte limit`)
|
|
405
517
|
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Chunked upload sessions (contract §2 wire shape, §6 v1 decisions).
|
|
2
|
+
//
|
|
3
|
+
// A chunked upload is ONE staging file appended across many requests, then handed
|
|
4
|
+
// to the EXISTING ingest. This module owns only the bookkeeping — which upload,
|
|
5
|
+
// how many bytes are committed, which index comes next, when it expires. It never
|
|
6
|
+
// validates media, never writes into assets/, and never ingests: forking any of
|
|
7
|
+
// that would be a second place for the safety rules to rot (contract §6).
|
|
8
|
+
//
|
|
9
|
+
// Time and filesystem are injectable so the route tests can drive expiry and a
|
|
10
|
+
// failed write without real timers or a real disk fault. media-store.ts already
|
|
11
|
+
// uses that constructor-dependency style for compressVideoFile.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { closeSync, ftruncateSync, openSync, statSync, writeSync } from 'node:fs'
|
|
15
|
+
import { getMediaStore, STAGED_TTL_MS, type MediaStagingFile } from './media-store.js'
|
|
16
|
+
import { MAX_CHUNKED_MEDIA_BYTES, MEDIA_CHUNK_BYTES } from './rich-media-safety.js'
|
|
17
|
+
|
|
18
|
+
/** An in-flight chunked upload IS an unsubmitted upload, so it retires on the
|
|
19
|
+
* same retention clock as one — contract §2, "expire on the existing
|
|
20
|
+
* quarantine/retention clock". Fixed from init rather than sliding: the
|
|
21
|
+
* advertised 2 GiB ceiling at the client's own 250 KiB/s floor is ~2.4 hours,
|
|
22
|
+
* so 4 hours covers a legitimate worst case, while a sliding window would let
|
|
23
|
+
* a slow drip hold that disk indefinitely. */
|
|
24
|
+
export const CHUNKED_UPLOAD_TTL_MS = STAGED_TTL_MS
|
|
25
|
+
|
|
26
|
+
/** Every live session may hold up to `chunkedMaxBytes` of staging file, so this
|
|
27
|
+
* count is a DISK bound, not a throughput one. The client sends one chunk at a
|
|
28
|
+
* time from one device (§6, "sequential and in-order"); this leaves headroom
|
|
29
|
+
* for a retry and a second device without letting a looping client reserve
|
|
30
|
+
* unbounded disk. */
|
|
31
|
+
export const MAX_CONCURRENT_CHUNKED_UPLOADS = 8
|
|
32
|
+
|
|
33
|
+
const UPLOAD_ID_PATTERN = /^u_[0-9a-f]{24}$/
|
|
34
|
+
|
|
35
|
+
/** Ids are minted here, so they are validated here. A value that cannot have
|
|
36
|
+
* come from this module is treated as unknown (404) rather than reaching the
|
|
37
|
+
* registry map — the id is a path component. */
|
|
38
|
+
export function isValidUploadId(value: unknown): value is string {
|
|
39
|
+
return typeof value === 'string' && UPLOAD_ID_PATTERN.test(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type UploadSessionErrorCode =
|
|
43
|
+
| 'upload_not_found'
|
|
44
|
+
| 'chunk_out_of_order'
|
|
45
|
+
| 'attachment_too_large'
|
|
46
|
+
| 'incomplete_upload'
|
|
47
|
+
| 'upload_size_mismatch'
|
|
48
|
+
| 'invalid_total_bytes'
|
|
49
|
+
| 'chunk_bytes_required'
|
|
50
|
+
| 'chunked_upload_unavailable'
|
|
51
|
+
| 'upload_staging_failed'
|
|
52
|
+
|
|
53
|
+
/** `detail` is merged into the JSON error body by the route, so a 409 can carry
|
|
54
|
+
* `expectedIndex` and a 400 can carry the byte counts without the route
|
|
55
|
+
* re-deriving them from state it would have to look up again. */
|
|
56
|
+
export class UploadSessionError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
readonly code: UploadSessionErrorCode,
|
|
59
|
+
message: string,
|
|
60
|
+
readonly detail: Readonly<Record<string, number>> = {},
|
|
61
|
+
) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'UploadSessionError'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface UploadSession {
|
|
68
|
+
uploadId: string
|
|
69
|
+
/** Absolute path inside the media store's tmp/. Boot reconcile rm -rf's that
|
|
70
|
+
* directory, which is exactly why resume is within a boot only (§6). */
|
|
71
|
+
stagingPath: string
|
|
72
|
+
/** The client's DECLARED total from init, already bounded to the ceiling. */
|
|
73
|
+
totalBytes: number
|
|
74
|
+
receivedBytes: number
|
|
75
|
+
nextIndex: number
|
|
76
|
+
mime?: string
|
|
77
|
+
label?: string
|
|
78
|
+
capturedAt?: string
|
|
79
|
+
sessionId?: string
|
|
80
|
+
createdAtMs: number
|
|
81
|
+
expiresAtMs: number
|
|
82
|
+
/** Removes the staging file. Idempotent, and a no-op once ingest has moved
|
|
83
|
+
* the file out — same handle contract as MediaStore.createStagingFile(). */
|
|
84
|
+
dispose: () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The resume-probe view (contract §2 GET). `expiresAt` is ISO 8601 to match
|
|
88
|
+
* every other expiresAt on the media wire (MediaAttachmentRef.expiresAt). */
|
|
89
|
+
export interface UploadSessionProgress {
|
|
90
|
+
uploadId: string
|
|
91
|
+
totalBytes: number
|
|
92
|
+
receivedBytes: number
|
|
93
|
+
nextIndex: number
|
|
94
|
+
expiresAt: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CreateUploadInput {
|
|
98
|
+
/** Untrusted client claim. Validated here because the ceiling is this
|
|
99
|
+
* module's business. */
|
|
100
|
+
totalBytes: unknown
|
|
101
|
+
mime?: string
|
|
102
|
+
label?: string
|
|
103
|
+
capturedAt?: string
|
|
104
|
+
sessionId?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Filesystem seam. `writeAt` MUST leave the file exactly `position +
|
|
108
|
+
* bytes.length` bytes long — the truncation is load-bearing, see appendChunk. */
|
|
109
|
+
export interface UploadSessionFs {
|
|
110
|
+
create: (path: string) => void
|
|
111
|
+
writeAt: (path: string, bytes: Buffer, position: number) => void
|
|
112
|
+
sizeOf: (path: string) => number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* fs.writeSync MAY RETURN SHORT, and discarding the count is the one path that defeats
|
|
117
|
+
* finalize's assembled-size re-verification: writeAt then ftruncates to the ASSUMED
|
|
118
|
+
* length, so the file ends up exactly the expected size with a zero-filled hole where
|
|
119
|
+
* the unwritten tail belongs. The size check passes and a silently corrupt video is
|
|
120
|
+
* published.
|
|
121
|
+
*
|
|
122
|
+
* Throwing is the correct response and needs no new client handling: the session
|
|
123
|
+
* counters commit only after writeAt returns, so a throw leaves them untouched and the
|
|
124
|
+
* client's retry at the same nextIndex overwrites from the same offset.
|
|
125
|
+
*
|
|
126
|
+
* Extracted rather than inlined so it is directly testable — inline, no test could
|
|
127
|
+
* reach it without stubbing a module-level fs import, and a mutation removing it passed.
|
|
128
|
+
*/
|
|
129
|
+
export function assertFullWrite(written: number, expected: number): void {
|
|
130
|
+
if (written !== expected) {
|
|
131
|
+
throw new Error(`short chunk write: ${written} of ${expected} bytes`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The real implementation, exported so a test can DECORATE it (inject one
|
|
136
|
+
* failing write and delegate the rest) rather than re-implement it — a fake that
|
|
137
|
+
* restates writeAt would pass forever while this drifted. */
|
|
138
|
+
export const defaultUploadSessionFs: UploadSessionFs = {
|
|
139
|
+
create: (path) => {
|
|
140
|
+
// Created eagerly and synchronously so the file provably exists before init
|
|
141
|
+
// answers: an upload whose staging file appears later cannot be swept, and
|
|
142
|
+
// a resume probe would report progress against a path that is not there.
|
|
143
|
+
closeSync(openSync(path, 'w', 0o600))
|
|
144
|
+
},
|
|
145
|
+
writeAt: (path, bytes, position) => {
|
|
146
|
+
const fd = openSync(path, 'r+')
|
|
147
|
+
try {
|
|
148
|
+
// fs.writeSync MAY RETURN SHORT. Discarding the count and then truncating to
|
|
149
|
+
// the assumed length is the one path that defeats finalize's assembled-size
|
|
150
|
+
// re-verification: the file ends up exactly the expected length with a
|
|
151
|
+
// zero-filled hole where the unwritten tail should be, so the size check
|
|
152
|
+
// passes and a silently corrupt video is published. Throwing instead leaves
|
|
153
|
+
// the counters untouched (they commit only after this returns), so the client's
|
|
154
|
+
// retry at the same nextIndex overwrites from the same offset — the existing
|
|
155
|
+
// idempotency path, no new client handling required.
|
|
156
|
+
assertFullWrite(writeSync(fd, bytes, 0, bytes.length, position), bytes.length)
|
|
157
|
+
// Truncate to the exact committed length. A previous attempt at this same
|
|
158
|
+
// index may have written MORE bytes here (it is retried after a reconnect,
|
|
159
|
+
// and the last chunk is short), which would otherwise leave a tail beyond
|
|
160
|
+
// the counters and make the finalize size check fail on a healthy upload.
|
|
161
|
+
ftruncateSync(fd, position + bytes.length)
|
|
162
|
+
} finally {
|
|
163
|
+
closeSync(fd)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
sizeOf: (path) => statSync(path).size,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface UploadSessionRegistryOptions {
|
|
170
|
+
createStagingFile?: () => MediaStagingFile
|
|
171
|
+
now?: () => number
|
|
172
|
+
fs?: Partial<UploadSessionFs>
|
|
173
|
+
ttlMs?: number
|
|
174
|
+
/** The assembled ceiling — contract's `chunkedMaxBytes`. Test seam. */
|
|
175
|
+
maxBytes?: number
|
|
176
|
+
/** Per-chunk ceiling — contract's `chunkBytes`. Test seam. */
|
|
177
|
+
maxChunkBytes?: number
|
|
178
|
+
maxConcurrent?: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class UploadSessionRegistry {
|
|
182
|
+
private readonly sessions = new Map<string, UploadSession>()
|
|
183
|
+
private readonly createStaging: () => MediaStagingFile
|
|
184
|
+
private readonly now: () => number
|
|
185
|
+
private readonly fs: UploadSessionFs
|
|
186
|
+
private readonly ttlMs: number
|
|
187
|
+
private readonly maxBytes: number
|
|
188
|
+
private readonly maxChunkBytes: number
|
|
189
|
+
private readonly maxConcurrent: number
|
|
190
|
+
|
|
191
|
+
constructor(options: UploadSessionRegistryOptions = {}) {
|
|
192
|
+
this.createStaging = options.createStagingFile ?? (() => getMediaStore().createStagingFile())
|
|
193
|
+
this.now = options.now ?? Date.now
|
|
194
|
+
this.fs = { ...defaultUploadSessionFs, ...options.fs }
|
|
195
|
+
this.ttlMs = options.ttlMs ?? CHUNKED_UPLOAD_TTL_MS
|
|
196
|
+
this.maxBytes = options.maxBytes ?? MAX_CHUNKED_MEDIA_BYTES
|
|
197
|
+
this.maxChunkBytes = options.maxChunkBytes ?? MEDIA_CHUNK_BYTES
|
|
198
|
+
this.maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_CHUNKED_UPLOADS
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
create(input: CreateUploadInput): UploadSession {
|
|
202
|
+
this.sweepExpired()
|
|
203
|
+
const totalBytes = input.totalBytes
|
|
204
|
+
if (typeof totalBytes !== 'number' || !Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
|
205
|
+
throw new UploadSessionError('invalid_total_bytes', 'totalBytes must be a positive integer')
|
|
206
|
+
}
|
|
207
|
+
// The declared size is a CLAIM (§6). Bounding it here is the cheap refusal;
|
|
208
|
+
// appendChunk still enforces it as bytes actually arrive.
|
|
209
|
+
if (totalBytes > this.maxBytes) {
|
|
210
|
+
throw new UploadSessionError(
|
|
211
|
+
'attachment_too_large',
|
|
212
|
+
`declared size exceeds ${this.maxBytes} byte ceiling`,
|
|
213
|
+
{ maxBytes: this.maxBytes },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (this.sessions.size >= this.maxConcurrent) {
|
|
217
|
+
throw new UploadSessionError('chunked_upload_unavailable', 'too many uploads in flight')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const staged = this.createStaging()
|
|
221
|
+
try {
|
|
222
|
+
this.fs.create(staged.path)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
staged.dispose()
|
|
225
|
+
throw new UploadSessionError(
|
|
226
|
+
'upload_staging_failed',
|
|
227
|
+
`staging file could not be created: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const startedAt = this.now()
|
|
231
|
+
const session: UploadSession = {
|
|
232
|
+
uploadId: `u_${randomBytes(12).toString('hex')}`,
|
|
233
|
+
stagingPath: staged.path,
|
|
234
|
+
totalBytes,
|
|
235
|
+
receivedBytes: 0,
|
|
236
|
+
nextIndex: 0,
|
|
237
|
+
...(input.mime ? { mime: input.mime } : {}),
|
|
238
|
+
...(input.label ? { label: input.label } : {}),
|
|
239
|
+
...(input.capturedAt ? { capturedAt: input.capturedAt } : {}),
|
|
240
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
241
|
+
createdAtMs: startedAt,
|
|
242
|
+
expiresAtMs: startedAt + this.ttlMs,
|
|
243
|
+
dispose: staged.dispose,
|
|
244
|
+
}
|
|
245
|
+
this.sessions.set(session.uploadId, session)
|
|
246
|
+
return session
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Resume probe. Sweeps first, so an expired upload reads as unknown rather
|
|
250
|
+
* than reporting progress that can never be finalized. */
|
|
251
|
+
peek(uploadId: string): UploadSession | undefined {
|
|
252
|
+
this.sweepExpired()
|
|
253
|
+
if (!isValidUploadId(uploadId)) return undefined
|
|
254
|
+
return this.sessions.get(uploadId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
appendChunk(uploadId: string, index: number, bytes: Buffer): UploadSession {
|
|
258
|
+
const session = this.require(uploadId)
|
|
259
|
+
if (bytes.length === 0) {
|
|
260
|
+
// A zero-byte chunk would advance nextIndex without progress, so a client
|
|
261
|
+
// looping on it would never finish and never fail.
|
|
262
|
+
throw new UploadSessionError('chunk_bytes_required', 'chunk body is empty')
|
|
263
|
+
}
|
|
264
|
+
// Repairable, so the session survives: the client can re-send this index at
|
|
265
|
+
// the advertised chunk size.
|
|
266
|
+
if (bytes.length > this.maxChunkBytes) {
|
|
267
|
+
throw new UploadSessionError(
|
|
268
|
+
'attachment_too_large',
|
|
269
|
+
`chunk exceeds ${this.maxChunkBytes} byte chunk size`,
|
|
270
|
+
{ maxBytes: this.maxChunkBytes, expectedIndex: session.nextIndex },
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
if (!Number.isSafeInteger(index) || index !== session.nextIndex) {
|
|
274
|
+
// Strictly in-order (§6). A re-send of an ALREADY-committed index lands
|
|
275
|
+
// here too and gets the recovery information it needs rather than a blind
|
|
276
|
+
// second append of the same bytes.
|
|
277
|
+
throw new UploadSessionError('chunk_out_of_order', `expected chunk ${session.nextIndex}`, {
|
|
278
|
+
expectedIndex: session.nextIndex,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
// "whichever is lower" (§6). create() already bounds totalBytes to maxBytes,
|
|
282
|
+
// so the DECLARED value is the operative half and the ceiling half is
|
|
283
|
+
// unreachable through the public API — measured: replacing this min() with
|
|
284
|
+
// `session.totalBytes` alone leaves the whole suite green, while replacing it
|
|
285
|
+
// with `this.maxBytes` alone fails. It stays as the contract's literal rule,
|
|
286
|
+
// and as what keeps this correct if init's bound ever loosens.
|
|
287
|
+
const limit = Math.min(session.totalBytes, this.maxBytes)
|
|
288
|
+
if (session.receivedBytes + bytes.length > limit) {
|
|
289
|
+
// Fatal, not repairable: the assembled file can no longer match what was
|
|
290
|
+
// declared, so the upload is dropped and its disk released immediately.
|
|
291
|
+
this.drop(uploadId)
|
|
292
|
+
throw new UploadSessionError('attachment_too_large', `upload exceeds ${limit} declared bytes`, {
|
|
293
|
+
maxBytes: limit,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.fs.writeAt(session.stagingPath, bytes, session.receivedBytes)
|
|
298
|
+
// Committed only after the write RETURNED. A throw above leaves the counters
|
|
299
|
+
// untouched, so the retry of this same index writes at the same offset and
|
|
300
|
+
// overwrites whatever the failed attempt left — that positional write, not
|
|
301
|
+
// an open(path,'a'), is what makes re-sending the chunk at nextIndex
|
|
302
|
+
// idempotent instead of duplicating bytes.
|
|
303
|
+
session.receivedBytes += bytes.length
|
|
304
|
+
session.nextIndex += 1
|
|
305
|
+
return session
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Completeness first (non-destructive: `incomplete_upload` means keep going),
|
|
309
|
+
* then hand the session over and forget it. The caller owns the staging file
|
|
310
|
+
* from that moment, so a duplicate finalize is a clean 404 rather than a
|
|
311
|
+
* second ingest of the same bytes. */
|
|
312
|
+
finalize(uploadId: string): UploadSession {
|
|
313
|
+
const session = this.require(uploadId)
|
|
314
|
+
if (session.receivedBytes !== session.totalBytes) {
|
|
315
|
+
throw new UploadSessionError('incomplete_upload', 'upload is not complete', {
|
|
316
|
+
receivedBytes: session.receivedBytes,
|
|
317
|
+
totalBytes: session.totalBytes,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
// §6: re-verify the ASSEMBLED size before ingest. The counters are ours and
|
|
321
|
+
// the file is the thing being ingested — when they disagree, the counters
|
|
322
|
+
// are the ones that cannot be trusted, so refuse rather than ingest a
|
|
323
|
+
// truncated or over-long body.
|
|
324
|
+
let actualBytes: number
|
|
325
|
+
try {
|
|
326
|
+
actualBytes = this.fs.sizeOf(session.stagingPath)
|
|
327
|
+
} catch (err) {
|
|
328
|
+
this.drop(uploadId)
|
|
329
|
+
throw new UploadSessionError(
|
|
330
|
+
'upload_size_mismatch',
|
|
331
|
+
`assembled upload could not be measured: ${err instanceof Error ? err.message : 'unknown error'}`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
if (actualBytes !== session.totalBytes) {
|
|
335
|
+
this.drop(uploadId)
|
|
336
|
+
throw new UploadSessionError('upload_size_mismatch', 'assembled size does not match the declared total', {
|
|
337
|
+
receivedBytes: actualBytes,
|
|
338
|
+
totalBytes: session.totalBytes,
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
this.sessions.delete(uploadId)
|
|
342
|
+
return session
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Forget the session AND release its staging file. */
|
|
346
|
+
drop(uploadId: string): boolean {
|
|
347
|
+
const session = this.sessions.get(uploadId)
|
|
348
|
+
if (!session) return false
|
|
349
|
+
this.sessions.delete(uploadId)
|
|
350
|
+
try { session.dispose() } catch { /* already moved or gone */ }
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Lazy sweep on every access rather than a second interval timer: tmp/ is
|
|
355
|
+
* wiped by MediaStore boot reconcile, so the only case a timer would add is a
|
|
356
|
+
* server that never touches media again before restarting. */
|
|
357
|
+
sweepExpired(now = this.now()): number {
|
|
358
|
+
let dropped = 0
|
|
359
|
+
for (const [uploadId, session] of this.sessions) {
|
|
360
|
+
if (session.expiresAtMs <= now) {
|
|
361
|
+
this.sessions.delete(uploadId)
|
|
362
|
+
try { session.dispose() } catch { /* already moved or gone */ }
|
|
363
|
+
dropped++
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return dropped
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
progressOf(session: UploadSession): UploadSessionProgress {
|
|
370
|
+
return {
|
|
371
|
+
uploadId: session.uploadId,
|
|
372
|
+
totalBytes: session.totalBytes,
|
|
373
|
+
receivedBytes: session.receivedBytes,
|
|
374
|
+
nextIndex: session.nextIndex,
|
|
375
|
+
expiresAt: new Date(session.expiresAtMs).toISOString(),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
size(): number {
|
|
380
|
+
return this.sessions.size
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private require(uploadId: string): UploadSession {
|
|
384
|
+
const session = this.peek(uploadId)
|
|
385
|
+
// A wiped, expired, or never-existent upload is the SAME answer (§6): 404,
|
|
386
|
+
// never a partial success. A restart legitimately produces this.
|
|
387
|
+
if (!session) throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
|
|
388
|
+
return session
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Default singleton ────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
let defaultRegistry: UploadSessionRegistry | null = null
|
|
395
|
+
|
|
396
|
+
export function getUploadSessions(): UploadSessionRegistry {
|
|
397
|
+
if (!defaultRegistry) defaultRegistry = new UploadSessionRegistry()
|
|
398
|
+
return defaultRegistry
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Test hook — mirrors _setMediaStoreForTests so a route test can install a
|
|
402
|
+
* registry with a fake clock and tiny ceilings. Returns the previous one. */
|
|
403
|
+
export function _setUploadSessionsForTests(
|
|
404
|
+
registry: UploadSessionRegistry | null,
|
|
405
|
+
): UploadSessionRegistry | null {
|
|
406
|
+
const prev = defaultRegistry
|
|
407
|
+
defaultRegistry = registry
|
|
408
|
+
return prev
|
|
409
|
+
}
|
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)
|
|
@@ -42,20 +46,28 @@ import {
|
|
|
42
46
|
import {
|
|
43
47
|
MAX_OTHER_MEDIA_BYTES,
|
|
44
48
|
MAX_VIDEO_MEDIA_BYTES,
|
|
49
|
+
MEDIA_CHUNK_BYTES,
|
|
45
50
|
MEDIA_SNIFF_BYTES,
|
|
46
51
|
RichMediaSafetyError,
|
|
47
52
|
isVideoUploadHead,
|
|
48
53
|
} from '../lib/rich-media-safety.js'
|
|
54
|
+
import {
|
|
55
|
+
UploadSessionError,
|
|
56
|
+
getUploadSessions,
|
|
57
|
+
isValidUploadId,
|
|
58
|
+
type UploadSessionErrorCode,
|
|
59
|
+
} from '../lib/upload-session.js'
|
|
49
60
|
|
|
50
61
|
// Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
|
|
51
62
|
// overhead. Mounted only for /api/media in server/index.ts — the global
|
|
52
63
|
// server limit is unchanged.
|
|
53
64
|
export const mediaBodyParser = json({ limit: '16mb' })
|
|
54
65
|
|
|
55
|
-
/** Chunked upload (contract §2) is
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
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
|
|
59
71
|
|
|
60
72
|
/** One streamed upload body, staged on disk. Held in a WeakMap keyed by the
|
|
61
73
|
* request so the handoff stays private to this module instead of widening the
|
|
@@ -198,6 +210,106 @@ function takeStagedUploadBody(req: Request): StagedUploadBody | undefined {
|
|
|
198
210
|
return staged
|
|
199
211
|
}
|
|
200
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
|
+
}
|
|
312
|
+
|
|
201
313
|
export const mediaRouter = Router()
|
|
202
314
|
|
|
203
315
|
const MEDIA_ERROR_STATUS: Record<string, number> = {
|
|
@@ -223,7 +335,25 @@ const SAFETY_ERROR_STATUS: Record<string, number> = {
|
|
|
223
335
|
video_too_long: 400,
|
|
224
336
|
}
|
|
225
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
|
+
|
|
226
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
|
+
}
|
|
227
357
|
if (err instanceof MediaStoreError) {
|
|
228
358
|
res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
|
|
229
359
|
return
|
|
@@ -344,6 +474,152 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
|
344
474
|
}
|
|
345
475
|
})
|
|
346
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()
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
347
623
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
348
624
|
|
|
349
625
|
mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {
|