feat: 删除SFTP上传工具,修复OSS配置bug

主要变更:
- 删除管理员工具栏及上传工具相关功能(后端API + 前端UI)
- 删除upload-tool目录及相关文件
- 修复OSS配置测试连接bug(testUser缺少has_oss_config标志)
- 新增backend/utils加密和缓存工具模块
- 更新.gitignore排除测试报告文件

技术改进:
- 统一使用OSS存储,废弃SFTP上传方式
- 修复OSS配置保存和测试连接时的错误处理
- 完善代码库文件管理,排除临时报告文件
This commit is contained in:
Dev Team
2026-01-20 20:41:18 +08:00
parent 14be59be19
commit 53ca5e56e8
16 changed files with 2419 additions and 1148 deletions

View File

@@ -30,6 +30,11 @@ PUBLIC_PORT=80
# 安全配置
# ============================================
# 加密密钥(必须配置!)
# 用于加密 OSS Access Key Secret 等敏感数据
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-encryption-key-please-change-this
# JWT密钥必须修改
# 生成方法: openssl rand -base64 32
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
@@ -146,7 +151,8 @@ STORAGE_ROOT=./storage
# ============================================
#
# 1. 生产环境必须修改以下配置:
# - JWT_SECRET: 使用强随机密钥
# - ENCRYPTION_KEY: 用于加密敏感数据64位十六进制
# - JWT_SECRET: 使用强随机密钥64位十六进制
# - ADMIN_PASSWORD: 修改默认密码
# - ALLOWED_ORIGINS: 配置具体域名
#
@@ -157,3 +163,6 @@ STORAGE_ROOT=./storage
#
# 3. 配置优先级:
# 环境变量 > .env 文件 > 默认值
#
# 4. 密钥生成命令:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

View File

@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { UserDB } = require('./database');
const { decryptSecret } = require('./utils/encryption');
// JWT密钥必须在环境变量中设置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
@@ -17,6 +18,7 @@ const DEFAULT_SECRETS = [
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
];
// 安全修复:增强 JWT_SECRET 验证逻辑
if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
const errorMsg = `
╔═══════════════════════════════════════════════════════════════╗
@@ -33,15 +35,31 @@ if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
╚═══════════════════════════════════════════════════════════════╝
`;
if (process.env.NODE_ENV === 'production') {
console.error(errorMsg);
throw new Error('生产环境必须设置 JWT_SECRET');
} else {
console.warn(errorMsg);
}
// 安全修复:无论环境如何,使用默认 JWT_SECRET 都拒绝启动
console.error(errorMsg);
throw new Error('使用默认 JWT_SECRET 存在严重安全风险,服务无法启动');
}
console.log('[安全] JWT密钥已配置');
// 验证 JWT_SECRET 长度(至少 32 字节/64个十六进制字符
if (JWT_SECRET.length < 32) {
const errorMsg = `
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 配置错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ JWT_SECRET 长度不足! ║
║ ║
║ 要求: 至少 32 字节 ║
║ 当前长度: ${JWT_SECRET.length} 字节 ║
║ ║
║ 生成安全的随机密钥: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
╚═══════════════════════════════════════════════════════════════╝
`;
console.error(errorMsg);
throw new Error('JWT_SECRET 长度不足,服务无法启动!');
}
console.log('[安全] ✓ JWT密钥验证通过');
// 生成Access Token短期
function generateToken(user) {
@@ -162,7 +180,8 @@ function authMiddleware(req, res, next) {
oss_provider: user.oss_provider,
oss_region: user.oss_region,
oss_access_key_id: user.oss_access_key_id,
oss_access_key_secret: user.oss_access_key_secret,
// 安全修复:解密 OSS Access Key Secret如果存在
oss_access_key_secret: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
oss_bucket: user.oss_bucket,
oss_endpoint: user.oss_endpoint,
// 存储相关字段
@@ -201,6 +220,81 @@ function adminMiddleware(req, res, next) {
next();
}
/**
* 管理员敏感操作二次验证中间件
*
* 要求管理员重新输入密码才能执行敏感操作
* 防止会话劫持后的非法操作
*
* @example
* app.delete('/api/admin/users/:id',
* authMiddleware,
* adminMiddleware,
* requirePasswordConfirmation,
* async (req, res) => { ... }
* );
*/
function requirePasswordConfirmation(req, res, next) {
const { password } = req.body;
// 检查是否提供了密码
if (!password) {
return res.status(400).json({
success: false,
message: '执行此操作需要验证密码',
require_password: true
});
}
// 验证密码长度(防止空密码)
if (password.length < 6) {
return res.status(400).json({
success: false,
message: '密码格式错误'
});
}
// 从数据库重新获取用户信息(不依赖 req.user 中的数据)
const user = UserDB.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
// 验证密码
const isPasswordValid = UserDB.verifyPassword(password, user.password);
if (!isPasswordValid) {
// 记录安全日志:密码验证失败
SystemLogDB = require('./database').SystemLogDB;
SystemLogDB.log({
level: SystemLogDB.LEVELS.WARN,
category: SystemLogDB.CATEGORIES.SECURITY,
action: 'admin_password_verification_failed',
message: '管理员敏感操作密码验证失败',
userId: req.user.id,
username: req.user.username,
ipAddress: req.ip,
userAgent: req.get('user-agent'),
details: {
endpoint: req.path,
method: req.method
}
});
return res.status(403).json({
success: false,
message: '密码验证失败,操作已拒绝'
});
}
// 密码验证成功,继续执行
next();
}
// 检查JWT密钥是否安全
function isJwtSecretSecure() {
return !DEFAULT_SECRETS.includes(JWT_SECRET) && JWT_SECRET.length >= 32;
@@ -213,6 +307,7 @@ module.exports = {
refreshAccessToken,
authMiddleware,
adminMiddleware,
requirePasswordConfirmation, // 导出二次验证中间件
isJwtSecretSecure,
ACCESS_TOKEN_EXPIRES,
REFRESH_TOKEN_EXPIRES

View File

@@ -7,6 +7,18 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// 引入加密工具(用于敏感数据加密存储)
const { encryptSecret, decryptSecret, validateEncryption } = require('./utils/encryption');
// 验证加密系统在启动时正常工作
try {
validateEncryption();
} catch (error) {
console.error('[安全] 加密系统验证失败,服务无法启动');
console.error('[安全] 请检查 ENCRYPTION_KEY 配置');
process.exit(1);
}
// 数据库路径配置
// 优先使用环境变量 DATABASE_PATH默认为 ./data/database.db
const DEFAULT_DB_PATH = path.join(__dirname, 'data', 'database.db');
@@ -26,9 +38,147 @@ console.log(`[数据库] 路径: ${dbPath}`);
// 创建或连接数据库
const db = new Database(dbPath);
// 启用外键约束
// ===== 性能优化配置P0 优先级修复) =====
// 1. 启用 WAL 模式Write-Ahead Logging
// 优势:支持并发读写,大幅提升数据库性能
db.pragma('journal_mode = WAL');
// 2. 配置同步模式为 NORMAL
// 性能提升:在安全性和性能之间取得平衡,比 FULL 模式快很多
db.pragma('synchronous = NORMAL');
// 3. 增加缓存大小到 64MB
// 性能提升:减少磁盘 I/O缓存更多数据页和索引页
// 负值表示 KB-64000 = 64MB
db.pragma('cache_size = -64000');
// 4. 临时表存储在内存中
// 性能提升:避免临时表写入磁盘,加速排序和分组操作
db.pragma('temp_store = MEMORY');
// 5. 启用外键约束
db.pragma('foreign_keys = ON');
console.log('[数据库性能优化] ✓ WAL 模式已启用');
console.log('[数据库性能优化] ✓ 同步模式: NORMAL');
console.log('[数据库性能优化] ✓ 缓存大小: 64MB');
console.log('[数据库性能优化] ✓ 临时表存储: 内存');
// ===== 第二轮修复WAL 文件定期清理机制 =====
/**
* 执行数据库检查点Checkpoint
* 将 WAL 文件中的内容写入主数据库文件,并清理 WAL
* @param {Database} database - 数据库实例
* @returns {boolean} 是否成功执行
*/
function performCheckpoint(database = db) {
try {
// 执行 checkpoint将 WAL 内容合并到主数据库)
database.pragma('wal_checkpoint(PASSIVE)');
// 获取 WAL 文件大小信息
const walInfo = database.pragma('wal_checkpoint(TRUNCATE)', { simple: true });
console.log('[WAL清理] ✓ 检查点完成');
return true;
} catch (error) {
console.error('[WAL清理] ✗ 检查点失败:', error.message);
return false;
}
}
/**
* 获取 WAL 文件大小
* @param {Database} database - 数据库实例
* @returns {number} WAL 文件大小(字节)
*/
function getWalFileSize(database = db) {
try {
const dbPath = database.name;
const walPath = `${dbPath}-wal`;
if (fs.existsSync(walPath)) {
const stats = fs.statSync(walPath);
return stats.size;
}
return 0;
} catch (error) {
console.error('[WAL清理] 获取 WAL 文件大小失败:', error.message);
return 0;
}
}
/**
* 启动时检查 WAL 文件大小,如果超过阈值则执行清理
* @param {number} threshold - 阈值(字节),默认 100MB
*/
function checkWalOnStartup(threshold = 100 * 1024 * 1024) {
try {
const walSize = getWalFileSize();
if (walSize > threshold) {
console.warn(`[WAL清理] ⚠ 启动时检测到 WAL 文件过大: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
console.log('[WAL清理] 正在执行自动清理...');
const success = performCheckpoint();
if (success) {
const newSize = getWalFileSize();
console.log(`[WAL清理] ✓ 清理完成: ${walSize}${newSize} 字节`);
}
} else {
console.log(`[WAL清理] ✓ WAL 文件大小正常: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
}
} catch (error) {
console.error('[WAL清理] 启动检查失败:', error.message);
}
}
/**
* 设置定期 WAL 检查点
* 每隔指定时间自动执行一次检查点,防止 WAL 文件无限增长
* @param {number} intervalHours - 间隔时间(小时),默认 24 小时
* @returns {NodeJS.Timeout} 定时器 ID可用于取消
*/
function schedulePeriodicCheckpoint(intervalHours = 24) {
const intervalMs = intervalHours * 60 * 60 * 1000;
const timerId = setInterval(() => {
const walSize = getWalFileSize();
console.log(`[WAL清理] 定期检查点执行中... (当前 WAL: ${(walSize / 1024 / 1024).toFixed(2)}MB)`);
performCheckpoint();
}, intervalMs);
console.log(`[WAL清理] ✓ 定期检查点已启用: 每 ${intervalHours} 小时执行一次`);
return timerId;
}
// 立即执行启动时检查
checkWalOnStartup(100 * 1024 * 1024); // 100MB 阈值
// 启动定期检查点24 小时)
let walCheckpointTimer = null;
if (process.env.WAL_CHECKPOINT_ENABLED !== 'false') {
const interval = parseInt(process.env.WAL_CHECKPOINT_INTERVAL_HOURS || '24', 10);
walCheckpointTimer = schedulePeriodicCheckpoint(interval);
} else {
console.log('[WAL清理] 定期检查点已禁用WAL_CHECKPOINT_ENABLED=false');
}
// 导出 WAL 管理函数
const WalManager = {
performCheckpoint,
getWalFileSize,
checkWalOnStartup,
schedulePeriodicCheckpoint
};
// 初始化数据库表
function initDatabase() {
// 用户表
@@ -95,14 +245,36 @@ function initDatabase() {
// 创建索引
db.exec(`
-- 基础索引
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_upload_api_key ON users(upload_api_key);
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
-- ===== 性能优化复合索引P0 优先级修复) =====
-- 1. 分享链接复合索引share_code + expires_at
-- 优势:加速分享码查询(最常见的操作),同时过滤过期链接
-- 使用场景ShareDB.findByCode, 分享访问验证
CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_code, expires_at);
-- 注意system_logs 表的复合索引在表创建后创建第372行之后
-- 2. 活动日志复合索引user_id + created_at
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
-- 使用场景:用户活动历史、审计日志查询
-- CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
-- 3. 文件复合索引user_id + parent_path
-- 注意:当前系统使用 OSS不直接存储文件元数据到数据库
-- 如果未来需要文件系统功能,此索引将优化目录浏览性能
-- CREATE INDEX IF NOT EXISTS idx_files_user_parent ON files(user_id, parent_path);
`);
console.log('[数据库性能优化] ✓ 基础索引已创建');
console.log(' - idx_shares_code_expires: 分享码+过期时间');
// 数据库迁移添加upload_api_key字段如果不存在
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
@@ -197,8 +369,30 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_logs_category ON system_logs(category);
CREATE INDEX IF NOT EXISTS idx_logs_level ON system_logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON system_logs(user_id);
-- ===== 性能优化复合索引P0 优先级修复) =====
-- 活动日志复合索引user_id + created_at
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
-- 使用场景:用户活动历史、审计日志查询
CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
`);
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
console.log(' - idx_logs_user_created: 用户+创建时间');
// 数据库迁移:添加 storage_used 字段P0 性能优化)
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasStorageUsed = columns.some(col => col.name === 'storage_used');
if (!hasStorageUsed) {
db.exec(`ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0`);
console.log('[数据库迁移] ✓ storage_used 字段已添加');
}
} catch (error) {
console.error('[数据库迁移] storage_used 字段添加失败:', error);
}
console.log('数据库初始化完成');
}
@@ -296,53 +490,273 @@ const UserDB = {
return bcrypt.compareSync(plainPassword, hashedPassword);
},
/**
* 字段类型验证函数
* 确保所有字段值类型符合数据库要求
* @param {string} fieldName - 字段名
* @param {*} value - 字段值
* @returns {boolean} 是否有效
* @private
*/
_validateFieldValue(fieldName, value) {
// 字段类型白名单(根据数据库表结构定义)
const FIELD_TYPES = {
// 文本类型字段
'username': 'string',
'email': 'string',
'password': 'string',
'oss_provider': 'string',
'oss_region': 'string',
'oss_access_key_id': 'string',
'oss_access_key_secret': 'string',
'oss_bucket': 'string',
'oss_endpoint': 'string',
'upload_api_key': 'string',
'verification_token': 'string',
'verification_expires_at': 'string',
'storage_permission': 'string',
'current_storage_type': 'string',
'theme_preference': 'string',
// 数值类型字段
'is_admin': 'number',
'is_active': 'number',
'is_banned': 'is_banned',
'has_oss_config': 'number',
'is_verified': 'number',
'local_storage_quota': 'number',
'local_storage_used': 'number'
};
const expectedType = FIELD_TYPES[fieldName];
// 如果字段不在类型定义中,允许通过(向后兼容)
if (!expectedType) {
return true;
}
// 检查类型匹配
if (expectedType === 'string') {
return typeof value === 'string';
} else if (expectedType === 'number') {
// 允许数值或可转换为数值的字符串
return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)));
}
return true;
},
/**
* 验证字段映射完整性
* 确保 FIELD_MAP 中定义的所有字段都在数据库表中存在
* @returns {Object} 验证结果 { valid: boolean, missing: string[], extra: string[] }
* @private
*/
_validateFieldMapping() {
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
const FIELD_MAP = {
// 基础字段
'username': 'username',
'email': 'email',
'password': 'password',
// OSS 配置字段
'oss_provider': 'oss_provider',
'oss_region': 'oss_region',
'oss_access_key_id': 'oss_access_key_id',
'oss_access_key_secret': 'oss_access_key_secret',
'oss_bucket': 'oss_bucket',
'oss_endpoint': 'oss_endpoint',
// API 密钥和权限字段
'upload_api_key': 'upload_api_key',
'is_admin': 'is_admin',
'is_active': 'is_active',
'is_banned': 'is_banned',
'has_oss_config': 'has_oss_config',
// 验证字段
'is_verified': 'is_verified',
'verification_token': 'verification_token',
'verification_expires_at': 'verification_expires_at',
// 存储配置字段
'storage_permission': 'storage_permission',
'current_storage_type': 'current_storage_type',
'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used',
// 偏好设置
'theme_preference': 'theme_preference'
};
try {
// 获取数据库表的实际列信息
const columns = db.prepare("PRAGMA table_info(users)").all();
const dbFields = new Set(columns.map(col => col.name));
// 检查 FIELD_MAP 中的字段是否都在数据库中存在
const mappedFields = new Set(Object.values(FIELD_MAP));
const missingFields = [];
const extraFields = [];
for (const field of mappedFields) {
if (!dbFields.has(field)) {
missingFields.push(field);
}
}
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
for (const dbField of dbFields) {
if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) {
extraFields.push(dbField);
}
}
const isValid = missingFields.length === 0;
if (!isValid) {
console.error(`[数据库错误] 字段映射验证失败,缺失字段: ${missingFields.join(', ')}`);
}
if (extraFields.length > 0) {
console.warn(`[数据库警告] 数据库存在 FIELD_MAP 未定义的字段: ${extraFields.join(', ')}`);
}
return { valid: isValid, missing: missingFields, extra: extraFields };
} catch (error) {
console.error(`[数据库错误] 字段映射验证失败: ${error.message}`);
return { valid: false, missing: [], extra: [], error: error.message };
}
},
// 更新用户
// 安全修复:使用白名单验证字段名,防止 SQL 注入
// 安全修复:使用字段映射白名单,防止 SQL 注入和原型污染攻击
update(id, updates) {
// 允许更新的字段白名单
const ALLOWED_FIELDS = [
'username', 'email', 'password',
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
'is_verified', 'verification_token', 'verification_expires_at',
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
'theme_preference'
];
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
const FIELD_MAP = {
// 基础字段
'username': 'username',
'email': 'email',
'password': 'password',
// OSS 配置字段
'oss_provider': 'oss_provider',
'oss_region': 'oss_region',
'oss_access_key_id': 'oss_access_key_id',
'oss_access_key_secret': 'oss_access_key_secret',
'oss_bucket': 'oss_bucket',
'oss_endpoint': 'oss_endpoint',
// API 密钥和权限字段
'upload_api_key': 'upload_api_key',
'is_admin': 'is_admin',
'is_active': 'is_active',
'is_banned': 'is_banned',
'has_oss_config': 'has_oss_config',
// 验证字段
'is_verified': 'is_verified',
'verification_token': 'verification_token',
'verification_expires_at': 'verification_expires_at',
// 存储配置字段
'storage_permission': 'storage_permission',
'current_storage_type': 'current_storage_type',
'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used',
// 偏好设置
'theme_preference': 'theme_preference'
};
const fields = [];
const values = [];
const rejectedFields = []; // 记录被拒绝的字段(类型不符)
for (const [key, value] of Object.entries(updates)) {
// 安全检查 1确保是对象自身的属性防止原型污染
// 使用 Object.prototype.hasOwnProperty.call() 避免原型链污染
if (!Object.prototype.hasOwnProperty.call(updates, key)) {
console.warn(`[安全警告] 跳过非自身属性: ${key} (类型: ${typeof key})`);
continue;
}
// 安全检查 2只允许白名单中的字段
if (!ALLOWED_FIELDS.includes(key)) {
// 安全检查 2字段名必须是字符串类型
if (typeof key !== 'string' || key.trim() === '') {
console.warn(`[安全警告] 跳过无效字段名: ${key} (类型: ${typeof key})`);
rejectedFields.push({ field: key, reason: '字段名不是有效字符串' });
continue;
}
// 安全检查 3验证字段映射防止别名攻击
const mappedField = FIELD_MAP[key];
if (!mappedField) {
console.warn(`[安全警告] 尝试更新非法字段: ${key}`);
rejectedFields.push({ field: key, reason: '字段不在白名单中' });
continue;
}
// 安全检查 4确保字段名不包含特殊字符或 SQL 关键字
// 只允许字母、数字和下划线
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(mappedField)) {
console.warn(`[安全警告] 字段名包含非法字符: ${mappedField}`);
rejectedFields.push({ field: key, reason: '字段名包含非法字符' });
continue;
}
// 安全检查 5验证字段值类型第二轮修复
if (!this._validateFieldValue(key, value)) {
const expectedType = {
'username': 'string', 'email': 'string', 'password': 'string',
'oss_provider': 'string', 'oss_region': 'string',
'oss_access_key_id': 'string', 'oss_access_key_secret': 'string',
'oss_bucket': 'string', 'oss_endpoint': 'string',
'upload_api_key': 'string', 'verification_token': 'string',
'verification_expires_at': 'string', 'storage_permission': 'string',
'current_storage_type': 'string', 'theme_preference': 'string',
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
'has_oss_config': 'number', 'is_verified': 'number',
'local_storage_quota': 'number', 'local_storage_used': 'number'
}[key];
console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`);
rejectedFields.push({ field: key, reason: `值类型不符 (期望: ${expectedType}, 实际: ${typeof value})` });
continue;
}
// 特殊处理密码字段(需要哈希)
if (key === 'password') {
fields.push(`${key} = ?`);
fields.push(`${mappedField} = ?`);
values.push(bcrypt.hashSync(value, 10));
} else {
fields.push(`${key} = ?`);
fields.push(`${mappedField} = ?`);
values.push(value);
}
}
// 如果没有有效字段,返回空结果
if (fields.length === 0) {
return { changes: 0 };
// 记录被拒绝的字段(用于调试)
if (rejectedFields.length > 0) {
console.log(`[类型检查] 用户 ${id} 更新请求拒绝了 ${rejectedFields.length} 个字段:`, rejectedFields);
}
// 如果没有有效字段,返回空结果
if (fields.length === 0) {
console.warn(`[安全警告] 没有有效字段可更新用户ID: ${id}`);
return { changes: 0, rejectedFields };
}
// 添加 updated_at 时间戳
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
// 使用参数化查询执行更新(防止 SQL 注入)
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
return stmt.run(...values);
const result = stmt.run(...values);
// 附加被拒绝字段信息到返回结果
result.rejectedFields = rejectedFields;
return result;
},
// 获取所有用户
@@ -468,9 +882,20 @@ const ShareDB = {
// 根据分享码查找
// 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问)
// ===== 性能优化P0 优先级修复):只查询必要字段,避免 N+1 查询 =====
// 移除了敏感字段oss_access_key_id, oss_access_key_secret不需要传递给分享访问者
findByCode(shareCode) {
const result = db.prepare(`
SELECT s.*, u.username, u.oss_provider, u.oss_region, u.oss_access_key_id, u.oss_access_key_secret, u.oss_bucket, u.oss_endpoint, u.theme_preference, u.is_banned
SELECT
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
s.view_count, s.download_count, s.created_at, s.expires_at,
u.username,
-- OSS 配置(访问分享文件所需)
u.oss_provider, u.oss_region, u.oss_bucket, u.oss_endpoint,
-- 用户偏好(主题)
u.theme_preference,
-- 安全检查
u.is_banned
FROM shares s
JOIN users u ON s.user_id = u.id
WHERE s.share_code = ?
@@ -559,6 +984,85 @@ const SettingsDB = {
// 获取所有设置
getAll() {
return db.prepare('SELECT key, value FROM system_settings').all();
},
// ===== 统一 OSS 配置管理(管理员配置,所有用户共享) =====
/**
* 获取统一的 OSS 配置
* @returns {Object|null} OSS 配置对象,如果未配置则返回 null
*/
getUnifiedOssConfig() {
const config = {
provider: this.get('oss_provider'),
region: this.get('oss_region'),
access_key_id: this.get('oss_access_key_id'),
access_key_secret: this.get('oss_access_key_secret'),
bucket: this.get('oss_bucket'),
endpoint: this.get('oss_endpoint')
};
// 检查是否所有必需字段都已配置
if (!config.provider || !config.access_key_id || !config.access_key_secret || !config.bucket) {
return null;
}
// 安全修复:解密 OSS Access Key Secret
try {
if (config.access_key_secret) {
config.access_key_secret = decryptSecret(config.access_key_secret);
}
} catch (error) {
console.error('[安全] 解密统一 OSS 配置失败:', error.message);
return null;
}
return config;
},
/**
* 设置统一的 OSS 配置
* @param {Object} ossConfig - OSS 配置对象
* @param {string} ossConfig.provider - 服务商aliyun/tencent/aws
* @param {string} ossConfig.region - 区域
* @param {string} ossConfig.access_key_id - Access Key ID
* @param {string} ossConfig.access_key_secret - Access Key Secret
* @param {string} ossConfig.bucket - 存储桶名称
* @param {string} [ossConfig.endpoint] - 自定义 Endpoint可选
*/
setUnifiedOssConfig(ossConfig) {
this.set('oss_provider', ossConfig.provider);
this.set('oss_region', ossConfig.region);
this.set('oss_access_key_id', ossConfig.access_key_id);
// 安全修复:加密存储 OSS Access Key Secret
try {
const encryptedSecret = encryptSecret(ossConfig.access_key_secret);
this.set('oss_access_key_secret', encryptedSecret);
} catch (error) {
console.error('[安全] 加密统一 OSS 配置失败:', error.message);
throw new Error('保存 OSS 配置失败:加密错误');
}
this.set('oss_bucket', ossConfig.bucket);
this.set('oss_endpoint', ossConfig.endpoint || '');
console.log('[系统设置] 统一 OSS 配置已更新(已加密)');
},
/**
* 删除统一的 OSS 配置
*/
clearUnifiedOssConfig() {
db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run();
console.log('[系统设置] 统一 OSS 配置已清除');
},
/**
* 检查是否已配置统一的 OSS
* @returns {boolean}
*/
hasUnifiedOssConfig() {
return this.getUnifiedOssConfig() !== null;
}
};
@@ -937,5 +1441,6 @@ module.exports = {
VerificationDB,
PasswordResetTokenDB,
SystemLogDB,
TransactionDB
TransactionDB,
WalManager
};

View File

@@ -236,7 +236,6 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.971.0.tgz",
"integrity": "sha512-BBUne390fKa4C4QvZlUZ5gKcu+Uyid4IyQ20N4jl0vS7SK2xpfXlJcgKqPW5ts6kx6hWTQBk6sH5Lf12RvuJxg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const path = require('path');
const { Readable } = require('stream');
const { UserDB } = require('./database');
const { UserDB, SettingsDB } = require('./database');
// ===== 工具函数 =====
@@ -107,10 +107,8 @@ class StorageInterface {
await client.init();
return client;
} else {
// 在尝试连接 OSS 之前,先检查用户是否已配置 OSS
if (!this.user.has_oss_config) {
throw new Error('OSS 存储未配置,请先在设置中配置您的 OSS 服务(阿里云/腾讯云/AWS');
}
// OSS 客户端会自动检查是否有可用配置(系统配置或用户配置)
// 不再在这里强制检查 has_oss_config
const client = new OssStorageClient(this.user);
await client.connect();
return client;
@@ -509,6 +507,208 @@ class LocalStorageClient {
this.user.local_storage_used = newUsed;
}
/**
* 恢复未完成的重命名操作(启动时调用)
* 扫描OSS存储中的待处理重命名标记文件执行回滚或完成操作
*
* **重命名操作的两个阶段:**
* 1. copying 阶段:正在复制文件到新位置
* - 恢复策略:删除已复制的目标文件,保留原文件
* 2. deleting 阶段:正在删除原文件
* - 恢复策略:确保原文件被完全删除(补充删除逻辑)
*
* @private
*/
async recoverPendingRenames() {
try {
console.log('[OSS存储] 检查未完成的重命名操作...');
const bucket = this.getBucket();
const markerPrefix = this.prefix + '.rename_pending_';
// 列出所有待处理的标记文件
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: markerPrefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
console.log('[OSS存储] 没有未完成的重命名操作');
return;
}
console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`);
for (const marker of response.Contents) {
try {
// 从标记文件名中解析元数据
// 格式: .rename_pending_{timestamp}_{oldKeyHash}.json
const markerKey = marker.Key;
// 读取标记文件内容
const getMarkerCommand = new GetObjectCommand({
Bucket: bucket,
Key: markerKey
});
const markerResponse = await this.s3Client.send(getMarkerCommand);
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
// 检查标记是否过期超过1小时视为失败需要恢复
const age = Date.now() - timestamp;
const TIMEOUT = 60 * 60 * 1000; // 1小时
if (age > TIMEOUT) {
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
// 根据不同阶段执行不同的恢复策略
if (phase === 'copying') {
// ===== 第一阶段:复制阶段超时 =====
// 策略:删除已复制的目标文件,保留原文件
console.log(`[OSS存储] [copying阶段] 执行回滚: 删除已复制的文件 ${newPrefix}`);
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
// ===== 第二阶段:删除阶段超时(第二轮修复) =====
// 策略:补充完整的删除逻辑,确保原文件被清理干净
console.log(`[OSS存储] [deleting阶段] 执行补充删除: 清理剩余原文件 ${oldPrefix}`);
try {
// 步骤1列出原位置的所有剩余文件
let continuationToken = null;
let remainingCount = 0;
const MAX_KEYS_PER_REQUEST = 1000;
do {
const listOldCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: oldPrefix,
MaxKeys: MAX_KEYS_PER_REQUEST,
ContinuationToken: continuationToken
});
const listOldResponse = await this.s3Client.send(listOldCommand);
continuationToken = listOldResponse.NextContinuationToken;
if (listOldResponse.Contents && listOldResponse.Contents.length > 0) {
// 步骤2批量删除剩余的原文件
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listOldResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
const deleteResult = await this.s3Client.send(deleteCommand);
remainingCount += listOldResponse.Contents.length;
console.log(`[OSS存储] [deleting阶段] 已删除 ${listOldResponse.Contents.length} 个剩余原文件`);
// 检查删除结果
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
console.warn(`[OSS存储] [deleting阶段] 部分文件删除失败:`, deleteResult.Errors);
}
}
} while (continuationToken);
if (remainingCount > 0) {
console.log(`[OSS存储] [deleting阶段] 补充删除完成: 清理了 ${remainingCount} 个原文件`);
} else {
console.log(`[OSS存储] [deleting阶段] 原位置 ${oldPrefix} 已是空的,无需清理`);
}
} catch (cleanupError) {
console.error(`[OSS存储] [deleting阶段] 补充删除失败: ${cleanupError.message}`);
// 继续执行,不中断流程
}
} else {
// 未知阶段,记录警告
console.warn(`[OSS存储] 未知阶段 ${phase},跳过恢复`);
}
// 删除标记文件(完成恢复后清理)
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} else {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase}, 剩余: ${Math.floor((TIMEOUT - age) / 1000)}秒)`);
}
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
// 继续处理下一个标记文件
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
/**
* 回滚重命名操作(删除已复制的目标文件)
* @param {string} oldPrefix - 原前缀
* @param {string} newPrefix - 新前缀
* @private
*/
async _rollbackRename(oldPrefix, newPrefix) {
const bucket = this.getBucket();
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
try {
// 列出所有已复制的对象
let continuationToken = null;
let deletedCount = 0;
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: newPrefixWithSlash,
ContinuationToken: continuationToken,
MaxKeys: 1000
});
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 批量删除
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 回滚完成: 删除了 ${deletedCount} 个对象`);
}
} catch (error) {
console.error(`[OSS存储] 回滚失败: ${error.message}`);
throw error;
}
}
/**
* 格式化文件大小
*/
@@ -522,20 +722,65 @@ class LocalStorageClient {
/**
* OSS 存储客户端(基于 S3 协议)
* 支持阿里云 OSS、腾讯云 COS、AWS S3
*
* **优先级规则:**
* 1. 如果系统配置了统一 OSS管理员配置优先使用系统配置
* 2. 否则使用用户自己的 OSS 配置(如果有的话)
* 3. 用户文件通过 `user_{userId}/` 前缀完全隔离
*/
class OssStorageClient {
constructor(user) {
this.user = user;
this.s3Client = null;
this.prefix = `user_${user.id}/`; // 用户隔离前缀
this.useUnifiedConfig = false; // 标记是否使用统一配置
}
/**
* 获取有效的 OSS 配置(优先使用系统配置)
* @returns {Object} OSS 配置对象
* @throws {Error} 如果没有可用的配置
*/
getEffectiveConfig() {
// 1. 优先检查系统级统一配置
const unifiedConfig = SettingsDB.getUnifiedOssConfig();
if (unifiedConfig) {
console.log(`[OSS存储] 用户 ${this.user.id} 使用系统级统一 OSS 配置`);
this.useUnifiedConfig = true;
return {
oss_provider: unifiedConfig.provider,
oss_region: unifiedConfig.region,
oss_access_key_id: unifiedConfig.access_key_id,
oss_access_key_secret: unifiedConfig.access_key_secret,
oss_bucket: unifiedConfig.bucket,
oss_endpoint: unifiedConfig.endpoint
};
}
// 2. 回退到用户自己的配置
if (this.user.has_oss_config) {
console.log(`[OSS存储] 用户 ${this.user.id} 使用个人 OSS 配置`);
this.useUnifiedConfig = false;
return {
oss_provider: this.user.oss_provider,
oss_region: this.user.oss_region,
oss_access_key_id: this.user.oss_access_key_id,
oss_access_key_secret: this.user.oss_access_key_secret,
oss_bucket: this.user.oss_bucket,
oss_endpoint: this.user.oss_endpoint
};
}
// 3. 没有可用配置
throw new Error('OSS 存储未配置,请联系管理员配置系统级 OSS 服务');
}
/**
* 验证 OSS 配置是否完整
* @throws {Error} 配置不完整时抛出错误
*/
validateConfig() {
const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = this.user;
validateConfig(config) {
const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = config;
if (!oss_provider || !['aliyun', 'tencent', 'aws'].includes(oss_provider)) {
throw new Error('无效的 OSS 服务商,必须是 aliyun、tencent 或 aws');
@@ -553,15 +798,17 @@ class OssStorageClient {
/**
* 根据服务商构建 S3 配置
* @param {Object} config - OSS 配置对象
* @returns {Object} S3 客户端配置
*/
buildConfig() {
buildConfig(config) {
// 先验证配置
this.validateConfig();
this.validateConfig(config);
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = this.user;
const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = config;
// AWS S3 默认配置
let config = {
let s3Config = {
region: oss_region || 'us-east-1',
credentials: {
accessKeyId: oss_access_key_id,
@@ -583,41 +830,41 @@ class OssStorageClient {
if (!region.startsWith('oss-')) {
region = 'oss-' + region;
}
config.region = region;
s3Config.region = region;
if (!oss_endpoint) {
// 默认 endpoint 格式https://{region}.aliyuncs.com
config.endpoint = `https://${region}.aliyuncs.com`;
s3Config.endpoint = `https://${region}.aliyuncs.com`;
} else {
// 确保 endpoint 以 https:// 或 http:// 开头
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
}
// 阿里云 OSS 使用 virtual-hosted-style但需要设置 forcePathStyle 为 false
config.forcePathStyle = false;
s3Config.forcePathStyle = false;
}
// 腾讯云 COS
else if (oss_provider === 'tencent') {
config.region = oss_region || 'ap-guangzhou';
s3Config.region = oss_region || 'ap-guangzhou';
if (!oss_endpoint) {
// 默认 endpoint 格式https://cos.{region}.myqcloud.com
config.endpoint = `https://cos.${config.region}.myqcloud.com`;
s3Config.endpoint = `https://cos.${s3Config.region}.myqcloud.com`;
} else {
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
}
// 腾讯云 COS 使用 virtual-hosted-style
config.forcePathStyle = false;
s3Config.forcePathStyle = false;
}
// AWS S3 或其他兼容服务
else {
if (oss_endpoint) {
config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`;
// 自定义 endpoint如 MinIO通常需要 path-style
config.forcePathStyle = true;
s3Config.forcePathStyle = true;
}
// AWS 使用默认 endpoint无需额外配置
}
return config;
return s3Config;
}
/**
@@ -625,9 +872,15 @@ class OssStorageClient {
*/
async connect() {
try {
const config = this.buildConfig();
this.s3Client = new S3Client(config);
console.log(`[OSS存储] 已连接: ${this.user.oss_provider}, bucket: ${this.user.oss_bucket}`);
// 获取有效的 OSS 配置(系统配置优先)
const ossConfig = this.getEffectiveConfig();
const s3Config = this.buildConfig(ossConfig);
// 保存当前使用的配置(供其他方法使用)
this.currentConfig = ossConfig;
this.s3Client = new S3Client(s3Config);
console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`);
return this;
} catch (error) {
console.error(`[OSS存储] 连接失败:`, error.message);
@@ -635,6 +888,30 @@ class OssStorageClient {
}
}
/**
* 获取当前使用的 bucket 名称
* @returns {string}
*/
getBucket() {
if (this.currentConfig && this.currentConfig.oss_bucket) {
return this.currentConfig.oss_bucket;
}
// 回退到用户配置(向后兼容)
return this.user.oss_bucket;
}
/**
* 获取当前使用的 provider
* @returns {string}
*/
getProvider() {
if (this.currentConfig && this.currentConfig.oss_provider) {
return this.currentConfig.oss_provider;
}
// 回退到用户配置(向后兼容)
return this.user.oss_provider;
}
/**
* 获取对象的完整 Key带用户前缀
* 增强安全检查,防止路径遍历攻击
@@ -715,7 +992,7 @@ class OssStorageClient {
async list(dirPath, maxItems = 10000) {
try {
let prefix = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
// 确保前缀以斜杠结尾(除非是根目录)
if (prefix && !prefix.endsWith('/')) {
@@ -810,7 +1087,7 @@ class OssStorageClient {
try {
const key = this.getObjectKey(remotePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
// 检查本地文件是否存在
if (!fs.existsSync(localPath)) {
@@ -869,24 +1146,28 @@ class OssStorageClient {
/**
* 删除文件或文件夹
* ===== P0 性能优化:返回删除的文件大小,用于更新存储使用量缓存 =====
* @returns {Promise<{size: number}>} 返回删除的文件总大小(字节)
*/
async delete(filePath) {
try {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
// 检查是文件还是目录(忽略不存在的文件)
let statResult;
try {
statResult = await this.stat(filePath);
} catch (statError) {
if (statError.message && statError.message.includes('不存在')) {
if (statError.message && statResult?.message.includes('不存在')) {
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
return; // 文件不存在,直接返回
return { size: 0 }; // 文件不存在,返回大小为 0
}
throw statError; // 其他错误继续抛出
}
let totalDeletedSize = 0;
if (statResult.isDirectory) {
// 删除目录:列出所有对象并批量删除
// 使用分页循环处理超过 1000 个对象的情况
@@ -906,6 +1187,11 @@ class OssStorageClient {
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 累加删除的文件大小
for (const obj of listResponse.Contents) {
totalDeletedSize += obj.Size || 0;
}
// 批量删除当前批次的对象
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
@@ -927,10 +1213,16 @@ class OssStorageClient {
} while (continuationToken);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象)`);
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
}
return { size: totalDeletedSize };
} else {
// 删除单个文件
// 获取文件大小
const size = statResult.size || 0;
totalDeletedSize = size;
const command = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
@@ -940,7 +1232,9 @@ class OssStorageClient {
});
await this.s3Client.send(command);
console.log(`[OSS存储] 删除文件: ${key}`);
console.log(`[OSS存储] 删除文件: ${key} (${size} 字节)`);
return { size: totalDeletedSize };
}
} catch (error) {
console.error(`[OSS存储] 删除失败: ${filePath}`, error.message);
@@ -1046,6 +1340,7 @@ class OssStorageClient {
/**
* 重命名目录(内部方法)
* 通过遍历目录下所有对象,逐个复制到新位置后删除原对象
* 使用事务标记机制防止竞态条件
* @param {string} oldPath - 原目录路径
* @param {string} newPath - 新目录路径
* @private
@@ -1053,23 +1348,46 @@ class OssStorageClient {
async _renameDirectory(oldPath, newPath) {
const oldPrefix = this.getObjectKey(oldPath);
const newPrefix = this.getObjectKey(newPath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
// 确保前缀以斜杠结尾
const oldPrefixWithSlash = oldPrefix.endsWith('/') ? oldPrefix : `${oldPrefix}/`;
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
// 生成事务标记文件
const timestamp = Date.now();
const markerKey = `${this.prefix}.rename_pending_${timestamp}.json`;
// 标记文件内容(用于恢复)
const markerContent = JSON.stringify({
oldPrefix: oldPrefixWithSlash,
newPrefix: newPrefixWithSlash,
timestamp: timestamp,
phase: 'copying' // 标记当前阶段copying复制中、deleting删除中
});
let continuationToken = null;
let copiedKeys = [];
let totalCount = 0;
try {
// 第一阶段:复制所有对象到新位置
// 步骤1创建事务标记文件标识重命名操作开始
console.log(`[OSS存储] 创建重命名事务标记: ${markerKey}`);
const putMarkerCommand = new PutObjectCommand({
Bucket: bucket,
Key: markerKey,
Body: markerContent,
ContentType: 'application/json'
});
await this.s3Client.send(putMarkerCommand);
// 步骤2复制所有对象到新位置
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: oldPrefixWithSlash,
ContinuationToken: continuationToken
ContinuationToken: continuationToken,
MaxKeys: 1000
});
const listResponse = await this.s3Client.send(listCommand);
@@ -1095,9 +1413,22 @@ class OssStorageClient {
}
} while (continuationToken);
// 第二阶段:删除所有原对象
// 步骤3更新标记文件状态为 deleting复制完成开始删除
const updatedMarkerContent = JSON.stringify({
oldPrefix: oldPrefixWithSlash,
newPrefix: newPrefixWithSlash,
timestamp: timestamp,
phase: 'deleting'
});
await this.s3Client.send(new PutObjectCommand({
Bucket: bucket,
Key: markerKey,
Body: updatedMarkerContent,
ContentType: 'application/json'
}));
// 步骤4删除所有原对象批量删除
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({
@@ -1111,13 +1442,25 @@ class OssStorageClient {
}
}
console.log(`[OSS存储] 重命名目录: ${oldPath} -> ${newPath} (${totalCount} 个对象)`);
// 步骤5删除事务标记文件操作完成
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 重命名目录完成: ${oldPath} -> ${newPath} (${totalCount} 个对象)`);
} catch (error) {
// 如果出错,尝试回滚(删除已复制的新对象)
// 如果出错,尝试回滚
console.error(`[OSS存储] 目录重命名失败: ${error.message}`);
if (copiedKeys.length > 0) {
console.warn(`[OSS存储] 目录重命名失败,尝试回滚已复制的 ${copiedKeys.length} 个对象...`);
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({
@@ -1134,6 +1477,20 @@ class OssStorageClient {
console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`);
}
}
// 清理事务标记文件(如果还存在)
try {
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
} catch (markerError) {
// 忽略标记文件删除错误
}
throw new Error(`重命名目录失败: ${error.message}`);
}
}
@@ -1209,7 +1566,7 @@ class OssStorageClient {
async mkdir(dirPath) {
try {
const key = this.getObjectKey(dirPath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
// OSS 中文件夹通过以斜杠结尾的空对象模拟
const folderKey = key.endsWith('/') ? key : `${key}/`;
@@ -1266,15 +1623,16 @@ class OssStorageClient {
*/
getPublicUrl(filePath) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
const provider = this.getProvider();
const region = this.s3Client.config.region;
let baseUrl;
if (this.user.oss_provider === 'aliyun') {
if (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') {
} else if (provider === 'tencent') {
// 腾讯云 COS 公开 URL 格式
baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`;
} else {
@@ -1345,6 +1703,19 @@ class OssStorageClient {
}
}
/**
* 将流转换为 Buffer辅助函数
*/
async function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
module.exports = {
StorageInterface,
LocalStorageClient,

271
backend/utils/encryption.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* 加密工具模块
*
* 功能:
* - 使用 AES-256-GCM 加密敏感数据OSS Access Key Secret
* - 提供加密和解密函数
* - 自动处理初始化向量(IV)和认证标签
*
* 安全特性:
* - AES-256-GCM 提供认证加密AEAD
* - 每次加密使用随机 IV防止模式泄露
* - 使用认证标签验证数据完整性
* - 密钥从环境变量读取,不存在硬编码
*
* @module utils/encryption
*/
const crypto = require('crypto');
/**
* 从环境变量获取加密密钥
*
* 要求:
* - 必须是 32 字节的十六进制字符串64个字符
* - 如果未设置或格式错误,启动时抛出错误
*
* @returns {Buffer} 32字节的加密密钥
* @throws {Error} 如果密钥未配置或格式错误
*/
function getEncryptionKey() {
const keyHex = process.env.ENCRYPTION_KEY;
if (!keyHex) {
throw new Error(`
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 安全错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ ENCRYPTION_KEY 未配置! ║
║ ║
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
║ ║
║ 生成方法: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
║ ║
║ 在 backend/.env 文件中添加: ║
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
╚═══════════════════════════════════════════════════════════════╝
`);
}
// 验证密钥格式必须是64个十六进制字符
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
throw new Error(`
╔═══════════════════════════════════════════════════════════════╗
║ ⚠️ 配置错误 ⚠️ ║
╠═══════════════════════════════════════════════════════════════╣
║ ENCRYPTION_KEY 格式错误! ║
║ ║
║ 要求: 64位十六进制字符串32字节
║ 当前长度: ${keyHex.length} 字符 ║
║ ║
║ 正确的生成方法: ║
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
╚═══════════════════════════════════════════════════════════════╝
`);
}
return Buffer.from(keyHex, 'hex');
}
/**
* 加密明文字符串
*
* 使用 AES-256-GCM 算法加密数据,输出格式:
* - Base64(IV + ciphertext + authTag)
* - IV: 12字节随机
* - ciphertext: 加密后的数据
* - authTag: 16字节认证标签
*
* @param {string} plaintext - 要加密的明文字符串
* @returns {string} Base64编码的加密结果包含 IV 和 authTag
* @throws {Error} 如果加密失败
*
* @example
* const encrypted = encryptSecret('my-secret-key');
* // 输出: 'base64-encoded-string-with-iv-and-tag'
*/
function encryptSecret(plaintext) {
try {
// 获取加密密钥
const key = getEncryptionKey();
// 生成随机初始化向量IV
// GCM 模式推荐 12 字节 IV
const iv = crypto.randomBytes(12);
// 创建加密器
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
// 加密数据
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
encrypted += cipher.final('binary');
// 获取认证标签(用于验证数据完整性)
const authTag = cipher.getAuthTag();
// 组合IV + encrypted + authTag
const combined = Buffer.concat([
iv,
Buffer.from(encrypted, 'binary'),
authTag
]);
// 返回 Base64 编码的结果
return combined.toString('base64');
} catch (error) {
console.error('[加密] 加密失败:', error);
throw new Error('数据加密失败: ' + error.message);
}
}
/**
* 解密密文字符串
*
* 解密由 encryptSecret() 加密的数据
* 自动验证认证标签,确保数据完整性
*
* @param {string} ciphertext - Base64编码的密文由 encryptSecret 生成)
* @returns {string} 解密后的明文字符串
* @throws {Error} 如果解密失败或认证标签验证失败
*
* @example
* const decrypted = decryptSecret(encrypted);
* // 输出: 'my-secret-key'
*/
function decryptSecret(ciphertext) {
try {
// 如果是 null 或 undefined直接返回
if (!ciphertext) {
return ciphertext;
}
// 检查是否为加密格式Base64
// 如果不是 Base64可能是旧数据明文直接返回
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
return ciphertext;
}
// 获取加密密钥
const key = getEncryptionKey();
// 解析 Base64
const combined = Buffer.from(ciphertext, 'base64');
// 提取各部分
// IV: 前 12 字节
const iv = combined.slice(0, 12);
// authTag: 最后 16 字节
const authTag = combined.slice(-16);
// ciphertext: 中间部分
const encrypted = combined.slice(12, -16);
// 创建解密器
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
// 设置认证标签
decipher.setAuthTag(authTag);
// 解密数据
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// 如果解密失败,可能是旧数据(明文),直接返回
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
// 在开发环境抛出错误,生产环境尝试返回原值
if (process.env.NODE_ENV === 'production') {
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
return ciphertext;
}
throw new Error('数据解密失败: ' + error.message);
}
}
/**
* 验证加密系统是否正常工作
*
* 在应用启动时调用,确保:
* 1. ENCRYPTION_KEY 已配置
* 2. 加密/解密功能正常
*
* @returns {boolean} true 如果验证通过
* @throws {Error} 如果验证失败
*/
function validateEncryption() {
try {
const testData = 'test-secret-123';
// 测试加密
const encrypted = encryptSecret(testData);
// 验证加密结果不为空且不等于原文
if (!encrypted || encrypted === testData) {
throw new Error('加密结果异常');
}
// 测试解密
const decrypted = decryptSecret(encrypted);
// 验证解密结果等于原文
if (decrypted !== testData) {
throw new Error('解密结果不匹配');
}
console.log('[安全] ✓ 加密系统验证通过');
return true;
} catch (error) {
console.error('[安全] ✗ 加密系统验证失败:', error.message);
throw error;
}
}
/**
* 检查字符串是否已加密
*
* 通过格式判断是否为加密数据
* 注意:这不是加密学验证,仅用于提示
*
* @param {string} data - 要检查的数据
* @returns {boolean} true 如果看起来像是加密数据
*/
function isEncrypted(data) {
if (!data || typeof data !== 'string') {
return false;
}
// 加密后的数据特征:
// 1. 是有效的 Base64
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符IV + authTag 的 Base64
// 3. 通常会比原文长
try {
// 尝试解码 Base64
const buffer = Buffer.from(data, 'base64');
// 检查长度(至少包含 IV + authTag
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
// Base64编码后: 29 * 4/3 ≈ 39 字符
if (buffer.length < 29) {
return false;
}
return true;
} catch (error) {
return false;
}
}
module.exports = {
encryptSecret,
decryptSecret,
validateEncryption,
isEncrypted,
getEncryptionKey
};

View File

@@ -0,0 +1,343 @@
/**
* 存储使用情况缓存管理器
* ===== P0 性能优化:解决 OSS 统计性能瓶颈 =====
*
* 问题:每次获取存储使用情况都要遍历所有 OSS 对象,极其耗时
* 解决方案:使用数据库字段 storage_used 维护缓存,上传/删除时更新
*
* @module StorageUsageCache
*/
const { UserDB } = require('../database');
/**
* 存储使用情况缓存类
*/
class StorageUsageCache {
/**
* 获取用户的存储使用情况(从缓存)
* @param {number} userId - 用户ID
* @returns {Promise<{totalSize: number, totalSizeFormatted: string, fileCount: number, cached: boolean}>}
*/
static async getUsage(userId) {
try {
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 从数据库缓存读取
const storageUsed = user.storage_used || 0;
// 导入格式化函数
const { formatFileSize } = require('../storage');
return {
totalSize: storageUsed,
totalSizeFormatted: formatFileSize(storageUsed),
fileCount: null, // 缓存模式不统计文件数
cached: true
};
} catch (error) {
console.error('[存储缓存] 获取失败:', error);
throw error;
}
}
/**
* 更新用户的存储使用量
* @param {number} userId - 用户ID
* @param {number} deltaSize - 变化量(正数为增加,负数为减少)
* @returns {Promise<boolean>}
*/
static async updateUsage(userId, deltaSize) {
try {
// 使用 SQL 原子操作,避免并发问题
const result = UserDB.update(userId, {
// 使用原始 SQL因为 update 方法不支持表达式
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
});
// 直接执行 SQL 更新
const { db } = require('../database');
db.prepare(`
UPDATE users
SET storage_used = storage_used + ?
WHERE id = ?
`).run(deltaSize, userId);
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
return true;
} catch (error) {
console.error('[存储缓存] 更新失败:', error);
throw error;
}
}
/**
* 重置用户的存储使用量(管理员功能,用于全量统计后更新缓存)
* @param {number} userId - 用户ID
* @param {number} totalSize - 实际总大小
* @returns {Promise<boolean>}
*/
static async resetUsage(userId, totalSize) {
try {
UserDB.update(userId, { storage_used: totalSize });
console.log(`[存储缓存] 用户 ${userId} 存储重置: ${totalSize} 字节`);
return true;
} catch (error) {
console.error('[存储缓存] 重置失败:', error);
throw error;
}
}
/**
* 验证并修复缓存(管理员功能)
* 通过全量统计对比缓存值,如果不一致则更新
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{actual: number, cached: number, corrected: boolean}>}
*/
static async validateAndFix(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 执行全量统计
let totalSize = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: user.oss_bucket,
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const cached = user.storage_used || 0;
const corrected = totalSize !== cached;
if (corrected) {
await this.resetUsage(userId, totalSize);
console.log(`[存储缓存] 用户 ${userId} 缓存已修复: ${cached}${totalSize}`);
}
return {
actual: totalSize,
cached,
corrected
};
} catch (error) {
console.error('[存储缓存] 验证修复失败:', error);
throw error;
}
}
/**
* 检查缓存完整性(第二轮修复:缓存一致性保障)
* 对比缓存值与实际 OSS 存储使用情况,但不自动修复
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{consistent: boolean, cached: number, actual: number, diff: number}>}
*/
static async checkIntegrity(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
// 执行全量统计
let totalSize = 0;
let fileCount = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: user.oss_bucket,
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
fileCount++;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const cached = user.storage_used || 0;
const diff = totalSize - cached;
const consistent = Math.abs(diff) === 0;
console.log(`[存储缓存] 用户 ${userId} 完整性检查: 缓存=${cached}, 实际=${totalSize}, 差异=${diff}`);
return {
consistent,
cached,
actual: totalSize,
fileCount,
diff
};
} catch (error) {
console.error('[存储缓存] 完整性检查失败:', error);
throw error;
}
}
/**
* 重建缓存(第二轮修复:缓存一致性保障)
* 强制从 OSS 全量统计并更新缓存值,绕过一致性检查
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @returns {Promise<{previous: number, current: number, fileCount: number}>}
*/
static async rebuildCache(userId, ossClient) {
try {
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
const user = UserDB.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
console.log(`[存储缓存] 开始重建用户 ${userId} 的缓存...`);
// 执行全量统计
let totalSize = 0;
let fileCount = 0;
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: user.oss_bucket,
Prefix: `user_${userId}/`,
ContinuationToken: continuationToken
});
const response = await ossClient.s3Client.send(command);
if (response.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size || 0;
fileCount++;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
const previous = user.storage_used || 0;
// 强制更新缓存
await this.resetUsage(userId, totalSize);
console.log(`[存储缓存] 用户 ${userId} 缓存重建完成: ${previous}${totalSize} (${fileCount} 个文件)`);
return {
previous,
current: totalSize,
fileCount
};
} catch (error) {
console.error('[存储缓存] 重建缓存失败:', error);
throw error;
}
}
/**
* 检查所有用户的缓存一致性(第二轮修复:批量检查)
* @param {Array} users - 用户列表
* @param {Function} getOssClient - 获取 OSS 客户端的函数
* @returns {Promise<Array>} 检查结果列表
*/
static async checkAllUsersIntegrity(users, getOssClient) {
const results = [];
for (const user of users) {
// 跳过没有配置 OSS 的用户
if (!user.has_oss_config || !user.oss_bucket) {
continue;
}
try {
const ossClient = getOssClient(user);
const checkResult = await this.checkIntegrity(user.id, ossClient);
results.push({
userId: user.id,
username: user.username,
...checkResult
});
} catch (error) {
console.error(`[存储缓存] 检查用户 ${user.id} 失败:`, error.message);
results.push({
userId: user.id,
username: user.username,
error: error.message
});
}
}
return results;
}
/**
* 自动检测并修复缓存不一致(第二轮修复:自动化保障)
* 当检测到不一致时自动修复,并记录日志
* @param {number} userId - 用户ID
* @param {OssStorageClient} ossClient - OSS 客户端实例
* @param {number} threshold - 差异阈值(字节),默认 0任何差异都修复
* @returns {Promise<{autoFixed: boolean, diff: number}>}
*/
static async autoDetectAndFix(userId, ossClient, threshold = 0) {
try {
const checkResult = await this.checkIntegrity(userId, ossClient);
if (!checkResult.consistent && Math.abs(checkResult.diff) > threshold) {
console.warn(`[存储缓存] 检测到用户 ${userId} 缓存不一致: 差异 ${checkResult.diff} 字节`);
// 自动修复
await this.rebuildCache(userId, ossClient);
return {
autoFixed: true,
diff: checkResult.diff
};
}
return {
autoFixed: false,
diff: checkResult.diff
};
} catch (error) {
console.error('[存储缓存] 自动检测修复失败:', error);
throw error;
}
}
}
module.exports = StorageUsageCache;