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:
@@ -12,6 +12,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const archiver = require('archiver');
|
||||
const crypto = require('crypto');
|
||||
const { exec, execSync, execFile } = require('child_process');
|
||||
const util = require('util');
|
||||
const execAsync = util.promisify(exec);
|
||||
@@ -1742,8 +1743,8 @@ app.post('/api/logout', (req, res) => {
|
||||
|
||||
// 获取当前用户信息
|
||||
app.get('/api/user/profile', authMiddleware, (req, res) => {
|
||||
// 不返回密码明文
|
||||
const { password, ...safeUser } = req.user;
|
||||
// 不返回敏感信息(密码和 OSS 密钥)
|
||||
const { password, oss_access_key_secret, ...safeUser } = req.user;
|
||||
res.json({
|
||||
success: true,
|
||||
user: safeUser
|
||||
@@ -1884,6 +1885,70 @@ app.post('/api/user/update-oss',
|
||||
}
|
||||
);
|
||||
|
||||
// 测试 OSS 连接(不保存配置,仅验证)
|
||||
app.post('/api/user/test-oss',
|
||||
authMiddleware,
|
||||
[
|
||||
body('oss_provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'),
|
||||
body('oss_region').notEmpty().withMessage('地域不能为空'),
|
||||
body('oss_access_key_id').notEmpty().withMessage('Access Key ID不能为空'),
|
||||
body('oss_bucket').notEmpty().withMessage('存储桶名称不能为空')
|
||||
],
|
||||
async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body;
|
||||
|
||||
// 如果密钥为空且用户已配置OSS,使用现有密钥
|
||||
let actualSecret = oss_access_key_secret;
|
||||
if (!oss_access_key_secret && req.user.has_oss_config && req.user.oss_access_key_secret) {
|
||||
actualSecret = req.user.oss_access_key_secret;
|
||||
} else if (!oss_access_key_secret) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Access Key Secret不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 OSS 连接
|
||||
const { OssStorageClient } = require('./storage');
|
||||
const testUser = {
|
||||
id: req.user.id,
|
||||
oss_provider,
|
||||
oss_region,
|
||||
oss_access_key_id,
|
||||
oss_access_key_secret: actualSecret,
|
||||
oss_bucket,
|
||||
oss_endpoint
|
||||
};
|
||||
const ossClient = new OssStorageClient(testUser);
|
||||
await ossClient.connect();
|
||||
|
||||
// 尝试列出 bucket 内容(验证配置是否正确)
|
||||
await ossClient.list('/');
|
||||
await ossClient.end();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OSS 连接测试成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[OSS测试] 连接失败:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'OSS 连接失败: ' + error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取OSS存储空间使用情况(带缓存)
|
||||
app.get('/api/user/oss-usage', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
@@ -2583,6 +2648,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
||||
// 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端)
|
||||
app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
||||
const filename = req.query.filename;
|
||||
const uploadPath = req.query.path || '/'; // 上传目标路径
|
||||
const contentType = req.query.contentType || 'application/octet-stream';
|
||||
|
||||
if (!filename) {
|
||||
@@ -2592,6 +2658,31 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 文件名安全校验
|
||||
if (!isSafePathSegment(filename)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件名包含非法字符'
|
||||
});
|
||||
}
|
||||
|
||||
// 文件扩展名安全检查(防止上传危险文件)
|
||||
if (!isFileExtensionSafe(filename)) {
|
||||
console.warn(`[安全] 拒绝上传危险文件: ${filename}, 用户: ${req.user.username}`);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '不允许上传此类型的文件(安全限制)'
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止目录遍历攻击
|
||||
if (uploadPath.includes('..') || uploadPath.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '上传路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否配置了 OSS
|
||||
if (!req.user.has_oss_config) {
|
||||
return res.status(400).json({
|
||||
@@ -2602,13 +2693,29 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
||||
|
||||
try {
|
||||
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
// 构建 S3 客户端
|
||||
const client = new S3Client(buildS3Config(req.user));
|
||||
|
||||
// 构建对象 Key
|
||||
const objectKey = `user_${req.user.id}/${Date.now()}_${sanitizeFilename(filename)}`;
|
||||
// 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致)
|
||||
// 格式:user_${id}/${path}/${filename}
|
||||
const sanitizedFilename = sanitizeFilename(filename);
|
||||
let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
// 移除开头的斜杠
|
||||
normalizedPath = normalizedPath.replace(/^\/+/, '');
|
||||
// 移除结尾的斜杠
|
||||
normalizedPath = normalizedPath.replace(/\/+$/, '');
|
||||
|
||||
// 构建完整的 objectKey
|
||||
let objectKey;
|
||||
if (normalizedPath === '' || normalizedPath === '.') {
|
||||
// 根目录上传
|
||||
objectKey = `user_${req.user.id}/${sanitizedFilename}`;
|
||||
} else {
|
||||
// 子目录上传
|
||||
objectKey = `user_${req.user.id}/${normalizedPath}/${sanitizedFilename}`;
|
||||
}
|
||||
|
||||
// 创建 PutObject 命令
|
||||
const command = new PutObjectCommand({
|
||||
@@ -2674,6 +2781,15 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止目录遍历攻击
|
||||
const normalizedPath = path.posix.normalize(filePath);
|
||||
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否配置了 OSS
|
||||
if (!req.user.has_oss_config) {
|
||||
return res.status(400).json({
|
||||
@@ -2684,13 +2800,13 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
||||
|
||||
try {
|
||||
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
// 构建 S3 客户端
|
||||
const client = new S3Client(buildS3Config(req.user));
|
||||
|
||||
// 构建对象 Key
|
||||
const objectKey = `user_${req.user.id}${filePath}`;
|
||||
// 构建对象 Key(使用安全的规范化路径)
|
||||
const objectKey = `user_${req.user.id}${normalizedPath}`;
|
||||
|
||||
// 创建 GetObject 命令
|
||||
const command = new GetObjectCommand({
|
||||
@@ -2856,6 +2972,19 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
|
||||
app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
let storage;
|
||||
let storageEnded = false; // 防止重复关闭
|
||||
|
||||
// 安全关闭存储连接的辅助函数
|
||||
const safeEndStorage = async () => {
|
||||
if (storage && !storageEnded) {
|
||||
storageEnded = true;
|
||||
try {
|
||||
await storage.end();
|
||||
} catch (err) {
|
||||
console.error('关闭存储连接失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
@@ -2864,6 +2993,15 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止目录遍历攻击
|
||||
const normalizedPath = path.posix.normalize(filePath);
|
||||
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用统一存储接口
|
||||
const { StorageInterface } = require('./storage');
|
||||
@@ -2895,31 +3033,22 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
// 发生错误时关闭存储连接
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
safeEndStorage();
|
||||
});
|
||||
|
||||
// 在传输完成后关闭存储连接
|
||||
stream.on('close', () => {
|
||||
console.log('[下载] 文件传输完成,关闭存储连接');
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
safeEndStorage();
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error);
|
||||
|
||||
|
||||
// 如果stream还未创建或发生错误,关闭storage连接
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
await safeEndStorage();
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -3410,7 +3539,9 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) =
|
||||
if (!shareOwner) {
|
||||
throw new Error('分享者不存在');
|
||||
}
|
||||
const storageType = shareOwner.current_storage_type || 'oss';
|
||||
// 使用分享创建时记录的存储类型,而非用户当前的存储类型
|
||||
// 这样即使用户切换了存储,分享链接仍然有效
|
||||
const storageType = share.storage_type || 'oss';
|
||||
|
||||
// 使用统一存储接口
|
||||
const { StorageInterface } = require('./storage');
|
||||
@@ -3722,7 +3853,7 @@ app.get('/api/share/:code/download-url', async (req, res) => {
|
||||
|
||||
// OSS 模式:生成签名 URL
|
||||
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
// 构建 S3 客户端
|
||||
const client = new S3Client(buildS3Config(shareOwner));
|
||||
@@ -3762,6 +3893,19 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
const { code } = req.params;
|
||||
const { path: filePath, password } = req.query;
|
||||
let storage;
|
||||
let storageEnded = false; // 防止重复关闭
|
||||
|
||||
// 安全关闭存储连接的辅助函数
|
||||
const safeEndStorage = async () => {
|
||||
if (storage && !storageEnded) {
|
||||
storageEnded = true;
|
||||
try {
|
||||
await storage.end();
|
||||
} catch (err) {
|
||||
console.error('关闭存储连接失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
@@ -3770,6 +3914,14 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止目录遍历攻击
|
||||
if (filePath.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径非法'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const share = ShareDB.findByCode(code);
|
||||
|
||||
@@ -3799,7 +3951,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 安全验证:检查请求路径是否在分享范围内(防止越权访问)
|
||||
// 安全验证:检查请求路径是否在分享范围内(防止越权访问)
|
||||
if (!isPathWithinShare(filePath, share)) {
|
||||
console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`);
|
||||
return res.status(403).json({
|
||||
@@ -3818,9 +3970,11 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
}
|
||||
|
||||
// 使用统一存储接口,根据分享的storage_type选择存储后端
|
||||
// 注意:必须使用分享创建时记录的 storage_type,而不是分享者当前的存储类型
|
||||
// 这样即使用户后来切换了存储类型,之前创建的分享仍然可以正常工作
|
||||
const { StorageInterface } = require('./storage');
|
||||
const storageType = shareOwner.current_storage_type || 'oss';
|
||||
console.log(`[分享下载] 存储类型: ${storageType} (分享者当前), 文件路径: ${filePath}`);
|
||||
const storageType = share.storage_type || 'oss';
|
||||
console.log(`[分享下载] 存储类型: ${storageType} (分享记录), 文件路径: ${filePath}`);
|
||||
|
||||
// 临时构造用户对象以使用存储接口
|
||||
const userForStorage = {
|
||||
@@ -3859,17 +4013,13 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
});
|
||||
}
|
||||
// 发生错误时关闭存储连接
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
safeEndStorage();
|
||||
});
|
||||
|
||||
// 在传输完成后关闭存储连接
|
||||
stream.on('close', () => {
|
||||
console.log('[分享下载] 文件传输完成,关闭存储连接');
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
safeEndStorage();
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
@@ -3883,9 +4033,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
||||
});
|
||||
}
|
||||
// 如果发生错误,关闭存储连接
|
||||
if (storage) {
|
||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
||||
}
|
||||
await safeEndStorage();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4284,8 +4432,16 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req,
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取存储统计失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取存储统计失败: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有用户
|
||||
} catch (error) { console.error('获取存储统计失败:', error); res.status(500).json({ success: false, message: '获取存储统计失败: ' + error.message }); }});
|
||||
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
||||
try {
|
||||
const users = UserDB.getAll();
|
||||
|
||||
Reference in New Issue
Block a user