Files
vue-driven-cloud-storage/backend/storage.js
yuyx ab7e08a21b fix: 全面修复和优化 OSS 功能
## 安全修复
- 修复 /api/user/profile 接口泄露 OSS 密钥的安全漏洞
- 增强 getObjectKey 路径安全检查(空字节注入、URL 编码绕过)
- 修复 storage.end() 重复调用问题
- 增强上传签名接口的安全检查

## Bug 修复
- 修复 rename 使用错误的 PutObjectCommand,改为 CopyObjectCommand
- 修复 CopySource 编码问题,正确处理特殊字符
- 修复签名 URL 生成功能(添加 @aws-sdk/s3-request-presigner)
- 修复 S3Client 配置(阿里云 region 格式、endpoint 处理)
- 修复分页删除和列表功能(超过 1000 文件的处理)
- 修复分享下载使用错误的存储类型字段
- 修复前端媒体预览异步处理错误
- 修复 OSS 直传 objectKey 格式不一致问题
- 修复包名错误 @aws-sdk/request-presigner -> @aws-sdk/s3-request-presigner
- 修复前端下载错误处理不完善

## 新增功能
- 添加 OSS 连接测试 API (/api/user/test-oss)
- 添加重命名失败回滚机制
- 添加 OSS 配置前端验证

## 其他改进
- 更新 install.sh 仓库地址为 git.workyai.cn
- 添加 crypto 模块导入
- 修复代码格式和重复定义问题
- 添加缺失的表单对象定义

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

1047 lines
32 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];
}
// ===== 统一存储接口 =====
/**
* 存储接口工厂
* 根据用户的存储类型返回对应的存储客户端
*/
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
}
};
// 阿里云 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 不支持直接重命名,需要复制后删除)
* 注意:此方法只支持单个文件的重命名,不支持目录
*/
async rename(oldPath, newPath) {
const oldKey = this.getObjectKey(oldPath);
const newKey = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
let copySuccess = false;
// 验证源和目标不同
if (oldKey === newKey) {
console.log(`[OSS存储] 源路径和目标路径相同,跳过: ${oldKey}`);
return;
}
try {
// 检查源文件是否存在
const statResult = await this.stat(oldPath);
if (statResult.isDirectory) {
throw new Error('不支持重命名目录,请使用移动操作');
}
// 使用 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}`);
}
}
/**
* 获取文件信息
*/
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}`);
}
}
/**
* 格式化文件大小
*/
formatSize(bytes) {
return formatFileSize(bytes);
}
/**
* 关闭连接S3Client 无需显式关闭)
*/
async end() {
this.s3Client = null;
}
}
module.exports = {
StorageInterface,
LocalStorageClient,
OssStorageClient,
formatFileSize // 导出共享的工具函数
};