Skip to content

Commit fad6f2c

Browse files
committed
Implement comprehensive URL support and improve file handling
- Add full HTTP/HTTPS URL support with axios for remote file fetching - Extract filename from Content-Disposition header or URL path for URLs - Use response Content-Type when available for better MIME detection - Replace blocking readFileSync with async fs.promises.readFile - Add proper input validation and JSDoc documentation - Implement concurrent uploads with Promise.allSettled for better performance - Add comprehensive error aggregation showing all failed uploads - Filter and trim attachment input for robustness
1 parent 7971ba8 commit fad6f2c

File tree

1 file changed

+82
-28
lines changed

1 file changed

+82
-28
lines changed

components/zendesk/zendesk.app.mjs

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -290,22 +290,23 @@ export default {
290290
...args,
291291
});
292292
},
293+
/**
294+
* Upload a single file (local path or http(s) URL) to Zendesk Uploads API.
295+
* @param {Object} params
296+
* @param {string} params.filePath - Local filesystem path or http(s) URL.
297+
* @param {string} [params.filename] - Optional filename override for the upload.
298+
* @param {string} [params.customSubdomain]
299+
* @param {*} [params.step]
300+
*/
293301
async uploadFile({
294302
filePath, filename, customSubdomain, step,
295303
} = {}) {
304+
if (!filePath || typeof filePath !== "string") {
305+
throw new Error("uploadFile: 'filePath' (string) is required");
306+
}
296307
const fs = await import("fs");
297308
const path = await import("path");
298-
299-
// If filename not provided, extract from filePath
300-
if (!filename && filePath) {
301-
filename = path.basename(filePath);
302-
}
303-
304-
// Read file content
305-
const fileContent = fs.readFileSync(filePath);
306-
307-
// Get file extension to determine Content-Type
308-
const ext = path.extname(filename).toLowerCase();
309+
309310
const contentTypeMap = {
310311
".pdf": "application/pdf",
311312
".png": "image/png",
@@ -319,8 +320,49 @@ export default {
319320
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
320321
".zip": "application/zip",
321322
};
322-
const contentType = contentTypeMap[ext] || "application/octet-stream";
323-
323+
324+
let fileContent;
325+
let contentType;
326+
327+
const isHttp = /^https?:\/\//i.test(filePath);
328+
if (isHttp) {
329+
// Fetch remote file as arraybuffer to preserve bytes
330+
const res = await axios(step, {
331+
method: "get",
332+
url: filePath,
333+
responseType: "arraybuffer",
334+
returnFullResponse: true,
335+
timeout: 60_000,
336+
});
337+
fileContent = res.data;
338+
339+
const headerCT = res.headers?.["content-type"];
340+
const cd = res.headers?.["content-disposition"];
341+
342+
if (!filename) {
343+
const cdMatch = cd?.match(/filename\*?=(?:UTF-8''|")?([^\";]+)/i);
344+
filename = cdMatch?.[1]
345+
? decodeURIComponent(cdMatch[1].replace(/(^"|"$)/g, ""))
346+
: (() => {
347+
try {
348+
return path.basename(new URL(filePath).pathname);
349+
} catch {
350+
return "attachment";
351+
}
352+
})();
353+
}
354+
const ext = path.extname(filename || "").toLowerCase();
355+
contentType = headerCT || contentTypeMap[ext] || "application/octet-stream";
356+
} else {
357+
// Local file: non-blocking read
358+
if (!filename) {
359+
filename = path.basename(filePath);
360+
}
361+
fileContent = await fs.promises.readFile(filePath);
362+
const ext = path.extname(filename || "").toLowerCase();
363+
contentType = contentTypeMap[ext] || "application/octet-stream";
364+
}
365+
324366
return this.makeRequest({
325367
step,
326368
method: "post",
@@ -338,25 +380,37 @@ export default {
338380
if (!attachments || !attachments.length) {
339381
return [];
340382
}
341-
342-
const uploadResults = [];
343-
for (const attachment of attachments) {
344-
try {
345-
const result = await this.uploadFile({
346-
filePath: attachment,
347-
customSubdomain,
348-
step,
349-
});
350-
const token = result?.upload?.token;
383+
const files = attachments
384+
.map((a) => (typeof a === "string" ? a.trim() : a))
385+
.filter(Boolean);
386+
387+
const settled = await Promise.allSettled(
388+
files.map((attachment) =>
389+
this.uploadFile({ filePath: attachment, customSubdomain, step }),
390+
),
391+
);
392+
393+
const tokens = [];
394+
const errors = [];
395+
settled.forEach((res, i) => {
396+
const attachment = files[i];
397+
if (res.status === "fulfilled") {
398+
const token = res.value?.upload?.token;
351399
if (!token) {
352-
throw new Error(`Upload API returned no token for ${attachment}`);
400+
errors.push(`Upload API returned no token for ${attachment}`);
401+
} else {
402+
tokens.push(token);
353403
}
354-
uploadResults.push(token);
355-
} catch (error) {
356-
throw error;
404+
} else {
405+
const reason = res.reason?.message || String(res.reason || "Unknown error");
406+
errors.push(`${attachment}: ${reason}`);
357407
}
408+
});
409+
410+
if (errors.length) {
411+
throw new Error(`Failed to upload ${errors.length}/${files.length} attachment(s): ${errors.join("; ")}`);
358412
}
359-
return uploadResults;
413+
return tokens;
360414
},
361415
async *paginate({
362416
fn, args, resourceKey, max,

0 commit comments

Comments
 (0)