@hubfluencer/mcp 0.8.2 → 0.9.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/src/uploads.ts CHANGED
@@ -12,15 +12,18 @@
12
12
  * are confined to HUBFLUENCER_INPUT_DIR (or cwd) and an extension allowlist.
13
13
  */
14
14
 
15
- import { createReadStream, type ReadStream } from "node:fs";
16
- import { open, readFile, stat } from "node:fs/promises";
15
+ import { createHash } from "node:crypto";
16
+ import { constants } from "node:fs";
17
+ import { type FileHandle, open, realpath, stat } from "node:fs/promises";
17
18
  import { basename, extname, isAbsolute, resolve, sep } from "node:path";
19
+ import { Readable } from "node:stream";
18
20
  import type { HubfluencerClient } from "./client.js";
19
21
  import {
20
22
  assertSafeFetchUrl,
21
23
  backoffDelayMs,
22
24
  DEFAULT_RETRY,
23
25
  isRetryableNetworkError,
26
+ isRetryableStatus,
24
27
  } from "./core.js";
25
28
  import { USER_AGENT } from "./version.js";
26
29
 
@@ -58,10 +61,13 @@ const ALLOW_LOOPBACK_FETCH =
58
61
  // Mirror the server-side constants (editor_upload.ex / editor_asset_controller.ex)
59
62
  // so we fail fast client-side with a clear message instead of after a wasted PUT.
60
63
  const MAX_VIDEO_BYTES = 500_000_000; // EditorUpload.@max_upload_bytes
61
- const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // editor_asset @max_image_size (logos share this inspector)
64
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
65
+ export const MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
66
+ export const MAX_LOGO_BYTES = 1 * 1024 * 1024; // Editor.Logo.@max_logo_bytes
67
+ export const MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
62
68
  const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart
63
69
  const MAX_CATALOG_VIDEO_DURATION_SECONDS = 60; // CatalogAsset video validator
64
- type FilePutBody = { filePath: string; size: number };
70
+ type FilePutBody = { handle: FileHandle; size: number };
65
71
 
66
72
  const VIDEO_EXT_MIME: Record<string, string> = {
67
73
  mp4: "video/mp4",
@@ -104,6 +110,95 @@ export interface ResolvedFile {
104
110
  size: number;
105
111
  }
106
112
 
113
+ export interface PreparedUploadFile extends ResolvedFile {
114
+ handle: FileHandle;
115
+ /** Optional pre-side-effect snapshot for bounded buffered uploads. */
116
+ bytes?: Buffer;
117
+ }
118
+
119
+ /** Stable identity for the exact pre-side-effect snapshot retained in memory. */
120
+ export function preparedUploadIdentity(file: PreparedUploadFile): string {
121
+ if (!file.bytes) {
122
+ throw new Error("Prepared upload has no retained byte snapshot.");
123
+ }
124
+ return `${file.size}:${createHash("sha256").update(file.bytes).digest("hex")}`;
125
+ }
126
+
127
+ /** Fill a positional range despite legal short reads; return early only at EOF. */
128
+ export async function fillPositionalRead(
129
+ handle: Pick<FileHandle, "read">,
130
+ buffer: Buffer,
131
+ offset: number,
132
+ length: number,
133
+ position: number,
134
+ ): Promise<number> {
135
+ let filled = 0;
136
+ while (filled < length) {
137
+ const { bytesRead } = await handle.read(
138
+ buffer,
139
+ offset + filled,
140
+ length - filled,
141
+ position + filled,
142
+ );
143
+ if (bytesRead === 0) break;
144
+ filled += bytesRead;
145
+ }
146
+ return filled;
147
+ }
148
+
149
+ async function closeReadHandle(handle: FileHandle): Promise<void> {
150
+ await handle.close();
151
+ }
152
+
153
+ /** Reads exactly the size validated at open time; later growth is ignored. */
154
+ async function readValidatedBytes(file: PreparedUploadFile): Promise<Buffer> {
155
+ const buffer = Buffer.allocUnsafe(file.size);
156
+ let offset = 0;
157
+ while (offset < file.size) {
158
+ const { bytesRead } = await file.handle.read(
159
+ buffer,
160
+ offset,
161
+ file.size - offset,
162
+ offset,
163
+ );
164
+ if (bytesRead === 0) {
165
+ throw new Error(
166
+ `Short read: got ${offset} of ${file.size} validated bytes (file changed under us?).`,
167
+ );
168
+ }
169
+ offset += bytesRead;
170
+ }
171
+ return buffer;
172
+ }
173
+
174
+ /** A replayable stream over exactly the bytes validated when the file was opened. */
175
+ function validatedByteStream(file: FilePutBody): Readable {
176
+ return Readable.from(
177
+ (async function* () {
178
+ const chunkSize = 1024 * 1024;
179
+ let offset = 0;
180
+ while (offset < file.size) {
181
+ const length = Math.min(chunkSize, file.size - offset);
182
+ const chunk = Buffer.allocUnsafe(length);
183
+ const filled = await fillPositionalRead(
184
+ file.handle,
185
+ chunk,
186
+ 0,
187
+ length,
188
+ offset,
189
+ );
190
+ if (filled !== length) {
191
+ throw new Error(
192
+ `Short stream read: got ${filled} of ${length} bytes at offset ${offset} (file changed under us?).`,
193
+ );
194
+ }
195
+ offset += filled;
196
+ yield chunk;
197
+ }
198
+ })(),
199
+ );
200
+ }
201
+
107
202
  export interface PartSlice {
108
203
  /** 1-based part number, as S3/R2 multipart expects. */
109
204
  part_number: number;
@@ -163,27 +258,52 @@ export function assertFullRead(
163
258
  }
164
259
 
165
260
  /**
166
- * Confines a model-supplied read path to an allowlisted input base and an
167
- * extension allowlist, then stats it. Base = HUBFLUENCER_INPUT_DIR if set, else
168
- * the current working directory. Rejects traversal outside the base the same
169
- * `target === base || target.startsWith(base + sep)` guard `resolveSavePath`
170
- * uses, which defeats the `/base-evil` sibling-prefix bypass.
261
+ * Opens a confined local file without ever reopening it by pathname. Both the
262
+ * base and target are canonicalized so a symlink cannot escape the input root.
263
+ * After open, the descriptor is checked against the canonical target inode,
264
+ * closing the realpath-to-open swap window before any bytes are read.
171
265
  */
172
- export async function resolveReadPath(
266
+ async function openReadPath(
173
267
  filePath: string,
174
268
  extToMime: Record<string, string>,
175
269
  maxBytes: number,
176
- ): Promise<ResolvedFile> {
270
+ ): Promise<PreparedUploadFile> {
177
271
  if (!filePath || typeof filePath !== "string") {
178
272
  throw new Error("file_path is required.");
179
273
  }
180
- const base = resolve(process.env.HUBFLUENCER_INPUT_DIR || process.cwd());
181
- const target = isAbsolute(filePath)
274
+ const configuredBase = resolve(
275
+ process.env.HUBFLUENCER_INPUT_DIR || process.cwd(),
276
+ );
277
+ let base: string;
278
+ try {
279
+ base = await realpath(configuredBase);
280
+ } catch (e) {
281
+ throw new Error(
282
+ `Cannot resolve input directory ${configuredBase}: ${e instanceof Error ? e.message : String(e)}`,
283
+ );
284
+ }
285
+ const requested = isAbsolute(filePath)
182
286
  ? resolve(filePath)
183
- : resolve(base, filePath);
287
+ : resolve(configuredBase, filePath);
288
+ if (
289
+ requested !== configuredBase &&
290
+ !requested.startsWith(configuredBase + sep)
291
+ ) {
292
+ throw new Error(
293
+ `file_path must be inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${requested}.`,
294
+ );
295
+ }
296
+ let target: string;
297
+ try {
298
+ target = await realpath(requested);
299
+ } catch (e) {
300
+ throw new Error(
301
+ `Cannot read ${requested}: ${e instanceof Error ? e.message : String(e)}`,
302
+ );
303
+ }
184
304
  if (target !== base && !target.startsWith(base + sep)) {
185
305
  throw new Error(
186
- `file_path must be inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${target}.`,
306
+ `file_path must resolve inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${target}.`,
187
307
  );
188
308
  }
189
309
  const ext = extname(target).slice(1).toLowerCase();
@@ -194,23 +314,71 @@ export async function resolveReadPath(
194
314
  .join(", ");
195
315
  throw new Error(`Unsupported file type ".${ext}". Allowed: ${allowed}.`);
196
316
  }
197
- let size: number;
317
+ let handle: FileHandle | undefined;
198
318
  try {
199
- const st = await stat(target);
319
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
320
+ const [openedStat, targetStat, currentTarget] = await Promise.all([
321
+ handle.stat(),
322
+ stat(target),
323
+ realpath(requested),
324
+ ]);
325
+ if (currentTarget !== target) throw new Error("path changed while opening");
326
+ const st = openedStat;
200
327
  if (!st.isFile()) throw new Error("not a regular file");
201
- size = st.size;
328
+ if (st.dev !== targetStat.dev || st.ino !== targetStat.ino) {
329
+ throw new Error("opened file does not match the validated target");
330
+ }
331
+ if (st.size <= 0) throw new Error(`${target} is empty.`);
332
+ if (st.size > maxBytes) {
333
+ throw new Error(
334
+ `${target} is ${st.size} bytes — over the ${maxBytes}-byte cap for this asset type.`,
335
+ );
336
+ }
337
+ return { path: target, ext, mime, size: st.size, handle };
202
338
  } catch (e) {
339
+ if (handle) await closeReadHandle(handle).catch(() => undefined);
203
340
  throw new Error(
204
341
  `Cannot read ${target}: ${e instanceof Error ? e.message : String(e)}`,
205
342
  );
206
343
  }
207
- if (size <= 0) throw new Error(`${target} is empty.`);
208
- if (size > maxBytes) {
209
- throw new Error(
210
- `${target} is ${size} bytes over the ${maxBytes}-byte cap for this asset type.`,
211
- );
344
+ }
345
+
346
+ /**
347
+ * Opens and retains a validated image descriptor for a multi-step operation.
348
+ * The caller must close it with `closePreparedUploadFile` in a finally block.
349
+ */
350
+ export async function prepareImageUpload(
351
+ filePath: string,
352
+ maxBytes: number = MAX_IMAGE_BYTES,
353
+ ): Promise<PreparedUploadFile> {
354
+ const file = await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
355
+ try {
356
+ file.bytes = await readValidatedBytes(file);
357
+ return file;
358
+ } catch (error) {
359
+ await closeReadHandle(file.handle);
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ export function closePreparedUploadFile(
365
+ file: PreparedUploadFile,
366
+ ): Promise<void> {
367
+ return closeReadHandle(file.handle);
368
+ }
369
+
370
+ export async function resolveReadPath(
371
+ filePath: string,
372
+ extToMime: Record<string, string>,
373
+ maxBytes: number,
374
+ ): Promise<ResolvedFile> {
375
+ const file = await openReadPath(filePath, extToMime, maxBytes);
376
+ try {
377
+ const { handle: _, ...resolved } = file;
378
+ return resolved;
379
+ } finally {
380
+ await closeReadHandle(file.handle);
212
381
  }
213
- return { path: target, ext, mime, size };
214
382
  }
215
383
 
216
384
  /**
@@ -225,6 +393,14 @@ export async function resolveVideoReadPath(
225
393
  return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
226
394
  }
227
395
 
396
+ /** Fail-fast validation for a local jpeg/png before a draft is created. */
397
+ export async function resolveImageReadPath(
398
+ filePath: string,
399
+ maxBytes: number = MAX_IMAGE_BYTES,
400
+ ): Promise<ResolvedFile> {
401
+ return resolveReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
402
+ }
403
+
228
404
  /**
229
405
  * PUTs bytes to a presigned S3/R2 URL. `contentType` is sent ONLY when the URL
230
406
  * signed it (single-object presign does — see S3Client.presign_put's
@@ -250,7 +426,7 @@ export async function putToPresignedUrl(
250
426
  if (contentType) headers["content-type"] = contentType;
251
427
  let fetchBody: BodyInit;
252
428
  let fetchInit: RequestInit & { duplex?: "half" };
253
- let stream: ReadStream | undefined;
429
+ let stream: Readable | undefined;
254
430
  if (Buffer.isBuffer(body)) {
255
431
  // Pass an exact ArrayBuffer slice — the generic Uint8Array<ArrayBufferLike>
256
432
  // type doesn't resolve cleanly against fetch's BodyInit union, but a plain
@@ -261,7 +437,10 @@ export async function putToPresignedUrl(
261
437
  ) as ArrayBuffer;
262
438
  fetchInit = { method: "PUT", headers, body: fetchBody };
263
439
  } else {
264
- stream = createReadStream(body.filePath);
440
+ // Never hand fetch the descriptor itself: Bun closes supplied fds even when
441
+ // fs.ReadStream says autoClose:false. This positional Readable has no fd
442
+ // ownership, reads exactly the validated range, and can be rebuilt for retry.
443
+ stream = validatedByteStream(body);
265
444
  headers["content-length"] = String(contentLength ?? body.size);
266
445
  fetchInit = {
267
446
  method: "PUT",
@@ -271,11 +450,19 @@ export async function putToPresignedUrl(
271
450
  };
272
451
  }
273
452
  fetchInit.signal = AbortSignal.timeout(timeoutMs);
453
+ const closeStream = async (): Promise<void> => {
454
+ if (!stream || stream.closed) return;
455
+ await new Promise<void>((resolve, reject) => {
456
+ stream?.once("close", resolve);
457
+ stream?.once("error", reject);
458
+ stream?.destroy();
459
+ });
460
+ };
274
461
  let resp: Response;
275
462
  try {
276
463
  resp = await fetch(url, fetchInit);
277
464
  } catch (e) {
278
- stream?.destroy();
465
+ await closeStream().catch(() => undefined);
279
466
  // A connection-level blip or timeout on a PUT is transient — re-wrap it with
280
467
  // the `retryable` marker so a caller (uploadPartWithRetry) can re-sign and
281
468
  // retry the part. The friendly message would otherwise erase the original
@@ -290,14 +477,19 @@ export async function putToPresignedUrl(
290
477
  ? markRetryable(`Upload PUT failed: ${e.message}`)
291
478
  : e;
292
479
  }
480
+ await closeStream();
293
481
  if (!resp.ok) {
294
482
  // An HTTP-status rejection from S3 (bad/expired signature, 4xx) is NOT
295
483
  // marked retryable — re-uploading the same bytes would just be rejected the
296
484
  // same way. Only the transient connection failures above are retried.
297
485
  const text = await resp.text().catch(() => "");
298
- throw new Error(
486
+ const error = new Error(
299
487
  `Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`,
300
488
  );
489
+ if (isRetryableStatus(resp.status)) {
490
+ Object.assign(error, { retryable: true });
491
+ }
492
+ throw error;
301
493
  }
302
494
  return resp.headers.get("etag") ?? undefined;
303
495
  }
@@ -348,48 +540,54 @@ export async function uploadVideoFile(
348
540
  onProgress?: UploadProgress;
349
541
  } = {},
350
542
  ): Promise<UploadedVideo> {
351
- const file = await resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
543
+ const file = await openReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
352
544
  const filename = basename(file.path);
353
545
  const fit_mode = opts.fitMode === "cover" ? "cover" : undefined;
354
546
  const product_description = opts.productDescription;
355
547
 
356
- if (file.size >= MULTIPART_THRESHOLD) {
357
- return uploadVideoMultipart(
358
- client,
359
- slug,
360
- file,
548
+ try {
549
+ if (file.size >= MULTIPART_THRESHOLD) {
550
+ return await uploadVideoMultipart(
551
+ client,
552
+ slug,
553
+ file,
554
+ {
555
+ filename,
556
+ fit_mode,
557
+ product_description,
558
+ },
559
+ opts.onProgress,
560
+ );
561
+ }
562
+ // Snapshot before creating the server upload row. A later truncate/growth
563
+ // cannot leave a post-presign orphan or change the bytes that were validated.
564
+ const buf = await readValidatedBytes(file);
565
+
566
+ const presign = await client.post<PresignVideoResponse>(
567
+ `/editor/${slug}/uploads/presign`,
361
568
  {
362
569
  filename,
570
+ mime_type: file.mime,
571
+ size_bytes: file.size,
363
572
  fit_mode,
364
573
  product_description,
365
574
  },
366
- opts.onProgress,
367
575
  );
576
+ const { upload_id, presigned_url } = presign.data;
577
+ // A small file is one PUT — report it as a single 1-of-1 step so the caller
578
+ // still gets a progress beat during the transfer.
579
+ await opts.onProgress?.(0, 1, `uploading ${filename}`);
580
+ // Content-Type MUST equal the presigned mime exactly — confirm HEADs the
581
+ // object and 422s on mismatch (editor/uploads.ex).
582
+ await putToPresignedUrl(presigned_url, buf, file.mime);
583
+ await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
584
+ const confirmed = await client.post<ConfirmUploadResponse>(
585
+ `/editor/${slug}/uploads/${upload_id}/confirm`,
586
+ );
587
+ return { upload_id, status: confirmed.data?.status ?? "processing" };
588
+ } finally {
589
+ await closeReadHandle(file.handle);
368
590
  }
369
-
370
- const presign = await client.post<PresignVideoResponse>(
371
- `/editor/${slug}/uploads/presign`,
372
- {
373
- filename,
374
- mime_type: file.mime,
375
- size_bytes: file.size,
376
- fit_mode,
377
- product_description,
378
- },
379
- );
380
- const { upload_id, presigned_url } = presign.data;
381
- const buf = await readFile(file.path);
382
- // A small file is one PUT — report it as a single 1-of-1 step so the caller
383
- // still gets a progress beat during the transfer.
384
- await opts.onProgress?.(0, 1, `uploading ${filename}`);
385
- // Content-Type MUST equal the presigned mime exactly — confirm HEADs the
386
- // object and 422s on mismatch (editor/uploads.ex).
387
- await putToPresignedUrl(presigned_url, buf, file.mime);
388
- await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
389
- const confirmed = await client.post<ConfirmUploadResponse>(
390
- `/editor/${slug}/uploads/${upload_id}/confirm`,
391
- );
392
- return { upload_id, status: confirmed.data?.status ?? "processing" };
393
591
  }
394
592
 
395
593
  /**
@@ -431,7 +629,12 @@ async function uploadPartWithRetry(
431
629
  // Retry only a transient network/timeout failure, and only while attempts
432
630
  // remain. A definite rejection (e.g. a 4xx from S3, or a sign-part error
433
631
  // the client already classified as non-retryable) propagates immediately.
434
- if (attempt <= PART_RETRIES && isRetryableNetworkError(e)) {
632
+ const status = (e as { status?: unknown })?.status;
633
+ if (
634
+ attempt <= PART_RETRIES &&
635
+ (isRetryableNetworkError(e) ||
636
+ (typeof status === "number" && isRetryableStatus(status)))
637
+ ) {
435
638
  await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
436
639
  continue;
437
640
  }
@@ -443,7 +646,7 @@ async function uploadPartWithRetry(
443
646
  async function uploadVideoMultipart(
444
647
  client: HubfluencerClient,
445
648
  slug: string,
446
- file: ResolvedFile,
649
+ file: PreparedUploadFile,
447
650
  meta: { filename: string; fit_mode?: string; product_description?: string },
448
651
  onProgress?: UploadProgress,
449
652
  ): Promise<UploadedVideo> {
@@ -458,29 +661,31 @@ async function uploadVideoMultipart(
458
661
  },
459
662
  );
460
663
  const { upload_id, s3_upload_id, parts_count, part_size } = init.data;
461
- const plan = planMultipartParts(file.size, part_size);
462
- // The server signs parts by number and validates the set on complete; if its
463
- // part count disagrees with our slicing, fail loudly before uploading rather
464
- // than producing an unmatchable set.
465
- if (plan.length !== parts_count) {
466
- throw new Error(
467
- `Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`,
468
- );
469
- }
470
- await onProgress?.(
471
- 0,
472
- plan.length,
473
- `uploading ${meta.filename} in ${plan.length} parts`,
474
- );
475
- const fh = await open(file.path, "r");
476
- const parts: Array<{ part_number: number; etag: string }> = [];
477
664
  try {
665
+ const plan = planMultipartParts(file.size, part_size);
666
+ // Once init succeeds, every later failure must abort the server upload,
667
+ // including an invalid server plan or a caller progress callback throwing.
668
+ if (plan.length !== parts_count) {
669
+ throw new Error(
670
+ `Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`,
671
+ );
672
+ }
673
+ await onProgress?.(
674
+ 0,
675
+ plan.length,
676
+ `uploading ${meta.filename} in ${plan.length} parts`,
677
+ );
678
+ const parts: Array<{ part_number: number; etag: string }> = [];
478
679
  for (const { part_number, offset, len } of plan) {
479
680
  const chunk = Buffer.alloc(len);
480
- // Verify the read filled the whole part — a short read would upload a
481
- // zero-padded, silently-corrupt part (see assertFullRead).
482
- const { bytesRead } = await fh.read(chunk, 0, len, offset);
483
- assertFullRead(bytesRead, len, part_number, offset);
681
+ const filled = await fillPositionalRead(
682
+ file.handle,
683
+ chunk,
684
+ 0,
685
+ len,
686
+ offset,
687
+ );
688
+ assertFullRead(filled, len, part_number, offset);
484
689
  const etag = await uploadPartWithRetry(
485
690
  client,
486
691
  slug,
@@ -514,8 +719,6 @@ async function uploadVideoMultipart(
514
719
  // abort is cleanup — never mask the original failure
515
720
  }
516
721
  throw e;
517
- } finally {
518
- await fh.close();
519
722
  }
520
723
  }
521
724
 
@@ -533,18 +736,25 @@ interface PresignImageResponse {
533
736
  export async function uploadImageFile(
534
737
  client: HubfluencerClient,
535
738
  presignPath: string,
536
- filePath: string,
739
+ filePath: string | PreparedUploadFile,
537
740
  maxBytes: number = MAX_IMAGE_BYTES,
538
741
  ): Promise<{ s3_key: string }> {
539
- const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
540
- const presign = await client.post<PresignImageResponse>(presignPath, {
541
- mime_type: file.mime,
542
- size_bytes: file.size,
543
- });
544
- const { presigned_url, s3_key } = presign.data;
545
- const buf = await readFile(file.path);
546
- await putToPresignedUrl(presigned_url, buf, file.mime);
547
- return { s3_key };
742
+ const ownsFile = typeof filePath === "string";
743
+ const file = ownsFile
744
+ ? await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes)
745
+ : filePath;
746
+ try {
747
+ const buf = file.bytes ?? (await readValidatedBytes(file));
748
+ const presign = await client.post<PresignImageResponse>(presignPath, {
749
+ mime_type: file.mime,
750
+ size_bytes: file.size,
751
+ });
752
+ const { presigned_url, s3_key } = presign.data;
753
+ await putToPresignedUrl(presigned_url, buf, file.mime);
754
+ return { s3_key };
755
+ } finally {
756
+ if (ownsFile) await closeReadHandle(file.handle);
757
+ }
548
758
  }
549
759
 
550
760
  /**
@@ -561,15 +771,19 @@ export async function uploadShortPoster(
561
771
  slug: string,
562
772
  filePath: string,
563
773
  ): Promise<{ s3_key: string }> {
564
- const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
565
- const presign = await client.post<{ upload_url: string; s3_key: string }>(
566
- `/shorts/${slug}/poster/presign`,
567
- { content_type: file.mime },
568
- );
569
- const { upload_url, s3_key } = presign;
570
- const buf = await readFile(file.path);
571
- await putToPresignedUrl(upload_url, buf, file.mime);
572
- return { s3_key };
774
+ const file = await openReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
775
+ try {
776
+ const buf = await readValidatedBytes(file);
777
+ const presign = await client.post<{ upload_url: string; s3_key: string }>(
778
+ `/shorts/${slug}/poster/presign`,
779
+ { content_type: file.mime },
780
+ );
781
+ const { upload_url, s3_key } = presign;
782
+ await putToPresignedUrl(upload_url, buf, file.mime);
783
+ return { s3_key };
784
+ } finally {
785
+ await closeReadHandle(file.handle);
786
+ }
573
787
  }
574
788
 
575
789
  interface CatalogPresignResponse {
@@ -618,70 +832,94 @@ export async function uploadCatalogAsset(
618
832
  // Resolve against the larger (video) ceiling first so a valid image/video
619
833
  // path passes the confinement + extension check, then enforce the true
620
834
  // per-type cap (images are capped lower) with a precise message.
621
- const file = await resolveReadPath(
622
- filePath,
623
- CATALOG_EXT_MIME,
624
- MAX_VIDEO_BYTES,
625
- );
626
- const cap = catalogMaxBytes(file.mime);
627
- if (file.size > cap) {
628
- throw new Error(
629
- `${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`,
630
- );
631
- }
632
- if (file.mime.startsWith("video/")) {
633
- const duration = opts.durationSeconds;
634
- if (
635
- typeof duration !== "number" ||
636
- !Number.isFinite(duration) ||
637
- duration <= 0 ||
638
- duration > MAX_CATALOG_VIDEO_DURATION_SECONDS
639
- ) {
835
+ const file = await openReadPath(filePath, CATALOG_EXT_MIME, MAX_VIDEO_BYTES);
836
+ try {
837
+ const cap = catalogMaxBytes(file.mime);
838
+ if (file.size > cap) {
640
839
  throw new Error(
641
- `Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`,
840
+ `${file.path} is ${file.size} bytes over the ${cap}-byte cap for ${file.mime} catalog uploads.`,
642
841
  );
643
842
  }
644
- }
645
- const filename = basename(file.path);
646
- const presign = await client.post<CatalogPresignResponse>("/assets/presign", {
647
- filename,
648
- mime_type: file.mime,
649
- size_bytes: file.size,
650
- });
651
- const { asset_id, presigned_url } = presign.data;
652
- // Content-Type MUST equal the presigned mime exactly — the catalog presign
653
- // signs it and confirm HEADs the object, 422ing on mismatch.
654
- if (file.size >= MULTIPART_THRESHOLD) {
655
- await putToPresignedUrl(
656
- presigned_url,
657
- { filePath: file.path, size: file.size },
658
- file.mime,
659
- 300_000,
660
- file.size,
843
+ if (file.mime.startsWith("video/")) {
844
+ const duration = opts.durationSeconds;
845
+ if (
846
+ typeof duration !== "number" ||
847
+ !Number.isFinite(duration) ||
848
+ duration <= 0 ||
849
+ duration > MAX_CATALOG_VIDEO_DURATION_SECONDS
850
+ ) {
851
+ throw new Error(
852
+ `Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`,
853
+ );
854
+ }
855
+ }
856
+ const bufferedBytes =
857
+ file.size < MULTIPART_THRESHOLD
858
+ ? await readValidatedBytes(file)
859
+ : undefined;
860
+ const filename = basename(file.path);
861
+ const presign = await client.post<CatalogPresignResponse>(
862
+ "/assets/presign",
863
+ {
864
+ filename,
865
+ mime_type: file.mime,
866
+ size_bytes: file.size,
867
+ },
661
868
  );
662
- } else {
663
- const buf = await readFile(file.path);
664
- await putToPresignedUrl(presigned_url, buf, file.mime);
869
+ const { asset_id, presigned_url } = presign.data;
870
+ // Content-Type MUST equal the presigned mime exactly — the catalog presign
871
+ // signs it and confirm HEADs the object, 422ing on mismatch.
872
+ if (file.size >= MULTIPART_THRESHOLD) {
873
+ for (let attempt = 1; ; attempt++) {
874
+ try {
875
+ await putToPresignedUrl(
876
+ presigned_url,
877
+ { handle: file.handle, size: file.size },
878
+ file.mime,
879
+ 300_000,
880
+ file.size,
881
+ );
882
+ break;
883
+ } catch (error) {
884
+ if (
885
+ attempt >= DEFAULT_RETRY.attempts ||
886
+ !isRetryableNetworkError(error)
887
+ ) {
888
+ throw error;
889
+ }
890
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
891
+ }
892
+ }
893
+ } else {
894
+ await putToPresignedUrl(
895
+ presigned_url,
896
+ bufferedBytes as Buffer,
897
+ file.mime,
898
+ );
899
+ }
900
+ const confirmBody: Record<string, unknown> = {};
901
+ if (typeof opts.description === "string" && opts.description.trim() !== "")
902
+ confirmBody.description = opts.description;
903
+ if (typeof opts.width === "number") confirmBody.width = opts.width;
904
+ if (typeof opts.height === "number") confirmBody.height = opts.height;
905
+ if (typeof opts.durationSeconds === "number")
906
+ confirmBody.duration_seconds = opts.durationSeconds;
907
+ const confirmed = await client.post<{ data: Record<string, unknown> }>(
908
+ `/assets/${asset_id}/confirm`,
909
+ confirmBody,
910
+ );
911
+ const data = confirmed.data ?? {};
912
+ return {
913
+ asset_id,
914
+ status: typeof data.status === "string" ? data.status : "ready",
915
+ media_type: typeof data.media_type === "string" ? data.media_type : "",
916
+ mime_type:
917
+ typeof data.mime_type === "string" ? data.mime_type : file.mime,
918
+ filename: typeof data.filename === "string" ? data.filename : filename,
919
+ size_bytes:
920
+ typeof data.size_bytes === "number" ? data.size_bytes : file.size,
921
+ };
922
+ } finally {
923
+ await closeReadHandle(file.handle);
665
924
  }
666
- const confirmBody: Record<string, unknown> = {};
667
- if (typeof opts.description === "string" && opts.description.trim() !== "")
668
- confirmBody.description = opts.description;
669
- if (typeof opts.width === "number") confirmBody.width = opts.width;
670
- if (typeof opts.height === "number") confirmBody.height = opts.height;
671
- if (typeof opts.durationSeconds === "number")
672
- confirmBody.duration_seconds = opts.durationSeconds;
673
- const confirmed = await client.post<{ data: Record<string, unknown> }>(
674
- `/assets/${asset_id}/confirm`,
675
- confirmBody,
676
- );
677
- const data = confirmed.data ?? {};
678
- return {
679
- asset_id,
680
- status: typeof data.status === "string" ? data.status : "ready",
681
- media_type: typeof data.media_type === "string" ? data.media_type : "",
682
- mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
683
- filename: typeof data.filename === "string" ? data.filename : filename,
684
- size_bytes:
685
- typeof data.size_bytes === "number" ? data.size_bytes : file.size,
686
- };
687
925
  }