@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/README.md +23 -12
- package/dist/index.js +611 -175
- package/package.json +1 -1
- package/src/campaign.ts +1 -1
- package/src/client.ts +4 -1
- package/src/core.ts +307 -15
- package/src/index.ts +511 -84
- package/src/output-schemas.ts +13 -2
- package/src/uploads.ts +401 -163
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 {
|
|
16
|
-
import {
|
|
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;
|
|
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 = {
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
|
|
266
|
+
async function openReadPath(
|
|
173
267
|
filePath: string,
|
|
174
268
|
extToMime: Record<string, string>,
|
|
175
269
|
maxBytes: number,
|
|
176
|
-
): Promise<
|
|
270
|
+
): Promise<PreparedUploadFile> {
|
|
177
271
|
if (!filePath || typeof filePath !== "string") {
|
|
178
272
|
throw new Error("file_path is required.");
|
|
179
273
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
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(
|
|
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
|
|
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
|
|
317
|
+
let handle: FileHandle | undefined;
|
|
198
318
|
try {
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
{
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
840
|
+
`${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`,
|
|
642
841
|
);
|
|
643
842
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
file.
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
}
|