@gotcos/glasses-server 6.24.5 → 6.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }