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>
This commit is contained in:
2026-01-20 09:46:00 +08:00
parent e8d053f28d
commit ab7e08a21b
6 changed files with 729 additions and 238 deletions

View File

@@ -1,6 +1,8 @@
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
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');
// ===== 工具函数 =====
@@ -379,23 +381,27 @@ class OssStorageClient {
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;
// 规范化 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') {
@@ -404,13 +410,17 @@ class OssStorageClient {
// 默认 endpoint 格式https://cos.{region}.myqcloud.com
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
} else {
config.endpoint = oss_endpoint;
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;
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
// 自定义 endpoint如 MinIO通常需要 path-style
config.forcePathStyle = true;
}
// AWS 使用默认 endpoint无需额外配置
}
@@ -435,77 +445,152 @@ class OssStorageClient {
/**
* 获取对象的完整 Key带用户前缀
* 增强安全检查,防止路径遍历攻击
*/
getObjectKey(relativePath) {
// 规范化路径
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
// 移除开头的斜杠
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
normalized = normalized.substring(1);
// 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 === '.') {
normalized = '';
return this.prefix;
}
// 拼接用户前缀
return normalized ? this.prefix + normalized : 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) {
async list(dirPath, maxItems = 10000) {
try {
const prefix = this.getObjectKey(dirPath);
let prefix = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
Delimiter: '/', // 使用分隔符模拟目录结构
MaxKeys: 1000
});
// 确保前缀以斜杠结尾(除非是根目录)
if (prefix && !prefix.endsWith('/')) {
prefix = prefix + '/';
}
const response = await this.s3Client.send(command);
const items = [];
const dirSet = new Set(); // 用于去重目录
let continuationToken = undefined;
const MAX_KEYS_PER_REQUEST = 1000;
// 处理"子目录"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()
});
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) {
items.push({
name: fileName,
type: '-',
size: obj.Size || 0,
modifyTime: obj.LastModified ? obj.LastModified.getTime() : 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) {
@@ -529,28 +614,45 @@ class OssStorageClient {
* @param {string} remotePath - 远程文件路径
*/
async put(localPath, remotePath) {
let fileStream = null;
try {
const key = this.getObjectKey(remotePath);
const bucket = this.user.oss_bucket;
const fileSize = fs.statSync(localPath).size;
// 检查本地文件是否存在
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请使用分片上传`);
}
// 创建文件读取流
const fileStream = fs.createReadStream(localPath);
fileStream = fs.createReadStream(localPath);
// 直接上传AWS S3 支持最大 5GB 的单文件上传)
// 处理流错误
fileStream.on('error', (err) => {
console.error(`[OSS存储] 文件流读取错误: ${localPath}`, err.message);
});
// 直接上传
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileStream
Body: fileStream,
ContentLength: fileSize // 明确指定内容长度,避免某些服务端问题
});
await this.s3Client.send(command);
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
// 关闭流
if (!fileStream.destroyed) {
fileStream.destroy();
}
} catch (error) {
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
@@ -561,8 +663,15 @@ class OssStorageClient {
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();
}
}
}
@@ -588,31 +697,45 @@ class OssStorageClient {
if (statResult.isDirectory) {
// 删除目录:列出所有对象并批量删除
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key
});
// 使用分页循环处理超过 1000 个对象的情况
let continuationToken = null;
let totalDeletedCount = 0;
const MAX_DELETE_BATCH = 1000; // AWS S3 单次最多删除 1000 个对象
const listResponse = await this.s3Client.send(listCommand);
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 分批删除AWS S3 单次最多删除 1000 个对象)
const deleteCommand = new DeleteObjectsCommand({
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: false
}
Prefix: key,
MaxKeys: MAX_DELETE_BATCH,
ContinuationToken: continuationToken
});
const deleteResult = await this.s3Client.send(deleteCommand);
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
// 检查删除结果
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors);
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);
console.log(`[OSS存储] 删除目录: ${key} (${listResponse.Contents.length} 个对象)`);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象)`);
}
} else {
// 删除单个文件
@@ -642,34 +765,81 @@ class OssStorageClient {
/**
* 重命名文件OSS 不支持直接重命名,需要复制后删除)
* 注意:此方法只支持单个文件的重命名,不支持目录
*/
async rename(oldPath, newPath) {
try {
const oldKey = this.getObjectKey(oldPath);
const newKey = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
const oldKey = this.getObjectKey(oldPath);
const newKey = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
let copySuccess = false;
// 先复制
const copyCommand = new PutObjectCommand({
// 验证源和目标不同
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,
Key: newKey,
CopySource: `${bucket}/${oldKey}`
CopySource: copySource,
Key: newKey
});
await this.s3Client.send(copyCommand);
copySuccess = true;
// 删除原文件
await this.delete(oldPath);
// 复制成功后删除原文件
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}`);
}
@@ -770,28 +940,87 @@ class OssStorageClient {
}
/**
* 获取下载 URL用于分享链接
* 获取签名下载 URL用于分享链接,支持私有 bucket
* @param {string} filePath - 文件路径
* @param {number} expiresIn - 过期时间(秒),默认 3600 秒1小时
* @returns {Promise<string>} 签名 URL
*/
getSignedUrl(filePath, expiresIn = 3600) {
async getPresignedUrl(filePath, expiresIn = 3600) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
// 简单的公开 URL 拼接(如果 bucket 是公共读)
const endpoint = this.s3Client.config.endpoint;
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 (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`;
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') {
baseUrl = `https://${bucket}.cos.${this.user.oss_region || 'ap-guangzhou'}.myqcloud.com`;
// 腾讯云 COS 公开 URL 格式
baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`;
} else {
// AWS S3 公开 URL 格式
baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`;
}
return `${baseUrl}/${key}`;
// 对 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}`);
}
}
/**