feat: apply UI/storage/share optimizations and quota improvements
This commit is contained in:
@@ -2,6 +2,8 @@ const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// JWT密钥(必须在环境变量中设置)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
@@ -169,6 +171,11 @@ function authMiddleware(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
const rawOssQuota = Number(user.oss_storage_quota);
|
||||
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
|
||||
? rawOssQuota
|
||||
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
|
||||
|
||||
// 将用户信息附加到请求对象(包含所有存储相关字段)
|
||||
req.user = {
|
||||
id: user.id,
|
||||
@@ -187,8 +194,10 @@ function authMiddleware(req, res, next) {
|
||||
// 存储相关字段
|
||||
storage_permission: user.storage_permission || 'oss_only',
|
||||
current_storage_type: user.current_storage_type || 'oss',
|
||||
local_storage_quota: user.local_storage_quota || 1073741824,
|
||||
local_storage_quota: user.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
|
||||
local_storage_used: user.local_storage_used || 0,
|
||||
oss_storage_quota: effectiveOssQuota,
|
||||
storage_used: user.storage_used || 0,
|
||||
// 主题偏好
|
||||
theme_preference: user.theme_preference || null
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ console.log(`[数据库] 路径: ${dbPath}`);
|
||||
|
||||
// 创建或连接数据库
|
||||
const db = new Database(dbPath);
|
||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// ===== 性能优化配置(P0 优先级修复) =====
|
||||
|
||||
@@ -525,7 +527,8 @@ const UserDB = {
|
||||
'has_oss_config': 'number',
|
||||
'is_verified': 'number',
|
||||
'local_storage_quota': 'number',
|
||||
'local_storage_used': 'number'
|
||||
'local_storage_used': 'number',
|
||||
'oss_storage_quota': 'number'
|
||||
};
|
||||
|
||||
const expectedType = FIELD_TYPES[fieldName];
|
||||
@@ -585,6 +588,7 @@ const UserDB = {
|
||||
'current_storage_type': 'current_storage_type',
|
||||
'local_storage_quota': 'local_storage_quota',
|
||||
'local_storage_used': 'local_storage_used',
|
||||
'oss_storage_quota': 'oss_storage_quota',
|
||||
|
||||
// 偏好设置
|
||||
'theme_preference': 'theme_preference'
|
||||
@@ -665,6 +669,7 @@ const UserDB = {
|
||||
'current_storage_type': 'current_storage_type',
|
||||
'local_storage_quota': 'local_storage_quota',
|
||||
'local_storage_used': 'local_storage_used',
|
||||
'oss_storage_quota': 'oss_storage_quota',
|
||||
|
||||
// 偏好设置
|
||||
'theme_preference': 'theme_preference'
|
||||
@@ -717,7 +722,8 @@ const UserDB = {
|
||||
'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'
|
||||
'local_storage_quota': 'number', 'local_storage_used': 'number',
|
||||
'oss_storage_quota': 'number'
|
||||
}[key];
|
||||
|
||||
console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`);
|
||||
@@ -883,11 +889,14 @@ const ShareDB = {
|
||||
// 根据分享码查找
|
||||
// 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问)
|
||||
// ===== 性能优化(P0 优先级修复):只查询必要字段,避免 N+1 查询 =====
|
||||
// 移除了敏感字段:oss_access_key_id, oss_access_key_secret(不需要传递给分享访问者)
|
||||
// 不返回 oss_access_key_id / oss_access_key_secret;
|
||||
// share_password 仅用于服务端验证,不会透传给前端。
|
||||
findByCode(shareCode) {
|
||||
const result = db.prepare(`
|
||||
SELECT
|
||||
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
||||
s.storage_type,
|
||||
s.share_password,
|
||||
s.view_count, s.download_count, s.created_at, s.expires_at,
|
||||
u.username,
|
||||
-- OSS 配置(访问分享文件所需)
|
||||
@@ -1172,8 +1181,9 @@ function migrateToV2() {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only';
|
||||
ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp';
|
||||
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT 1073741824;
|
||||
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT ${DEFAULT_LOCAL_STORAGE_QUOTA_BYTES};
|
||||
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN oss_storage_quota INTEGER DEFAULT ${DEFAULT_OSS_STORAGE_QUOTA_BYTES};
|
||||
`);
|
||||
|
||||
console.log('[数据库迁移] ✓ 用户表已升级');
|
||||
@@ -1247,6 +1257,34 @@ function migrateToOss() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 数据库迁移 - OSS 存储配额字段
|
||||
function migrateOssQuotaField() {
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasOssStorageQuota = columns.some(col => col.name === 'oss_storage_quota');
|
||||
|
||||
if (!hasOssStorageQuota) {
|
||||
console.log('[数据库迁移] 添加 oss_storage_quota 字段...');
|
||||
db.exec(`ALTER TABLE users ADD COLUMN oss_storage_quota INTEGER DEFAULT ${DEFAULT_OSS_STORAGE_QUOTA_BYTES}`);
|
||||
console.log('[数据库迁移] ✓ oss_storage_quota 字段已添加');
|
||||
}
|
||||
|
||||
// 统一策略:未配置或无效值默认 1GB
|
||||
const backfillResult = db.prepare(`
|
||||
UPDATE users
|
||||
SET oss_storage_quota = ?
|
||||
WHERE oss_storage_quota IS NULL OR oss_storage_quota <= 0
|
||||
`).run(DEFAULT_OSS_STORAGE_QUOTA_BYTES);
|
||||
|
||||
if (backfillResult.changes > 0) {
|
||||
console.log(`[数据库迁移] ✓ OSS 配额默认值已回填: ${backfillResult.changes} 条记录`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[数据库迁移] oss_storage_quota 迁移失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 系统日志操作
|
||||
const SystemLogDB = {
|
||||
// 日志级别常量
|
||||
@@ -1432,6 +1470,7 @@ initDefaultSettings();
|
||||
migrateToV2(); // 执行数据库迁移
|
||||
migrateThemePreference(); // 主题偏好迁移
|
||||
migrateToOss(); // SFTP → OSS 迁移
|
||||
migrateOssQuotaField(); // OSS 配额字段迁移
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
|
||||
577
backend/package-lock.json
generated
577
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@
|
||||
"author": "玩玩云团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.985.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.985.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
@@ -28,10 +30,9 @@
|
||||
"express-session": "^1.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.23",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.14",
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
* - POST /api/share/:code/verify
|
||||
* - POST /api/share/:code/list
|
||||
* - POST /api/share/:code/download
|
||||
* - GET /api/share/:code/download-url
|
||||
* - POST /api/share/:code/download-url
|
||||
* - GET /api/share/:code/download-file
|
||||
*
|
||||
* 6. routes/admin.js - 管理员功能
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1534,7 +1534,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async createReadStream(filePath) {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
@@ -1589,7 +1589,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async getPresignedUrl(filePath, expiresIn = 3600) {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
@@ -1646,7 +1646,7 @@ class OssStorageClient {
|
||||
*/
|
||||
async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') {
|
||||
const key = this.getObjectKey(filePath);
|
||||
const bucket = this.user.oss_bucket;
|
||||
const bucket = this.getBucket();
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
|
||||
@@ -448,7 +448,7 @@ async function testGetDownloadUrl() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request('GET', `/api/share/${testShareCode}/download-url?path=/test-file.txt`);
|
||||
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/test-file.txt' });
|
||||
|
||||
// 如果文件存在
|
||||
if (res.status === 200) {
|
||||
@@ -481,7 +481,7 @@ async function testDownloadWithPassword() {
|
||||
|
||||
// 测试无密码
|
||||
try {
|
||||
const res1 = await request('GET', `/api/share/${passwordShareCode}/download-url?path=/test-file-password.txt`);
|
||||
const res1 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt' });
|
||||
assert(res1.status === 401, '无密码应返回 401');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] 测试无密码下载: ${error.message}`);
|
||||
@@ -489,7 +489,7 @@ async function testDownloadWithPassword() {
|
||||
|
||||
// 测试带密码
|
||||
try {
|
||||
const res2 = await request('GET', `/api/share/${passwordShareCode}/download-url?path=/test-file-password.txt&password=test123`);
|
||||
const res2 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt', password: 'test123' });
|
||||
// 密码正确,根据文件是否存在返回不同结果
|
||||
if (res2.status === 200) {
|
||||
assert(res2.data.downloadUrl, '应返回下载链接');
|
||||
@@ -535,7 +535,7 @@ async function testDownloadPathValidation() {
|
||||
|
||||
// 测试越权访问
|
||||
try {
|
||||
const res = await request('GET', `/api/share/${testShareCode}/download-url?path=/other-file.txt`);
|
||||
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/other-file.txt' });
|
||||
|
||||
// 单文件分享应该禁止访问其他文件
|
||||
assert(res.status === 403 || res.status === 404, '越权访问应被拒绝');
|
||||
@@ -546,7 +546,7 @@ async function testDownloadPathValidation() {
|
||||
|
||||
// 测试路径遍历
|
||||
try {
|
||||
const res2 = await request('GET', `/api/share/${testShareCode}/download-url?path=/../../../etc/passwd`);
|
||||
const res2 = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/../../../etc/passwd' });
|
||||
assert(res2.status === 403 || res2.status === 400, '路径遍历应被拒绝');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] 路径遍历测试: ${error.message}`);
|
||||
@@ -682,7 +682,7 @@ async function testShareNotExists() {
|
||||
|
||||
// 下载
|
||||
try {
|
||||
const res3 = await request('GET', `/api/share/${nonExistentCode}/download-url?path=/test.txt`);
|
||||
const res3 = await request('POST', `/api/share/${nonExistentCode}/download-url`, { path: '/test.txt' });
|
||||
assert(res3.status === 404, '下载不存在分享应返回 404');
|
||||
} catch (error) {
|
||||
console.log(` [ERROR] ${error.message}`);
|
||||
|
||||
@@ -225,7 +225,7 @@ async function testPathTraversalAttacks() {
|
||||
|
||||
let blocked = 0;
|
||||
for (const attackPath of attackPaths) {
|
||||
const res = await request('GET', `/api/share/${shareCode}/download-url?path=${encodeURIComponent(attackPath)}`);
|
||||
const res = await request('POST', `/api/share/${shareCode}/download-url`, { path: attackPath });
|
||||
|
||||
if (res.status === 403 || res.status === 400) {
|
||||
blocked++;
|
||||
|
||||
@@ -53,21 +53,20 @@ class StorageUsageCache {
|
||||
*/
|
||||
static async updateUsage(userId, deltaSize) {
|
||||
try {
|
||||
// 使用 SQL 原子操作,避免并发问题
|
||||
const result = UserDB.update(userId, {
|
||||
// 使用原始 SQL,因为 update 方法不支持表达式
|
||||
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
|
||||
});
|
||||
const numericDelta = Number(deltaSize);
|
||||
if (!Number.isFinite(numericDelta)) {
|
||||
throw new Error('deltaSize 必须是有效数字');
|
||||
}
|
||||
|
||||
// 直接执行 SQL 更新
|
||||
// 直接执行 SQL 原子更新,并保证不小于 0
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = storage_used + ?
|
||||
SET storage_used = MAX(COALESCE(storage_used, 0) + ?, 0)
|
||||
WHERE id = ?
|
||||
`).run(deltaSize, userId);
|
||||
`).run(numericDelta, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${numericDelta > 0 ? '+' : ''}${numericDelta} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 更新失败:', error);
|
||||
|
||||
Reference in New Issue
Block a user