@hubfluencer/mcp 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.js +30790 -0
- package/dist/login.js +377 -0
- package/package.json +51 -0
- package/src/client.ts +206 -0
- package/src/core.ts +244 -0
- package/src/credentials.ts +48 -0
- package/src/index.ts +2310 -0
- package/src/login.ts +129 -0
- package/src/uploads.ts +369 -0
- package/tsconfig.json +19 -0
package/src/login.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `hubfluencer-login` — device-link CLI.
|
|
4
|
+
*
|
|
5
|
+
* Connects this agent to a Hubfluencer account without copy-pasting a token:
|
|
6
|
+
* starts a link request, prints a URL + code for the user to approve in the
|
|
7
|
+
* signed-in app, polls until approved, then stores the scoped access token in
|
|
8
|
+
* ~/.hubfluencer/credentials.json (mode 0600). The MCP server reads it from
|
|
9
|
+
* there (or from HUBFLUENCER_API_TOKEN).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { assertSafeBaseUrl } from "./client.js";
|
|
15
|
+
import { CREDENTIALS_PATH } from "./credentials.js";
|
|
16
|
+
|
|
17
|
+
const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com")
|
|
18
|
+
.replace(/\/+$/, "")
|
|
19
|
+
.replace(/\/api$/, "");
|
|
20
|
+
// Fail before printing a code / polling if the base would leak the token.
|
|
21
|
+
assertSafeBaseUrl(BASE);
|
|
22
|
+
const MAX_POLLS = 120;
|
|
23
|
+
|
|
24
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
const clientName = process.argv[2] || "Claude Code";
|
|
28
|
+
|
|
29
|
+
const startRes = await fetch(`${BASE}/api/agent-link/start`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
client_name: clientName,
|
|
34
|
+
scopes: ["video:generate", "video:read"],
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
if (!startRes.ok) {
|
|
38
|
+
console.error(
|
|
39
|
+
`Could not start login (HTTP ${startRes.status}). Check HUBFLUENCER_BASE_URL.`,
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const start = (await startRes.json()) as {
|
|
44
|
+
device_code: string;
|
|
45
|
+
user_code: string;
|
|
46
|
+
verification_uri: string;
|
|
47
|
+
verification_uri_complete?: string;
|
|
48
|
+
interval?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const url = start.verification_uri_complete || start.verification_uri;
|
|
52
|
+
console.error("\nConnect this agent to Hubfluencer:\n");
|
|
53
|
+
console.error(` 1. Open: ${url}`);
|
|
54
|
+
console.error(` 2. Confirm code: ${start.user_code}`);
|
|
55
|
+
console.error(" 3. Click Approve (you'll need to be signed in).\n");
|
|
56
|
+
console.error("Waiting for approval…");
|
|
57
|
+
|
|
58
|
+
// Mutable: the server can ask us to back off via `slow_down` (device-flow
|
|
59
|
+
// spec), at which point we widen the interval rather than keep hammering.
|
|
60
|
+
let intervalMs = Math.max(2, start.interval ?? 5) * 1000;
|
|
61
|
+
const MAX_INTERVAL_MS = 30_000;
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
64
|
+
await sleep(intervalMs);
|
|
65
|
+
const pollRes = await fetch(`${BASE}/api/agent-link/poll`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
72
|
+
});
|
|
73
|
+
const body = (await pollRes.json().catch(() => ({}))) as {
|
|
74
|
+
status?: string;
|
|
75
|
+
token?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (pollRes.ok && body.status === "approved" && body.token) {
|
|
80
|
+
await storeToken(body.token);
|
|
81
|
+
console.error(
|
|
82
|
+
`\n✓ Connected. Access token saved to ${CREDENTIALS_PATH}.`,
|
|
83
|
+
);
|
|
84
|
+
console.error(" Revoke anytime in the app: Settings → Access tokens.\n");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (body.status === "slow_down") {
|
|
88
|
+
// Honour the backoff request: widen the interval (capped) and keep polling.
|
|
89
|
+
intervalMs = Math.min(intervalMs + 5000, MAX_INTERVAL_MS);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (body.status === "pending") continue;
|
|
93
|
+
|
|
94
|
+
console.error(
|
|
95
|
+
`\n✗ ${body.error || body.status || `HTTP ${pollRes.status}`}. Run login again.`,
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.error("\n✗ Timed out waiting for approval. Run login again.");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function storeToken(token: string) {
|
|
105
|
+
const dir = dirname(CREDENTIALS_PATH);
|
|
106
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
await writeFile(
|
|
108
|
+
CREDENTIALS_PATH,
|
|
109
|
+
JSON.stringify({ token, base_url: BASE }, null, 2),
|
|
110
|
+
{
|
|
111
|
+
mode: 0o600,
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
// The `mode` option only applies when the file is freshly created — an
|
|
115
|
+
// existing (possibly loose-perm) file keeps its perms. chmod explicitly so a
|
|
116
|
+
// pre-existing world/group-readable token file is tightened. Best-effort on
|
|
117
|
+
// platforms where chmod is a no-op (Windows).
|
|
118
|
+
try {
|
|
119
|
+
await chmod(CREDENTIALS_PATH, 0o600);
|
|
120
|
+
await chmod(dir, 0o700);
|
|
121
|
+
} catch {
|
|
122
|
+
// chmod is hardening — never fail the login over it
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch((e) => {
|
|
127
|
+
console.error("Fatal:", e instanceof Error ? e.message : String(e));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-asset upload mechanics for the editor pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The base REST client (`client.ts`) only speaks JSON to the API host. Bringing
|
|
5
|
+
* a local file in needs two things it can't do: PUT raw bytes to a presigned
|
|
6
|
+
* S3/R2 URL, and drive a resumable multipart upload. Both live here.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: every local path the agent hands us is funnelled through
|
|
9
|
+
* `resolveReadPath`, the read-side mirror of `index.ts`'s `resolveSavePath`. A
|
|
10
|
+
* prompt-injected `file_path` (e.g. "~/.ssh/id_rsa") would otherwise be
|
|
11
|
+
* exfiltrated to the user's own bucket and read back via `get_editor` — so reads
|
|
12
|
+
* are confined to HUBFLUENCER_INPUT_DIR (or cwd) and an extension allowlist.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, extname, isAbsolute, resolve, sep } from "node:path";
|
|
17
|
+
import type { HubfluencerClient } from "./client.js";
|
|
18
|
+
import { assertSafeFetchUrl } from "./core.js";
|
|
19
|
+
|
|
20
|
+
// Server-returned presign/download URLs are normally on R2/S3 over https. Allow
|
|
21
|
+
// loopback http only when the API base itself is local (dev MinIO/localstack),
|
|
22
|
+
// mirroring the base-URL exception — so a compromised/MITM'd API response can't
|
|
23
|
+
// turn a PUT into an SSRF write to an internal host.
|
|
24
|
+
const ALLOW_LOOPBACK_FETCH =
|
|
25
|
+
/^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(
|
|
26
|
+
process.env.HUBFLUENCER_BASE_URL || "",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Mirror the server-side constants (editor_upload.ex / editor_asset_controller.ex)
|
|
30
|
+
// so we fail fast client-side with a clear message instead of after a wasted PUT.
|
|
31
|
+
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
|
|
34
|
+
const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart
|
|
35
|
+
|
|
36
|
+
const VIDEO_EXT_MIME: Record<string, string> = {
|
|
37
|
+
mp4: "video/mp4",
|
|
38
|
+
mov: "video/quicktime",
|
|
39
|
+
webm: "video/webm",
|
|
40
|
+
mkv: "video/x-matroska",
|
|
41
|
+
};
|
|
42
|
+
// Logos/product/closing accept only jpeg+png server-side (ImageAssetInspector).
|
|
43
|
+
// WebP is intentionally excluded until the backend mime source-of-truth is
|
|
44
|
+
// reconciled, so an agent never hits a presign-passes/confirm-fails trap.
|
|
45
|
+
const IMAGE_EXT_MIME: Record<string, string> = {
|
|
46
|
+
jpg: "image/jpeg",
|
|
47
|
+
jpeg: "image/jpeg",
|
|
48
|
+
png: "image/png",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const VIDEO_EXTS = Object.keys(VIDEO_EXT_MIME);
|
|
52
|
+
export const IMAGE_EXTS = Object.keys(IMAGE_EXT_MIME);
|
|
53
|
+
|
|
54
|
+
export interface ResolvedFile {
|
|
55
|
+
path: string;
|
|
56
|
+
ext: string;
|
|
57
|
+
mime: string;
|
|
58
|
+
size: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PartSlice {
|
|
62
|
+
/** 1-based part number, as S3/R2 multipart expects. */
|
|
63
|
+
part_number: number;
|
|
64
|
+
/** Byte offset of this part within the file. */
|
|
65
|
+
offset: number;
|
|
66
|
+
/** Byte length of this part (the last part is the remainder). */
|
|
67
|
+
len: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Splits a file of `size` bytes into 1-based multipart slices of `partSize`
|
|
72
|
+
* bytes each, the last part carrying the remainder. Pure + total: the single
|
|
73
|
+
* source of truth for how `uploadVideoMultipart` chunks a file, so its edge
|
|
74
|
+
* cases (exact multiple, sub-part file, large remainder) are unit-testable
|
|
75
|
+
* without touching the filesystem or the network.
|
|
76
|
+
*/
|
|
77
|
+
export function planMultipartParts(
|
|
78
|
+
size: number,
|
|
79
|
+
partSize: number,
|
|
80
|
+
): PartSlice[] {
|
|
81
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
82
|
+
throw new Error(`size must be a positive number, got ${size}.`);
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isFinite(partSize) || partSize <= 0) {
|
|
85
|
+
throw new Error(`partSize must be a positive number, got ${partSize}.`);
|
|
86
|
+
}
|
|
87
|
+
const parts: PartSlice[] = [];
|
|
88
|
+
for (let offset = 0, n = 1; offset < size; offset += partSize, n++) {
|
|
89
|
+
parts.push({
|
|
90
|
+
part_number: n,
|
|
91
|
+
offset,
|
|
92
|
+
len: Math.min(partSize, size - offset),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return parts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Confines a model-supplied read path to an allowlisted input base and an
|
|
100
|
+
* extension allowlist, then stats it. Base = HUBFLUENCER_INPUT_DIR if set, else
|
|
101
|
+
* the current working directory. Rejects traversal outside the base — the same
|
|
102
|
+
* `target === base || target.startsWith(base + sep)` guard `resolveSavePath`
|
|
103
|
+
* uses, which defeats the `/base-evil` sibling-prefix bypass.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveReadPath(
|
|
106
|
+
filePath: string,
|
|
107
|
+
extToMime: Record<string, string>,
|
|
108
|
+
maxBytes: number,
|
|
109
|
+
): Promise<ResolvedFile> {
|
|
110
|
+
if (!filePath || typeof filePath !== "string") {
|
|
111
|
+
throw new Error("file_path is required.");
|
|
112
|
+
}
|
|
113
|
+
const base = resolve(process.env.HUBFLUENCER_INPUT_DIR || process.cwd());
|
|
114
|
+
const target = isAbsolute(filePath)
|
|
115
|
+
? resolve(filePath)
|
|
116
|
+
: resolve(base, filePath);
|
|
117
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`file_path must be inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${target}.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const ext = extname(target).slice(1).toLowerCase();
|
|
123
|
+
const mime = extToMime[ext];
|
|
124
|
+
if (!mime) {
|
|
125
|
+
const allowed = Object.keys(extToMime)
|
|
126
|
+
.map((e) => `.${e}`)
|
|
127
|
+
.join(", ");
|
|
128
|
+
throw new Error(`Unsupported file type ".${ext}". Allowed: ${allowed}.`);
|
|
129
|
+
}
|
|
130
|
+
let size: number;
|
|
131
|
+
try {
|
|
132
|
+
const st = await stat(target);
|
|
133
|
+
if (!st.isFile()) throw new Error("not a regular file");
|
|
134
|
+
size = st.size;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Cannot read ${target}: ${e instanceof Error ? e.message : String(e)}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (size <= 0) throw new Error(`${target} is empty.`);
|
|
141
|
+
if (size > maxBytes) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${target} is ${size} bytes — over the ${maxBytes}-byte cap for this asset type.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return { path: target, ext, mime, size };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* PUTs bytes to a presigned S3/R2 URL. `contentType` is sent ONLY when the URL
|
|
151
|
+
* signed it (single-object presign does — see S3Client.presign_put's
|
|
152
|
+
* query_params; multipart part presign does NOT, so callers omit it there to
|
|
153
|
+
* avoid sending an unsigned header). Returns the object/part ETag from the
|
|
154
|
+
* response (needed to complete a multipart upload).
|
|
155
|
+
*/
|
|
156
|
+
export async function putToPresignedUrl(
|
|
157
|
+
url: string,
|
|
158
|
+
body: Buffer,
|
|
159
|
+
contentType: string | undefined,
|
|
160
|
+
timeoutMs = 300_000,
|
|
161
|
+
): Promise<string | undefined> {
|
|
162
|
+
// The presigned URL comes back in API JSON — refuse to PUT bytes to an
|
|
163
|
+
// insecure or private/internal host (SSRF / cleartext exfil of file bytes).
|
|
164
|
+
assertSafeFetchUrl(url, { allowLoopback: ALLOW_LOOPBACK_FETCH });
|
|
165
|
+
const headers: Record<string, string> = {};
|
|
166
|
+
if (contentType) headers["content-type"] = contentType;
|
|
167
|
+
// Pass an exact ArrayBuffer slice — the generic Uint8Array<ArrayBufferLike>
|
|
168
|
+
// type doesn't resolve cleanly against fetch's BodyInit union, but a plain
|
|
169
|
+
// ArrayBuffer is unambiguous (and this is a one-time copy of a bounded chunk).
|
|
170
|
+
const ab = body.buffer.slice(
|
|
171
|
+
body.byteOffset,
|
|
172
|
+
body.byteOffset + body.byteLength,
|
|
173
|
+
) as ArrayBuffer;
|
|
174
|
+
let resp: Response;
|
|
175
|
+
try {
|
|
176
|
+
resp = await fetch(url, {
|
|
177
|
+
method: "PUT",
|
|
178
|
+
headers,
|
|
179
|
+
body: ab,
|
|
180
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
181
|
+
});
|
|
182
|
+
} catch (e) {
|
|
183
|
+
if (
|
|
184
|
+
e instanceof Error &&
|
|
185
|
+
(e.name === "TimeoutError" || e.name === "AbortError")
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
|
|
188
|
+
}
|
|
189
|
+
throw e instanceof Error ? new Error(`Upload PUT failed: ${e.message}`) : e;
|
|
190
|
+
}
|
|
191
|
+
if (!resp.ok) {
|
|
192
|
+
const text = await resp.text().catch(() => "");
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return resp.headers.get("etag") ?? undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface PresignVideoResponse {
|
|
201
|
+
data: { upload_id: number | string; presigned_url: string; s3_key: string };
|
|
202
|
+
}
|
|
203
|
+
interface ConfirmUploadResponse {
|
|
204
|
+
data: { id: number | string; status: string; error_message?: string | null };
|
|
205
|
+
}
|
|
206
|
+
interface MultipartInitResponse {
|
|
207
|
+
data: {
|
|
208
|
+
upload_id: number | string;
|
|
209
|
+
s3_upload_id: string;
|
|
210
|
+
s3_key: string;
|
|
211
|
+
parts_count: number;
|
|
212
|
+
part_size: number;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
interface SignPartResponse {
|
|
216
|
+
data: { presigned_url: string; part_number: number };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface UploadedVideo {
|
|
220
|
+
upload_id: number | string;
|
|
221
|
+
status: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Uploads a local video file to an editor project and returns the upload id +
|
|
226
|
+
* its post-confirm status. Picks single-PUT for small files and resumable
|
|
227
|
+
* multipart at/above the 50 MiB threshold. The returned upload is queued for
|
|
228
|
+
* async processing (status "pending"/"processing") — poll the uploads list
|
|
229
|
+
* until it reaches "ready" before placing it on the timeline.
|
|
230
|
+
*/
|
|
231
|
+
export async function uploadVideoFile(
|
|
232
|
+
client: HubfluencerClient,
|
|
233
|
+
slug: string,
|
|
234
|
+
filePath: string,
|
|
235
|
+
opts: { fitMode?: string; productDescription?: string } = {},
|
|
236
|
+
): Promise<UploadedVideo> {
|
|
237
|
+
const file = await resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
238
|
+
const filename = basename(file.path);
|
|
239
|
+
const fit_mode = opts.fitMode === "cover" ? "cover" : undefined;
|
|
240
|
+
const product_description = opts.productDescription;
|
|
241
|
+
|
|
242
|
+
if (file.size >= MULTIPART_THRESHOLD) {
|
|
243
|
+
return uploadVideoMultipart(client, slug, file, {
|
|
244
|
+
filename,
|
|
245
|
+
fit_mode,
|
|
246
|
+
product_description,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const presign = await client.post<PresignVideoResponse>(
|
|
251
|
+
`/editor/${slug}/uploads/presign`,
|
|
252
|
+
{
|
|
253
|
+
filename,
|
|
254
|
+
mime_type: file.mime,
|
|
255
|
+
size_bytes: file.size,
|
|
256
|
+
fit_mode,
|
|
257
|
+
product_description,
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
const { upload_id, presigned_url } = presign.data;
|
|
261
|
+
const buf = await readFile(file.path);
|
|
262
|
+
// Content-Type MUST equal the presigned mime exactly — confirm HEADs the
|
|
263
|
+
// object and 422s on mismatch (editor/uploads.ex).
|
|
264
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
265
|
+
const confirmed = await client.post<ConfirmUploadResponse>(
|
|
266
|
+
`/editor/${slug}/uploads/${upload_id}/confirm`,
|
|
267
|
+
);
|
|
268
|
+
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function uploadVideoMultipart(
|
|
272
|
+
client: HubfluencerClient,
|
|
273
|
+
slug: string,
|
|
274
|
+
file: ResolvedFile,
|
|
275
|
+
meta: { filename: string; fit_mode?: string; product_description?: string },
|
|
276
|
+
): Promise<UploadedVideo> {
|
|
277
|
+
const init = await client.post<MultipartInitResponse>(
|
|
278
|
+
`/editor/${slug}/uploads/multipart/init`,
|
|
279
|
+
{
|
|
280
|
+
filename: meta.filename,
|
|
281
|
+
mime_type: file.mime,
|
|
282
|
+
size_bytes: file.size,
|
|
283
|
+
fit_mode: meta.fit_mode,
|
|
284
|
+
product_description: meta.product_description,
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
const { upload_id, s3_upload_id, parts_count, part_size } = init.data;
|
|
288
|
+
const plan = planMultipartParts(file.size, part_size);
|
|
289
|
+
// The server signs parts by number and validates the set on complete; if its
|
|
290
|
+
// part count disagrees with our slicing, fail loudly before uploading rather
|
|
291
|
+
// than producing an unmatchable set.
|
|
292
|
+
if (plan.length !== parts_count) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const fh = await open(file.path, "r");
|
|
298
|
+
const parts: Array<{ part_number: number; etag: string }> = [];
|
|
299
|
+
try {
|
|
300
|
+
for (const { part_number, offset, len } of plan) {
|
|
301
|
+
const chunk = Buffer.alloc(len);
|
|
302
|
+
await fh.read(chunk, 0, len, offset);
|
|
303
|
+
const sign = await client.post<SignPartResponse>(
|
|
304
|
+
`/editor/${slug}/uploads/multipart/sign-part`,
|
|
305
|
+
{ upload_id, s3_upload_id, part_number },
|
|
306
|
+
);
|
|
307
|
+
// No content-type here — the part presign does not sign one.
|
|
308
|
+
const etag = await putToPresignedUrl(
|
|
309
|
+
sign.data.presigned_url,
|
|
310
|
+
chunk,
|
|
311
|
+
undefined,
|
|
312
|
+
);
|
|
313
|
+
if (!etag) {
|
|
314
|
+
throw new Error(`Part ${part_number} returned no ETag from S3.`);
|
|
315
|
+
}
|
|
316
|
+
parts.push({ part_number, etag });
|
|
317
|
+
}
|
|
318
|
+
const done = await client.post<ConfirmUploadResponse>(
|
|
319
|
+
`/editor/${slug}/uploads/multipart/complete`,
|
|
320
|
+
{ upload_id, s3_upload_id, parts },
|
|
321
|
+
);
|
|
322
|
+
return { upload_id, status: done.data?.status ?? "processing" };
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// Best-effort abort so a failed run doesn't leak a dangling multipart
|
|
325
|
+
// upload (and its pending row) on R2.
|
|
326
|
+
try {
|
|
327
|
+
await client.post(`/editor/${slug}/uploads/multipart/abort`, {
|
|
328
|
+
upload_id,
|
|
329
|
+
s3_upload_id,
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
// abort is cleanup — never mask the original failure
|
|
333
|
+
}
|
|
334
|
+
throw e;
|
|
335
|
+
} finally {
|
|
336
|
+
await fh.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface PresignImageResponse {
|
|
341
|
+
data: { presigned_url: string; s3_key: string };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Uploads a local image to one of the editor's image-asset slots
|
|
346
|
+
* (product / closing-image / logo). These share the same presign→PUT shape;
|
|
347
|
+
* the caller then POSTs the returned `s3_key` to the slot's confirm endpoint
|
|
348
|
+
* (the confirm validates the key against an anchored regex, so it must be
|
|
349
|
+
* threaded through verbatim). Returns the `s3_key`.
|
|
350
|
+
*/
|
|
351
|
+
export async function uploadImageFile(
|
|
352
|
+
client: HubfluencerClient,
|
|
353
|
+
presignPath: string,
|
|
354
|
+
filePath: string,
|
|
355
|
+
maxBytes: number = MAX_IMAGE_BYTES,
|
|
356
|
+
): Promise<{ s3_key: string }> {
|
|
357
|
+
const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
|
|
358
|
+
const presign = await client.post<PresignImageResponse>(presignPath, {
|
|
359
|
+
mime_type: file.mime,
|
|
360
|
+
size_bytes: file.size,
|
|
361
|
+
});
|
|
362
|
+
const { presigned_url, s3_key } = presign.data;
|
|
363
|
+
const buf = await readFile(file.path);
|
|
364
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
365
|
+
return { s3_key };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const LOGO_MAX_BYTES = MAX_LOGO_BYTES;
|
|
369
|
+
export const IMAGE_MAX_BYTES = MAX_IMAGE_BYTES;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"resolveJsonModule": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|