import isPathInside from "is-path-inside"; import getPath, { isEletron } from "@/utils/getPath"; import fs from "node:fs/promises"; import path from "node:path"; // 规范化路径:去除前导斜杠,并将路径分隔符统一转换为系统分隔符 function normalizeUserPath(userPath: string): string { // 去除前导的 / 或 \ const trimmedPath = userPath.replace(/^[/\\]+/, ""); // 将所有 / 替换为系统路径分隔符(path.sep) // 这样在 Windows 上会转为 \,在 Unix 上保持 / return trimmedPath.split("/").join(path.sep); } // 校验路径 function resolveSafeLocalPath(userPath: string, rootDir: string): string { const safePath = normalizeUserPath(userPath); const absPath = path.join(rootDir, safePath); if (!isPathInside(absPath, rootDir)) { throw new Error(`${userPath} 不在 OSS 根目录内`); } return absPath; } class OSS { private rootDir: string; private initPromise: Promise; constructor() { this.rootDir = getPath("oss"); // 初始化时自动创建根目录 this.initPromise = fs.mkdir(this.rootDir, { recursive: true }).then(() => {}); } /** * 等待根目录初始化完成。用于保证所有文件操作在目录已创建后执行。 * @private */ private async ensureInit() { await this.initPromise; } /** * 获取指定相对路径文件的访问 URL。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @returns 文件的 http 链接(本地服务地址) */ async getFileUrl(userRelPath: string): Promise { await this.ensureInit(); const safePath = normalizeUserPath(userRelPath); // URL 始终使用 /,所以这里需要将系统分隔符转回 / let url = process.env.OSSURL || `http://127.0.0.1:10588/`; if (isEletron()) url = `http://localhost:${process.env.PORT}/`; return `${url}${safePath.split(path.sep).join("/")}`; } /** * 读取指定路径的文件内容为 Buffer。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @returns 文件内容的 Buffer * @throws 路径不在 OSS 根目录内、文件不存在等错误 */ async getFile(userRelPath: string): Promise { await this.ensureInit(); return fs.readFile(resolveSafeLocalPath(userRelPath, this.rootDir)); } /** * 读取图片文件并转换为 base64 编码的 Data URL。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @returns base64 编码的 Data URL (例如: data:image/png;base64,iVBORw0KGgo...) * @throws 路径不在 OSS 根目录内、文件不存在、不是图片文件等错误 */ async getImageBase64(userRelPath: string): Promise { await this.ensureInit(); const absPath = resolveSafeLocalPath(userRelPath, this.rootDir); // 检查文件是否存在且为文件 const stat = await fs.stat(absPath); if (!stat.isFile()) { throw new Error(`${userRelPath} 不是文件`); } // 获取文件扩展名并确定 MIME 类型 const ext = path.extname(userRelPath).toLowerCase(); const mimeTypes: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".tiff": "image/tiff", ".tif": "image/tiff", }; const mimeType = mimeTypes[ext]; if (!mimeType) { throw new Error(`不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`); } // 读取文件并转换为 base64 const data = await fs.readFile(absPath); const base64 = data.toString("base64"); // 返回完整的 Data URL return `data:${mimeType};base64,${base64}`; } /** * 删除指定路径的文件。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @throws 路径不在 OSS 根目录内、文件不存在等错误 */ async deleteFile(userRelPath: string): Promise { await this.ensureInit(); await fs.unlink(resolveSafeLocalPath(userRelPath, this.rootDir)); } /** * 删除指定路径的文件夹及其所有内容。 * @param userRelPath 用户传入的相对文件夹路径(使用 / 作为分隔符) * @throws 路径不在 OSS 根目录内、文件夹不存在、目标是文件而非文件夹等错误 */ async deleteDirectory(userRelPath: string): Promise { await this.ensureInit(); const absPath = resolveSafeLocalPath(userRelPath, this.rootDir); const stat = await fs.stat(absPath); if (!stat.isDirectory()) { throw new Error(`${userRelPath} 不是文件夹`); } await fs.rm(absPath, { recursive: true, force: true }); } /** * 将数据写入指定路径的新文件或覆盖已有文件。 * 写入前自动创建所需的父文件夹。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @param data 要写入的数据,可以为 Buffer 或字符串 * @throws 路径不在 OSS 根目录内等错误 */ async writeFile(userRelPath: string, data: Buffer | string): Promise { await this.ensureInit(); const absPath = resolveSafeLocalPath(userRelPath, this.rootDir); await fs.mkdir(path.dirname(absPath), { recursive: true }); // 如果 data 是 string,则视为 base64 编码,先解码再写入 const buffer = typeof data === "string" ? Buffer.from(data, "base64") : data; await fs.writeFile(absPath, buffer); } /** * 检查指定路径文件是否存在。 * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @returns 文件存在返回 true,否则 false */ async fileExists(userRelPath: string): Promise { await this.ensureInit(); try { const stat = await fs.stat(resolveSafeLocalPath(userRelPath, this.rootDir)); return stat.isFile(); } catch { return false; } } } export default new OSS();