fix: harden cloud storage security

This commit is contained in:
237899745
2026-06-13 18:45:12 +08:00
parent 7943b04ee2
commit bb6ad01018
28 changed files with 2229 additions and 996 deletions

View File

@@ -40,6 +40,23 @@ 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
function normalizeStoredDownloadTrafficQuota(rawQuota) {
if (rawQuota === null || rawQuota === undefined || rawQuota === '') {
return -1; // 默认不限下载
}
const parsedQuota = Number(rawQuota);
if (!Number.isFinite(parsedQuota)) {
return -1;
}
if (parsedQuota < 0) {
return -1; // -1 表示不限流量
}
return Math.max(0, Math.floor(parsedQuota)); // 0 表示禁止下载
}
// ===== 性能优化配置P0 优先级修复) =====
// 1. 启用 WAL 模式Write-Ahead Logging
@@ -507,6 +524,26 @@ function initDatabase() {
)
`);
// OSS 直传临时对象登记表:直传必须完成服务端确认后才进入用户目录
db.exec(`
CREATE TABLE IF NOT EXISTS oss_upload_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
final_object_key TEXT NOT NULL,
temp_object_key TEXT NOT NULL,
expected_size INTEGER NOT NULL DEFAULT 0,
previous_size INTEGER NOT NULL DEFAULT 0,
file_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending/completed/expired/cancelled
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
completed_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 在线设备会话(用于设备管理和强制下线)
db.exec(`
CREATE TABLE IF NOT EXISTS user_device_sessions (
@@ -575,6 +612,12 @@ function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
ON upload_sessions(user_id, file_hash, file_size);
-- OSS 直传临时对象索引
CREATE INDEX IF NOT EXISTS idx_oss_upload_reservations_status_expires
ON oss_upload_reservations(status, expires_at);
CREATE INDEX IF NOT EXISTS idx_oss_upload_reservations_user_status
ON oss_upload_reservations(user_id, status, expires_at);
-- 在线设备会话索引
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_user_active
ON user_device_sessions(user_id, revoked_at, expires_at, last_active_at);
@@ -615,14 +658,22 @@ function createDefaultAdmin() {
// 从环境变量读取管理员账号密码,如果没有则使用默认值
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const defaultAdminPasswords = new Set(['admin123', 'password', '123456', '12345678', 'change-this-admin-password']);
if (process.env.NODE_ENV === 'production' && defaultAdminPasswords.has(String(adminPassword).trim())) {
console.error('[安全] 生产环境禁止使用默认管理员密码,请设置 ADMIN_PASSWORD');
process.exit(1);
}
const hashedPassword = bcrypt.hashSync(adminPassword, 10);
db.prepare(`
INSERT INTO users (
username, email, password,
is_admin, is_active, has_oss_config, is_verified
) VALUES (?, ?, ?, ?, ?, ?, ?)
is_admin, is_active, has_oss_config, is_verified,
download_traffic_quota, download_traffic_used,
download_traffic_reset_cycle, download_traffic_last_reset_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
adminUsername,
`${adminUsername}@example.com`,
@@ -630,7 +681,11 @@ function createDefaultAdmin() {
1,
1,
0, // 管理员不需要OSS配置
1 // 管理员默认已验证
1, // 管理员默认已验证
-1, // 默认不限下载
0,
'none',
null
);
console.log('默认管理员账号已创建');
@@ -658,10 +713,15 @@ const UserDB = {
username, email, password,
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
has_oss_config,
is_verified, verification_token, verification_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_verified, verification_token, verification_expires_at,
storage_permission, current_storage_type,
download_traffic_quota, download_traffic_used,
download_traffic_reset_cycle, download_traffic_last_reset_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const downloadTrafficQuota = normalizeStoredDownloadTrafficQuota(userData.download_traffic_quota);
const result = stmt.run(
userData.username,
userData.email,
@@ -675,7 +735,13 @@ const UserDB = {
hasOssConfig,
userData.is_verified !== undefined ? userData.is_verified : 0,
hashedVerificationToken,
userData.verification_expires_at || null
userData.verification_expires_at || null,
userData.storage_permission || 'oss_only',
userData.current_storage_type || 'oss',
downloadTrafficQuota,
0,
'none',
null
);
return result.lastInsertRowid;
@@ -751,9 +817,8 @@ const UserDB = {
'theme_preference': 'string',
// 数值类型字段
'is_admin': 'number',
'is_active': 'number',
'is_banned': 'is_banned',
'is_banned': 'number',
'has_oss_config': 'number',
'is_verified': 'number',
'local_storage_quota': 'number',
@@ -808,7 +873,6 @@ const UserDB = {
// 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',
@@ -852,7 +916,7 @@ const UserDB = {
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
for (const dbField of dbFields) {
if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) {
if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at', 'is_admin'].includes(dbField)) {
extraFields.push(dbField);
}
}
@@ -894,7 +958,6 @@ const UserDB = {
// 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',
@@ -968,7 +1031,7 @@ const UserDB = {
'download_traffic_quota_expires_at': 'string',
'download_traffic_reset_cycle': 'string',
'download_traffic_last_reset_at': 'string',
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
'is_active': 'number', 'is_banned': 'number',
'has_oss_config': 'number', 'is_verified': 'number',
'local_storage_quota': 'number', 'local_storage_used': 'number',
'oss_storage_quota': 'number', 'download_traffic_quota': 'number',
@@ -1014,6 +1077,43 @@ const UserDB = {
return result;
},
adjustLocalStorageUsed(id, delta) {
const amount = Number(delta);
if (!Number.isFinite(amount) || amount === 0) {
return this.findById(id);
}
db.prepare(`
UPDATE users
SET local_storage_used = MAX(COALESCE(local_storage_used, 0) + ?, 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(Math.trunc(amount), id);
return this.findById(id);
},
reserveLocalStorageSpace(id, additionalSize) {
const amount = Math.max(0, Math.trunc(Number(additionalSize) || 0));
if (amount === 0) {
return this.findById(id);
}
const stmt = db.prepare(`
UPDATE users
SET local_storage_used = COALESCE(local_storage_used, 0) + ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND COALESCE(local_storage_used, 0) + ? <= COALESCE(local_storage_quota, 0)
`);
const result = stmt.run(amount, id, amount);
if (result.changes === 0) {
return null;
}
return this.findById(id);
},
// 获取所有用户
getAll(filters = {}) {
let query = 'SELECT * FROM users WHERE 1=1';
@@ -1278,7 +1378,7 @@ const ShareDB = {
if (attempts > 10) {
shareCode = this.generateShareCode(10); // 增加长度
}
} while (this.findByCode(shareCode) && attempts < 20);
} while (this.findAnyByCode(shareCode) && attempts < 20);
// 计算过期时间
let expiresAt = null;
@@ -1376,6 +1476,10 @@ const ShareDB = {
return result;
},
findAnyByCode(shareCode) {
return db.prepare('SELECT * FROM shares WHERE share_code = ?').get(shareCode);
},
// 根据ID查找
findById(id) {
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
@@ -1396,6 +1500,7 @@ const ShareDB = {
AND share_type = ?
AND share_path = ?
AND COALESCE(storage_type, 'oss') = ?
AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime'))
ORDER BY id DESC
LIMIT 1
`).get(
@@ -1557,6 +1662,7 @@ const DirectLinkDB = {
WHERE user_id = ?
AND file_path = ?
AND COALESCE(storage_type, 'oss') = ?
AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime'))
ORDER BY id DESC
LIMIT 1
`).get(
@@ -1691,7 +1797,17 @@ const SettingsDB = {
* 删除统一的 OSS 配置
*/
clearUnifiedOssConfig() {
db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run();
db.prepare(`
DELETE FROM system_settings
WHERE key IN (
'oss_provider',
'oss_region',
'oss_access_key_id',
'oss_access_key_secret',
'oss_bucket',
'oss_endpoint'
)
`).run();
console.log('[系统设置] 统一 OSS 配置已清除');
},
@@ -1721,12 +1837,13 @@ const VerificationDB = {
const row = db.prepare(`
SELECT * FROM users
WHERE verification_token = ?
AND (
verification_expires_at IS NULL
OR verification_expires_at = ''
OR verification_expires_at > strftime('%s','now')*1000 -- 数值时间戳ms
OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
)
AND verification_expires_at IS NOT NULL
AND verification_expires_at != ''
AND CASE
WHEN typeof(verification_expires_at) IN ('integer', 'real')
THEN verification_expires_at > strftime('%s','now') * 1000
ELSE datetime(verification_expires_at) > datetime('now','localtime')
END
AND is_verified = 0
`).get(hashedToken);
if (!row) return null;
@@ -1757,10 +1874,14 @@ const PasswordResetTokenDB = {
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const row = db.prepare(`
SELECT * FROM password_reset_tokens
WHERE token = ? AND used = 0 AND (
expires_at > strftime('%s','now')*1000 -- 数值时间戳
OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
)
WHERE token = ? AND used = 0
AND expires_at IS NOT NULL
AND expires_at != ''
AND CASE
WHEN typeof(expires_at) IN ('integer', 'real')
THEN expires_at > strftime('%s','now') * 1000
ELSE datetime(expires_at) > datetime('now','localtime')
END
`).get(hashedToken);
if (!row) return null;
// 立即标记为已使用(防止重复使用)
@@ -1960,7 +2081,7 @@ function migrateDownloadTrafficFields() {
if (!hasDownloadTrafficQuota) {
console.log('[数据库迁移] 添加 download_traffic_quota 字段...');
db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT 0');
db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT -1');
console.log('[数据库迁移] ✓ download_traffic_quota 字段已添加');
}
@@ -1988,10 +2109,10 @@ function migrateDownloadTrafficFields() {
console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加');
}
// 统一策略download_traffic_quota 为空时回填为 00 表示禁止下载,-1 表示不限流量)
// 未设置下载配额的旧记录默认回填为不限流量,避免升级后默认无法下载
const quotaBackfillResult = db.prepare(`
UPDATE users
SET download_traffic_quota = 0
SET download_traffic_quota = -1
WHERE download_traffic_quota IS NULL
`).run();
@@ -2009,6 +2130,53 @@ function migrateDownloadTrafficFields() {
console.log(`[数据库迁移] ✓ 不限流量配额已标准化为 -1: ${quotaUnlimitedNormalizeResult.changes} 条记录`);
}
const legacyRepairMarkerKey = 'download_traffic_zero_default_repaired_v1';
if (SettingsDB.get(legacyRepairMarkerKey) !== 'true') {
const legacyRepairCandidates = db.prepare(`
SELECT u.id, u.username
FROM users u
WHERE COALESCE(u.download_traffic_quota, 0) = 0
AND NOT EXISTS (
SELECT 1
FROM system_logs l
WHERE l.action = 'update_user_storage_and_traffic'
AND l.details LIKE '%"targetUserId":' || u.id || ',%'
)
`).all();
if (legacyRepairCandidates.length > 0) {
const repairLegacyZeroQuotaDefaults = db.transaction((candidates) => {
const stmt = db.prepare(`
UPDATE users
SET download_traffic_quota = -1
WHERE id = ? AND download_traffic_quota = 0
`);
let repaired = 0;
for (const candidate of candidates) {
const result = stmt.run(candidate.id);
repaired += Number(result?.changes || 0);
}
return repaired;
});
const repairedCount = repairLegacyZeroQuotaDefaults(legacyRepairCandidates);
if (repairedCount > 0) {
const repairedUsers = legacyRepairCandidates
.slice(0, 8)
.map(item => `${item.username}(#${item.id})`)
.join(', ');
const suffix = legacyRepairCandidates.length > 8 ? ' ...' : '';
console.log(
`[数据库迁移] ✓ 已修复旧版默认下载配额为 0 的遗留数据: ${repairedCount} 条记录` +
` (${repairedUsers}${suffix})`
);
}
}
SettingsDB.set(legacyRepairMarkerKey, 'true');
}
const usedBackfillResult = db.prepare(`
UPDATE users
SET download_traffic_used = 0
@@ -2645,6 +2813,120 @@ const UploadSessionDB = {
}
};
const OssUploadReservationDB = {
create({
reservationToken,
userId,
finalObjectKey,
tempObjectKey,
expectedSize,
previousSize = 0,
fileHash = null,
expiresAt
}) {
const token = typeof reservationToken === 'string' ? reservationToken.trim() : '';
const uid = Number(userId);
const expected = Math.max(0, Math.floor(Number(expectedSize) || 0));
const previous = Math.max(0, Math.floor(Number(previousSize) || 0));
const finalKey = typeof finalObjectKey === 'string' ? finalObjectKey.trim() : '';
const tempKey = typeof tempObjectKey === 'string' ? tempObjectKey.trim() : '';
const hash = typeof fileHash === 'string' && fileHash.trim() ? fileHash.trim() : null;
const expiresAtValue = typeof expiresAt === 'string' ? expiresAt : null;
if (!token || !Number.isFinite(uid) || uid <= 0 || !finalKey || !tempKey || expected <= 0 || !expiresAtValue) {
return null;
}
const result = db.prepare(`
INSERT INTO oss_upload_reservations (
reservation_token, user_id, final_object_key, temp_object_key,
expected_size, previous_size, file_hash, status, expires_at,
created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, 'pending', ?,
datetime('now', 'localtime'), datetime('now', 'localtime')
)
`).run(token, Math.floor(uid), finalKey, tempKey, expected, previous, hash, expiresAtValue);
return db.prepare('SELECT * FROM oss_upload_reservations WHERE id = ?').get(result.lastInsertRowid);
},
findPendingByToken(reservationToken) {
const token = typeof reservationToken === 'string' ? reservationToken.trim() : '';
if (!token) return null;
return db.prepare(`
SELECT *
FROM oss_upload_reservations
WHERE reservation_token = ?
AND status = 'pending'
AND expires_at > datetime('now', 'localtime')
LIMIT 1
`).get(token);
},
complete(reservationToken) {
const token = typeof reservationToken === 'string' ? reservationToken.trim() : '';
if (!token) return { changes: 0 };
return db.prepare(`
UPDATE oss_upload_reservations
SET status = 'completed',
completed_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE reservation_token = ?
AND status = 'pending'
`).run(token);
},
cancel(reservationToken) {
const token = typeof reservationToken === 'string' ? reservationToken.trim() : '';
if (!token) return { changes: 0 };
return db.prepare(`
UPDATE oss_upload_reservations
SET status = 'cancelled',
updated_at = datetime('now', 'localtime')
WHERE reservation_token = ?
AND status = 'pending'
`).run(token);
},
listExpiredPending(limit = 100) {
const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)));
return db.prepare(`
SELECT *
FROM oss_upload_reservations
WHERE status = 'pending'
AND expires_at <= datetime('now', 'localtime')
ORDER BY expires_at ASC, id ASC
LIMIT ?
`).all(safeLimit);
},
expire(reservationToken) {
const token = typeof reservationToken === 'string' ? reservationToken.trim() : '';
if (!token) return { changes: 0 };
return db.prepare(`
UPDATE oss_upload_reservations
SET status = 'expired',
updated_at = datetime('now', 'localtime')
WHERE reservation_token = ?
AND status = 'pending'
`).run(token);
},
cleanupFinalizedHistory(keepDays = 7) {
const days = Math.min(365, Math.max(1, Math.floor(Number(keepDays) || 7)));
return db.prepare(`
DELETE FROM oss_upload_reservations
WHERE status IN ('completed', 'expired', 'cancelled')
AND updated_at < datetime('now', 'localtime', '-' || ? || ' days')
`).run(days);
}
};
const DeviceSessionDB = {
_normalizeSessionId(sessionId) {
return typeof sessionId === 'string' ? sessionId.trim() : '';
@@ -3190,13 +3472,13 @@ const TransactionDB = {
// 初始化数据库
initDatabase();
createDefaultAdmin();
initDefaultSettings();
migrateToV2(); // 执行数据库迁移
migrateThemePreference(); // 主题偏好迁移
migrateToOss(); // SFTP → OSS 迁移
migrateOssQuotaField(); // OSS 配额字段迁移
migrateDownloadTrafficFields(); // 下载流量字段迁移
createDefaultAdmin();
module.exports = {
db,
@@ -3209,6 +3491,7 @@ module.exports = {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
OssUploadReservationDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,