@openclaw-china/shared 0.1.37 → 0.1.39
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/package.json +1 -1
- package/src/cli/china-setup.ts +15 -24
- package/src/cli/index.ts +2 -2
- package/src/file/file-utils.test.ts +141 -141
- package/src/file/file-utils.ts +284 -284
- package/src/file/index.ts +10 -10
- package/src/index.ts +3 -3
- package/src/logger/index.ts +1 -1
- package/src/logger/logger.ts +51 -51
- package/src/media/index.ts +22 -22
- package/src/media/media-io.ts +328 -328
- package/vitest.config.ts +8 -8
package/src/media/media-io.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* @module @openclaw-china/shared/media
|
|
7
7
|
*/
|
|
8
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";
|
|
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
15
|
|
|
16
16
|
// ============================================================================
|
|
17
17
|
// 类型定义
|
|
@@ -20,7 +20,7 @@ import { resolveExtension } from "../file/file-utils.js";
|
|
|
20
20
|
/**
|
|
21
21
|
* 媒体读取结果
|
|
22
22
|
*/
|
|
23
|
-
export interface MediaReadResult {
|
|
23
|
+
export interface MediaReadResult {
|
|
24
24
|
/** 文件内容 Buffer */
|
|
25
25
|
buffer: Buffer;
|
|
26
26
|
/** 文件名 */
|
|
@@ -29,71 +29,71 @@ export interface MediaReadResult {
|
|
|
29
29
|
size: number;
|
|
30
30
|
/** MIME 类型(如果可检测) */
|
|
31
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
|
-
}
|
|
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
49
|
|
|
50
50
|
/**
|
|
51
51
|
* 媒体读取选项
|
|
52
52
|
*/
|
|
53
|
-
export interface MediaReadOptions {
|
|
53
|
+
export interface MediaReadOptions {
|
|
54
54
|
/** 超时时间(毫秒),默认 30000 */
|
|
55
55
|
timeout?: number;
|
|
56
56
|
/** 最大文件大小(字节),默认 100MB */
|
|
57
57
|
maxSize?: number;
|
|
58
58
|
/** 自定义 fetch 函数(用于依赖注入) */
|
|
59
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
|
-
}
|
|
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
97
|
|
|
98
98
|
/**
|
|
99
99
|
* 路径安全检查选项
|
|
@@ -118,68 +118,68 @@ const DEFAULT_TIMEOUT = 30000;
|
|
|
118
118
|
const DEFAULT_MAX_SIZE = 100 * 1024 * 1024;
|
|
119
119
|
|
|
120
120
|
/** 默认最大路径长度 */
|
|
121
|
-
const DEFAULT_MAX_PATH_LENGTH = 4096;
|
|
121
|
+
const DEFAULT_MAX_PATH_LENGTH = 4096;
|
|
122
122
|
|
|
123
123
|
/** 默认允许的路径前缀(Unix) */
|
|
124
|
-
const DEFAULT_UNIX_PREFIXES = [
|
|
124
|
+
const DEFAULT_UNIX_PREFIXES = [
|
|
125
125
|
"/tmp",
|
|
126
126
|
"/var/tmp",
|
|
127
127
|
"/private/tmp",
|
|
128
128
|
"/Users",
|
|
129
129
|
"/home",
|
|
130
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
|
-
}
|
|
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
183
|
|
|
184
184
|
/** 扩展名到 MIME 类型映射 */
|
|
185
185
|
const EXT_TO_MIME: Record<string, string> = {
|
|
@@ -374,7 +374,7 @@ export function getMimeType(filePath: string): string | undefined {
|
|
|
374
374
|
* @param options - 读取选项
|
|
375
375
|
* @returns 媒体读取结果
|
|
376
376
|
*/
|
|
377
|
-
export async function fetchMediaFromUrl(
|
|
377
|
+
export async function fetchMediaFromUrl(
|
|
378
378
|
url: string,
|
|
379
379
|
options: MediaReadOptions = {}
|
|
380
380
|
): Promise<MediaReadResult> {
|
|
@@ -440,106 +440,106 @@ export async function fetchMediaFromUrl(
|
|
|
440
440
|
} finally {
|
|
441
441
|
clearTimeout(timeoutId);
|
|
442
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
|
-
}
|
|
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
543
|
|
|
544
544
|
/**
|
|
545
545
|
* 从本地路径读取媒体
|
|
@@ -609,7 +609,7 @@ export async function readMedia(
|
|
|
609
609
|
* @param options - 读取选项
|
|
610
610
|
* @returns 媒体读取结果列表(包含成功和失败的结果)
|
|
611
611
|
*/
|
|
612
|
-
export async function readMediaBatch(
|
|
612
|
+
export async function readMediaBatch(
|
|
613
613
|
sources: string[],
|
|
614
614
|
options: MediaReadOptions & PathSecurityOptions = {}
|
|
615
615
|
): Promise<Array<{ source: string; result?: MediaReadResult; error?: Error }>> {
|
|
@@ -617,116 +617,116 @@ export async function readMediaBatch(
|
|
|
617
617
|
sources.map((source) => readMedia(source, options))
|
|
618
618
|
);
|
|
619
619
|
|
|
620
|
-
return results.map((result, index) => {
|
|
620
|
+
return results.map((result, index) => {
|
|
621
621
|
if (result.status === "fulfilled") {
|
|
622
622
|
return { source: sources[index], result: result.value };
|
|
623
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
|
-
}
|
|
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
|
+
}
|