@openclaw-china/shared 0.1.31 → 0.1.32

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.
@@ -1,732 +1,732 @@
1
- /**
2
- * 媒体 IO 模块
3
- *
4
- * 提供统一的媒体文件下载和读取功能
5
- *
6
- * @module @openclaw-china/shared/media
7
- */
8
-
9
- import * as fs from "fs";
10
- import * as fsPromises from "fs/promises";
11
- import * as path from "path";
12
- import * as os from "os";
13
- import { isHttpUrl, normalizeLocalPath, getExtension } from "./media-parser.js";
14
- import { resolveExtension } from "../file/file-utils.js";
15
-
16
- // ============================================================================
17
- // 类型定义
18
- // ============================================================================
19
-
20
- /**
21
- * 媒体读取结果
22
- */
23
- export interface MediaReadResult {
24
- /** 文件内容 Buffer */
25
- buffer: Buffer;
26
- /** 文件名 */
27
- fileName: string;
28
- /** 文件大小(字节) */
29
- size: number;
30
- /** MIME 类型(如果可检测) */
31
- mimeType?: string;
32
- }
33
-
34
- /**
35
- * 下载并落盘到临时文件的结果
36
- */
37
- export interface DownloadToTempFileResult {
38
- /** 绝对路径 */
39
- path: string;
40
- /** 写入文件名 */
41
- fileName: string;
42
- /** MIME 类型 */
43
- contentType: string;
44
- /** 文件大小(字节) */
45
- size: number;
46
- /** 来源文件名(若可解析) */
47
- sourceFileName?: string;
48
- }
49
-
50
- /**
51
- * 媒体读取选项
52
- */
53
- export interface MediaReadOptions {
54
- /** 超时时间(毫秒),默认 30000 */
55
- timeout?: number;
56
- /** 最大文件大小(字节),默认 100MB */
57
- maxSize?: number;
58
- /** 自定义 fetch 函数(用于依赖注入) */
59
- fetch?: typeof globalThis.fetch;
60
- }
61
-
62
- /**
63
- * 下载并落盘到临时文件的选项
64
- */
65
- export interface DownloadToTempFileOptions extends MediaReadOptions {
66
- /** 目标临时目录,默认 os.tmpdir() */
67
- tempDir?: string;
68
- /** 文件名前缀,默认 "media" */
69
- tempPrefix?: string;
70
- /** 来源文件名(优先用于扩展名推断) */
71
- sourceFileName?: string;
72
- }
73
-
74
- /**
75
- * 归档入站媒体参数
76
- */
77
- export interface FinalizeInboundMediaOptions {
78
- /** 当前文件路径 */
79
- filePath: string;
80
- /** 临时目录(仅该目录下文件会归档) */
81
- tempDir: string;
82
- /** 入站归档根目录 */
83
- inboundDir: string;
84
- }
85
-
86
- /**
87
- * 过期清理参数
88
- */
89
- export interface PruneInboundMediaDirOptions {
90
- /** 入站归档根目录 */
91
- inboundDir: string;
92
- /** 保留天数,>=0 生效 */
93
- keepDays: number;
94
- /** 当前时间(测试用) */
95
- nowMs?: number;
96
- }
97
-
98
- /**
99
- * 路径安全检查选项
100
- */
101
- export interface PathSecurityOptions {
102
- /** 允许的路径前缀白名单 */
103
- allowedPrefixes?: string[];
104
- /** 最大路径长度,默认 4096 */
105
- maxPathLength?: number;
106
- /** 是否禁止路径穿越,默认 true */
107
- preventTraversal?: boolean;
108
- }
109
-
110
- // ============================================================================
111
- // 常量定义
112
- // ============================================================================
113
-
114
- /** 默认超时时间(毫秒) */
115
- const DEFAULT_TIMEOUT = 30000;
116
-
117
- /** 默认最大文件大小(100MB) */
118
- const DEFAULT_MAX_SIZE = 100 * 1024 * 1024;
119
-
120
- /** 默认最大路径长度 */
121
- const DEFAULT_MAX_PATH_LENGTH = 4096;
122
-
123
- /** 默认允许的路径前缀(Unix) */
124
- const DEFAULT_UNIX_PREFIXES = [
125
- "/tmp",
126
- "/var/tmp",
127
- "/private/tmp",
128
- "/Users",
129
- "/home",
130
- "/root",
131
- ];
132
-
133
- function parseContentDispositionFilename(value: string | null): string | undefined {
134
- if (!value) return undefined;
135
- const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
136
- if (utf8Match?.[1]) {
137
- try {
138
- return decodeURIComponent(utf8Match[1].trim());
139
- } catch {
140
- return utf8Match[1].trim();
141
- }
142
- }
143
- const plainMatch = value.match(/filename=([^;]+)/i);
144
- if (!plainMatch?.[1]) return undefined;
145
- const raw = plainMatch[1].trim().replace(/^["']|["']$/g, "");
146
- return raw || undefined;
147
- }
148
-
149
- function sanitizeFileName(name: string): string {
150
- const trimmed = name.trim();
151
- if (!trimmed) return "file";
152
- const normalized = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
153
- return normalized || "file";
154
- }
155
-
156
- function resolveFileNameFromUrl(url: string): string | undefined {
157
- try {
158
- const parsed = new URL(url);
159
- const base = path.basename(parsed.pathname);
160
- if (!base || base === "/") return undefined;
161
- return base;
162
- } catch {
163
- return undefined;
164
- }
165
- }
166
-
167
- function normalizeForCompare(value: string): string {
168
- return path.resolve(value).replace(/\\/g, "/").toLowerCase();
169
- }
170
-
171
- function isPathUnderDir(filePath: string, dirPath: string): boolean {
172
- const f = normalizeForCompare(filePath);
173
- const d = normalizeForCompare(dirPath).replace(/\/+$/, "");
174
- return f === d || f.startsWith(`${d}/`);
175
- }
176
-
177
- function formatDateDir(date = new Date()): string {
178
- const yyyy = date.getFullYear();
179
- const mm = String(date.getMonth() + 1).padStart(2, "0");
180
- const dd = String(date.getDate()).padStart(2, "0");
181
- return `${yyyy}-${mm}-${dd}`;
182
- }
183
-
184
- /** 扩展名到 MIME 类型映射 */
185
- const EXT_TO_MIME: Record<string, string> = {
186
- // 图片
187
- jpg: "image/jpeg",
188
- jpeg: "image/jpeg",
189
- png: "image/png",
190
- gif: "image/gif",
191
- webp: "image/webp",
192
- bmp: "image/bmp",
193
- svg: "image/svg+xml",
194
- ico: "image/x-icon",
195
- // 音频
196
- mp3: "audio/mpeg",
197
- wav: "audio/wav",
198
- ogg: "audio/ogg",
199
- m4a: "audio/x-m4a",
200
- amr: "audio/amr",
201
- // 视频
202
- mp4: "video/mp4",
203
- mov: "video/quicktime",
204
- avi: "video/x-msvideo",
205
- mkv: "video/x-matroska",
206
- webm: "video/webm",
207
- // 文档
208
- pdf: "application/pdf",
209
- doc: "application/msword",
210
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
211
- xls: "application/vnd.ms-excel",
212
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
213
- ppt: "application/vnd.ms-powerpoint",
214
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
215
- txt: "text/plain",
216
- csv: "text/csv",
217
- // 压缩包
218
- zip: "application/zip",
219
- rar: "application/x-rar-compressed",
220
- "7z": "application/x-7z-compressed",
221
- tar: "application/x-tar",
222
- gz: "application/gzip",
223
- };
224
-
225
- // ============================================================================
226
- // 错误类
227
- // ============================================================================
228
-
229
- /**
230
- * 文件大小超限错误
231
- */
232
- export class FileSizeLimitError extends Error {
233
- /** 实际文件大小(字节) */
234
- public readonly actualSize: number;
235
- /** 大小限制(字节) */
236
- public readonly limitSize: number;
237
-
238
- constructor(actualSize: number, limitSize: number) {
239
- super(`File size ${actualSize} bytes exceeds limit ${limitSize} bytes`);
240
- this.name = "FileSizeLimitError";
241
- this.actualSize = actualSize;
242
- this.limitSize = limitSize;
243
-
244
- if (Error.captureStackTrace) {
245
- Error.captureStackTrace(this, FileSizeLimitError);
246
- }
247
- }
248
- }
249
-
250
- /**
251
- * 下载超时错误
252
- */
253
- export class MediaTimeoutError extends Error {
254
- /** 超时时间(毫秒) */
255
- public readonly timeoutMs: number;
256
-
257
- constructor(timeoutMs: number) {
258
- super(`Operation timed out after ${timeoutMs}ms`);
259
- this.name = "MediaTimeoutError";
260
- this.timeoutMs = timeoutMs;
261
-
262
- if (Error.captureStackTrace) {
263
- Error.captureStackTrace(this, MediaTimeoutError);
264
- }
265
- }
266
- }
267
-
268
- /**
269
- * 路径安全错误
270
- */
271
- export class PathSecurityError extends Error {
272
- /** 不安全的路径 */
273
- public readonly unsafePath: string;
274
- /** 错误原因 */
275
- public readonly reason: string;
276
-
277
- constructor(unsafePath: string, reason: string) {
278
- super(`Path security violation: ${reason} - ${unsafePath}`);
279
- this.name = "PathSecurityError";
280
- this.unsafePath = unsafePath;
281
- this.reason = reason;
282
-
283
- if (Error.captureStackTrace) {
284
- Error.captureStackTrace(this, PathSecurityError);
285
- }
286
- }
287
- }
288
-
289
- // ============================================================================
290
- // 路径安全检查
291
- // ============================================================================
292
-
293
- /**
294
- * 检查路径是否安全
295
- *
296
- * @param filePath - 要检查的路径
297
- * @param options - 安全检查选项
298
- * @throws PathSecurityError 如果路径不安全
299
- */
300
- export function validatePathSecurity(
301
- filePath: string,
302
- options: PathSecurityOptions = {}
303
- ): void {
304
- const {
305
- allowedPrefixes,
306
- maxPathLength = DEFAULT_MAX_PATH_LENGTH,
307
- preventTraversal = true,
308
- } = options;
309
-
310
- // 检查路径长度
311
- if (filePath.length > maxPathLength) {
312
- throw new PathSecurityError(
313
- filePath,
314
- `Path length ${filePath.length} exceeds maximum ${maxPathLength}`
315
- );
316
- }
317
-
318
- // 检查路径穿越
319
- if (preventTraversal) {
320
- const normalized = path.normalize(filePath);
321
- if (normalized.includes("..")) {
322
- throw new PathSecurityError(filePath, "Path traversal detected");
323
- }
324
- }
325
-
326
- // 检查路径前缀白名单
327
- if (allowedPrefixes && allowedPrefixes.length > 0) {
328
- const normalizedPath = path.normalize(filePath);
329
- const isAllowed = allowedPrefixes.some((prefix) =>
330
- normalizedPath.startsWith(path.normalize(prefix))
331
- );
332
- if (!isAllowed) {
333
- throw new PathSecurityError(
334
- filePath,
335
- `Path not in allowed prefixes: ${allowedPrefixes.join(", ")}`
336
- );
337
- }
338
- }
339
- }
340
-
341
- /**
342
- * 获取默认的路径白名单
343
- */
344
- export function getDefaultAllowedPrefixes(): string[] {
345
- if (process.platform === "win32") {
346
- // Windows: 允许所有驱动器的临时目录和用户目录
347
- const tempDir = os.tmpdir();
348
- const homeDir = os.homedir();
349
- return [tempDir, homeDir];
350
- }
351
- return DEFAULT_UNIX_PREFIXES;
352
- }
353
-
354
- // ============================================================================
355
- // MIME 类型检测
356
- // ============================================================================
357
-
358
- /**
359
- * 根据文件扩展名获取 MIME 类型
360
- */
361
- export function getMimeType(filePath: string): string | undefined {
362
- const ext = getExtension(filePath);
363
- return EXT_TO_MIME[ext];
364
- }
365
-
366
- // ============================================================================
367
- // 媒体读取函数
368
- // ============================================================================
369
-
370
- /**
371
- * 从 HTTP URL 下载媒体
372
- *
373
- * @param url - 媒体 URL
374
- * @param options - 读取选项
375
- * @returns 媒体读取结果
376
- */
377
- export async function fetchMediaFromUrl(
378
- url: string,
379
- options: MediaReadOptions = {}
380
- ): Promise<MediaReadResult> {
381
- const {
382
- timeout = DEFAULT_TIMEOUT,
383
- maxSize = DEFAULT_MAX_SIZE,
384
- fetch: customFetch = globalThis.fetch,
385
- } = options;
386
-
387
- const controller = new AbortController();
388
- const timeoutId = setTimeout(() => controller.abort(), timeout);
389
-
390
- try {
391
- const response = await customFetch(url, { signal: controller.signal });
392
-
393
- if (!response.ok) {
394
- const errorText = await response.text();
395
- throw new Error(`HTTP ${response.status}: ${errorText}`);
396
- }
397
-
398
- // 检查 Content-Length
399
- const contentLength = response.headers.get("content-length");
400
- if (contentLength) {
401
- const size = parseInt(contentLength, 10);
402
- if (size > maxSize) {
403
- throw new FileSizeLimitError(size, maxSize);
404
- }
405
- }
406
-
407
- const arrayBuffer = await response.arrayBuffer();
408
- const buffer = Buffer.from(arrayBuffer);
409
-
410
- // 检查实际大小
411
- if (buffer.length > maxSize) {
412
- throw new FileSizeLimitError(buffer.length, maxSize);
413
- }
414
-
415
- // 提取文件名
416
- let fileName = "file";
417
- try {
418
- const urlPath = new URL(url).pathname;
419
- fileName = path.basename(urlPath) || "file";
420
- } catch {
421
- // 忽略 URL 解析错误
422
- }
423
-
424
- // 获取 MIME 类型
425
- const mimeType =
426
- response.headers.get("content-type")?.split(";")[0].trim() ||
427
- getMimeType(fileName);
428
-
429
- return {
430
- buffer,
431
- fileName,
432
- size: buffer.length,
433
- mimeType,
434
- };
435
- } catch (error) {
436
- if (error instanceof Error && error.name === "AbortError") {
437
- throw new MediaTimeoutError(timeout);
438
- }
439
- throw error;
440
- } finally {
441
- clearTimeout(timeoutId);
442
- }
443
- }
444
-
445
- /**
446
- * 下载 HTTP 媒体并写入临时文件
447
- */
448
- export async function downloadToTempFile(
449
- url: string,
450
- options: DownloadToTempFileOptions = {}
451
- ): Promise<DownloadToTempFileResult> {
452
- if (!isHttpUrl(url)) {
453
- throw new Error(`downloadToTempFile expects an HTTP URL, got: ${url}`);
454
- }
455
-
456
- const {
457
- timeout = DEFAULT_TIMEOUT,
458
- maxSize = DEFAULT_MAX_SIZE,
459
- fetch: customFetch = globalThis.fetch,
460
- tempDir = os.tmpdir(),
461
- tempPrefix = "media",
462
- sourceFileName,
463
- } = options;
464
-
465
- const controller = new AbortController();
466
- const timeoutId = setTimeout(() => controller.abort(), timeout);
467
-
468
- try {
469
- const response = await customFetch(url, { signal: controller.signal });
470
- if (!response.ok) {
471
- const responseBody = await response.text().catch(() => "");
472
- throw new Error(`HTTP ${response.status}: ${responseBody}`);
473
- }
474
-
475
- const contentType =
476
- response.headers.get("content-type")?.split(";")[0].trim() ||
477
- "application/octet-stream";
478
-
479
- const contentLength = response.headers.get("content-length");
480
- if (contentLength) {
481
- const declared = parseInt(contentLength, 10);
482
- if (!Number.isNaN(declared) && declared > maxSize) {
483
- throw new FileSizeLimitError(declared, maxSize);
484
- }
485
- }
486
-
487
- const body = response.body;
488
- if (!body) {
489
- throw new Error("Response body is null");
490
- }
491
-
492
- const chunks: Uint8Array[] = [];
493
- let totalBytes = 0;
494
- const reader = body.getReader();
495
-
496
- try {
497
- while (true) {
498
- const { done, value } = await reader.read();
499
- if (done) break;
500
- totalBytes += value.length;
501
- if (totalBytes > maxSize) {
502
- reader.cancel();
503
- throw new FileSizeLimitError(totalBytes, maxSize);
504
- }
505
- chunks.push(value);
506
- }
507
- } finally {
508
- reader.releaseLock();
509
- }
510
-
511
- const sourceName =
512
- sourceFileName ||
513
- parseContentDispositionFilename(response.headers.get("content-disposition")) ||
514
- resolveFileNameFromUrl(url) ||
515
- "file";
516
-
517
- const safePrefix = sanitizeFileName(tempPrefix) || "media";
518
- const ext = resolveExtension(contentType, sourceName);
519
- const random = Math.random().toString(36).slice(2, 8);
520
- const fileName = `${safePrefix}-${Date.now()}-${random}${ext}`;
521
- const fullPath = path.join(tempDir, fileName);
522
-
523
- await fsPromises.mkdir(tempDir, { recursive: true });
524
- const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
525
- await fsPromises.writeFile(fullPath, buffer);
526
-
527
- return {
528
- path: fullPath,
529
- fileName,
530
- contentType,
531
- size: totalBytes,
532
- sourceFileName: sourceName,
533
- };
534
- } catch (error) {
535
- if (error instanceof Error && error.name === "AbortError") {
536
- throw new MediaTimeoutError(timeout);
537
- }
538
- throw error;
539
- } finally {
540
- clearTimeout(timeoutId);
541
- }
542
- }
543
-
544
- /**
545
- * 从本地路径读取媒体
546
- *
547
- * @param filePath - 本地文件路径(支持 file://, MEDIA:, attachment:// 前缀)
548
- * @param options - 读取选项
549
- * @returns 媒体读取结果
550
- */
551
- export async function readMediaFromLocal(
552
- filePath: string,
553
- options: MediaReadOptions & PathSecurityOptions = {}
554
- ): Promise<MediaReadResult> {
555
- const { maxSize = DEFAULT_MAX_SIZE } = options;
556
-
557
- // 规范化路径
558
- const localPath = normalizeLocalPath(filePath);
559
-
560
- // 安全检查
561
- validatePathSecurity(localPath, options);
562
-
563
- // 检查文件存在性
564
- if (!fs.existsSync(localPath)) {
565
- throw new Error(`File not found: ${localPath}`);
566
- }
567
-
568
- // 检查文件大小
569
- const stats = await fsPromises.stat(localPath);
570
- if (stats.size > maxSize) {
571
- throw new FileSizeLimitError(stats.size, maxSize);
572
- }
573
-
574
- // 读取文件
575
- const buffer = await fsPromises.readFile(localPath);
576
- const fileName = path.basename(localPath);
577
- const mimeType = getMimeType(localPath);
578
-
579
- return {
580
- buffer,
581
- fileName,
582
- size: buffer.length,
583
- mimeType,
584
- };
585
- }
586
-
587
- /**
588
- * 统一的媒体读取函数
589
- * 自动判断是 HTTP URL 还是本地路径
590
- *
591
- * @param source - 媒体源(URL 或本地路径)
592
- * @param options - 读取选项
593
- * @returns 媒体读取结果
594
- */
595
- export async function readMedia(
596
- source: string,
597
- options: MediaReadOptions & PathSecurityOptions = {}
598
- ): Promise<MediaReadResult> {
599
- if (isHttpUrl(source)) {
600
- return fetchMediaFromUrl(source, options);
601
- }
602
- return readMediaFromLocal(source, options);
603
- }
604
-
605
- /**
606
- * 批量读取媒体
607
- *
608
- * @param sources - 媒体源列表
609
- * @param options - 读取选项
610
- * @returns 媒体读取结果列表(包含成功和失败的结果)
611
- */
612
- export async function readMediaBatch(
613
- sources: string[],
614
- options: MediaReadOptions & PathSecurityOptions = {}
615
- ): Promise<Array<{ source: string; result?: MediaReadResult; error?: Error }>> {
616
- const results = await Promise.allSettled(
617
- sources.map((source) => readMedia(source, options))
618
- );
619
-
620
- return results.map((result, index) => {
621
- if (result.status === "fulfilled") {
622
- return { source: sources[index], result: result.value };
623
- }
624
- return { source: sources[index], error: result.reason as Error };
625
- });
626
- }
627
-
628
- /**
629
- * 将临时媒体文件归档到 inbound/YYYY-MM-DD
630
- * - 仅归档 tempDir 下的文件
631
- * - 失败时返回原路径,不抛错
632
- */
633
- export async function finalizeInboundMediaFile(
634
- options: FinalizeInboundMediaOptions
635
- ): Promise<string> {
636
- const current = String(options.filePath ?? "").trim();
637
- if (!current) return current;
638
-
639
- if (!isPathUnderDir(current, options.tempDir)) {
640
- return current;
641
- }
642
-
643
- const datedDir = path.join(options.inboundDir, formatDateDir());
644
- const target = path.join(datedDir, path.basename(current));
645
-
646
- try {
647
- await fsPromises.mkdir(datedDir, { recursive: true });
648
- await fsPromises.rename(current, target);
649
- return target;
650
- } catch {
651
- return current;
652
- }
653
- }
654
-
655
- /**
656
- * 清理 inbound 目录中过期文件
657
- * - 仅处理 YYYY-MM-DD 目录
658
- * - 仅删除过期文件,不递归子目录,不删除目录
659
- */
660
- export async function pruneInboundMediaDir(
661
- options: PruneInboundMediaDirOptions
662
- ): Promise<void> {
663
- const keepDays = Number(options.keepDays);
664
- if (!Number.isFinite(keepDays) || keepDays < 0) return;
665
-
666
- const now = options.nowMs ?? Date.now();
667
- const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
668
-
669
- let entries: string[] = [];
670
- try {
671
- entries = await fsPromises.readdir(options.inboundDir);
672
- } catch {
673
- return;
674
- }
675
-
676
- for (const entry of entries) {
677
- if (!/^\d{4}-\d{2}-\d{2}$/.test(entry)) continue;
678
- const dirPath = path.join(options.inboundDir, entry);
679
-
680
- let dirStats;
681
- try {
682
- dirStats = await fsPromises.stat(dirPath);
683
- } catch {
684
- continue;
685
- }
686
- if (!dirStats.isDirectory()) continue;
687
-
688
- const dirTime = dirStats.mtimeMs || dirStats.ctimeMs || 0;
689
- if (dirTime >= cutoff) continue;
690
-
691
- let files: string[] = [];
692
- try {
693
- files = await fsPromises.readdir(dirPath);
694
- } catch {
695
- continue;
696
- }
697
-
698
- for (const file of files) {
699
- const fp = path.join(dirPath, file);
700
- try {
701
- const fst = await fsPromises.stat(fp);
702
- if (fst.isFile() && (fst.mtimeMs || fst.ctimeMs || 0) < cutoff) {
703
- await fsPromises.unlink(fp);
704
- }
705
- } catch {
706
- // ignore
707
- }
708
- }
709
- }
710
- }
711
-
712
- /**
713
- * 安全删除文件
714
- * - filePath 为空直接返回
715
- * - ENOENT 视为成功
716
- * - 其他错误可通过 onError 回调记录
717
- */
718
- export async function cleanupFileSafe(
719
- filePath: string | undefined,
720
- onError?: (error: unknown, filePath: string) => void
721
- ): Promise<void> {
722
- if (!filePath) return;
723
-
724
- try {
725
- await fsPromises.unlink(filePath);
726
- } catch (error) {
727
- if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
728
- return;
729
- }
730
- onError?.(error, filePath);
731
- }
732
- }
1
+ /**
2
+ * 媒体 IO 模块
3
+ *
4
+ * 提供统一的媒体文件下载和读取功能
5
+ *
6
+ * @module @openclaw-china/shared/media
7
+ */
8
+
9
+ import * as fs from "fs";
10
+ import * as fsPromises from "fs/promises";
11
+ import * as path from "path";
12
+ import * as os from "os";
13
+ import { isHttpUrl, normalizeLocalPath, getExtension } from "./media-parser.js";
14
+ import { resolveExtension } from "../file/file-utils.js";
15
+
16
+ // ============================================================================
17
+ // 类型定义
18
+ // ============================================================================
19
+
20
+ /**
21
+ * 媒体读取结果
22
+ */
23
+ export interface MediaReadResult {
24
+ /** 文件内容 Buffer */
25
+ buffer: Buffer;
26
+ /** 文件名 */
27
+ fileName: string;
28
+ /** 文件大小(字节) */
29
+ size: number;
30
+ /** MIME 类型(如果可检测) */
31
+ mimeType?: string;
32
+ }
33
+
34
+ /**
35
+ * 下载并落盘到临时文件的结果
36
+ */
37
+ export interface DownloadToTempFileResult {
38
+ /** 绝对路径 */
39
+ path: string;
40
+ /** 写入文件名 */
41
+ fileName: string;
42
+ /** MIME 类型 */
43
+ contentType: string;
44
+ /** 文件大小(字节) */
45
+ size: number;
46
+ /** 来源文件名(若可解析) */
47
+ sourceFileName?: string;
48
+ }
49
+
50
+ /**
51
+ * 媒体读取选项
52
+ */
53
+ export interface MediaReadOptions {
54
+ /** 超时时间(毫秒),默认 30000 */
55
+ timeout?: number;
56
+ /** 最大文件大小(字节),默认 100MB */
57
+ maxSize?: number;
58
+ /** 自定义 fetch 函数(用于依赖注入) */
59
+ fetch?: typeof globalThis.fetch;
60
+ }
61
+
62
+ /**
63
+ * 下载并落盘到临时文件的选项
64
+ */
65
+ export interface DownloadToTempFileOptions extends MediaReadOptions {
66
+ /** 目标临时目录,默认 os.tmpdir() */
67
+ tempDir?: string;
68
+ /** 文件名前缀,默认 "media" */
69
+ tempPrefix?: string;
70
+ /** 来源文件名(优先用于扩展名推断) */
71
+ sourceFileName?: string;
72
+ }
73
+
74
+ /**
75
+ * 归档入站媒体参数
76
+ */
77
+ export interface FinalizeInboundMediaOptions {
78
+ /** 当前文件路径 */
79
+ filePath: string;
80
+ /** 临时目录(仅该目录下文件会归档) */
81
+ tempDir: string;
82
+ /** 入站归档根目录 */
83
+ inboundDir: string;
84
+ }
85
+
86
+ /**
87
+ * 过期清理参数
88
+ */
89
+ export interface PruneInboundMediaDirOptions {
90
+ /** 入站归档根目录 */
91
+ inboundDir: string;
92
+ /** 保留天数,>=0 生效 */
93
+ keepDays: number;
94
+ /** 当前时间(测试用) */
95
+ nowMs?: number;
96
+ }
97
+
98
+ /**
99
+ * 路径安全检查选项
100
+ */
101
+ export interface PathSecurityOptions {
102
+ /** 允许的路径前缀白名单 */
103
+ allowedPrefixes?: string[];
104
+ /** 最大路径长度,默认 4096 */
105
+ maxPathLength?: number;
106
+ /** 是否禁止路径穿越,默认 true */
107
+ preventTraversal?: boolean;
108
+ }
109
+
110
+ // ============================================================================
111
+ // 常量定义
112
+ // ============================================================================
113
+
114
+ /** 默认超时时间(毫秒) */
115
+ const DEFAULT_TIMEOUT = 30000;
116
+
117
+ /** 默认最大文件大小(100MB) */
118
+ const DEFAULT_MAX_SIZE = 100 * 1024 * 1024;
119
+
120
+ /** 默认最大路径长度 */
121
+ const DEFAULT_MAX_PATH_LENGTH = 4096;
122
+
123
+ /** 默认允许的路径前缀(Unix) */
124
+ const DEFAULT_UNIX_PREFIXES = [
125
+ "/tmp",
126
+ "/var/tmp",
127
+ "/private/tmp",
128
+ "/Users",
129
+ "/home",
130
+ "/root",
131
+ ];
132
+
133
+ function parseContentDispositionFilename(value: string | null): string | undefined {
134
+ if (!value) return undefined;
135
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
136
+ if (utf8Match?.[1]) {
137
+ try {
138
+ return decodeURIComponent(utf8Match[1].trim());
139
+ } catch {
140
+ return utf8Match[1].trim();
141
+ }
142
+ }
143
+ const plainMatch = value.match(/filename=([^;]+)/i);
144
+ if (!plainMatch?.[1]) return undefined;
145
+ const raw = plainMatch[1].trim().replace(/^["']|["']$/g, "");
146
+ return raw || undefined;
147
+ }
148
+
149
+ function sanitizeFileName(name: string): string {
150
+ const trimmed = name.trim();
151
+ if (!trimmed) return "file";
152
+ const normalized = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
153
+ return normalized || "file";
154
+ }
155
+
156
+ function resolveFileNameFromUrl(url: string): string | undefined {
157
+ try {
158
+ const parsed = new URL(url);
159
+ const base = path.basename(parsed.pathname);
160
+ if (!base || base === "/") return undefined;
161
+ return base;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ function normalizeForCompare(value: string): string {
168
+ return path.resolve(value).replace(/\\/g, "/").toLowerCase();
169
+ }
170
+
171
+ function isPathUnderDir(filePath: string, dirPath: string): boolean {
172
+ const f = normalizeForCompare(filePath);
173
+ const d = normalizeForCompare(dirPath).replace(/\/+$/, "");
174
+ return f === d || f.startsWith(`${d}/`);
175
+ }
176
+
177
+ function formatDateDir(date = new Date()): string {
178
+ const yyyy = date.getFullYear();
179
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
180
+ const dd = String(date.getDate()).padStart(2, "0");
181
+ return `${yyyy}-${mm}-${dd}`;
182
+ }
183
+
184
+ /** 扩展名到 MIME 类型映射 */
185
+ const EXT_TO_MIME: Record<string, string> = {
186
+ // 图片
187
+ jpg: "image/jpeg",
188
+ jpeg: "image/jpeg",
189
+ png: "image/png",
190
+ gif: "image/gif",
191
+ webp: "image/webp",
192
+ bmp: "image/bmp",
193
+ svg: "image/svg+xml",
194
+ ico: "image/x-icon",
195
+ // 音频
196
+ mp3: "audio/mpeg",
197
+ wav: "audio/wav",
198
+ ogg: "audio/ogg",
199
+ m4a: "audio/x-m4a",
200
+ amr: "audio/amr",
201
+ // 视频
202
+ mp4: "video/mp4",
203
+ mov: "video/quicktime",
204
+ avi: "video/x-msvideo",
205
+ mkv: "video/x-matroska",
206
+ webm: "video/webm",
207
+ // 文档
208
+ pdf: "application/pdf",
209
+ doc: "application/msword",
210
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
211
+ xls: "application/vnd.ms-excel",
212
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
213
+ ppt: "application/vnd.ms-powerpoint",
214
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
215
+ txt: "text/plain",
216
+ csv: "text/csv",
217
+ // 压缩包
218
+ zip: "application/zip",
219
+ rar: "application/x-rar-compressed",
220
+ "7z": "application/x-7z-compressed",
221
+ tar: "application/x-tar",
222
+ gz: "application/gzip",
223
+ };
224
+
225
+ // ============================================================================
226
+ // 错误类
227
+ // ============================================================================
228
+
229
+ /**
230
+ * 文件大小超限错误
231
+ */
232
+ export class FileSizeLimitError extends Error {
233
+ /** 实际文件大小(字节) */
234
+ public readonly actualSize: number;
235
+ /** 大小限制(字节) */
236
+ public readonly limitSize: number;
237
+
238
+ constructor(actualSize: number, limitSize: number) {
239
+ super(`File size ${actualSize} bytes exceeds limit ${limitSize} bytes`);
240
+ this.name = "FileSizeLimitError";
241
+ this.actualSize = actualSize;
242
+ this.limitSize = limitSize;
243
+
244
+ if (Error.captureStackTrace) {
245
+ Error.captureStackTrace(this, FileSizeLimitError);
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * 下载超时错误
252
+ */
253
+ export class MediaTimeoutError extends Error {
254
+ /** 超时时间(毫秒) */
255
+ public readonly timeoutMs: number;
256
+
257
+ constructor(timeoutMs: number) {
258
+ super(`Operation timed out after ${timeoutMs}ms`);
259
+ this.name = "MediaTimeoutError";
260
+ this.timeoutMs = timeoutMs;
261
+
262
+ if (Error.captureStackTrace) {
263
+ Error.captureStackTrace(this, MediaTimeoutError);
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 路径安全错误
270
+ */
271
+ export class PathSecurityError extends Error {
272
+ /** 不安全的路径 */
273
+ public readonly unsafePath: string;
274
+ /** 错误原因 */
275
+ public readonly reason: string;
276
+
277
+ constructor(unsafePath: string, reason: string) {
278
+ super(`Path security violation: ${reason} - ${unsafePath}`);
279
+ this.name = "PathSecurityError";
280
+ this.unsafePath = unsafePath;
281
+ this.reason = reason;
282
+
283
+ if (Error.captureStackTrace) {
284
+ Error.captureStackTrace(this, PathSecurityError);
285
+ }
286
+ }
287
+ }
288
+
289
+ // ============================================================================
290
+ // 路径安全检查
291
+ // ============================================================================
292
+
293
+ /**
294
+ * 检查路径是否安全
295
+ *
296
+ * @param filePath - 要检查的路径
297
+ * @param options - 安全检查选项
298
+ * @throws PathSecurityError 如果路径不安全
299
+ */
300
+ export function validatePathSecurity(
301
+ filePath: string,
302
+ options: PathSecurityOptions = {}
303
+ ): void {
304
+ const {
305
+ allowedPrefixes,
306
+ maxPathLength = DEFAULT_MAX_PATH_LENGTH,
307
+ preventTraversal = true,
308
+ } = options;
309
+
310
+ // 检查路径长度
311
+ if (filePath.length > maxPathLength) {
312
+ throw new PathSecurityError(
313
+ filePath,
314
+ `Path length ${filePath.length} exceeds maximum ${maxPathLength}`
315
+ );
316
+ }
317
+
318
+ // 检查路径穿越
319
+ if (preventTraversal) {
320
+ const normalized = path.normalize(filePath);
321
+ if (normalized.includes("..")) {
322
+ throw new PathSecurityError(filePath, "Path traversal detected");
323
+ }
324
+ }
325
+
326
+ // 检查路径前缀白名单
327
+ if (allowedPrefixes && allowedPrefixes.length > 0) {
328
+ const normalizedPath = path.normalize(filePath);
329
+ const isAllowed = allowedPrefixes.some((prefix) =>
330
+ normalizedPath.startsWith(path.normalize(prefix))
331
+ );
332
+ if (!isAllowed) {
333
+ throw new PathSecurityError(
334
+ filePath,
335
+ `Path not in allowed prefixes: ${allowedPrefixes.join(", ")}`
336
+ );
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * 获取默认的路径白名单
343
+ */
344
+ export function getDefaultAllowedPrefixes(): string[] {
345
+ if (process.platform === "win32") {
346
+ // Windows: 允许所有驱动器的临时目录和用户目录
347
+ const tempDir = os.tmpdir();
348
+ const homeDir = os.homedir();
349
+ return [tempDir, homeDir];
350
+ }
351
+ return DEFAULT_UNIX_PREFIXES;
352
+ }
353
+
354
+ // ============================================================================
355
+ // MIME 类型检测
356
+ // ============================================================================
357
+
358
+ /**
359
+ * 根据文件扩展名获取 MIME 类型
360
+ */
361
+ export function getMimeType(filePath: string): string | undefined {
362
+ const ext = getExtension(filePath);
363
+ return EXT_TO_MIME[ext];
364
+ }
365
+
366
+ // ============================================================================
367
+ // 媒体读取函数
368
+ // ============================================================================
369
+
370
+ /**
371
+ * 从 HTTP URL 下载媒体
372
+ *
373
+ * @param url - 媒体 URL
374
+ * @param options - 读取选项
375
+ * @returns 媒体读取结果
376
+ */
377
+ export async function fetchMediaFromUrl(
378
+ url: string,
379
+ options: MediaReadOptions = {}
380
+ ): Promise<MediaReadResult> {
381
+ const {
382
+ timeout = DEFAULT_TIMEOUT,
383
+ maxSize = DEFAULT_MAX_SIZE,
384
+ fetch: customFetch = globalThis.fetch,
385
+ } = options;
386
+
387
+ const controller = new AbortController();
388
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
389
+
390
+ try {
391
+ const response = await customFetch(url, { signal: controller.signal });
392
+
393
+ if (!response.ok) {
394
+ const errorText = await response.text();
395
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
396
+ }
397
+
398
+ // 检查 Content-Length
399
+ const contentLength = response.headers.get("content-length");
400
+ if (contentLength) {
401
+ const size = parseInt(contentLength, 10);
402
+ if (size > maxSize) {
403
+ throw new FileSizeLimitError(size, maxSize);
404
+ }
405
+ }
406
+
407
+ const arrayBuffer = await response.arrayBuffer();
408
+ const buffer = Buffer.from(arrayBuffer);
409
+
410
+ // 检查实际大小
411
+ if (buffer.length > maxSize) {
412
+ throw new FileSizeLimitError(buffer.length, maxSize);
413
+ }
414
+
415
+ // 提取文件名
416
+ let fileName = "file";
417
+ try {
418
+ const urlPath = new URL(url).pathname;
419
+ fileName = path.basename(urlPath) || "file";
420
+ } catch {
421
+ // 忽略 URL 解析错误
422
+ }
423
+
424
+ // 获取 MIME 类型
425
+ const mimeType =
426
+ response.headers.get("content-type")?.split(";")[0].trim() ||
427
+ getMimeType(fileName);
428
+
429
+ return {
430
+ buffer,
431
+ fileName,
432
+ size: buffer.length,
433
+ mimeType,
434
+ };
435
+ } catch (error) {
436
+ if (error instanceof Error && error.name === "AbortError") {
437
+ throw new MediaTimeoutError(timeout);
438
+ }
439
+ throw error;
440
+ } finally {
441
+ clearTimeout(timeoutId);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * 下载 HTTP 媒体并写入临时文件
447
+ */
448
+ export async function downloadToTempFile(
449
+ url: string,
450
+ options: DownloadToTempFileOptions = {}
451
+ ): Promise<DownloadToTempFileResult> {
452
+ if (!isHttpUrl(url)) {
453
+ throw new Error(`downloadToTempFile expects an HTTP URL, got: ${url}`);
454
+ }
455
+
456
+ const {
457
+ timeout = DEFAULT_TIMEOUT,
458
+ maxSize = DEFAULT_MAX_SIZE,
459
+ fetch: customFetch = globalThis.fetch,
460
+ tempDir = os.tmpdir(),
461
+ tempPrefix = "media",
462
+ sourceFileName,
463
+ } = options;
464
+
465
+ const controller = new AbortController();
466
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
467
+
468
+ try {
469
+ const response = await customFetch(url, { signal: controller.signal });
470
+ if (!response.ok) {
471
+ const responseBody = await response.text().catch(() => "");
472
+ throw new Error(`HTTP ${response.status}: ${responseBody}`);
473
+ }
474
+
475
+ const contentType =
476
+ response.headers.get("content-type")?.split(";")[0].trim() ||
477
+ "application/octet-stream";
478
+
479
+ const contentLength = response.headers.get("content-length");
480
+ if (contentLength) {
481
+ const declared = parseInt(contentLength, 10);
482
+ if (!Number.isNaN(declared) && declared > maxSize) {
483
+ throw new FileSizeLimitError(declared, maxSize);
484
+ }
485
+ }
486
+
487
+ const body = response.body;
488
+ if (!body) {
489
+ throw new Error("Response body is null");
490
+ }
491
+
492
+ const chunks: Uint8Array[] = [];
493
+ let totalBytes = 0;
494
+ const reader = body.getReader();
495
+
496
+ try {
497
+ while (true) {
498
+ const { done, value } = await reader.read();
499
+ if (done) break;
500
+ totalBytes += value.length;
501
+ if (totalBytes > maxSize) {
502
+ reader.cancel();
503
+ throw new FileSizeLimitError(totalBytes, maxSize);
504
+ }
505
+ chunks.push(value);
506
+ }
507
+ } finally {
508
+ reader.releaseLock();
509
+ }
510
+
511
+ const sourceName =
512
+ sourceFileName ||
513
+ parseContentDispositionFilename(response.headers.get("content-disposition")) ||
514
+ resolveFileNameFromUrl(url) ||
515
+ "file";
516
+
517
+ const safePrefix = sanitizeFileName(tempPrefix) || "media";
518
+ const ext = resolveExtension(contentType, sourceName);
519
+ const random = Math.random().toString(36).slice(2, 8);
520
+ const fileName = `${safePrefix}-${Date.now()}-${random}${ext}`;
521
+ const fullPath = path.join(tempDir, fileName);
522
+
523
+ await fsPromises.mkdir(tempDir, { recursive: true });
524
+ const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
525
+ await fsPromises.writeFile(fullPath, buffer);
526
+
527
+ return {
528
+ path: fullPath,
529
+ fileName,
530
+ contentType,
531
+ size: totalBytes,
532
+ sourceFileName: sourceName,
533
+ };
534
+ } catch (error) {
535
+ if (error instanceof Error && error.name === "AbortError") {
536
+ throw new MediaTimeoutError(timeout);
537
+ }
538
+ throw error;
539
+ } finally {
540
+ clearTimeout(timeoutId);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * 从本地路径读取媒体
546
+ *
547
+ * @param filePath - 本地文件路径(支持 file://, MEDIA:, attachment:// 前缀)
548
+ * @param options - 读取选项
549
+ * @returns 媒体读取结果
550
+ */
551
+ export async function readMediaFromLocal(
552
+ filePath: string,
553
+ options: MediaReadOptions & PathSecurityOptions = {}
554
+ ): Promise<MediaReadResult> {
555
+ const { maxSize = DEFAULT_MAX_SIZE } = options;
556
+
557
+ // 规范化路径
558
+ const localPath = normalizeLocalPath(filePath);
559
+
560
+ // 安全检查
561
+ validatePathSecurity(localPath, options);
562
+
563
+ // 检查文件存在性
564
+ if (!fs.existsSync(localPath)) {
565
+ throw new Error(`File not found: ${localPath}`);
566
+ }
567
+
568
+ // 检查文件大小
569
+ const stats = await fsPromises.stat(localPath);
570
+ if (stats.size > maxSize) {
571
+ throw new FileSizeLimitError(stats.size, maxSize);
572
+ }
573
+
574
+ // 读取文件
575
+ const buffer = await fsPromises.readFile(localPath);
576
+ const fileName = path.basename(localPath);
577
+ const mimeType = getMimeType(localPath);
578
+
579
+ return {
580
+ buffer,
581
+ fileName,
582
+ size: buffer.length,
583
+ mimeType,
584
+ };
585
+ }
586
+
587
+ /**
588
+ * 统一的媒体读取函数
589
+ * 自动判断是 HTTP URL 还是本地路径
590
+ *
591
+ * @param source - 媒体源(URL 或本地路径)
592
+ * @param options - 读取选项
593
+ * @returns 媒体读取结果
594
+ */
595
+ export async function readMedia(
596
+ source: string,
597
+ options: MediaReadOptions & PathSecurityOptions = {}
598
+ ): Promise<MediaReadResult> {
599
+ if (isHttpUrl(source)) {
600
+ return fetchMediaFromUrl(source, options);
601
+ }
602
+ return readMediaFromLocal(source, options);
603
+ }
604
+
605
+ /**
606
+ * 批量读取媒体
607
+ *
608
+ * @param sources - 媒体源列表
609
+ * @param options - 读取选项
610
+ * @returns 媒体读取结果列表(包含成功和失败的结果)
611
+ */
612
+ export async function readMediaBatch(
613
+ sources: string[],
614
+ options: MediaReadOptions & PathSecurityOptions = {}
615
+ ): Promise<Array<{ source: string; result?: MediaReadResult; error?: Error }>> {
616
+ const results = await Promise.allSettled(
617
+ sources.map((source) => readMedia(source, options))
618
+ );
619
+
620
+ return results.map((result, index) => {
621
+ if (result.status === "fulfilled") {
622
+ return { source: sources[index], result: result.value };
623
+ }
624
+ return { source: sources[index], error: result.reason as Error };
625
+ });
626
+ }
627
+
628
+ /**
629
+ * 将临时媒体文件归档到 inbound/YYYY-MM-DD
630
+ * - 仅归档 tempDir 下的文件
631
+ * - 失败时返回原路径,不抛错
632
+ */
633
+ export async function finalizeInboundMediaFile(
634
+ options: FinalizeInboundMediaOptions
635
+ ): Promise<string> {
636
+ const current = String(options.filePath ?? "").trim();
637
+ if (!current) return current;
638
+
639
+ if (!isPathUnderDir(current, options.tempDir)) {
640
+ return current;
641
+ }
642
+
643
+ const datedDir = path.join(options.inboundDir, formatDateDir());
644
+ const target = path.join(datedDir, path.basename(current));
645
+
646
+ try {
647
+ await fsPromises.mkdir(datedDir, { recursive: true });
648
+ await fsPromises.rename(current, target);
649
+ return target;
650
+ } catch {
651
+ return current;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * 清理 inbound 目录中过期文件
657
+ * - 仅处理 YYYY-MM-DD 目录
658
+ * - 仅删除过期文件,不递归子目录,不删除目录
659
+ */
660
+ export async function pruneInboundMediaDir(
661
+ options: PruneInboundMediaDirOptions
662
+ ): Promise<void> {
663
+ const keepDays = Number(options.keepDays);
664
+ if (!Number.isFinite(keepDays) || keepDays < 0) return;
665
+
666
+ const now = options.nowMs ?? Date.now();
667
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
668
+
669
+ let entries: string[] = [];
670
+ try {
671
+ entries = await fsPromises.readdir(options.inboundDir);
672
+ } catch {
673
+ return;
674
+ }
675
+
676
+ for (const entry of entries) {
677
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(entry)) continue;
678
+ const dirPath = path.join(options.inboundDir, entry);
679
+
680
+ let dirStats;
681
+ try {
682
+ dirStats = await fsPromises.stat(dirPath);
683
+ } catch {
684
+ continue;
685
+ }
686
+ if (!dirStats.isDirectory()) continue;
687
+
688
+ const dirTime = dirStats.mtimeMs || dirStats.ctimeMs || 0;
689
+ if (dirTime >= cutoff) continue;
690
+
691
+ let files: string[] = [];
692
+ try {
693
+ files = await fsPromises.readdir(dirPath);
694
+ } catch {
695
+ continue;
696
+ }
697
+
698
+ for (const file of files) {
699
+ const fp = path.join(dirPath, file);
700
+ try {
701
+ const fst = await fsPromises.stat(fp);
702
+ if (fst.isFile() && (fst.mtimeMs || fst.ctimeMs || 0) < cutoff) {
703
+ await fsPromises.unlink(fp);
704
+ }
705
+ } catch {
706
+ // ignore
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ /**
713
+ * 安全删除文件
714
+ * - filePath 为空直接返回
715
+ * - ENOENT 视为成功
716
+ * - 其他错误可通过 onError 回调记录
717
+ */
718
+ export async function cleanupFileSafe(
719
+ filePath: string | undefined,
720
+ onError?: (error: unknown, filePath: string) => void
721
+ ): Promise<void> {
722
+ if (!filePath) return;
723
+
724
+ try {
725
+ await fsPromises.unlink(filePath);
726
+ } catch (error) {
727
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") {
728
+ return;
729
+ }
730
+ onError?.(error, filePath);
731
+ }
732
+ }