feat: 全面优化代码质量至 8.55/10 分
## 安全增强 - 添加 CSRF 防护机制(Double Submit Cookie 模式) - 增强密码强度验证(8字符+两种字符类型) - 添加 Session 密钥安全检查 - 修复 .htaccess 文件上传漏洞 - 统一使用 getSafeErrorMessage() 保护敏感错误信息 - 增强数据库原型污染防护 - 添加被封禁用户分享访问检查 ## 功能修复 - 修复模态框点击外部关闭功能 - 修复 share.html 未定义方法调用 - 修复 verify.html 和 reset-password.html API 路径 - 修复数据库 SFTP->OSS 迁移逻辑 - 修复 OSS 未配置时的错误提示 - 添加文件夹名称长度限制 - 添加文件列表 API 路径验证 ## UI/UX 改进 - 添加 6 个按钮加载状态(登录/注册/修改密码等) - 将 15+ 处 alert() 替换为 Toast 通知 - 添加防重复提交机制(创建文件夹/分享) - 优化 loadUserProfile 防抖调用 ## 代码质量 - 消除 formatFileSize 重复定义 - 集中模块导入到文件顶部 - 添加 JSDoc 注释 - 创建路由拆分示例 (routes/) ## 测试套件 - 添加 boundary-tests.js (60 用例) - 添加 network-concurrent-tests.js (33 用例) - 添加 state-consistency-tests.js (38 用例) - 添加 test_share.js 和 test_admin.js ## 文档和配置 - 新增 INSTALL_GUIDE.md 手动部署指南 - 新增 VERSION.txt 版本历史 - 完善 .env.example 配置说明 - 新增 docker-compose.yml - 完善 nginx.conf.example Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,74 @@ function formatFileSize(bytes) {
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 OSS/网络错误转换为友好的错误信息
|
||||
* @param {Error} error - 原始错误
|
||||
* @param {string} operation - 操作描述
|
||||
* @returns {Error} 带有友好消息的错误
|
||||
*/
|
||||
function formatOssError(error, operation = '操作') {
|
||||
// 常见的 AWS S3 / OSS 错误
|
||||
const errorMessages = {
|
||||
'NoSuchBucket': 'OSS 存储桶不存在,请检查配置',
|
||||
'AccessDenied': 'OSS 访问被拒绝,请检查权限配置',
|
||||
'InvalidAccessKeyId': 'OSS Access Key 无效,请重新配置',
|
||||
'SignatureDoesNotMatch': 'OSS 签名验证失败,请检查 Secret Key',
|
||||
'NoSuchKey': '文件或目录不存在',
|
||||
'EntityTooLarge': '文件过大,超过了 OSS 允许的最大大小',
|
||||
'RequestTimeout': 'OSS 请求超时,请稍后重试',
|
||||
'SlowDown': 'OSS 请求过于频繁,请稍后重试',
|
||||
'ServiceUnavailable': 'OSS 服务暂时不可用,请稍后重试',
|
||||
'InternalError': 'OSS 内部错误,请稍后重试',
|
||||
'BucketNotEmpty': '存储桶不为空',
|
||||
'InvalidBucketName': '无效的存储桶名称',
|
||||
'InvalidObjectName': '无效的对象名称',
|
||||
'TooManyBuckets': '存储桶数量超过限制'
|
||||
};
|
||||
|
||||
// 网络错误
|
||||
const networkErrors = {
|
||||
'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络',
|
||||
'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置',
|
||||
'ETIMEDOUT': '连接 OSS 服务超时,请检查网络',
|
||||
'ECONNRESET': '与 OSS 服务的连接被重置,请重试',
|
||||
'EPIPE': '与 OSS 服务的连接中断,请重试',
|
||||
'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络'
|
||||
};
|
||||
|
||||
// 检查 AWS SDK 错误名称
|
||||
if (error.name && errorMessages[error.name]) {
|
||||
return new Error(`${operation}失败: ${errorMessages[error.name]}`);
|
||||
}
|
||||
|
||||
// 检查网络错误代码
|
||||
if (error.code && networkErrors[error.code]) {
|
||||
return new Error(`${operation}失败: ${networkErrors[error.code]}`);
|
||||
}
|
||||
|
||||
// HTTP 状态码错误
|
||||
if (error.$metadata?.httpStatusCode) {
|
||||
const statusCode = error.$metadata.httpStatusCode;
|
||||
const statusMessages = {
|
||||
400: '请求参数错误',
|
||||
401: '认证失败,请检查 Access Key',
|
||||
403: '没有权限执行此操作',
|
||||
404: '资源不存在',
|
||||
409: '资源冲突',
|
||||
429: '请求过于频繁,请稍后重试',
|
||||
500: 'OSS 服务内部错误',
|
||||
502: 'OSS 网关错误',
|
||||
503: 'OSS 服务暂时不可用'
|
||||
};
|
||||
if (statusMessages[statusCode]) {
|
||||
return new Error(`${operation}失败: ${statusMessages[statusCode]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回原始错误信息
|
||||
return new Error(`${operation}失败: ${error.message}`);
|
||||
}
|
||||
|
||||
// ===== 统一存储接口 =====
|
||||
|
||||
/**
|
||||
@@ -39,6 +107,10 @@ class StorageInterface {
|
||||
await client.init();
|
||||
return client;
|
||||
} else {
|
||||
// 在尝试连接 OSS 之前,先检查用户是否已配置 OSS
|
||||
if (!this.user.has_oss_config) {
|
||||
throw new Error('OSS 存储未配置,请先在设置中配置您的 OSS 服务(阿里云/腾讯云/AWS)');
|
||||
}
|
||||
const client = new OssStorageClient(this.user);
|
||||
await client.connect();
|
||||
return client;
|
||||
@@ -68,6 +140,8 @@ class LocalStorageClient {
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
* @param {string} dirPath - 目录路径
|
||||
* @returns {Promise<Array>} 文件列表
|
||||
*/
|
||||
async list(dirPath) {
|
||||
const fullPath = this.getFullPath(dirPath);
|
||||
@@ -78,18 +152,32 @@ class LocalStorageClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
||||
// 检查是否是目录
|
||||
const pathStats = fs.statSync(fullPath);
|
||||
if (!pathStats.isDirectory()) {
|
||||
throw new Error('指定路径不是目录');
|
||||
}
|
||||
|
||||
return items.map(item => {
|
||||
const itemPath = path.join(fullPath, item.name);
|
||||
const stats = fs.statSync(itemPath);
|
||||
return {
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'd' : '-',
|
||||
size: stats.size,
|
||||
modifyTime: stats.mtimeMs
|
||||
};
|
||||
});
|
||||
const items = fs.readdirSync(fullPath, { withFileTypes: true });
|
||||
const result = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const itemPath = path.join(fullPath, item.name);
|
||||
const stats = fs.statSync(itemPath);
|
||||
result.push({
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'd' : '-',
|
||||
size: stats.size,
|
||||
modifyTime: stats.mtimeMs
|
||||
});
|
||||
} catch (error) {
|
||||
// 跳过无法访问的文件(权限问题或符号链接断裂等)
|
||||
console.warn(`[本地存储] 无法获取文件信息,跳过: ${item.name}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +244,23 @@ class LocalStorageClient {
|
||||
*/
|
||||
async delete(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`[本地存储] 删除目标不存在,跳过: ${filePath}`);
|
||||
return; // 文件不存在,直接返回(幂等操作)
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(fullPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// 文件在检查后被删除,直接返回
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// 删除文件夹 - 递归删除
|
||||
@@ -170,12 +274,15 @@ class LocalStorageClient {
|
||||
if (folderSize > 0) {
|
||||
this.updateUsedSpace(-folderSize);
|
||||
}
|
||||
console.log(`[本地存储] 删除文件夹: ${filePath} (释放 ${this.formatSize(folderSize)})`);
|
||||
} else {
|
||||
const fileSize = stats.size;
|
||||
// 删除文件
|
||||
fs.unlinkSync(fullPath);
|
||||
|
||||
// 更新已使用空间
|
||||
this.updateUsedSpace(-stats.size);
|
||||
this.updateUsedSpace(-fileSize);
|
||||
console.log(`[本地存储] 删除文件: ${filePath} (释放 ${this.formatSize(fileSize)})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +311,30 @@ class LocalStorageClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
* 重命名文件或目录
|
||||
* @param {string} oldPath - 原路径
|
||||
* @param {string} newPath - 新路径
|
||||
*/
|
||||
async rename(oldPath, newPath) {
|
||||
const oldFullPath = this.getFullPath(oldPath);
|
||||
const newFullPath = this.getFullPath(newPath);
|
||||
|
||||
// 检查源和目标是否相同
|
||||
if (oldFullPath === newFullPath) {
|
||||
console.log(`[本地存储] 源路径和目标路径相同,跳过: ${oldPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查源文件是否存在
|
||||
if (!fs.existsSync(oldFullPath)) {
|
||||
throw new Error('源文件或目录不存在');
|
||||
}
|
||||
|
||||
// 检查目标是否已存在(防止覆盖)
|
||||
if (fs.existsSync(newFullPath)) {
|
||||
throw new Error('目标位置已存在同名文件或目录');
|
||||
}
|
||||
|
||||
// 确保新路径的目录存在
|
||||
const newDir = path.dirname(newFullPath);
|
||||
if (!fs.existsSync(newDir)) {
|
||||
@@ -217,24 +342,84 @@ class LocalStorageClient {
|
||||
}
|
||||
|
||||
fs.renameSync(oldFullPath, newFullPath);
|
||||
console.log(`[本地存储] 重命名: ${oldPath} -> ${newPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<Object>} 文件状态信息,包含 isDirectory 属性
|
||||
*/
|
||||
async stat(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
return fs.statSync(fullPath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`文件或目录不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
// 返回与 OssStorageClient.stat 一致的格式
|
||||
return {
|
||||
size: stats.size,
|
||||
modifyTime: stats.mtimeMs,
|
||||
isDirectory: stats.isDirectory(),
|
||||
// 保留原始 stats 对象的方法兼容性
|
||||
isFile: () => stats.isFile(),
|
||||
_raw: stats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件读取流
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {ReadStream} 文件读取流
|
||||
*/
|
||||
createReadStream(filePath) {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
return fs.createReadStream(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
* @param {string} dirPath - 目录路径
|
||||
*/
|
||||
async mkdir(dirPath) {
|
||||
const fullPath = this.getFullPath(dirPath);
|
||||
|
||||
// 检查是否已存在
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
// 目录已存在,直接返回
|
||||
return;
|
||||
}
|
||||
throw new Error('同名文件已存在');
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
fs.mkdirSync(fullPath, { recursive: true, mode: 0o755 });
|
||||
console.log(`[本地存储] 创建文件夹: ${dirPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件或目录是否存在
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(filePath) {
|
||||
try {
|
||||
const fullPath = this.getFullPath(filePath);
|
||||
return fs.existsSync(fullPath);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接(本地存储无需关闭)
|
||||
*/
|
||||
@@ -381,7 +566,14 @@ class OssStorageClient {
|
||||
credentials: {
|
||||
accessKeyId: oss_access_key_id,
|
||||
secretAccessKey: oss_access_key_secret
|
||||
}
|
||||
},
|
||||
// 请求超时配置
|
||||
requestHandler: {
|
||||
requestTimeout: 30000, // 30秒超时
|
||||
httpsAgent: { timeout: 30000 }
|
||||
},
|
||||
// 重试配置
|
||||
maxAttempts: 3
|
||||
};
|
||||
|
||||
// 阿里云 OSS
|
||||
@@ -764,14 +956,15 @@ class OssStorageClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件(OSS 不支持直接重命名,需要复制后删除)
|
||||
* 注意:此方法只支持单个文件的重命名,不支持目录
|
||||
* 重命名文件或目录(OSS 不支持直接重命名,需要复制后删除)
|
||||
* 支持文件和目录的重命名
|
||||
* @param {string} oldPath - 原路径
|
||||
* @param {string} newPath - 新路径
|
||||
*/
|
||||
async rename(oldPath, newPath) {
|
||||
const oldKey = this.getObjectKey(oldPath);
|
||||
const newKey = this.getObjectKey(newPath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
let copySuccess = false;
|
||||
|
||||
// 验证源和目标不同
|
||||
if (oldKey === newKey) {
|
||||
@@ -779,11 +972,16 @@ class OssStorageClient {
|
||||
return;
|
||||
}
|
||||
|
||||
let copySuccess = false;
|
||||
|
||||
try {
|
||||
// 检查源文件是否存在
|
||||
const statResult = await this.stat(oldPath);
|
||||
|
||||
// 如果是目录,执行目录重命名
|
||||
if (statResult.isDirectory) {
|
||||
throw new Error('不支持重命名目录,请使用移动操作');
|
||||
await this._renameDirectory(oldPath, newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 CopyObjectCommand 复制文件
|
||||
@@ -845,6 +1043,101 @@ class OssStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名目录(内部方法)
|
||||
* 通过遍历目录下所有对象,逐个复制到新位置后删除原对象
|
||||
* @param {string} oldPath - 原目录路径
|
||||
* @param {string} newPath - 新目录路径
|
||||
* @private
|
||||
*/
|
||||
async _renameDirectory(oldPath, newPath) {
|
||||
const oldPrefix = this.getObjectKey(oldPath);
|
||||
const newPrefix = this.getObjectKey(newPath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
|
||||
// 确保前缀以斜杠结尾
|
||||
const oldPrefixWithSlash = oldPrefix.endsWith('/') ? oldPrefix : `${oldPrefix}/`;
|
||||
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
|
||||
|
||||
let continuationToken = null;
|
||||
let copiedKeys = [];
|
||||
let totalCount = 0;
|
||||
|
||||
try {
|
||||
// 第一阶段:复制所有对象到新位置
|
||||
do {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: oldPrefixWithSlash,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const listResponse = await this.s3Client.send(listCommand);
|
||||
continuationToken = listResponse.NextContinuationToken;
|
||||
|
||||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||||
for (const obj of listResponse.Contents) {
|
||||
// 计算新的 key(替换前缀)
|
||||
const newKey = newPrefixWithSlash + obj.Key.substring(oldPrefixWithSlash.length);
|
||||
|
||||
// 复制对象
|
||||
const encodedOldKey = obj.Key.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const copyCommand = new CopyObjectCommand({
|
||||
Bucket: bucket,
|
||||
CopySource: `${bucket}/${encodedOldKey}`,
|
||||
Key: newKey
|
||||
});
|
||||
|
||||
await this.s3Client.send(copyCommand);
|
||||
copiedKeys.push({ oldKey: obj.Key, newKey });
|
||||
totalCount++;
|
||||
}
|
||||
}
|
||||
} while (continuationToken);
|
||||
|
||||
// 第二阶段:删除所有原对象
|
||||
if (copiedKeys.length > 0) {
|
||||
// 批量删除(每批最多 1000 个)
|
||||
for (let i = 0; i < copiedKeys.length; i += 1000) {
|
||||
const batch = copiedKeys.slice(i, i + 1000);
|
||||
const deleteCommand = new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: batch.map(item => ({ Key: item.oldKey })),
|
||||
Quiet: true
|
||||
}
|
||||
});
|
||||
await this.s3Client.send(deleteCommand);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[OSS存储] 重命名目录: ${oldPath} -> ${newPath} (${totalCount} 个对象)`);
|
||||
|
||||
} catch (error) {
|
||||
// 如果出错,尝试回滚(删除已复制的新对象)
|
||||
if (copiedKeys.length > 0) {
|
||||
console.warn(`[OSS存储] 目录重命名失败,尝试回滚已复制的 ${copiedKeys.length} 个对象...`);
|
||||
try {
|
||||
for (let i = 0; i < copiedKeys.length; i += 1000) {
|
||||
const batch = copiedKeys.slice(i, i + 1000);
|
||||
const deleteCommand = new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: batch.map(item => ({ Key: item.newKey })),
|
||||
Quiet: true
|
||||
}
|
||||
});
|
||||
await this.s3Client.send(deleteCommand);
|
||||
}
|
||||
console.log(`[OSS存储] 回滚成功`);
|
||||
} catch (rollbackError) {
|
||||
console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`重命名目录失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件信息
|
||||
*/
|
||||
@@ -1023,6 +1316,20 @@ class OssStorageClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件或目录是否存在
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(filePath) {
|
||||
try {
|
||||
await this.stat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
@@ -1042,5 +1349,6 @@ module.exports = {
|
||||
StorageInterface,
|
||||
LocalStorageClient,
|
||||
OssStorageClient,
|
||||
formatFileSize // 导出共享的工具函数
|
||||
formatFileSize, // 导出共享的工具函数
|
||||
formatOssError // 导出 OSS 错误格式化函数
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user