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:
81
backend/package-lock.json
generated
81
backend/package-lock.json
generated
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.600.0",
|
"@aws-sdk/client-s3": "^3.600.0",
|
||||||
"@aws-sdk/lib-storage": "^3.600.0",
|
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
@@ -542,27 +542,6 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/lib-storage": {
|
|
||||||
"version": "3.971.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.971.0.tgz",
|
|
||||||
"integrity": "sha512-THTCXZiYjuAU2kPD8rIuvtYRT83BxEzbv4uayPlQJ8v5bybLTYDbNEbpfZGilyAqUAdSGTMOkoLu9ROryCJ3/g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@smithy/abort-controller": "^4.2.8",
|
|
||||||
"@smithy/middleware-endpoint": "^4.4.7",
|
|
||||||
"@smithy/smithy-client": "^4.10.8",
|
|
||||||
"buffer": "5.6.0",
|
|
||||||
"events": "3.3.0",
|
|
||||||
"stream-browserify": "3.0.0",
|
|
||||||
"tslib": "^2.6.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@aws-sdk/client-s3": "3.971.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
|
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
|
||||||
"version": "3.969.0",
|
"version": "3.969.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.969.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.969.0.tgz",
|
||||||
@@ -802,6 +781,25 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||||
|
"version": "3.971.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.971.0.tgz",
|
||||||
|
"integrity": "sha512-j4wCCoQ//xm03JQn7/Jq6BJ0HV3VzlI/HrIQSQupWWjZTrdxyqa9PXBhcYNNtvZtF1adA/cRpYTMS+2SUsZGRg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "3.970.0",
|
||||||
|
"@aws-sdk/types": "3.969.0",
|
||||||
|
"@aws-sdk/util-format-url": "3.969.0",
|
||||||
|
"@smithy/middleware-endpoint": "^4.4.7",
|
||||||
|
"@smithy/protocol-http": "^5.3.8",
|
||||||
|
"@smithy/smithy-client": "^4.10.8",
|
||||||
|
"@smithy/types": "^4.12.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.970.0",
|
"version": "3.970.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.970.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.970.0.tgz",
|
||||||
@@ -878,6 +876,21 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/util-format-url": {
|
||||||
|
"version": "3.969.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.969.0.tgz",
|
||||||
|
"integrity": "sha512-C7ZiE8orcrEF9In+XDlIKrZhMjp0HCPUH6u74pgadE3T2LRre5TmOQcTt785/wVS2G0we9cxkjlzMrfDsfPvFw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "3.969.0",
|
||||||
|
"@smithy/querystring-builder": "^4.2.8",
|
||||||
|
"@smithy/types": "^4.12.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/util-locate-window": {
|
"node_modules/@aws-sdk/util-locate-window": {
|
||||||
"version": "3.965.2",
|
"version": "3.965.2",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.2.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.2.tgz",
|
||||||
@@ -4025,30 +4038,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stream-browserify": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "~2.0.4",
|
|
||||||
"readable-stream": "^3.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stream-browserify/node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/streamsearch": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^6.9.14",
|
||||||
"@aws-sdk/client-s3": "^3.600.0",
|
"@aws-sdk/client-s3": "^3.600.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||||
"svg-captcha": "^1.4.0"
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { exec, execSync, execFile } = require('child_process');
|
const { exec, execSync, execFile } = require('child_process');
|
||||||
const util = require('util');
|
const util = require('util');
|
||||||
const execAsync = util.promisify(exec);
|
const execAsync = util.promisify(exec);
|
||||||
@@ -1742,8 +1743,8 @@ app.post('/api/logout', (req, res) => {
|
|||||||
|
|
||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
app.get('/api/user/profile', authMiddleware, (req, res) => {
|
app.get('/api/user/profile', authMiddleware, (req, res) => {
|
||||||
// 不返回密码明文
|
// 不返回敏感信息(密码和 OSS 密钥)
|
||||||
const { password, ...safeUser } = req.user;
|
const { password, oss_access_key_secret, ...safeUser } = req.user;
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: safeUser
|
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存储空间使用情况(带缓存)
|
// 获取OSS存储空间使用情况(带缓存)
|
||||||
app.get('/api/user/oss-usage', authMiddleware, async (req, res) => {
|
app.get('/api/user/oss-usage', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -2583,6 +2648,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
|||||||
// 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端)
|
// 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端)
|
||||||
app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
||||||
const filename = req.query.filename;
|
const filename = req.query.filename;
|
||||||
|
const uploadPath = req.query.path || '/'; // 上传目标路径
|
||||||
const contentType = req.query.contentType || 'application/octet-stream';
|
const contentType = req.query.contentType || 'application/octet-stream';
|
||||||
|
|
||||||
if (!filename) {
|
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
|
// 检查用户是否配置了 OSS
|
||||||
if (!req.user.has_oss_config) {
|
if (!req.user.has_oss_config) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -2602,13 +2693,29 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
// 构建 S3 客户端
|
// 构建 S3 客户端
|
||||||
const client = new S3Client(buildS3Config(req.user));
|
const client = new S3Client(buildS3Config(req.user));
|
||||||
|
|
||||||
// 构建对象 Key
|
// 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致)
|
||||||
const objectKey = `user_${req.user.id}/${Date.now()}_${sanitizeFilename(filename)}`;
|
// 格式: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 命令
|
// 创建 PutObject 命令
|
||||||
const command = new PutObjectCommand({
|
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
|
// 检查用户是否配置了 OSS
|
||||||
if (!req.user.has_oss_config) {
|
if (!req.user.has_oss_config) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -2684,13 +2800,13 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
// 构建 S3 客户端
|
// 构建 S3 客户端
|
||||||
const client = new S3Client(buildS3Config(req.user));
|
const client = new S3Client(buildS3Config(req.user));
|
||||||
|
|
||||||
// 构建对象 Key
|
// 构建对象 Key(使用安全的规范化路径)
|
||||||
const objectKey = `user_${req.user.id}${filePath}`;
|
const objectKey = `user_${req.user.id}${normalizedPath}`;
|
||||||
|
|
||||||
// 创建 GetObject 命令
|
// 创建 GetObject 命令
|
||||||
const command = new GetObjectCommand({
|
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) => {
|
app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||||
const filePath = req.query.path;
|
const filePath = req.query.path;
|
||||||
let storage;
|
let storage;
|
||||||
|
let storageEnded = false; // 防止重复关闭
|
||||||
|
|
||||||
|
// 安全关闭存储连接的辅助函数
|
||||||
|
const safeEndStorage = async () => {
|
||||||
|
if (storage && !storageEnded) {
|
||||||
|
storageEnded = true;
|
||||||
|
try {
|
||||||
|
await storage.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('关闭存储连接失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return res.status(400).json({
|
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 {
|
try {
|
||||||
// 使用统一存储接口
|
// 使用统一存储接口
|
||||||
const { StorageInterface } = require('./storage');
|
const { StorageInterface } = require('./storage');
|
||||||
@@ -2895,31 +3033,22 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 发生错误时关闭存储连接
|
// 发生错误时关闭存储连接
|
||||||
if (storage) {
|
safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在传输完成后关闭存储连接
|
// 在传输完成后关闭存储连接
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
console.log('[下载] 文件传输完成,关闭存储连接');
|
console.log('[下载] 文件传输完成,关闭存储连接');
|
||||||
if (storage) {
|
safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载文件失败:', error);
|
console.error('下载文件失败:', error);
|
||||||
|
|
||||||
// 如果stream还未创建或发生错误,关闭storage连接
|
// 如果stream还未创建或发生错误,关闭storage连接
|
||||||
if (storage) {
|
await safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -3410,7 +3539,9 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) =
|
|||||||
if (!shareOwner) {
|
if (!shareOwner) {
|
||||||
throw new Error('分享者不存在');
|
throw new Error('分享者不存在');
|
||||||
}
|
}
|
||||||
const storageType = shareOwner.current_storage_type || 'oss';
|
// 使用分享创建时记录的存储类型,而非用户当前的存储类型
|
||||||
|
// 这样即使用户切换了存储,分享链接仍然有效
|
||||||
|
const storageType = share.storage_type || 'oss';
|
||||||
|
|
||||||
// 使用统一存储接口
|
// 使用统一存储接口
|
||||||
const { StorageInterface } = require('./storage');
|
const { StorageInterface } = require('./storage');
|
||||||
@@ -3722,7 +3853,7 @@ app.get('/api/share/:code/download-url', async (req, res) => {
|
|||||||
|
|
||||||
// OSS 模式:生成签名 URL
|
// OSS 模式:生成签名 URL
|
||||||
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { getSignedUrl } = require('@aws-sdk/request-presigner');
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
// 构建 S3 客户端
|
// 构建 S3 客户端
|
||||||
const client = new S3Client(buildS3Config(shareOwner));
|
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 { code } = req.params;
|
||||||
const { path: filePath, password } = req.query;
|
const { path: filePath, password } = req.query;
|
||||||
let storage;
|
let storage;
|
||||||
|
let storageEnded = false; // 防止重复关闭
|
||||||
|
|
||||||
|
// 安全关闭存储连接的辅助函数
|
||||||
|
const safeEndStorage = async () => {
|
||||||
|
if (storage && !storageEnded) {
|
||||||
|
storageEnded = true;
|
||||||
|
try {
|
||||||
|
await storage.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('关闭存储连接失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return res.status(400).json({
|
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 {
|
try {
|
||||||
const share = ShareDB.findByCode(code);
|
const share = ShareDB.findByCode(code);
|
||||||
|
|
||||||
@@ -3799,7 +3951,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 安全验证:检查请求路径是否在分享范围内(防止越权访问)
|
// 安全验证:检查请求路径是否在分享范围内(防止越权访问)
|
||||||
if (!isPathWithinShare(filePath, share)) {
|
if (!isPathWithinShare(filePath, share)) {
|
||||||
console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`);
|
console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`);
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -3818,9 +3970,11 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一存储接口,根据分享的storage_type选择存储后端
|
// 使用统一存储接口,根据分享的storage_type选择存储后端
|
||||||
|
// 注意:必须使用分享创建时记录的 storage_type,而不是分享者当前的存储类型
|
||||||
|
// 这样即使用户后来切换了存储类型,之前创建的分享仍然可以正常工作
|
||||||
const { StorageInterface } = require('./storage');
|
const { StorageInterface } = require('./storage');
|
||||||
const storageType = shareOwner.current_storage_type || 'oss';
|
const storageType = share.storage_type || 'oss';
|
||||||
console.log(`[分享下载] 存储类型: ${storageType} (分享者当前), 文件路径: ${filePath}`);
|
console.log(`[分享下载] 存储类型: ${storageType} (分享记录), 文件路径: ${filePath}`);
|
||||||
|
|
||||||
// 临时构造用户对象以使用存储接口
|
// 临时构造用户对象以使用存储接口
|
||||||
const userForStorage = {
|
const userForStorage = {
|
||||||
@@ -3859,17 +4013,13 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 发生错误时关闭存储连接
|
// 发生错误时关闭存储连接
|
||||||
if (storage) {
|
safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在传输完成后关闭存储连接
|
// 在传输完成后关闭存储连接
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
console.log('[分享下载] 文件传输完成,关闭存储连接');
|
console.log('[分享下载] 文件传输完成,关闭存储连接');
|
||||||
if (storage) {
|
safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
@@ -3883,9 +4033,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 如果发生错误,关闭存储连接
|
// 如果发生错误,关闭存储连接
|
||||||
if (storage) {
|
await safeEndStorage();
|
||||||
storage.end().catch(err => console.error('关闭存储连接失败:', err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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) => {
|
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = UserDB.getAll();
|
const users = UserDB.getAll();
|
||||||
|
|||||||
@@ -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 fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { Readable } = require('stream');
|
||||||
const { UserDB } = require('./database');
|
const { UserDB } = require('./database');
|
||||||
|
|
||||||
// ===== 工具函数 =====
|
// ===== 工具函数 =====
|
||||||
@@ -379,23 +381,27 @@ class OssStorageClient {
|
|||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: oss_access_key_id,
|
accessKeyId: oss_access_key_id,
|
||||||
secretAccessKey: oss_access_key_secret
|
secretAccessKey: oss_access_key_secret
|
||||||
},
|
|
||||||
// 设置超时时间
|
|
||||||
requestHandler: {
|
|
||||||
requestTimeout: 30000, // 30秒
|
|
||||||
httpsAgent: undefined // 可后续添加 keep-alive agent
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 阿里云 OSS
|
// 阿里云 OSS
|
||||||
if (oss_provider === 'aliyun') {
|
if (oss_provider === 'aliyun') {
|
||||||
config.region = oss_region || 'oss-cn-hangzhou';
|
// 规范化 region:确保格式为 oss-cn-xxx
|
||||||
if (!oss_endpoint) {
|
let region = oss_region || 'oss-cn-hangzhou';
|
||||||
// 默认 endpoint 格式:https://oss-{region}.aliyuncs.com
|
if (!region.startsWith('oss-')) {
|
||||||
config.endpoint = `https://oss-${config.region.replace('oss-', '')}.aliyuncs.com`;
|
region = 'oss-' + region;
|
||||||
} else {
|
|
||||||
config.endpoint = oss_endpoint;
|
|
||||||
}
|
}
|
||||||
|
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
|
// 腾讯云 COS
|
||||||
else if (oss_provider === 'tencent') {
|
else if (oss_provider === 'tencent') {
|
||||||
@@ -404,13 +410,17 @@ class OssStorageClient {
|
|||||||
// 默认 endpoint 格式:https://cos.{region}.myqcloud.com
|
// 默认 endpoint 格式:https://cos.{region}.myqcloud.com
|
||||||
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
|
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
|
||||||
} else {
|
} 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 或其他兼容服务
|
// AWS S3 或其他兼容服务
|
||||||
else {
|
else {
|
||||||
if (oss_endpoint) {
|
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,无需额外配置
|
// AWS 使用默认 endpoint,无需额外配置
|
||||||
}
|
}
|
||||||
@@ -435,77 +445,152 @@ class OssStorageClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取对象的完整 Key(带用户前缀)
|
* 获取对象的完整 Key(带用户前缀)
|
||||||
|
* 增强安全检查,防止路径遍历攻击
|
||||||
*/
|
*/
|
||||||
getObjectKey(relativePath) {
|
getObjectKey(relativePath) {
|
||||||
// 规范化路径
|
// 0. 输入类型验证
|
||||||
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
|
if (relativePath === null || relativePath === undefined) {
|
||||||
|
return this.prefix; // null/undefined 返回根目录
|
||||||
// 移除开头的斜杠
|
|
||||||
if (normalized.startsWith('/') || normalized.startsWith('\\')) {
|
|
||||||
normalized = normalized.substring(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空路径表示根目录
|
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 === '.') {
|
if (normalized === '' || normalized === '.') {
|
||||||
normalized = '';
|
return this.prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拼接用户前缀
|
// 9. 拼接用户前缀(确保不会产生双斜杠)
|
||||||
return normalized ? this.prefix + normalized : this.prefix;
|
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 {
|
try {
|
||||||
const prefix = this.getObjectKey(dirPath);
|
let prefix = this.getObjectKey(dirPath);
|
||||||
const bucket = this.user.oss_bucket;
|
const bucket = this.user.oss_bucket;
|
||||||
|
|
||||||
const command = new ListObjectsV2Command({
|
// 确保前缀以斜杠结尾(除非是根目录)
|
||||||
Bucket: bucket,
|
if (prefix && !prefix.endsWith('/')) {
|
||||||
Prefix: prefix,
|
prefix = prefix + '/';
|
||||||
Delimiter: '/', // 使用分隔符模拟目录结构
|
}
|
||||||
MaxKeys: 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.s3Client.send(command);
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
const dirSet = new Set(); // 用于去重目录
|
||||||
|
let continuationToken = undefined;
|
||||||
|
const MAX_KEYS_PER_REQUEST = 1000;
|
||||||
|
|
||||||
// 处理"子目录"(CommonPrefixes)
|
do {
|
||||||
if (response.CommonPrefixes) {
|
const command = new ListObjectsV2Command({
|
||||||
for (const prefixObj of response.CommonPrefixes) {
|
Bucket: bucket,
|
||||||
const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, '');
|
Prefix: prefix,
|
||||||
if (dirName) {
|
Delimiter: '/', // 使用分隔符模拟目录结构
|
||||||
items.push({
|
MaxKeys: MAX_KEYS_PER_REQUEST,
|
||||||
name: dirName,
|
ContinuationToken: continuationToken
|
||||||
type: 'd',
|
});
|
||||||
size: 0,
|
|
||||||
modifyTime: Date.now()
|
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)
|
// 处理文件(Contents)
|
||||||
if (response.Contents) {
|
if (response.Contents) {
|
||||||
for (const obj of response.Contents) {
|
for (const obj of response.Contents) {
|
||||||
const key = obj.Key;
|
const key = obj.Key;
|
||||||
// 跳过目录标记本身
|
// 跳过目录标记本身(以斜杠结尾的空对象)
|
||||||
if (key === prefix || key.endsWith('/')) {
|
if (key === prefix || key.endsWith('/')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fileName = key.substring(prefix.length);
|
const fileName = key.substring(prefix.length);
|
||||||
if (fileName) {
|
// 跳过包含子路径的文件(不应该出现,但以防万一)
|
||||||
items.push({
|
if (fileName && !fileName.includes('/')) {
|
||||||
name: fileName,
|
items.push({
|
||||||
type: '-',
|
name: fileName,
|
||||||
size: obj.Size || 0,
|
type: '-',
|
||||||
modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now()
|
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;
|
return items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -529,28 +614,45 @@ class OssStorageClient {
|
|||||||
* @param {string} remotePath - 远程文件路径
|
* @param {string} remotePath - 远程文件路径
|
||||||
*/
|
*/
|
||||||
async put(localPath, remotePath) {
|
async put(localPath, remotePath) {
|
||||||
|
let fileStream = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = this.getObjectKey(remotePath);
|
const key = this.getObjectKey(remotePath);
|
||||||
const bucket = this.user.oss_bucket;
|
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({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: fileStream
|
Body: fileStream,
|
||||||
|
ContentLength: fileSize // 明确指定内容长度,避免某些服务端问题
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.s3Client.send(command);
|
await this.s3Client.send(command);
|
||||||
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
|
||||||
|
|
||||||
// 关闭流
|
|
||||||
if (!fileStream.destroyed) {
|
|
||||||
fileStream.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
|
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
|
||||||
|
|
||||||
@@ -561,8 +663,15 @@ class OssStorageClient {
|
|||||||
throw new Error('OSS 访问被拒绝,请检查权限配置');
|
throw new Error('OSS 访问被拒绝,请检查权限配置');
|
||||||
} else if (error.name === 'EntityTooLarge') {
|
} else if (error.name === 'EntityTooLarge') {
|
||||||
throw new Error('文件过大,超过了 OSS 允许的最大大小');
|
throw new Error('文件过大,超过了 OSS 允许的最大大小');
|
||||||
|
} else if (error.code === 'ENOENT') {
|
||||||
|
throw new Error(`本地文件不存在: ${localPath}`);
|
||||||
}
|
}
|
||||||
throw new Error(`文件上传失败: ${error.message}`);
|
throw new Error(`文件上传失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
// 确保流被正确关闭(无论成功还是失败)
|
||||||
|
if (fileStream && !fileStream.destroyed) {
|
||||||
|
fileStream.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,31 +697,45 @@ class OssStorageClient {
|
|||||||
|
|
||||||
if (statResult.isDirectory) {
|
if (statResult.isDirectory) {
|
||||||
// 删除目录:列出所有对象并批量删除
|
// 删除目录:列出所有对象并批量删除
|
||||||
const listCommand = new ListObjectsV2Command({
|
// 使用分页循环处理超过 1000 个对象的情况
|
||||||
Bucket: bucket,
|
let continuationToken = null;
|
||||||
Prefix: key
|
let totalDeletedCount = 0;
|
||||||
});
|
const MAX_DELETE_BATCH = 1000; // AWS S3 单次最多删除 1000 个对象
|
||||||
|
|
||||||
const listResponse = await this.s3Client.send(listCommand);
|
do {
|
||||||
|
const listCommand = new ListObjectsV2Command({
|
||||||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
|
||||||
// 分批删除(AWS S3 单次最多删除 1000 个对象)
|
|
||||||
const deleteCommand = new DeleteObjectsCommand({
|
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Delete: {
|
Prefix: key,
|
||||||
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
|
MaxKeys: MAX_DELETE_BATCH,
|
||||||
Quiet: false
|
ContinuationToken: continuationToken
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteResult = await this.s3Client.send(deleteCommand);
|
const listResponse = await this.s3Client.send(listCommand);
|
||||||
|
continuationToken = listResponse.NextContinuationToken;
|
||||||
|
|
||||||
// 检查删除结果
|
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||||||
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
|
// 批量删除当前批次的对象
|
||||||
console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors);
|
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 {
|
} else {
|
||||||
// 删除单个文件
|
// 删除单个文件
|
||||||
@@ -642,34 +765,81 @@ class OssStorageClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 重命名文件(OSS 不支持直接重命名,需要复制后删除)
|
* 重命名文件(OSS 不支持直接重命名,需要复制后删除)
|
||||||
|
* 注意:此方法只支持单个文件的重命名,不支持目录
|
||||||
*/
|
*/
|
||||||
async rename(oldPath, newPath) {
|
async rename(oldPath, newPath) {
|
||||||
try {
|
const oldKey = this.getObjectKey(oldPath);
|
||||||
const oldKey = this.getObjectKey(oldPath);
|
const newKey = this.getObjectKey(newPath);
|
||||||
const newKey = this.getObjectKey(newPath);
|
const bucket = this.user.oss_bucket;
|
||||||
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,
|
Bucket: bucket,
|
||||||
Key: newKey,
|
CopySource: copySource,
|
||||||
CopySource: `${bucket}/${oldKey}`
|
Key: newKey
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.s3Client.send(copyCommand);
|
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}`);
|
console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message);
|
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') {
|
if (error.name === 'NoSuchBucket') {
|
||||||
throw new Error('OSS 存储桶不存在,请检查配置');
|
throw new Error('OSS 存储桶不存在,请检查配置');
|
||||||
} else if (error.name === 'AccessDenied') {
|
} else if (error.name === 'AccessDenied') {
|
||||||
throw new Error('OSS 访问被拒绝,请检查权限配置');
|
throw new Error('OSS 访问被拒绝,请检查权限配置');
|
||||||
|
} else if (error.name === 'NoSuchKey') {
|
||||||
|
throw new Error('源文件不存在');
|
||||||
}
|
}
|
||||||
throw new Error(`重命名文件失败: ${error.message}`);
|
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 key = this.getObjectKey(filePath);
|
||||||
const bucket = this.user.oss_bucket;
|
const bucket = this.user.oss_bucket;
|
||||||
|
|
||||||
// 简单的公开 URL 拼接(如果 bucket 是公共读)
|
try {
|
||||||
const endpoint = this.s3Client.config.endpoint;
|
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;
|
const region = this.s3Client.config.region;
|
||||||
|
|
||||||
let baseUrl;
|
let baseUrl;
|
||||||
if (endpoint) {
|
if (this.user.oss_provider === 'aliyun') {
|
||||||
baseUrl = endpoint.href || endpoint.toString();
|
// 阿里云 OSS 公开 URL 格式
|
||||||
} else if (this.user.oss_provider === 'aliyun') {
|
const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`;
|
||||||
baseUrl = `https://${bucket}.${this.user.oss_region || 'oss-cn-hangzhou'}.aliyuncs.com`;
|
baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`;
|
||||||
} else if (this.user.oss_provider === 'tencent') {
|
} 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 {
|
} else {
|
||||||
|
// AWS S3 公开 URL 格式
|
||||||
baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`;
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
184
frontend/app.js
184
frontend/app.js
@@ -59,6 +59,7 @@ createApp({
|
|||||||
},
|
},
|
||||||
showOssConfigModal: false,
|
showOssConfigModal: false,
|
||||||
ossConfigSaving: false, // OSS 配置保存中状态
|
ossConfigSaving: false, // OSS 配置保存中状态
|
||||||
|
ossConfigTesting: false, // OSS 配置测试中状态
|
||||||
|
|
||||||
// 修改密码表单
|
// 修改密码表单
|
||||||
changePasswordForm: {
|
changePasswordForm: {
|
||||||
@@ -69,6 +70,20 @@ createApp({
|
|||||||
usernameForm: {
|
usernameForm: {
|
||||||
newUsername: ''
|
newUsername: ''
|
||||||
},
|
},
|
||||||
|
// 用户资料表单
|
||||||
|
profileForm: {
|
||||||
|
email: ''
|
||||||
|
},
|
||||||
|
// 管理员资料表单
|
||||||
|
adminProfileForm: {
|
||||||
|
username: ''
|
||||||
|
},
|
||||||
|
// 分享表单(通用)
|
||||||
|
shareForm: {
|
||||||
|
path: '',
|
||||||
|
password: '',
|
||||||
|
expiryDays: null
|
||||||
|
},
|
||||||
currentPath: '/',
|
currentPath: '/',
|
||||||
files: [],
|
files: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -275,7 +290,6 @@ createApp({
|
|||||||
|
|
||||||
// OSS配置引导弹窗
|
// OSS配置引导弹窗
|
||||||
showOssGuideModal: false,
|
showOssGuideModal: false,
|
||||||
showOssConfigModal: false,
|
|
||||||
|
|
||||||
// OSS空间使用统计
|
// OSS空间使用统计
|
||||||
ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount }
|
ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount }
|
||||||
@@ -499,7 +513,6 @@ createApp({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 模态框点击外部关闭优化 - 防止拖动选择文本时误关闭
|
// 模态框点击外部关闭优化 - 防止拖动选择文本时误关闭
|
||||||
modalMouseDownTarget: null,
|
|
||||||
handleModalMouseDown(e) {
|
handleModalMouseDown(e) {
|
||||||
// 记录鼠标按下时的目标
|
// 记录鼠标按下时的目标
|
||||||
this.modalMouseDownTarget = e.target;
|
this.modalMouseDownTarget = e.target;
|
||||||
@@ -816,6 +829,28 @@ handleDragLeave(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 前端验证
|
||||||
|
if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) {
|
||||||
|
this.showToast('error', '配置错误', '请选择有效的 OSS 服务商');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', '地域/Region 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', 'Bucket 名称不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.ossConfigSaving = true;
|
this.ossConfigSaving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -863,6 +898,55 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 测试 OSS 连接(不保存配置)
|
||||||
|
async testOssConnection() {
|
||||||
|
// 防止重复提交
|
||||||
|
if (this.ossConfigTesting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端验证
|
||||||
|
if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) {
|
||||||
|
this.showToast('error', '配置错误', '请选择有效的 OSS 服务商');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', '地域/Region 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果用户已有配置,Secret 可以为空(使用现有密钥)
|
||||||
|
if (!this.user?.has_oss_config && (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '')) {
|
||||||
|
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') {
|
||||||
|
this.showToast('error', '配置错误', 'Bucket 名称不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ossConfigTesting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${this.apiBase}/api/user/test-oss`,
|
||||||
|
this.ossConfigForm,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
this.showToast('success', '连接成功', 'OSS 配置验证通过,可以保存');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OSS连接测试失败:', error);
|
||||||
|
this.showToast('error', '连接失败', error.response?.data?.message || error.message || '请检查配置信息');
|
||||||
|
} finally {
|
||||||
|
this.ossConfigTesting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async updateAdminProfile() {
|
async updateAdminProfile() {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
@@ -1200,20 +1284,20 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleFileClick(file) {
|
async handleFileClick(file) {
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
const newPath = this.currentPath === '/'
|
const newPath = this.currentPath === '/'
|
||||||
? `/${file.name}`
|
? `/${file.name}`
|
||||||
: `${this.currentPath}/${file.name}`;
|
: `${this.currentPath}/${file.name}`;
|
||||||
this.loadFiles(newPath);
|
this.loadFiles(newPath);
|
||||||
} else {
|
} else {
|
||||||
// 检查文件类型,打开相应的预览
|
// 检查文件类型,打开相应的预览(异步)
|
||||||
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||||
this.openImageViewer(file);
|
await this.openImageViewer(file);
|
||||||
} else if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
} else if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
||||||
this.openVideoPlayer(file);
|
await this.openVideoPlayer(file);
|
||||||
} else if (file.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
} else if (file.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
||||||
this.openAudioPlayer(file);
|
await this.openAudioPlayer(file);
|
||||||
}
|
}
|
||||||
// 其他文件类型不做任何操作,用户可以通过右键菜单下载
|
// 其他文件类型不做任何操作,用户可以通过右键菜单下载
|
||||||
}
|
}
|
||||||
@@ -1261,10 +1345,15 @@ handleDragLeave(e) {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
// 直连 OSS 下载
|
// 直连 OSS 下载
|
||||||
window.open(data.downloadUrl, '_blank');
|
window.open(data.downloadUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
// 处理后端返回的错误
|
||||||
|
console.error('获取下载链接失败:', data.message);
|
||||||
|
this.showToast('error', '下载失败', data.message || '获取下载链接失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取下载链接失败:', error);
|
console.error('获取下载链接失败:', error);
|
||||||
this.showToast('error', '错误', '获取下载链接失败');
|
const errorMsg = error.response?.data?.message || error.message || '获取下载链接失败';
|
||||||
|
this.showToast('error', '下载失败', errorMsg);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1457,18 +1546,18 @@ handleDragLeave(e) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 从菜单执行操作
|
// 从菜单执行操作
|
||||||
contextMenuAction(action) {
|
async contextMenuAction(action) {
|
||||||
if (!this.contextMenuFile) return;
|
if (!this.contextMenuFile) return;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'preview':
|
case 'preview':
|
||||||
// 根据文件类型打开对应的预览
|
// 根据文件类型打开对应的预览(异步)
|
||||||
if (this.contextMenuFile.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
if (this.contextMenuFile.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
|
||||||
this.openImageViewer(this.contextMenuFile);
|
await this.openImageViewer(this.contextMenuFile);
|
||||||
} else if (this.contextMenuFile.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
} else if (this.contextMenuFile.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) {
|
||||||
this.openVideoPlayer(this.contextMenuFile);
|
await this.openVideoPlayer(this.contextMenuFile);
|
||||||
} else if (this.contextMenuFile.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
} else if (this.contextMenuFile.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) {
|
||||||
this.openAudioPlayer(this.contextMenuFile);
|
await this.openAudioPlayer(this.contextMenuFile);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'download':
|
case 'download':
|
||||||
@@ -1516,7 +1605,8 @@ handleDragLeave(e) {
|
|||||||
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取文件缩略图URL
|
// 获取文件缩略图URL(同步方法,用于本地存储模式)
|
||||||
|
// 注意:OSS 模式下缩略图需要单独处理,此处返回本地存储的直接URL
|
||||||
getThumbnailUrl(file) {
|
getThumbnailUrl(file) {
|
||||||
if (!file || file.isDirectory) return null;
|
if (!file || file.isDirectory) return null;
|
||||||
|
|
||||||
@@ -1526,31 +1616,56 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
if (!isImage && !isVideo) return null;
|
if (!isImage && !isVideo) return null;
|
||||||
|
|
||||||
return this.getMediaUrl(file);
|
// 本地存储模式:返回同步的下载 URL
|
||||||
|
// OSS 模式下缩略图功能暂不支持(需要预签名 URL,建议点击文件预览)
|
||||||
|
if (this.storageType !== 'oss') {
|
||||||
|
const filePath = this.currentPath === '/'
|
||||||
|
? `/${file.name}`
|
||||||
|
: `${this.currentPath}/${file.name}`;
|
||||||
|
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSS 模式暂不支持同步缩略图,返回 null
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 打开图片预览
|
// 打开图片预览
|
||||||
openImageViewer(file) {
|
async openImageViewer(file) {
|
||||||
this.currentMediaUrl = this.getMediaUrl(file);
|
const url = await this.getMediaUrl(file);
|
||||||
this.currentMediaName = file.name;
|
if (url) {
|
||||||
this.currentMediaType = 'image';
|
this.currentMediaUrl = url;
|
||||||
this.showImageViewer = true;
|
this.currentMediaName = file.name;
|
||||||
|
this.currentMediaType = 'image';
|
||||||
|
this.showImageViewer = true;
|
||||||
|
} else {
|
||||||
|
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 打开视频播放器
|
// 打开视频播放器
|
||||||
openVideoPlayer(file) {
|
async openVideoPlayer(file) {
|
||||||
this.currentMediaUrl = this.getMediaUrl(file);
|
const url = await this.getMediaUrl(file);
|
||||||
this.currentMediaName = file.name;
|
if (url) {
|
||||||
this.currentMediaType = 'video';
|
this.currentMediaUrl = url;
|
||||||
this.showVideoPlayer = true;
|
this.currentMediaName = file.name;
|
||||||
|
this.currentMediaType = 'video';
|
||||||
|
this.showVideoPlayer = true;
|
||||||
|
} else {
|
||||||
|
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 打开音频播放器
|
// 打开音频播放器
|
||||||
openAudioPlayer(file) {
|
async openAudioPlayer(file) {
|
||||||
this.currentMediaUrl = this.getMediaUrl(file);
|
const url = await this.getMediaUrl(file);
|
||||||
this.currentMediaName = file.name;
|
if (url) {
|
||||||
this.currentMediaType = 'audio';
|
this.currentMediaUrl = url;
|
||||||
this.showAudioPlayer = true;
|
this.currentMediaName = file.name;
|
||||||
|
this.currentMediaType = 'audio';
|
||||||
|
this.showAudioPlayer = true;
|
||||||
|
} else {
|
||||||
|
this.showToast('error', '错误', '无法获取文件预览链接');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 关闭媒体预览
|
// 关闭媒体预览
|
||||||
@@ -1769,10 +1884,11 @@ handleDragLeave(e) {
|
|||||||
// OSS 直连上传
|
// OSS 直连上传
|
||||||
async uploadToOSSDirect(file) {
|
async uploadToOSSDirect(file) {
|
||||||
try {
|
try {
|
||||||
// 1. 获取签名 URL
|
// 1. 获取签名 URL(传递当前路径)
|
||||||
const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, {
|
const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, {
|
||||||
params: {
|
params: {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
path: this.currentPath,
|
||||||
contentType: file.type || 'application/octet-stream'
|
contentType: file.type || 'application/octet-stream'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2349,7 +2465,8 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
// 每30秒检查一次用户配置是否有更新
|
// 每30秒检查一次用户配置是否有更新
|
||||||
this.profileCheckInterval = setInterval(() => {
|
this.profileCheckInterval = setInterval(() => {
|
||||||
if (this.isLoggedIn && this.token) {
|
// 注意:token 通过 HttpOnly Cookie 管理,仅检查 isLoggedIn
|
||||||
|
if (this.isLoggedIn) {
|
||||||
this.loadUserProfile();
|
this.loadUserProfile();
|
||||||
}
|
}
|
||||||
}, 30000); // 30秒
|
}, 30000); // 30秒
|
||||||
@@ -2677,8 +2794,7 @@ handleDragLeave(e) {
|
|||||||
console.error('更新系统设置失败:', error);
|
console.error('更新系统设置失败:', error);
|
||||||
this.showToast('error', '错误', '更新系统设置失败');
|
this.showToast('error', '错误', '更新系统设置失败');
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
,
|
|
||||||
|
|
||||||
async testSmtp() {
|
async testSmtp() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
22
install.sh
22
install.sh
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# 玩玩云 (WanWanYun) - 一键部署/卸载/更新脚本
|
# 玩玩云 (WanWanYun) - 一键部署/卸载/更新脚本
|
||||||
# 项目地址: https://gitee.com/yu-yon/vue-driven-cloud-storage
|
# 项目地址: https://git.workyai.cn/237899745/vue-driven-cloud-storage
|
||||||
# 版本: v1.2.0
|
# 版本: v1.2.0
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ NC='\033[0m' # No Color
|
|||||||
# 全局变量
|
# 全局变量
|
||||||
PROJECT_NAME="wanwanyun"
|
PROJECT_NAME="wanwanyun"
|
||||||
PROJECT_DIR="/var/www/${PROJECT_NAME}"
|
PROJECT_DIR="/var/www/${PROJECT_NAME}"
|
||||||
REPO_URL="https://gitee.com/yu-yon/vue-driven-cloud-storage.git"
|
REPO_URL="https://git.workyai.cn/237899745/vue-driven-cloud-storage.git"
|
||||||
NODE_VERSION="20"
|
NODE_VERSION="20"
|
||||||
ADMIN_USERNAME=""
|
ADMIN_USERNAME=""
|
||||||
ADMIN_PASSWORD=""
|
ADMIN_PASSWORD=""
|
||||||
@@ -212,7 +212,7 @@ system_check() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 检测网络
|
# 检测网络
|
||||||
if ping -c 1 gitee.com &> /dev/null; then
|
if ping -c 1 git.workyai.cn &> /dev/null; then
|
||||||
print_success "网络连接正常"
|
print_success "网络连接正常"
|
||||||
else
|
else
|
||||||
print_error "无法连接到网络"
|
print_error "无法连接到网络"
|
||||||
@@ -1998,7 +1998,7 @@ create_project_directory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download_project() {
|
download_project() {
|
||||||
print_step "正在从Gitee下载项目..."
|
print_step "正在从仓库下载项目..."
|
||||||
|
|
||||||
cd /tmp
|
cd /tmp
|
||||||
if [[ -d "${PROJECT_NAME}" ]]; then
|
if [[ -d "${PROJECT_NAME}" ]]; then
|
||||||
@@ -3407,7 +3407,7 @@ confirm_update() {
|
|||||||
echo "本脚本将执行以下操作:"
|
echo "本脚本将执行以下操作:"
|
||||||
echo ""
|
echo ""
|
||||||
echo "【将要更新】"
|
echo "【将要更新】"
|
||||||
echo " ✓ 从Gitee拉取最新代码"
|
echo " ✓ 从仓库拉取最新代码"
|
||||||
echo " ✓ 更新后端依赖(npm install)"
|
echo " ✓ 更新后端依赖(npm install)"
|
||||||
echo " ✓ 重启后端服务"
|
echo " ✓ 重启后端服务"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -3489,7 +3489,7 @@ update_stop_services() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_pull_latest_code() {
|
update_pull_latest_code() {
|
||||||
print_step "正在从Gitee拉取最新代码..."
|
print_step "正在从仓库拉取最新代码..."
|
||||||
|
|
||||||
cd /tmp
|
cd /tmp
|
||||||
if [[ -d "${PROJECT_NAME}-update" ]]; then
|
if [[ -d "${PROJECT_NAME}-update" ]]; then
|
||||||
@@ -4035,11 +4035,11 @@ main() {
|
|||||||
print_warning "如需其他操作,请下载脚本后运行"
|
print_warning "如需其他操作,请下载脚本后运行"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}提示:${NC}"
|
echo -e "${YELLOW}提示:${NC}"
|
||||||
echo " 安装: wget https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh"
|
echo " 安装: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh"
|
||||||
echo " 更新: wget https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --update"
|
echo " 更新: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --update"
|
||||||
echo " 修复: wget https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --repair"
|
echo " 修复: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --repair"
|
||||||
echo " SSL管理: wget https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --ssl"
|
echo " SSL管理: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --ssl"
|
||||||
echo " 卸载: wget https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --uninstall"
|
echo " 卸载: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --uninstall"
|
||||||
echo ""
|
echo ""
|
||||||
sleep 2
|
sleep 2
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user