const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); const fs = require('fs'); const path = require('path'); const { UserDB } = require('./database'); // ===== 工具函数 ===== /** * 格式化文件大小 */ function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } // ===== 统一存储接口 ===== /** * 存储接口工厂 * 根据用户的存储类型返回对应的存储客户端 */ class StorageInterface { constructor(user) { this.user = user; this.type = user.current_storage_type || 'oss'; } /** * 创建并返回存储客户端 */ async connect() { if (this.type === 'local') { const client = new LocalStorageClient(this.user); await client.init(); return client; } else { const client = new OssStorageClient(this.user); await client.connect(); return client; } } } // ===== 本地存储客户端 ===== class LocalStorageClient { constructor(user) { this.user = user; // 使用环境变量或默认路径(不硬编码) const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); this.basePath = path.join(storageRoot, `user_${user.id}`); } /** * 初始化用户存储目录 */ async init() { if (!fs.existsSync(this.basePath)) { fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 }); console.log(`[本地存储] 创建用户目录: ${this.basePath}`); } } /** * 列出目录内容 */ async list(dirPath) { const fullPath = this.getFullPath(dirPath); // 确保目录存在 if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); return []; } const items = fs.readdirSync(fullPath, { withFileTypes: true }); return items.map(item => { const itemPath = path.join(fullPath, item.name); const stats = fs.statSync(itemPath); return { name: item.name, type: item.isDirectory() ? 'd' : '-', size: stats.size, modifyTime: stats.mtimeMs }; }); } /** * 上传文件 */ async put(localPath, remotePath) { const destPath = this.getFullPath(remotePath); // 获取新文件大小 const newFileSize = fs.statSync(localPath).size; // 如果目标文件存在,计算实际需要的额外空间 let oldFileSize = 0; if (fs.existsSync(destPath)) { try { oldFileSize = fs.statSync(destPath).size; } catch (err) { // 文件可能已被删除,忽略错误 } } // 检查配额:只检查净增量(新文件大小 - 旧文件大小) const netIncrease = newFileSize - oldFileSize; if (netIncrease > 0) { this.checkQuota(netIncrease); } // 确保目标目录存在 const destDir = path.dirname(destPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // 使用临时文件+重命名模式,避免文件被占用问题 const tempPath = `${destPath}.uploading_${Date.now()}`; try { // 复制到临时文件 fs.copyFileSync(localPath, tempPath); // 如果目标文件存在,先删除 if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } // 重命名临时文件为目标文件 fs.renameSync(tempPath, destPath); // 更新已使用空间(使用净增量) if (netIncrease !== 0) { this.updateUsedSpace(netIncrease); } } catch (error) { // 清理临时文件 if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } throw error; } } /** * 删除文件或文件夹 */ async delete(filePath) { const fullPath = this.getFullPath(filePath); const stats = fs.statSync(fullPath); if (stats.isDirectory()) { // 删除文件夹 - 递归删除 // 先计算文件夹内所有文件的总大小 const folderSize = this.calculateFolderSize(fullPath); // 删除文件夹及其内容 fs.rmSync(fullPath, { recursive: true, force: true }); // 更新已使用空间 if (folderSize > 0) { this.updateUsedSpace(-folderSize); } } else { // 删除文件 fs.unlinkSync(fullPath); // 更新已使用空间 this.updateUsedSpace(-stats.size); } } /** * 计算文件夹大小 */ calculateFolderSize(folderPath) { let totalSize = 0; const items = fs.readdirSync(folderPath, { withFileTypes: true }); for (const item of items) { const itemPath = path.join(folderPath, item.name); if (item.isDirectory()) { // 递归计算子文件夹 totalSize += this.calculateFolderSize(itemPath); } else { // 累加文件大小 const stats = fs.statSync(itemPath); totalSize += stats.size; } } return totalSize; } /** * 重命名文件 */ async rename(oldPath, newPath) { const oldFullPath = this.getFullPath(oldPath); const newFullPath = this.getFullPath(newPath); // 确保新路径的目录存在 const newDir = path.dirname(newFullPath); if (!fs.existsSync(newDir)) { fs.mkdirSync(newDir, { recursive: true }); } fs.renameSync(oldFullPath, newFullPath); } /** * 获取文件信息 */ async stat(filePath) { const fullPath = this.getFullPath(filePath); return fs.statSync(fullPath); } /** * 创建文件读取流 */ createReadStream(filePath) { const fullPath = this.getFullPath(filePath); return fs.createReadStream(fullPath); } /** * 关闭连接(本地存储无需关闭) */ async end() { // 本地存储无需关闭连接 } // ===== 辅助方法 ===== /** * 获取完整路径(带安全检查) * 增强的路径遍历防护 */ getFullPath(relativePath) { // 0. 输入验证:检查空字节注入和其他危险字符 if (typeof relativePath !== 'string') { throw new Error('无效的路径类型'); } // 检查空字节注入(%00, \x00) if (relativePath.includes('\x00') || relativePath.includes('%00')) { console.warn('[安全] 检测到空字节注入尝试:', relativePath); throw new Error('路径包含非法字符'); } // 1. 规范化路径,移除 ../ 等危险路径 let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, ''); // 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过) // 解析后的路径不应包含 .. if (normalized.includes('..')) { console.warn('[安全] 检测到目录遍历尝试:', relativePath); throw new Error('路径包含非法字符'); } // 3. 将绝对路径转换为相对路径(解决Linux环境下的问题) if (path.isAbsolute(normalized)) { // 移除开头的 / 或 Windows 盘符,转为相对路径 normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, ''); } // 4. 空字符串或 . 表示根目录 if (normalized === '' || normalized === '.') { return this.basePath; } // 5. 拼接完整路径 const fullPath = path.join(this.basePath, normalized); // 6. 解析真实路径(处理符号链接)后再次验证 const resolvedBasePath = path.resolve(this.basePath); const resolvedFullPath = path.resolve(fullPath); // 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击) if (!resolvedFullPath.startsWith(resolvedBasePath)) { console.warn('[安全] 检测到路径遍历攻击:', { input: relativePath, resolved: resolvedFullPath, base: resolvedBasePath }); throw new Error('非法路径访问'); } return fullPath; } /** * 检查配额 */ checkQuota(additionalSize) { const newUsed = (this.user.local_storage_used || 0) + additionalSize; if (newUsed > this.user.local_storage_quota) { const used = this.formatSize(this.user.local_storage_used); const quota = this.formatSize(this.user.local_storage_quota); const need = this.formatSize(additionalSize); throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`); } } /** * 更新已使用空间 */ updateUsedSpace(delta) { const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta); UserDB.update(this.user.id, { local_storage_used: newUsed }); // 更新内存中的值 this.user.local_storage_used = newUsed; } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } } // ===== OSS存储客户端 ===== /** * OSS 存储客户端(基于 S3 协议) * 支持阿里云 OSS、腾讯云 COS、AWS S3 */ class OssStorageClient { constructor(user) { this.user = user; this.s3Client = null; this.prefix = `user_${user.id}/`; // 用户隔离前缀 } /** * 验证 OSS 配置是否完整 * @throws {Error} 配置不完整时抛出错误 */ validateConfig() { const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = this.user; if (!oss_provider || !['aliyun', 'tencent', 'aws'].includes(oss_provider)) { throw new Error('无效的 OSS 服务商,必须是 aliyun、tencent 或 aws'); } if (!oss_access_key_id || oss_access_key_id.trim() === '') { throw new Error('OSS Access Key ID 不能为空'); } if (!oss_access_key_secret || oss_access_key_secret.trim() === '') { throw new Error('OSS Access Key Secret 不能为空'); } if (!oss_bucket || oss_bucket.trim() === '') { throw new Error('OSS 存储桶名称不能为空'); } } /** * 根据服务商构建 S3 配置 */ buildConfig() { // 先验证配置 this.validateConfig(); const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = this.user; // AWS S3 默认配置 let config = { region: oss_region || 'us-east-1', credentials: { accessKeyId: oss_access_key_id, secretAccessKey: oss_access_key_secret }, // 设置超时时间 requestHandler: { requestTimeout: 30000, // 30秒 httpsAgent: undefined // 可后续添加 keep-alive agent } }; // 阿里云 OSS if (oss_provider === 'aliyun') { config.region = oss_region || 'oss-cn-hangzhou'; if (!oss_endpoint) { // 默认 endpoint 格式:https://oss-{region}.aliyuncs.com config.endpoint = `https://oss-${config.region.replace('oss-', '')}.aliyuncs.com`; } else { config.endpoint = oss_endpoint; } } // 腾讯云 COS else if (oss_provider === 'tencent') { config.region = oss_region || 'ap-guangzhou'; if (!oss_endpoint) { // 默认 endpoint 格式:https://cos.{region}.myqcloud.com config.endpoint = `https://cos.${config.region}.myqcloud.com`; } else { config.endpoint = oss_endpoint; } } // AWS S3 或其他兼容服务 else { if (oss_endpoint) { config.endpoint = oss_endpoint; } // AWS 使用默认 endpoint,无需额外配置 } return config; } /** * 连接 OSS 服务(初始化 S3 客户端) */ async connect() { try { const config = this.buildConfig(); this.s3Client = new S3Client(config); console.log(`[OSS存储] 已连接: ${this.user.oss_provider}, bucket: ${this.user.oss_bucket}`); return this; } catch (error) { console.error(`[OSS存储] 连接失败:`, error.message); throw new Error(`OSS 连接失败: ${error.message}`); } } /** * 获取对象的完整 Key(带用户前缀) */ getObjectKey(relativePath) { // 规范化路径 let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, ''); // 移除开头的斜杠 if (normalized.startsWith('/') || normalized.startsWith('\\')) { normalized = normalized.substring(1); } // 空路径表示根目录 if (normalized === '' || normalized === '.') { normalized = ''; } // 拼接用户前缀 return normalized ? this.prefix + normalized : this.prefix; } /** * 列出目录内容 */ async list(dirPath) { try { const prefix = this.getObjectKey(dirPath); const bucket = this.user.oss_bucket; const command = new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: '/', // 使用分隔符模拟目录结构 MaxKeys: 1000 }); const response = await this.s3Client.send(command); const items = []; // 处理"子目录"(CommonPrefixes) if (response.CommonPrefixes) { for (const prefixObj of response.CommonPrefixes) { const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, ''); if (dirName) { items.push({ name: dirName, type: 'd', size: 0, modifyTime: Date.now() }); } } } // 处理文件(Contents) if (response.Contents) { for (const obj of response.Contents) { const key = obj.Key; // 跳过目录标记本身 if (key === prefix || key.endsWith('/')) { continue; } const fileName = key.substring(prefix.length); if (fileName) { items.push({ name: fileName, type: '-', size: obj.Size || 0, modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now() }); } } } return items; } catch (error) { console.error(`[OSS存储] 列出目录失败: ${dirPath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } else if (error.name === 'InvalidAccessKeyId') { throw new Error('OSS Access Key 无效,请重新配置'); } throw new Error(`列出目录失败: ${error.message}`); } } /** * 上传文件(直接上传,简单高效) * @param {string} localPath - 本地文件路径 * @param {string} remotePath - 远程文件路径 */ async put(localPath, remotePath) { try { const key = this.getObjectKey(remotePath); const bucket = this.user.oss_bucket; const fileSize = fs.statSync(localPath).size; // 创建文件读取流 const fileStream = fs.createReadStream(localPath); // 直接上传(AWS S3 支持最大 5GB 的单文件上传) const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: fileStream }); await this.s3Client.send(command); console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`); // 关闭流 if (!fileStream.destroyed) { fileStream.destroy(); } } catch (error) { console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } else if (error.name === 'EntityTooLarge') { throw new Error('文件过大,超过了 OSS 允许的最大大小'); } throw new Error(`文件上传失败: ${error.message}`); } } /** * 删除文件或文件夹 */ async delete(filePath) { try { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; // 检查是文件还是目录(忽略不存在的文件) let statResult; try { statResult = await this.stat(filePath); } catch (statError) { if (statError.message && statError.message.includes('不存在')) { console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`); return; // 文件不存在,直接返回 } throw statError; // 其他错误继续抛出 } if (statResult.isDirectory) { // 删除目录:列出所有对象并批量删除 const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: key }); const listResponse = await this.s3Client.send(listCommand); if (listResponse.Contents && listResponse.Contents.length > 0) { // 分批删除(AWS S3 单次最多删除 1000 个对象) const deleteCommand = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })), Quiet: false } }); const deleteResult = await this.s3Client.send(deleteCommand); // 检查删除结果 if (deleteResult.Errors && deleteResult.Errors.length > 0) { console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors); } console.log(`[OSS存储] 删除目录: ${key} (${listResponse.Contents.length} 个对象)`); } } else { // 删除单个文件 const command = new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: [{ Key: key }], Quiet: false } }); await this.s3Client.send(command); console.log(`[OSS存储] 删除文件: ${key}`); } } catch (error) { console.error(`[OSS存储] 删除失败: ${filePath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } throw new Error(`删除文件失败: ${error.message}`); } } /** * 重命名文件(OSS 不支持直接重命名,需要复制后删除) */ async rename(oldPath, newPath) { try { const oldKey = this.getObjectKey(oldPath); const newKey = this.getObjectKey(newPath); const bucket = this.user.oss_bucket; // 先复制 const copyCommand = new PutObjectCommand({ Bucket: bucket, Key: newKey, CopySource: `${bucket}/${oldKey}` }); await this.s3Client.send(copyCommand); // 再删除原文件 await this.delete(oldPath); console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`); } catch (error) { console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message); // 判断错误类型并给出友好的错误信息 if (error.name === 'NoSuchBucket') { throw new Error('OSS 存储桶不存在,请检查配置'); } else if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } throw new Error(`重命名文件失败: ${error.message}`); } } /** * 获取文件信息 */ async stat(filePath) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; try { const command = new HeadObjectCommand({ Bucket: bucket, Key: key }); const response = await this.s3Client.send(command); return { size: response.ContentLength || 0, modifyTime: response.LastModified ? response.LastModified.getTime() : Date.now(), isDirectory: false }; } catch (error) { if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { // 可能是目录,尝试列出前缀 const listCommand = new ListObjectsV2Command({ Bucket: bucket, Prefix: key.endsWith('/') ? key : key + '/', MaxKeys: 1 }); try { const listResponse = await this.s3Client.send(listCommand); if (listResponse.Contents && listResponse.Contents.length > 0) { return { isDirectory: true, size: 0, modifyTime: Date.now() }; } } catch (listError) { // 忽略列表错误 } } throw new Error(`对象不存在: ${key}`); } } /** * 创建文件读取流(异步方法) * @returns {Promise} 返回可读流 Promise */ async createReadStream(filePath) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; const command = new GetObjectCommand({ Bucket: bucket, Key: key }); try { const response = await this.s3Client.send(command); // AWS SDK v3 返回的 Body 是一个 IncomingMessage 类型的流 return response.Body; } catch (error) { console.error(`[OSS存储] 创建读取流失败: ${key}`, error.message); throw error; } } /** * 创建文件夹(通过创建空对象模拟) * OSS 中文件夹实际上是一个以斜杠结尾的空对象 */ async mkdir(dirPath) { try { const key = this.getObjectKey(dirPath); const bucket = this.user.oss_bucket; // OSS 中文件夹通过以斜杠结尾的空对象模拟 const folderKey = key.endsWith('/') ? key : `${key}/`; const command = new PutObjectCommand({ Bucket: bucket, Key: folderKey, Body: '', // 空内容 ContentType: 'application/x-directory' }); await this.s3Client.send(command); console.log(`[OSS存储] 创建文件夹: ${folderKey}`); } catch (error) { console.error(`[OSS存储] 创建文件夹失败: ${dirPath}`, error.message); if (error.name === 'AccessDenied') { throw new Error('OSS 访问被拒绝,请检查权限配置'); } throw new Error(`创建文件夹失败: ${error.message}`); } } /** * 获取下载 URL(用于分享链接) */ getSignedUrl(filePath, expiresIn = 3600) { const key = this.getObjectKey(filePath); const bucket = this.user.oss_bucket; // 简单的公开 URL 拼接(如果 bucket 是公共读) const endpoint = this.s3Client.config.endpoint; const region = this.s3Client.config.region; let baseUrl; if (endpoint) { baseUrl = endpoint.href || endpoint.toString(); } else if (this.user.oss_provider === 'aliyun') { baseUrl = `https://${bucket}.${this.user.oss_region || 'oss-cn-hangzhou'}.aliyuncs.com`; } else if (this.user.oss_provider === 'tencent') { baseUrl = `https://${bucket}.cos.${this.user.oss_region || 'ap-guangzhou'}.myqcloud.com`; } else { baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`; } return `${baseUrl}/${key}`; } /** * 格式化文件大小 */ formatSize(bytes) { return formatFileSize(bytes); } /** * 关闭连接(S3Client 无需显式关闭) */ async end() { this.s3Client = null; } } module.exports = { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize // 导出共享的工具函数 };