diff --git a/backend/package-lock.json b/backend/package-lock.json index 2c9035d..eccfb1f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@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", "bcryptjs": "^3.0.3", "better-sqlite3": "^11.8.1", @@ -542,27 +542,6 @@ "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": { "version": "3.969.0", "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_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": { "version": "3.970.0", "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_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": { "version": "3.965.2", "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_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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 4f44df2..917b632 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "multer": "^2.0.2", "nodemailer": "^6.9.14", "@aws-sdk/client-s3": "^3.600.0", + "@aws-sdk/s3-request-presigner": "^3.600.0", "svg-captcha": "^1.4.0" }, "devDependencies": { diff --git a/backend/server.js b/backend/server.js index 2277f03..3455e61 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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(); diff --git a/backend/storage.js b/backend/storage.js index 8014a8b..68a9153 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -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} 签名 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} 签名 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}`); + } } /** diff --git a/frontend/app.js b/frontend/app.js index 918a8f7..fa24724 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -59,6 +59,7 @@ createApp({ }, showOssConfigModal: false, ossConfigSaving: false, // OSS 配置保存中状态 + ossConfigTesting: false, // OSS 配置测试中状态 // 修改密码表单 changePasswordForm: { @@ -69,6 +70,20 @@ createApp({ usernameForm: { newUsername: '' }, + // 用户资料表单 + profileForm: { + email: '' + }, + // 管理员资料表单 + adminProfileForm: { + username: '' + }, + // 分享表单(通用) + shareForm: { + path: '', + password: '', + expiryDays: null + }, currentPath: '/', files: [], loading: false, @@ -275,7 +290,6 @@ createApp({ // OSS配置引导弹窗 showOssGuideModal: false, - showOssConfigModal: false, // OSS空间使用统计 ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount } @@ -499,7 +513,6 @@ createApp({ }, // 模态框点击外部关闭优化 - 防止拖动选择文本时误关闭 - modalMouseDownTarget: null, handleModalMouseDown(e) { // 记录鼠标按下时的目标 this.modalMouseDownTarget = e.target; @@ -816,6 +829,28 @@ handleDragLeave(e) { 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; 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() { try { const response = await axios.post( @@ -1200,20 +1284,20 @@ handleDragLeave(e) { } }, - handleFileClick(file) { + async handleFileClick(file) { if (file.isDirectory) { const newPath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`; this.loadFiles(newPath); } else { - // 检查文件类型,打开相应的预览 + // 检查文件类型,打开相应的预览(异步) 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)) { - this.openVideoPlayer(file); + await this.openVideoPlayer(file); } 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) { // 直连 OSS 下载 window.open(data.downloadUrl, '_blank'); + } else { + // 处理后端返回的错误 + console.error('获取下载链接失败:', data.message); + this.showToast('error', '下载失败', data.message || '获取下载链接失败'); } } catch (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; switch (action) { case 'preview': - // 根据文件类型打开对应的预览 + // 根据文件类型打开对应的预览(异步) 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)) { - this.openVideoPlayer(this.contextMenuFile); + await this.openVideoPlayer(this.contextMenuFile); } else if (this.contextMenuFile.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) { - this.openAudioPlayer(this.contextMenuFile); + await this.openAudioPlayer(this.contextMenuFile); } break; case 'download': @@ -1516,41 +1605,67 @@ handleDragLeave(e) { return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`; }, - // 获取文件缩略图URL + // 获取文件缩略图URL(同步方法,用于本地存储模式) + // 注意:OSS 模式下缩略图需要单独处理,此处返回本地存储的直接URL getThumbnailUrl(file) { if (!file || file.isDirectory) return null; - + // 检查是否是图片或视频 const isImage = file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i); const isVideo = file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i); - + 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) { - this.currentMediaUrl = this.getMediaUrl(file); - this.currentMediaName = file.name; - this.currentMediaType = 'image'; - this.showImageViewer = true; + async openImageViewer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'image'; + this.showImageViewer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } }, // 打开视频播放器 - openVideoPlayer(file) { - this.currentMediaUrl = this.getMediaUrl(file); - this.currentMediaName = file.name; - this.currentMediaType = 'video'; - this.showVideoPlayer = true; + async openVideoPlayer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'video'; + this.showVideoPlayer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } }, // 打开音频播放器 - openAudioPlayer(file) { - this.currentMediaUrl = this.getMediaUrl(file); - this.currentMediaName = file.name; - this.currentMediaType = 'audio'; - this.showAudioPlayer = true; + async openAudioPlayer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'audio'; + this.showAudioPlayer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } }, // 关闭媒体预览 @@ -1769,10 +1884,11 @@ handleDragLeave(e) { // OSS 直连上传 async uploadToOSSDirect(file) { try { - // 1. 获取签名 URL + // 1. 获取签名 URL(传递当前路径) const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, { params: { filename: file.name, + path: this.currentPath, contentType: file.type || 'application/octet-stream' } }); @@ -2349,7 +2465,8 @@ handleDragLeave(e) { // 每30秒检查一次用户配置是否有更新 this.profileCheckInterval = setInterval(() => { - if (this.isLoggedIn && this.token) { + // 注意:token 通过 HttpOnly Cookie 管理,仅检查 isLoggedIn + if (this.isLoggedIn) { this.loadUserProfile(); } }, 30000); // 30秒 @@ -2677,8 +2794,7 @@ handleDragLeave(e) { console.error('更新系统设置失败:', error); this.showToast('error', '错误', '更新系统设置失败'); } - } -, + }, async testSmtp() { try { diff --git a/install.sh b/install.sh index d90c0f5..85d2f28 100644 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ ################################################################################ # 玩玩云 (WanWanYun) - 一键部署/卸载/更新脚本 -# 项目地址: https://gitee.com/yu-yon/vue-driven-cloud-storage +# 项目地址: https://git.workyai.cn/237899745/vue-driven-cloud-storage # 版本: v1.2.0 ################################################################################ @@ -33,7 +33,7 @@ NC='\033[0m' # No Color # 全局变量 PROJECT_NAME="wanwanyun" 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" ADMIN_USERNAME="" ADMIN_PASSWORD="" @@ -212,7 +212,7 @@ system_check() { fi # 检测网络 - if ping -c 1 gitee.com &> /dev/null; then + if ping -c 1 git.workyai.cn &> /dev/null; then print_success "网络连接正常" else print_error "无法连接到网络" @@ -1998,7 +1998,7 @@ create_project_directory() { } download_project() { - print_step "正在从Gitee下载项目..." + print_step "正在从仓库下载项目..." cd /tmp if [[ -d "${PROJECT_NAME}" ]]; then @@ -3407,7 +3407,7 @@ confirm_update() { echo "本脚本将执行以下操作:" echo "" echo "【将要更新】" - echo " ✓ 从Gitee拉取最新代码" + echo " ✓ 从仓库拉取最新代码" echo " ✓ 更新后端依赖(npm install)" echo " ✓ 重启后端服务" echo "" @@ -3489,7 +3489,7 @@ update_stop_services() { } update_pull_latest_code() { - print_step "正在从Gitee拉取最新代码..." + print_step "正在从仓库拉取最新代码..." cd /tmp if [[ -d "${PROJECT_NAME}-update" ]]; then @@ -4035,11 +4035,11 @@ main() { print_warning "如需其他操作,请下载脚本后运行" echo "" 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://gitee.com/yu-yon/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 " SSL管理: wget https://gitee.com/yu-yon/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" + echo " 更新: wget https://git.workyai.cn/237899745/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 --repair" + echo " SSL管理: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --ssl" + echo " 卸载: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --uninstall" echo "" sleep 2 fi