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:
@@ -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'))"
|
||||
|
||||
111
backend/auth.js
111
backend/auth.js
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@@ -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
@@ -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
271
backend/utils/encryption.js
Normal 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
|
||||
};
|
||||
343
backend/utils/storage-cache.js
Normal file
343
backend/utils/storage-cache.js
Normal 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;
|
||||
Reference in New Issue
Block a user