Files
vue-driven-cloud-storage/backend/storage.js
yuyx efaa2308eb feat: 全面优化代码质量至 8.55/10 分
## 安全增强
- 添加 CSRF 防护机制(Double Submit Cookie 模式)
- 增强密码强度验证(8字符+两种字符类型)
- 添加 Session 密钥安全检查
- 修复 .htaccess 文件上传漏洞
- 统一使用 getSafeErrorMessage() 保护敏感错误信息
- 增强数据库原型污染防护
- 添加被封禁用户分享访问检查

## 功能修复
- 修复模态框点击外部关闭功能
- 修复 share.html 未定义方法调用
- 修复 verify.html 和 reset-password.html API 路径
- 修复数据库 SFTP->OSS 迁移逻辑
- 修复 OSS 未配置时的错误提示
- 添加文件夹名称长度限制
- 添加文件列表 API 路径验证

## UI/UX 改进
- 添加 6 个按钮加载状态(登录/注册/修改密码等)
- 将 15+ 处 alert() 替换为 Toast 通知
- 添加防重复提交机制(创建文件夹/分享)
- 优化 loadUserProfile 防抖调用

## 代码质量
- 消除 formatFileSize 重复定义
- 集中模块导入到文件顶部
- 添加 JSDoc 注释
- 创建路由拆分示例 (routes/)

## 测试套件
- 添加 boundary-tests.js (60 用例)
- 添加 network-concurrent-tests.js (33 用例)
- 添加 state-consistency-tests.js (38 用例)
- 添加 test_share.js 和 test_admin.js

## 文档和配置
- 新增 INSTALL_GUIDE.md 手动部署指南
- 新增 VERSION.txt 版本历史
- 完善 .env.example 配置说明
- 新增 docker-compose.yml
- 完善 nginx.conf.example

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:45:51 +08:00

1355 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const path = require('path');
const { Readable } = require('stream');
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];
}
/**
* 将 OSS/网络错误转换为友好的错误信息
* @param {Error} error - 原始错误
* @param {string} operation - 操作描述
* @returns {Error} 带有友好消息的错误
*/
function formatOssError(error, operation = '操作') {
// 常见的 AWS S3 / OSS 错误
const errorMessages = {
'NoSuchBucket': 'OSS 存储桶不存在,请检查配置',
'AccessDenied': 'OSS 访问被拒绝,请检查权限配置',
'InvalidAccessKeyId': 'OSS Access Key 无效,请重新配置',
'SignatureDoesNotMatch': 'OSS 签名验证失败,请检查 Secret Key',
'NoSuchKey': '文件或目录不存在',
'EntityTooLarge': '文件过大,超过了 OSS 允许的最大大小',
'RequestTimeout': 'OSS 请求超时,请稍后重试',
'SlowDown': 'OSS 请求过于频繁,请稍后重试',
'ServiceUnavailable': 'OSS 服务暂时不可用,请稍后重试',
'InternalError': 'OSS 内部错误,请稍后重试',
'BucketNotEmpty': '存储桶不为空',
'InvalidBucketName': '无效的存储桶名称',
'InvalidObjectName': '无效的对象名称',
'TooManyBuckets': '存储桶数量超过限制'
};
// 网络错误
const networkErrors = {
'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络',
'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置',
'ETIMEDOUT': '连接 OSS 服务超时,请检查网络',
'ECONNRESET': '与 OSS 服务的连接被重置,请重试',
'EPIPE': '与 OSS 服务的连接中断,请重试',
'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络'
};
// 检查 AWS SDK 错误名称
if (error.name && errorMessages[error.name]) {
return new Error(`${operation}失败: ${errorMessages[error.name]}`);
}
// 检查网络错误代码
if (error.code && networkErrors[error.code]) {
return new Error(`${operation}失败: ${networkErrors[error.code]}`);
}
// HTTP 状态码错误
if (error.$metadata?.httpStatusCode) {
const statusCode = error.$metadata.httpStatusCode;
const statusMessages = {
400: '请求参数错误',
401: '认证失败,请检查 Access Key',
403: '没有权限执行此操作',
404: '资源不存在',
409: '资源冲突',
429: '请求过于频繁,请稍后重试',
500: 'OSS 服务内部错误',
502: 'OSS 网关错误',
503: 'OSS 服务暂时不可用'
};
if (statusMessages[statusCode]) {
return new Error(`${operation}失败: ${statusMessages[statusCode]}`);
}
}
// 返回原始错误信息
return new Error(`${operation}失败: ${error.message}`);
}
// ===== 统一存储接口 =====
/**
* 存储接口工厂
* 根据用户的存储类型返回对应的存储客户端
*/
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 {
// 在尝试连接 OSS 之前,先检查用户是否已配置 OSS
if (!this.user.has_oss_config) {
throw new Error('OSS 存储未配置,请先在设置中配置您的 OSS 服务(阿里云/腾讯云/AWS');
}
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}`);
}
}
/**
* 列出目录内容
* @param {string} dirPath - 目录路径
* @returns {Promise<Array>} 文件列表
*/
async list(dirPath) {
const fullPath = this.getFullPath(dirPath);
// 确保目录存在
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
return [];
}
// 检查是否是目录
const pathStats = fs.statSync(fullPath);
if (!pathStats.isDirectory()) {
throw new Error('指定路径不是目录');
}
const items = fs.readdirSync(fullPath, { withFileTypes: true });
const result = [];
for (const item of items) {
try {
const itemPath = path.join(fullPath, item.name);
const stats = fs.statSync(itemPath);
result.push({
name: item.name,
type: item.isDirectory() ? 'd' : '-',
size: stats.size,
modifyTime: stats.mtimeMs
});
} catch (error) {
// 跳过无法访问的文件(权限问题或符号链接断裂等)
console.warn(`[本地存储] 无法获取文件信息,跳过: ${item.name}`, error.message);
}
}
return result;
}
/**
* 上传文件
*/
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);
// 检查文件是否存在
if (!fs.existsSync(fullPath)) {
console.warn(`[本地存储] 删除目标不存在,跳过: ${filePath}`);
return; // 文件不存在,直接返回(幂等操作)
}
let stats;
try {
stats = fs.statSync(fullPath);
} catch (error) {
if (error.code === 'ENOENT') {
// 文件在检查后被删除,直接返回
return;
}
throw error;
}
if (stats.isDirectory()) {
// 删除文件夹 - 递归删除
// 先计算文件夹内所有文件的总大小
const folderSize = this.calculateFolderSize(fullPath);
// 删除文件夹及其内容
fs.rmSync(fullPath, { recursive: true, force: true });
// 更新已使用空间
if (folderSize > 0) {
this.updateUsedSpace(-folderSize);
}
console.log(`[本地存储] 删除文件夹: ${filePath} (释放 ${this.formatSize(folderSize)})`);
} else {
const fileSize = stats.size;
// 删除文件
fs.unlinkSync(fullPath);
// 更新已使用空间
this.updateUsedSpace(-fileSize);
console.log(`[本地存储] 删除文件: ${filePath} (释放 ${this.formatSize(fileSize)})`);
}
}
/**
* 计算文件夹大小
*/
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;
}
/**
* 重命名文件或目录
* @param {string} oldPath - 原路径
* @param {string} newPath - 新路径
*/
async rename(oldPath, newPath) {
const oldFullPath = this.getFullPath(oldPath);
const newFullPath = this.getFullPath(newPath);
// 检查源和目标是否相同
if (oldFullPath === newFullPath) {
console.log(`[本地存储] 源路径和目标路径相同,跳过: ${oldPath}`);
return;
}
// 检查源文件是否存在
if (!fs.existsSync(oldFullPath)) {
throw new Error('源文件或目录不存在');
}
// 检查目标是否已存在(防止覆盖)
if (fs.existsSync(newFullPath)) {
throw new Error('目标位置已存在同名文件或目录');
}
// 确保新路径的目录存在
const newDir = path.dirname(newFullPath);
if (!fs.existsSync(newDir)) {
fs.mkdirSync(newDir, { recursive: true });
}
fs.renameSync(oldFullPath, newFullPath);
console.log(`[本地存储] 重命名: ${oldPath} -> ${newPath}`);
}
/**
* 获取文件信息
* @param {string} filePath - 文件路径
* @returns {Promise<Object>} 文件状态信息,包含 isDirectory 属性
*/
async stat(filePath) {
const fullPath = this.getFullPath(filePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`文件或目录不存在: ${filePath}`);
}
const stats = fs.statSync(fullPath);
// 返回与 OssStorageClient.stat 一致的格式
return {
size: stats.size,
modifyTime: stats.mtimeMs,
isDirectory: stats.isDirectory(),
// 保留原始 stats 对象的方法兼容性
isFile: () => stats.isFile(),
_raw: stats
};
}
/**
* 创建文件读取流
* @param {string} filePath - 文件路径
* @returns {ReadStream} 文件读取流
*/
createReadStream(filePath) {
const fullPath = this.getFullPath(filePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`文件不存在: ${filePath}`);
}
return fs.createReadStream(fullPath);
}
/**
* 创建文件夹
* @param {string} dirPath - 目录路径
*/
async mkdir(dirPath) {
const fullPath = this.getFullPath(dirPath);
// 检查是否已存在
if (fs.existsSync(fullPath)) {
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
// 目录已存在,直接返回
return;
}
throw new Error('同名文件已存在');
}
// 创建目录
fs.mkdirSync(fullPath, { recursive: true, mode: 0o755 });
console.log(`[本地存储] 创建文件夹: ${dirPath}`);
}
/**
* 检查文件或目录是否存在
* @param {string} filePath - 文件路径
* @returns {Promise<boolean>}
*/
async exists(filePath) {
try {
const fullPath = this.getFullPath(filePath);
return fs.existsSync(fullPath);
} catch (error) {
return false;
}
}
/**
* 关闭连接(本地存储无需关闭)
*/
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: { timeout: 30000 }
},
// 重试配置
maxAttempts: 3
};
// 阿里云 OSS
if (oss_provider === 'aliyun') {
// 规范化 region确保格式为 oss-cn-xxx
let region = oss_region || 'oss-cn-hangzhou';
if (!region.startsWith('oss-')) {
region = 'oss-' + region;
}
config.region = region;
if (!oss_endpoint) {
// 默认 endpoint 格式https://{region}.aliyuncs.com
config.endpoint = `https://${region}.aliyuncs.com`;
} else {
// 确保 endpoint 以 https:// 或 http:// 开头
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
}
// 阿里云 OSS 使用 virtual-hosted-style但需要设置 forcePathStyle 为 false
config.forcePathStyle = false;
}
// 腾讯云 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.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
}
// 腾讯云 COS 使用 virtual-hosted-style
config.forcePathStyle = false;
}
// AWS S3 或其他兼容服务
else {
if (oss_endpoint) {
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
// 自定义 endpoint如 MinIO通常需要 path-style
config.forcePathStyle = true;
}
// 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) {
// 0. 输入类型验证
if (relativePath === null || relativePath === undefined) {
return this.prefix; // null/undefined 返回根目录
}
if (typeof relativePath !== 'string') {
throw new Error('无效的路径类型');
}
// 1. 检查空字节注入(%00, \x00和其他危险字符
if (relativePath.includes('\x00') || relativePath.includes('%00')) {
console.warn('[OSS安全] 检测到空字节注入尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 2. 先进行 URL 解码(防止双重编码绕过)
let decoded = relativePath;
try {
decoded = decodeURIComponent(relativePath);
} catch (e) {
// 解码失败使用原始值
}
// 3. 检查解码后的空字节
if (decoded.includes('\x00')) {
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
throw new Error('路径包含非法字符');
}
// 4. 规范化路径统一使用正斜杠OSS 使用正斜杠作为分隔符)
let normalized = decoded
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
.replace(/\/+/g, '/'); // 合并多个连续斜杠
// 5. 严格检查:路径中不允许包含 ..(防止目录遍历)
// 检查各种变体:../, /../, /..
if (normalized.includes('..')) {
console.warn('[OSS安全] 检测到目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 6. 移除开头的斜杠
normalized = normalized.replace(/^\/+/, '');
// 7. 移除结尾的斜杠(除非是根目录)
if (normalized.length > 0 && normalized !== '/') {
normalized = normalized.replace(/\/+$/, '');
}
// 8. 空路径或 . 表示根目录
if (normalized === '' || normalized === '.') {
return this.prefix;
}
// 9. 拼接用户前缀(确保不会产生双斜杠)
const objectKey = this.prefix + normalized;
// 10. 最终验证:确保生成的 key 以用户前缀开头(双重保险)
if (!objectKey.startsWith(this.prefix)) {
console.warn('[OSS安全] Key 前缀验证失败:', { input: relativePath, key: objectKey, prefix: this.prefix });
throw new Error('非法路径访问');
}
return objectKey;
}
/**
* 列出目录内容
* 支持分页,可列出超过 1000 个文件的目录
* @param {string} dirPath - 目录路径
* @param {number} maxItems - 最大返回数量,默认 10000设为 0 表示不限制
*/
async list(dirPath, maxItems = 10000) {
try {
let prefix = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
// 确保前缀以斜杠结尾(除非是根目录)
if (prefix && !prefix.endsWith('/')) {
prefix = prefix + '/';
}
const items = [];
const dirSet = new Set(); // 用于去重目录
let continuationToken = undefined;
const MAX_KEYS_PER_REQUEST = 1000;
do {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
Delimiter: '/', // 使用分隔符模拟目录结构
MaxKeys: MAX_KEYS_PER_REQUEST,
ContinuationToken: continuationToken
});
const response = await this.s3Client.send(command);
continuationToken = response.NextContinuationToken;
// 处理"子目录"CommonPrefixes
if (response.CommonPrefixes) {
for (const prefixObj of response.CommonPrefixes) {
const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, '');
if (dirName && !dirSet.has(dirName)) {
dirSet.add(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 && !fileName.includes('/')) {
items.push({
name: fileName,
type: '-',
size: obj.Size || 0,
modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now()
});
}
}
}
// 检查是否达到最大数量限制
if (maxItems > 0 && items.length >= maxItems) {
console.log(`[OSS存储] 列出目录达到限制: ${dirPath} (${items.length}/${maxItems})`);
break;
}
} while (continuationToken);
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) {
let fileStream = null;
try {
const key = this.getObjectKey(remotePath);
const bucket = this.user.oss_bucket;
// 检查本地文件是否存在
if (!fs.existsSync(localPath)) {
throw new Error(`本地文件不存在: ${localPath}`);
}
const fileStats = fs.statSync(localPath);
const fileSize = fileStats.size;
// 检查文件大小AWS S3 单次上传最大 5GB
const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB
if (fileSize > MAX_SINGLE_UPLOAD_SIZE) {
throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB请使用分片上传`);
}
// 创建文件读取流
fileStream = fs.createReadStream(localPath);
// 处理流错误
fileStream.on('error', (err) => {
console.error(`[OSS存储] 文件流读取错误: ${localPath}`, err.message);
});
// 直接上传
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileStream,
ContentLength: fileSize // 明确指定内容长度,避免某些服务端问题
});
await this.s3Client.send(command);
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
} 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 允许的最大大小');
} else if (error.code === 'ENOENT') {
throw new Error(`本地文件不存在: ${localPath}`);
}
throw new Error(`文件上传失败: ${error.message}`);
} finally {
// 确保流被正确关闭(无论成功还是失败)
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
}
}
/**
* 删除文件或文件夹
*/
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) {
// 删除目录:列出所有对象并批量删除
// 使用分页循环处理超过 1000 个对象的情况
let continuationToken = null;
let totalDeletedCount = 0;
const MAX_DELETE_BATCH = 1000; // AWS S3 单次最多删除 1000 个对象
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key,
MaxKeys: MAX_DELETE_BATCH,
ContinuationToken: continuationToken
});
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 批量删除当前批次的对象
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);
}
totalDeletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象)`);
}
} 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 不支持直接重命名,需要复制后删除)
* 支持文件和目录的重命名
* @param {string} oldPath - 原路径
* @param {string} newPath - 新路径
*/
async rename(oldPath, newPath) {
const oldKey = this.getObjectKey(oldPath);
const newKey = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
// 验证源和目标不同
if (oldKey === newKey) {
console.log(`[OSS存储] 源路径和目标路径相同,跳过: ${oldKey}`);
return;
}
let copySuccess = false;
try {
// 检查源文件是否存在
const statResult = await this.stat(oldPath);
// 如果是目录,执行目录重命名
if (statResult.isDirectory) {
await this._renameDirectory(oldPath, newPath);
return;
}
// 使用 CopyObjectCommand 复制文件
// CopySource 格式bucket/key需要对 key 中的特殊字符进行编码
// 但保留路径分隔符(/)不编码
const encodedOldKey = oldKey.split('/').map(segment => encodeURIComponent(segment)).join('/');
const copySource = `${bucket}/${encodedOldKey}`;
const copyCommand = new CopyObjectCommand({
Bucket: bucket,
CopySource: copySource,
Key: newKey
});
await this.s3Client.send(copyCommand);
copySuccess = true;
// 复制成功后删除原文件
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: oldKey }],
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`);
} catch (error) {
console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message);
// 如果复制成功但删除失败,尝试回滚(删除新复制的文件)
if (copySuccess) {
try {
console.log(`[OSS存储] 尝试回滚:删除已复制的文件 ${newKey}`);
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: newKey }],
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
console.log(`[OSS存储] 回滚成功:已删除 ${newKey}`);
} catch (rollbackError) {
console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`);
}
}
// 判断错误类型并给出友好的错误信息
if (error.name === 'NoSuchBucket') {
throw new Error('OSS 存储桶不存在,请检查配置');
} else if (error.name === 'AccessDenied') {
throw new Error('OSS 访问被拒绝,请检查权限配置');
} else if (error.name === 'NoSuchKey') {
throw new Error('源文件不存在');
}
throw new Error(`重命名文件失败: ${error.message}`);
}
}
/**
* 重命名目录(内部方法)
* 通过遍历目录下所有对象,逐个复制到新位置后删除原对象
* @param {string} oldPath - 原目录路径
* @param {string} newPath - 新目录路径
* @private
*/
async _renameDirectory(oldPath, newPath) {
const oldPrefix = this.getObjectKey(oldPath);
const newPrefix = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
// 确保前缀以斜杠结尾
const oldPrefixWithSlash = oldPrefix.endsWith('/') ? oldPrefix : `${oldPrefix}/`;
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
let continuationToken = null;
let copiedKeys = [];
let totalCount = 0;
try {
// 第一阶段:复制所有对象到新位置
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: oldPrefixWithSlash,
ContinuationToken: continuationToken
});
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
for (const obj of listResponse.Contents) {
// 计算新的 key替换前缀
const newKey = newPrefixWithSlash + obj.Key.substring(oldPrefixWithSlash.length);
// 复制对象
const encodedOldKey = obj.Key.split('/').map(segment => encodeURIComponent(segment)).join('/');
const copyCommand = new CopyObjectCommand({
Bucket: bucket,
CopySource: `${bucket}/${encodedOldKey}`,
Key: newKey
});
await this.s3Client.send(copyCommand);
copiedKeys.push({ oldKey: obj.Key, newKey });
totalCount++;
}
}
} while (continuationToken);
// 第二阶段:删除所有原对象
if (copiedKeys.length > 0) {
// 批量删除(每批最多 1000 个)
for (let i = 0; i < copiedKeys.length; i += 1000) {
const batch = copiedKeys.slice(i, i + 1000);
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: batch.map(item => ({ Key: item.oldKey })),
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
}
}
console.log(`[OSS存储] 重命名目录: ${oldPath} -> ${newPath} (${totalCount} 个对象)`);
} catch (error) {
// 如果出错,尝试回滚(删除已复制的新对象)
if (copiedKeys.length > 0) {
console.warn(`[OSS存储] 目录重命名失败,尝试回滚已复制的 ${copiedKeys.length} 个对象...`);
try {
for (let i = 0; i < copiedKeys.length; i += 1000) {
const batch = copiedKeys.slice(i, i + 1000);
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: batch.map(item => ({ Key: item.newKey })),
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
}
console.log(`[OSS存储] 回滚成功`);
} catch (rollbackError) {
console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`);
}
}
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<Readable>} 返回可读流 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用于分享链接支持私有 bucket
* @param {string} filePath - 文件路径
* @param {number} expiresIn - 过期时间(秒),默认 3600 秒1小时
* @returns {Promise<string>} 签名 URL
*/
async getPresignedUrl(filePath, expiresIn = 3600) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
try {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
// 使用 AWS SDK 的 getSignedUrl 生成真正的签名 URL
const signedUrl = await getSignedUrl(this.s3Client, command, {
expiresIn: Math.min(expiresIn, 604800) // 最大 7 天
});
return signedUrl;
} catch (error) {
console.error(`[OSS存储] 生成签名 URL 失败: ${filePath}`, error.message);
throw new Error(`生成签名 URL 失败: ${error.message}`);
}
}
/**
* 获取公开 URL仅适用于公共读的 bucket
* @deprecated 建议使用 getPresignedUrl 代替
*/
getPublicUrl(filePath) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const region = this.s3Client.config.region;
let baseUrl;
if (this.user.oss_provider === 'aliyun') {
// 阿里云 OSS 公开 URL 格式
const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`;
baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`;
} else if (this.user.oss_provider === 'tencent') {
// 腾讯云 COS 公开 URL 格式
baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`;
} else {
// AWS S3 公开 URL 格式
baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`;
}
// 对 key 中的特殊字符进行 URL 编码,但保留路径分隔符
const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/');
return `${baseUrl}/${encodedKey}`;
}
/**
* 获取上传签名 URL用于前端直传
* @param {string} filePath - 文件路径
* @param {number} expiresIn - 过期时间(秒),默认 900 秒15分钟
* @param {string} contentType - 文件 MIME 类型
* @returns {Promise<string>} 签名 URL
*/
async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
try {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType
});
const signedUrl = await getSignedUrl(this.s3Client, command, {
expiresIn: Math.min(expiresIn, 3600) // 上传 URL 最大 1 小时
});
return signedUrl;
} catch (error) {
console.error(`[OSS存储] 生成上传签名 URL 失败: ${filePath}`, error.message);
throw new Error(`生成上传签名 URL 失败: ${error.message}`);
}
}
/**
* 检查文件或目录是否存在
* @param {string} filePath - 文件路径
* @returns {Promise<boolean>}
*/
async exists(filePath) {
try {
await this.stat(filePath);
return true;
} catch (error) {
return false;
}
}
/**
* 格式化文件大小
*/
formatSize(bytes) {
return formatFileSize(bytes);
}
/**
* 关闭连接S3Client 无需显式关闭)
*/
async end() {
this.s3Client = null;
}
}
module.exports = {
StorageInterface,
LocalStorageClient,
OssStorageClient,
formatFileSize, // 导出共享的工具函数
formatOssError // 导出 OSS 错误格式化函数
};