@hubfluencer/mcp 0.5.0 → 0.6.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 +188 -31
- package/dist/index.js +2706 -133
- package/package.json +9 -1
- package/src/campaign.ts +797 -0
- package/src/client.ts +195 -36
- package/src/core.ts +633 -24
- package/src/index.ts +3025 -121
- package/src/output-schemas.ts +360 -0
- package/src/perf-review-assets.ts +470 -0
- package/src/series-calendar.ts +351 -0
- package/src/uploads.ts +335 -42
- package/src/version.ts +18 -0
package/src/uploads.ts
CHANGED
|
@@ -12,10 +12,39 @@
|
|
|
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";
|
|
15
16
|
import { open, readFile, stat } from "node:fs/promises";
|
|
16
17
|
import { basename, extname, isAbsolute, resolve, sep } from "node:path";
|
|
17
18
|
import type { HubfluencerClient } from "./client.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
assertSafeFetchUrl,
|
|
21
|
+
backoffDelayMs,
|
|
22
|
+
DEFAULT_RETRY,
|
|
23
|
+
isRetryableNetworkError,
|
|
24
|
+
} from "./core.js";
|
|
25
|
+
import { USER_AGENT } from "./version.js";
|
|
26
|
+
|
|
27
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Progress sink for the upload phase, bound by the caller to the MCP progress
|
|
31
|
+
* helper. Lives as a plain callback (not the SDK's `reportProgress`) so uploads.ts
|
|
32
|
+
* stays free of the index.ts dependency — index.ts boots a live stdio server at
|
|
33
|
+
* module load. Best-effort: index.ts's reportProgress already swallows its own
|
|
34
|
+
* failures, so this never needs to.
|
|
35
|
+
*/
|
|
36
|
+
export type UploadProgress = (
|
|
37
|
+
completed: number,
|
|
38
|
+
total: number,
|
|
39
|
+
message: string,
|
|
40
|
+
) => Promise<void> | void;
|
|
41
|
+
|
|
42
|
+
// How many extra attempts a single multipart part gets after its first PUT
|
|
43
|
+
// fails: the part URL is re-signed before each retry (the old presign may have
|
|
44
|
+
// expired, or the failure may be specific to that signed URL). 2 retries = 3
|
|
45
|
+
// total PUT attempts per part, mirroring DEFAULT_RETRY.attempts for GETs. A part
|
|
46
|
+
// PUT is idempotent (same bytes, same object/part), so re-uploading it is safe.
|
|
47
|
+
const PART_RETRIES = 2;
|
|
19
48
|
|
|
20
49
|
// Server-returned presign/download URLs are normally on R2/S3 over https. Allow
|
|
21
50
|
// loopback http only when the API base itself is local (dev MinIO/localstack),
|
|
@@ -29,9 +58,10 @@ const ALLOW_LOOPBACK_FETCH =
|
|
|
29
58
|
// Mirror the server-side constants (editor_upload.ex / editor_asset_controller.ex)
|
|
30
59
|
// so we fail fast client-side with a clear message instead of after a wasted PUT.
|
|
31
60
|
const MAX_VIDEO_BYTES = 500_000_000; // EditorUpload.@max_upload_bytes
|
|
32
|
-
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // editor_asset @max_image_size
|
|
33
|
-
const MAX_LOGO_BYTES = 20 * 1024 * 1024; // logos go through the image inspector too
|
|
61
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // editor_asset @max_image_size (logos share this inspector)
|
|
34
62
|
const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart
|
|
63
|
+
const MAX_CATALOG_VIDEO_DURATION_SECONDS = 60; // CatalogAsset video validator
|
|
64
|
+
type FilePutBody = { filePath: string; size: number };
|
|
35
65
|
|
|
36
66
|
const VIDEO_EXT_MIME: Record<string, string> = {
|
|
37
67
|
mp4: "video/mp4",
|
|
@@ -51,6 +81,22 @@ const IMAGE_EXT_MIME: Record<string, string> = {
|
|
|
51
81
|
export const VIDEO_EXTS = Object.keys(VIDEO_EXT_MIME);
|
|
52
82
|
export const IMAGE_EXTS = Object.keys(IMAGE_EXT_MIME);
|
|
53
83
|
|
|
84
|
+
// The reusable ASSET CATALOG (catalog_asset.ex) accepts BOTH images and videos:
|
|
85
|
+
// image/jpeg,image/png (≤20 MiB) + video/mp4,quicktime,webm,x-matroska (≤500 MB).
|
|
86
|
+
// One combined ext→mime map so a single upload primitive covers the catalog; the
|
|
87
|
+
// per-type byte cap is resolved by `catalogMaxBytes` below, mirroring
|
|
88
|
+
// CatalogAsset.max_upload_bytes/1 so we fail fast client-side.
|
|
89
|
+
const CATALOG_EXT_MIME: Record<string, string> = {
|
|
90
|
+
...IMAGE_EXT_MIME,
|
|
91
|
+
...VIDEO_EXT_MIME,
|
|
92
|
+
};
|
|
93
|
+
export const CATALOG_ASSET_EXTS = Object.keys(CATALOG_EXT_MIME);
|
|
94
|
+
|
|
95
|
+
/** The catalog byte cap for a resolved mime — 20 MiB for images, 500 MB for video. */
|
|
96
|
+
function catalogMaxBytes(mime: string): number {
|
|
97
|
+
return mime.startsWith("video/") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
98
|
+
}
|
|
99
|
+
|
|
54
100
|
export interface ResolvedFile {
|
|
55
101
|
path: string;
|
|
56
102
|
ext: string;
|
|
@@ -95,6 +141,27 @@ export function planMultipartParts(
|
|
|
95
141
|
return parts;
|
|
96
142
|
}
|
|
97
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Guards a multipart part read: `fh.read` can return fewer bytes than requested
|
|
146
|
+
* (the file shrank/was truncated under us, or the plan disagrees with the file's
|
|
147
|
+
* real size). A short read would leave the tail of the part buffer zero-filled
|
|
148
|
+
* and upload a silently-corrupt part (the confirm still succeeds — the asset is
|
|
149
|
+
* garbage), so fail loudly instead. Pure + total so the check is unit-testable
|
|
150
|
+
* without the filesystem or the network, like `planMultipartParts`.
|
|
151
|
+
*/
|
|
152
|
+
export function assertFullRead(
|
|
153
|
+
bytesRead: number,
|
|
154
|
+
len: number,
|
|
155
|
+
partNumber: number,
|
|
156
|
+
offset: number,
|
|
157
|
+
): void {
|
|
158
|
+
if (bytesRead !== len) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Short read on part ${partNumber}: got ${bytesRead} of ${len} bytes at offset ${offset} (file changed under us?).`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
98
165
|
/**
|
|
99
166
|
* Confines a model-supplied read path to an allowlisted input base and an
|
|
100
167
|
* extension allowlist, then stats it. Base = HUBFLUENCER_INPUT_DIR if set, else
|
|
@@ -146,49 +213,87 @@ export async function resolveReadPath(
|
|
|
146
213
|
return { path: target, ext, mime, size };
|
|
147
214
|
}
|
|
148
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Validates a model-supplied video read path with the SAME ext allowlist + size
|
|
218
|
+
* cap `uploadVideoFile` will use, without leaking the underlying constants. Lets
|
|
219
|
+
* a caller fail fast on an invalid path BEFORE a side effect (e.g. creating a
|
|
220
|
+
* draft project) instead of orphaning it when `uploadVideoFile` later rejects.
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveVideoReadPath(
|
|
223
|
+
filePath: string,
|
|
224
|
+
): Promise<ResolvedFile> {
|
|
225
|
+
return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
226
|
+
}
|
|
227
|
+
|
|
149
228
|
/**
|
|
150
229
|
* PUTs bytes to a presigned S3/R2 URL. `contentType` is sent ONLY when the URL
|
|
151
230
|
* signed it (single-object presign does — see S3Client.presign_put's
|
|
152
231
|
* query_params; multipart part presign does NOT, so callers omit it there to
|
|
153
|
-
* avoid sending an unsigned header).
|
|
154
|
-
*
|
|
232
|
+
* avoid sending an unsigned header). `contentLength` is supplied for file streams
|
|
233
|
+
* so large catalog uploads use a file-backed body without relying on chunked
|
|
234
|
+
* transfer. Returns the object/part ETag from the response (needed to complete a
|
|
235
|
+
* multipart upload).
|
|
155
236
|
*/
|
|
156
237
|
export async function putToPresignedUrl(
|
|
157
238
|
url: string,
|
|
158
|
-
body: Buffer,
|
|
239
|
+
body: Buffer | FilePutBody,
|
|
159
240
|
contentType: string | undefined,
|
|
160
241
|
timeoutMs = 300_000,
|
|
242
|
+
contentLength?: number,
|
|
161
243
|
): Promise<string | undefined> {
|
|
162
244
|
// The presigned URL comes back in API JSON — refuse to PUT bytes to an
|
|
163
245
|
// insecure or private/internal host (SSRF / cleartext exfil of file bytes).
|
|
164
246
|
assertSafeFetchUrl(url, { allowLoopback: ALLOW_LOOPBACK_FETCH });
|
|
165
|
-
|
|
247
|
+
// user-agent is not part of the presigned SignedHeaders, so S3/R2 ignores it
|
|
248
|
+
// for signature purposes — safe to send on both single-object and part PUTs.
|
|
249
|
+
const headers: Record<string, string> = { "user-agent": USER_AGENT };
|
|
166
250
|
if (contentType) headers["content-type"] = contentType;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
251
|
+
let fetchBody: BodyInit;
|
|
252
|
+
let fetchInit: RequestInit & { duplex?: "half" };
|
|
253
|
+
let stream: ReadStream | undefined;
|
|
254
|
+
if (Buffer.isBuffer(body)) {
|
|
255
|
+
// Pass an exact ArrayBuffer slice — the generic Uint8Array<ArrayBufferLike>
|
|
256
|
+
// type doesn't resolve cleanly against fetch's BodyInit union, but a plain
|
|
257
|
+
// ArrayBuffer is unambiguous (and this is a one-time copy of a bounded chunk).
|
|
258
|
+
fetchBody = body.buffer.slice(
|
|
259
|
+
body.byteOffset,
|
|
260
|
+
body.byteOffset + body.byteLength,
|
|
261
|
+
) as ArrayBuffer;
|
|
262
|
+
fetchInit = { method: "PUT", headers, body: fetchBody };
|
|
263
|
+
} else {
|
|
264
|
+
stream = createReadStream(body.filePath);
|
|
265
|
+
headers["content-length"] = String(contentLength ?? body.size);
|
|
266
|
+
fetchInit = {
|
|
177
267
|
method: "PUT",
|
|
178
268
|
headers,
|
|
179
|
-
body:
|
|
180
|
-
|
|
181
|
-
}
|
|
269
|
+
body: stream as unknown as BodyInit,
|
|
270
|
+
duplex: "half",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
fetchInit.signal = AbortSignal.timeout(timeoutMs);
|
|
274
|
+
let resp: Response;
|
|
275
|
+
try {
|
|
276
|
+
resp = await fetch(url, fetchInit);
|
|
182
277
|
} catch (e) {
|
|
278
|
+
stream?.destroy();
|
|
279
|
+
// A connection-level blip or timeout on a PUT is transient — re-wrap it with
|
|
280
|
+
// the `retryable` marker so a caller (uploadPartWithRetry) can re-sign and
|
|
281
|
+
// retry the part. The friendly message would otherwise erase the original
|
|
282
|
+
// error name isRetryableNetworkError keys on.
|
|
183
283
|
if (
|
|
184
284
|
e instanceof Error &&
|
|
185
285
|
(e.name === "TimeoutError" || e.name === "AbortError")
|
|
186
286
|
) {
|
|
187
|
-
throw
|
|
287
|
+
throw markRetryable(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
|
|
188
288
|
}
|
|
189
|
-
throw e instanceof Error
|
|
289
|
+
throw e instanceof Error
|
|
290
|
+
? markRetryable(`Upload PUT failed: ${e.message}`)
|
|
291
|
+
: e;
|
|
190
292
|
}
|
|
191
293
|
if (!resp.ok) {
|
|
294
|
+
// An HTTP-status rejection from S3 (bad/expired signature, 4xx) is NOT
|
|
295
|
+
// marked retryable — re-uploading the same bytes would just be rejected the
|
|
296
|
+
// same way. Only the transient connection failures above are retried.
|
|
192
297
|
const text = await resp.text().catch(() => "");
|
|
193
298
|
throw new Error(
|
|
194
299
|
`Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`,
|
|
@@ -197,6 +302,11 @@ export async function putToPresignedUrl(
|
|
|
197
302
|
return resp.headers.get("etag") ?? undefined;
|
|
198
303
|
}
|
|
199
304
|
|
|
305
|
+
/** An Error carrying the `retryable` marker isRetryableNetworkError honours. */
|
|
306
|
+
function markRetryable(message: string): Error {
|
|
307
|
+
return Object.assign(new Error(message), { retryable: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
200
310
|
interface PresignVideoResponse {
|
|
201
311
|
data: { upload_id: number | string; presigned_url: string; s3_key: string };
|
|
202
312
|
}
|
|
@@ -232,7 +342,11 @@ export async function uploadVideoFile(
|
|
|
232
342
|
client: HubfluencerClient,
|
|
233
343
|
slug: string,
|
|
234
344
|
filePath: string,
|
|
235
|
-
opts: {
|
|
345
|
+
opts: {
|
|
346
|
+
fitMode?: string;
|
|
347
|
+
productDescription?: string;
|
|
348
|
+
onProgress?: UploadProgress;
|
|
349
|
+
} = {},
|
|
236
350
|
): Promise<UploadedVideo> {
|
|
237
351
|
const file = await resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
238
352
|
const filename = basename(file.path);
|
|
@@ -240,11 +354,17 @@ export async function uploadVideoFile(
|
|
|
240
354
|
const product_description = opts.productDescription;
|
|
241
355
|
|
|
242
356
|
if (file.size >= MULTIPART_THRESHOLD) {
|
|
243
|
-
return uploadVideoMultipart(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
357
|
+
return uploadVideoMultipart(
|
|
358
|
+
client,
|
|
359
|
+
slug,
|
|
360
|
+
file,
|
|
361
|
+
{
|
|
362
|
+
filename,
|
|
363
|
+
fit_mode,
|
|
364
|
+
product_description,
|
|
365
|
+
},
|
|
366
|
+
opts.onProgress,
|
|
367
|
+
);
|
|
248
368
|
}
|
|
249
369
|
|
|
250
370
|
const presign = await client.post<PresignVideoResponse>(
|
|
@@ -259,20 +379,73 @@ export async function uploadVideoFile(
|
|
|
259
379
|
);
|
|
260
380
|
const { upload_id, presigned_url } = presign.data;
|
|
261
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}`);
|
|
262
385
|
// Content-Type MUST equal the presigned mime exactly — confirm HEADs the
|
|
263
386
|
// object and 422s on mismatch (editor/uploads.ex).
|
|
264
387
|
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
388
|
+
await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
|
|
265
389
|
const confirmed = await client.post<ConfirmUploadResponse>(
|
|
266
390
|
`/editor/${slug}/uploads/${upload_id}/confirm`,
|
|
267
391
|
);
|
|
268
392
|
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
269
393
|
}
|
|
270
394
|
|
|
395
|
+
/**
|
|
396
|
+
* PUTs one multipart part, re-signing the part URL before each retry. A part PUT
|
|
397
|
+
* is idempotent (same bytes → same object part), so a transient network failure
|
|
398
|
+
* or an expired presign is safe to retry: we ask the server for a FRESH signed
|
|
399
|
+
* URL each attempt (the previous one may have timed out) and re-PUT. After the
|
|
400
|
+
* final attempt the error propagates so the caller aborts the whole upload.
|
|
401
|
+
* Returns the part's ETag (needed to complete the multipart upload).
|
|
402
|
+
*/
|
|
403
|
+
async function uploadPartWithRetry(
|
|
404
|
+
client: HubfluencerClient,
|
|
405
|
+
slug: string,
|
|
406
|
+
ids: { upload_id: number | string; s3_upload_id: string },
|
|
407
|
+
part_number: number,
|
|
408
|
+
chunk: Buffer,
|
|
409
|
+
): Promise<string> {
|
|
410
|
+
for (let attempt = 1; ; attempt++) {
|
|
411
|
+
try {
|
|
412
|
+
const sign = await client.post<SignPartResponse>(
|
|
413
|
+
`/editor/${slug}/uploads/multipart/sign-part`,
|
|
414
|
+
{
|
|
415
|
+
upload_id: ids.upload_id,
|
|
416
|
+
s3_upload_id: ids.s3_upload_id,
|
|
417
|
+
part_number,
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
// No content-type here — the part presign does not sign one.
|
|
421
|
+
const etag = await putToPresignedUrl(
|
|
422
|
+
sign.data.presigned_url,
|
|
423
|
+
chunk,
|
|
424
|
+
undefined,
|
|
425
|
+
);
|
|
426
|
+
if (!etag) {
|
|
427
|
+
throw new Error(`Part ${part_number} returned no ETag from S3.`);
|
|
428
|
+
}
|
|
429
|
+
return etag;
|
|
430
|
+
} catch (e) {
|
|
431
|
+
// Retry only a transient network/timeout failure, and only while attempts
|
|
432
|
+
// remain. A definite rejection (e.g. a 4xx from S3, or a sign-part error
|
|
433
|
+
// the client already classified as non-retryable) propagates immediately.
|
|
434
|
+
if (attempt <= PART_RETRIES && isRetryableNetworkError(e)) {
|
|
435
|
+
await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
271
443
|
async function uploadVideoMultipart(
|
|
272
444
|
client: HubfluencerClient,
|
|
273
445
|
slug: string,
|
|
274
446
|
file: ResolvedFile,
|
|
275
447
|
meta: { filename: string; fit_mode?: string; product_description?: string },
|
|
448
|
+
onProgress?: UploadProgress,
|
|
276
449
|
): Promise<UploadedVideo> {
|
|
277
450
|
const init = await client.post<MultipartInitResponse>(
|
|
278
451
|
`/editor/${slug}/uploads/multipart/init`,
|
|
@@ -294,26 +467,35 @@ async function uploadVideoMultipart(
|
|
|
294
467
|
`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`,
|
|
295
468
|
);
|
|
296
469
|
}
|
|
470
|
+
await onProgress?.(
|
|
471
|
+
0,
|
|
472
|
+
plan.length,
|
|
473
|
+
`uploading ${meta.filename} in ${plan.length} parts`,
|
|
474
|
+
);
|
|
297
475
|
const fh = await open(file.path, "r");
|
|
298
476
|
const parts: Array<{ part_number: number; etag: string }> = [];
|
|
299
477
|
try {
|
|
300
478
|
for (const { part_number, offset, len } of plan) {
|
|
301
479
|
const chunk = Buffer.alloc(len);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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);
|
|
484
|
+
const etag = await uploadPartWithRetry(
|
|
485
|
+
client,
|
|
486
|
+
slug,
|
|
487
|
+
{ upload_id, s3_upload_id },
|
|
488
|
+
part_number,
|
|
310
489
|
chunk,
|
|
311
|
-
undefined,
|
|
312
490
|
);
|
|
313
|
-
if (!etag) {
|
|
314
|
-
throw new Error(`Part ${part_number} returned no ETag from S3.`);
|
|
315
|
-
}
|
|
316
491
|
parts.push({ part_number, etag });
|
|
492
|
+
// Report after each part lands, so a long large-file upload shows steady
|
|
493
|
+
// forward progress (N of M parts) rather than a single silent stall.
|
|
494
|
+
await onProgress?.(
|
|
495
|
+
part_number,
|
|
496
|
+
plan.length,
|
|
497
|
+
`uploaded part ${part_number}/${plan.length}`,
|
|
498
|
+
);
|
|
317
499
|
}
|
|
318
500
|
const done = await client.post<ConfirmUploadResponse>(
|
|
319
501
|
`/editor/${slug}/uploads/multipart/complete`,
|
|
@@ -390,5 +572,116 @@ export async function uploadShortPoster(
|
|
|
390
572
|
return { s3_key };
|
|
391
573
|
}
|
|
392
574
|
|
|
393
|
-
|
|
394
|
-
|
|
575
|
+
interface CatalogPresignResponse {
|
|
576
|
+
data: {
|
|
577
|
+
asset_id: number | string;
|
|
578
|
+
presigned_url: string;
|
|
579
|
+
s3_key: string;
|
|
580
|
+
expires_in_seconds?: number;
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface UploadedCatalogAsset {
|
|
585
|
+
asset_id: number | string;
|
|
586
|
+
status: string;
|
|
587
|
+
media_type: string;
|
|
588
|
+
mime_type: string;
|
|
589
|
+
filename: string;
|
|
590
|
+
size_bytes: number;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Uploads a local file to the reusable ASSET CATALOG (catalog_asset.ex) and
|
|
595
|
+
* confirms it: presign (`POST /assets/presign`) → PUT the bytes to the signed
|
|
596
|
+
* URL → confirm (`POST /assets/:id/confirm`). Reuses the same path-confinement +
|
|
597
|
+
* SSRF-safe PUT primitives as the editor uploads (`resolveReadPath`,
|
|
598
|
+
* `putToPresignedUrl`); the ONLY difference from the editor asset flow is the
|
|
599
|
+
* envelope shape (asset_id + a separate `/assets/:id/confirm`).
|
|
600
|
+
*
|
|
601
|
+
* The catalog presign signs the content-type (S3Client.presign_put content_type),
|
|
602
|
+
* so — unlike a multipart part — the PUT MUST send it; the confirm HEADs the
|
|
603
|
+
* object and 422s on a mismatch. For a VIDEO the confirm needs a
|
|
604
|
+
* `duration_seconds` (the server rejects a video with no/over-long duration,
|
|
605
|
+
* `:invalid_video_duration`); the caller passes it through `opts`. Returns the
|
|
606
|
+
* confirmed asset's id + status + media/mime so the tool can report it.
|
|
607
|
+
*/
|
|
608
|
+
export async function uploadCatalogAsset(
|
|
609
|
+
client: HubfluencerClient,
|
|
610
|
+
filePath: string,
|
|
611
|
+
opts: {
|
|
612
|
+
description?: string;
|
|
613
|
+
width?: number;
|
|
614
|
+
height?: number;
|
|
615
|
+
durationSeconds?: number;
|
|
616
|
+
} = {},
|
|
617
|
+
): Promise<UploadedCatalogAsset> {
|
|
618
|
+
// Resolve against the larger (video) ceiling first so a valid image/video
|
|
619
|
+
// path passes the confinement + extension check, then enforce the true
|
|
620
|
+
// 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
|
+
) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
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,
|
|
661
|
+
);
|
|
662
|
+
} else {
|
|
663
|
+
const buf = await readFile(file.path);
|
|
664
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
665
|
+
}
|
|
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
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the package version.
|
|
3
|
+
*
|
|
4
|
+
* Imported from package.json so the version is declared in exactly one place —
|
|
5
|
+
* the McpServer handshake, the outbound `User-Agent`, and the published package
|
|
6
|
+
* can never drift apart. `bun build --target node` inlines this JSON into the
|
|
7
|
+
* single-file bundle (no runtime file read), and `tsc` resolves it via
|
|
8
|
+
* `resolveJsonModule`. A plain default import (no `with { type: "json" }`
|
|
9
|
+
* attribute) is used deliberately: import attributes require `module: nodenext`,
|
|
10
|
+
* but this package targets `Node16`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import pkg from "../package.json";
|
|
14
|
+
|
|
15
|
+
export const VERSION: string = pkg.version;
|
|
16
|
+
|
|
17
|
+
/** The `User-Agent` sent on every outbound HTTP request (API + S3 + download). */
|
|
18
|
+
export const USER_AGENT = `hubfluencer-mcp/${VERSION}`;
|