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

@@ -20,7 +20,15 @@ NODE_ENV=production
# 强制HTTPS访问生产环境建议开启
# 设置为 true 时,仅接受 HTTPS 访问
ENFORCE_HTTPS=false
ENFORCE_HTTPS=true
# 公开访问地址(生产环境必须配置,用于邮件、分享、直链等外部链接)
# 示例: PUBLIC_BASE_URL=https://cs.workyai.cn
PUBLIC_BASE_URL=https://your-domain.example
# Host 白名单(可选;未配置 PUBLIC_BASE_URL 时生产环境必须配置)
# 示例: ALLOWED_HOSTS=cs.workyai.cn
ALLOWED_HOSTS=your-domain.example
# 公开访问端口nginx监听的端口用于生成分享链接
# 标准端口(80/443)可不配置
@@ -33,20 +41,20 @@ 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
ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHARACTERS_GENERATED_BY_COMMAND
# JWT密钥必须修改
# 生成方法: openssl rand -base64 32
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_AT_LEAST_32_CHARS
# Refresh Token 密钥(可选,默认使用 JWT_SECRET 派生)
# 建议生产环境设置独立的密钥
# REFRESH_SECRET=your-refresh-secret-key
# REFRESH_SECRET=REPLACE_WITH_SEPARATE_RANDOM_REFRESH_SECRET
# 管理员账号配置(首次启动时创建)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=REPLACE_WITH_STRONG_ADMIN_PASSWORD
# ============================================
# CORS 跨域配置(重要!)
@@ -69,17 +77,17 @@ ADMIN_PASSWORD=admin123
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
#
ALLOWED_ORIGINS=
ALLOWED_ORIGINS=https://your-domain.example
# Cookie 安全配置
# 使用 HTTPS 时必须设置为 true
# HTTP 环境设置为 false
COOKIE_SECURE=false
COOKIE_SECURE=true
# CSRF 防护配置
# 启用 CSRF 保护(建议生产环境开启)
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
ENABLE_CSRF=false
ENABLE_CSRF=true
# ============================================
# 反向代理配置Nginx/Cloudflare等
@@ -97,7 +105,7 @@ ENABLE_CSRF=false
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
# 必须配置 TRUST_PROXY=1否则后端无法正确识别HTTPS请求
#
TRUST_PROXY=false
TRUST_PROXY=1
# ============================================
# 存储配置
@@ -126,15 +134,11 @@ STORAGE_ROOT=./storage
# OSS_ENDPOINT= # 自定义 Endpoint可选
# ============================================
# Session 配置
# 验证码票据配置
# ============================================
# Session 密钥(用于验证码等功能
# 默认使用随机生成的密钥
# SESSION_SECRET=your-session-secret
# Session 过期时间(毫秒),默认 30 分钟
# SESSION_MAX_AGE=1800000
# 验证码票据签名密钥(可选;默认复用 JWT_SECRET
# CAPTCHA_SECRET=replace-with-random-32-byte-hex
# ============================================
# 开发调试配置

View File

@@ -1,10 +1,27 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { UserDB, DeviceSessionDB } = require('./database');
const { UserDB, DeviceSessionDB, SystemLogDB } = 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
function normalizeDownloadTrafficQuotaForAuth(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 表示禁止下载
}
// JWT密钥必须在环境变量中设置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
// Refresh Token密钥使用不同的密钥
@@ -17,7 +34,8 @@ const REFRESH_TOKEN_EXPIRES = '7d'; // Refresh token 7天
// 安全检查验证JWT密钥配置
const DEFAULT_SECRETS = [
'your-secret-key-change-in-production',
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS',
'your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION'
];
// 安全修复:增强 JWT_SECRET 验证逻辑
@@ -234,10 +252,7 @@ function authMiddleware(req, res, next) {
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
? rawOssQuota
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
const rawDownloadTrafficQuota = Number(user.download_traffic_quota);
const effectiveDownloadTrafficQuota = Number.isFinite(rawDownloadTrafficQuota) && rawDownloadTrafficQuota > 0
? Math.floor(rawDownloadTrafficQuota)
: 0; // 0 表示不限流量
const effectiveDownloadTrafficQuota = normalizeDownloadTrafficQuotaForAuth(user.download_traffic_quota);
const rawDownloadTrafficUsed = Number(user.download_traffic_used);
const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0
? Math.floor(rawDownloadTrafficUsed)
@@ -257,8 +272,6 @@ 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如果存在
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,
// 存储相关字段
@@ -276,6 +289,12 @@ function authMiddleware(req, res, next) {
// 主题偏好
theme_preference: user.theme_preference || null
};
Object.defineProperty(req.user, 'oss_access_key_secret', {
value: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
enumerable: false,
configurable: false,
writable: false
});
req.authSessionId = sessionId || null;
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
@@ -321,14 +340,21 @@ function adminMiddleware(req, res, next) {
* );
*/
function requirePasswordConfirmation(req, res, next) {
const { password } = req.body;
const password = String(
req.body?.current_password ||
req.body?.admin_password ||
req.body?.password_confirmation ||
req.body?.password ||
''
);
// 检查是否提供了密码
if (!password) {
return res.status(400).json({
success: false,
message: '执行此操作需要验证密码',
require_password: true
require_password: true,
requirePasswordConfirmation: true
});
}
@@ -355,7 +381,6 @@ function requirePasswordConfirmation(req, res, next) {
if (!isPasswordValid) {
// 记录安全日志:密码验证失败
SystemLogDB = require('./database').SystemLogDB;
SystemLogDB.log({
level: SystemLogDB.LEVELS.WARN,
category: SystemLogDB.CATEGORIES.SECURITY,

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,

View File

@@ -19,8 +19,20 @@ shares.forEach(share => {
// 如果是ISO格式(包含T和Z),需要转换
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
// 转换为 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
const newFormat = oldFormat.replace('T', ' ').replace(/\.\d+Z$/, '');
const parsed = new Date(oldFormat);
if (Number.isNaN(parsed.getTime())) {
console.warn(`跳过无法解析的时间: ${share.share_code} -> ${oldFormat}`);
return;
}
// 转换为本地时区 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
const hours = String(parsed.getHours()).padStart(2, '0');
const minutes = String(parsed.getMinutes()).padStart(2, '0');
const seconds = String(parsed.getSeconds()).padStart(2, '0');
const newFormat = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
updateStmt.run(newFormat, share.id);
fixed++;

View File

@@ -18,7 +18,6 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.23",
@@ -940,13 +939,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"version": "3.972.29",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz",
"integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.3.4",
"@smithy/types": "^4.14.3",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2"
},
"engines": {
@@ -979,6 +978,18 @@
"node": ">=12"
}
},
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1477,9 +1488,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -1784,6 +1795,18 @@
"node": ">= 8"
}
},
"node_modules/anynum": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz",
"integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -1960,9 +1983,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -1973,7 +1996,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -1990,9 +2013,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -2450,9 +2473,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2513,14 +2536,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -2539,7 +2562,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -2558,31 +2581,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
@@ -2602,10 +2600,10 @@
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
@@ -2614,7 +2612,26 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -2838,9 +2855,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3115,9 +3132,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
@@ -3250,12 +3267,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3282,18 +3299,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -3307,21 +3312,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/napi-build-utils": {
@@ -3352,9 +3358,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -3390,9 +3396,9 @@
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3419,9 +3425,9 @@
}
},
"node_modules/nodemon/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3480,15 +3486,6 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -3525,6 +3522,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3551,15 +3563,15 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3641,9 +3653,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -3655,15 +3667,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -3753,9 +3756,9 @@
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -3888,14 +3891,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -3907,13 +3910,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -4172,16 +4175,19 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
"license": "MIT",
"dependencies": {
"anynum": "^1.0.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
@@ -4345,18 +4351,6 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -4518,13 +4512,19 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=0.4"
"node": ">=16.0.0"
}
},
"node_modules/zip-stream": {

View File

@@ -27,7 +27,6 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.23",

View File

@@ -4,7 +4,6 @@ require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const svgCaptcha = require('svg-captcha');
const multer = require('multer');
const nodemailer = require('nodemailer');
@@ -74,6 +73,7 @@ const {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
OssUploadReservationDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
@@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
10,
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
);
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.28';
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.31';
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase();
const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0));
@@ -124,6 +124,16 @@ const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/;
const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时
const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB
const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB
const OSS_DIRECT_UPLOAD_TTL_SECONDS = Math.max(
60,
Math.min(3600, Number(process.env.OSS_DIRECT_UPLOAD_TTL_SECONDS || 900))
);
const OSS_UPLOAD_RESERVATION_TTL_MS = Math.max(
30 * 60 * 1000,
Number(process.env.OSS_UPLOAD_RESERVATION_TTL_MS || (45 * 60 * 1000)),
OSS_DIRECT_UPLOAD_TTL_SECONDS * 1000
);
const OSS_UPLOAD_TEMP_PREFIX = '__wanwan_tmp_uploads';
const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80);
const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200);
const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000);
@@ -150,6 +160,17 @@ const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase()
const SHOULD_USE_SECURE_COOKIES =
COOKIE_SECURE_MODE === 'true' ||
(process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false');
const CAPTCHA_COOKIE_NAME = 'captcha.ticket';
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
const CAPTCHA_COOKIE_MAX_AGE_MS = CAPTCHA_TTL_MS;
const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || JWT_SECRET;
const DEFAULT_CAPTCHA_SECRETS = [
'your-session-secret-change-in-production',
'session-secret-change-me',
'your-captcha-secret-change-in-production'
];
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
const sameSiteMode = isSecureCookie ? 'none' : 'lax';
function normalizeVersion(rawVersion, fallback = '0.0.0') {
const value = String(rawVersion || '').trim();
@@ -421,9 +442,8 @@ function getSecureBaseUrl(req) {
return `${getProtocol(req)}://${req.get('host')}`;
}
// 生产环境没有配置时,记录警告并使用请求的 Host不推荐
console.error('[安全警告] 生产环境配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!');
return `${getProtocol(req)}://${req.get('host')}`;
// 生产环境绝不使用请求 Host 生成外部链接,避免 Host Header 注入。
throw new Error('生产环境必须配置 PUBLIC_BASE_URL 或 ALLOWED_HOSTS');
}
// ===== 安全配置:信任代理 =====
@@ -522,7 +542,7 @@ function applySecurityHeaders(req, res) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// 内容安全策略
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';");
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';");
// 隐藏X-Powered-By
res.removeHeader('X-Powered-By');
}
@@ -631,44 +651,6 @@ app.use((req, res, next) => {
return next();
});
// Session配置用于验证码
const isSecureCookie = SHOULD_USE_SECURE_COOKIES;
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
// 安全检查Session密钥配置
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production';
const DEFAULT_SESSION_SECRETS = [
'your-session-secret-change-in-production',
'session-secret-change-me'
];
if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) {
const sessionWarnMsg = `
[安全警告] SESSION_SECRET 使用默认值,存在安全风险!
请在 .env 文件中设置随机生成的 SESSION_SECRET
生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
`;
if (process.env.NODE_ENV === 'production') {
console.error(sessionWarnMsg);
throw new Error('生产环境必须设置 SESSION_SECRET');
} else {
console.warn(sessionWarnMsg);
}
}
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false, // 仅在写入 session 时创建,减少无效会话
name: 'captcha.sid', // 自定义session cookie名称
cookie: {
secure: isSecureCookie,
httpOnly: true,
sameSite: sameSiteMode,
maxAge: 10 * 60 * 1000 // 10分钟
}
}));
// 安全响应头中间件
app.use((req, res, next) => {
applySecurityHeaders(req, res);
@@ -861,16 +843,32 @@ function isFileExtensionSafe(filename) {
// 应用XSS过滤到所有POST/PUT请求的body
app.use((req, res, next) => {
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
const sensitiveBodyFields = new Set([
'password',
'current_password',
'new_password',
'admin_password',
'api_key',
'access_key_secret',
'oss_access_key_secret',
'smtp_password',
'token',
'refreshToken'
]);
// 递归过滤所有字符串字段
function sanitizeObject(obj) {
function sanitizeObject(obj, fieldName = '') {
if (typeof obj === 'string') {
if (sensitiveBodyFields.has(fieldName)) {
return obj;
}
return sanitizeInput(obj);
} else if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item));
return obj.map(item => sanitizeObject(item, fieldName));
} else if (obj && typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
sanitized[key] = sanitizeObject(value, key);
}
return sanitized;
}
@@ -1419,6 +1417,53 @@ function handleDownloadSecurityBlock(req, res, blockResult, options = {}) {
});
}
function requirePasswordConfirmation(req, res, next) {
try {
const password = String(
req.body?.current_password ||
req.body?.admin_password ||
req.body?.password_confirmation ||
''
);
if (!password) {
return res.status(403).json({
success: false,
message: '需要输入当前管理员密码进行确认',
requirePasswordConfirmation: true
});
}
const user = UserDB.findById(req.user?.id);
if (!user || !user.password) {
return res.status(403).json({
success: false,
message: '管理员账号不存在或状态异常'
});
}
const ok = require('bcryptjs').compareSync(password, user.password);
if (!ok) {
logAuth(req, 'admin_password_confirmation_failed', '管理员敏感操作密码确认失败', {
userId: req.user?.id
}, 'warning');
return res.status(403).json({
success: false,
message: '管理员密码确认失败',
requirePasswordConfirmation: true
});
}
next();
} catch (error) {
console.error('[安全] 管理员密码确认失败:', error);
res.status(500).json({
success: false,
message: '管理员密码确认失败,请稍后重试'
});
}
}
function sendPlainTextError(res, statusCode, message) {
return res.status(statusCode).type('text/plain; charset=utf-8').send(message);
}
@@ -1454,6 +1499,26 @@ function formatDateTimeForSqlite(date = new Date()) {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function createOssUploadReservationToken() {
return crypto.randomBytes(24).toString('hex');
}
function buildOssTempObjectKey(userId, reservationToken, filename) {
const safeUserId = Math.max(0, Math.floor(Number(userId) || 0));
const safeToken = String(reservationToken || '').replace(/[^a-f0-9]/gi, '').slice(0, 64);
const safeName = sanitizeFilename(filename || 'upload.bin') || 'upload.bin';
return `${OSS_UPLOAD_TEMP_PREFIX}/user_${safeUserId}/${safeToken}/${safeName}`;
}
function encodeS3CopySource(bucket, key) {
const encodedBucket = encodeURIComponent(String(bucket || ''));
const encodedKey = String(key || '')
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
return `${encodedBucket}/${encodedKey}`;
}
function getDateKeyFromDate(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
if (Number.isNaN(target.getTime())) {
@@ -2227,10 +2292,14 @@ function buildStorageUserContext(user, overrides = {}) {
return user;
}
const directSecret = user.oss_access_key_secret;
const storageUser = {
...user,
...overrides
};
if (!storageUser.oss_access_key_secret && directSecret) {
storageUser.oss_access_key_secret = directSecret;
}
if (typeof storageUser.oss_access_key_secret === 'string' && storageUser.oss_access_key_secret) {
try {
@@ -2270,6 +2339,70 @@ function createS3ClientContextForUser(user, overrides = {}) {
};
}
async function cleanupExpiredOssUploadReservations(trigger = 'interval') {
const rows = OssUploadReservationDB.listExpiredPending(200);
if (!Array.isArray(rows) || rows.length === 0) {
return;
}
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
let cleaned = 0;
for (const row of rows) {
try {
const user = UserDB.findById(row.user_id);
if (!user) {
OssUploadReservationDB.expire(row.reservation_token);
cleaned += 1;
continue;
}
const ossClient = createOssClientForUser(user);
await ossClient.connect();
try {
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: row.temp_object_key
}));
} catch (deleteError) {
const statusCode = deleteError?.$metadata?.httpStatusCode;
if (deleteError?.name !== 'NotFound' && deleteError?.name !== 'NoSuchKey' && statusCode !== 404) {
throw deleteError;
}
}
OssUploadReservationDB.expire(row.reservation_token);
cleaned += 1;
} catch (error) {
console.error(`[OSS直传] 清理过期临时对象失败: reservation=${row.id}, user=${row.user_id}`, error);
}
}
const deletedHistory = OssUploadReservationDB.cleanupFinalizedHistory(7);
if (cleaned > 0 || Number(deletedHistory?.changes || 0) > 0) {
console.log(
`[OSS直传] 清理完成 (trigger=${trigger}) ` +
`expired=${cleaned}, deleted_history=${Number(deletedHistory?.changes || 0)}`
);
}
}
const ossUploadReservationSweepTimer = setInterval(() => {
cleanupExpiredOssUploadReservations('interval').catch(error => {
console.error('[OSS直传] 定期清理失败:', error);
});
}, 5 * 60 * 1000);
if (ossUploadReservationSweepTimer && typeof ossUploadReservationSweepTimer.unref === 'function') {
ossUploadReservationSweepTimer.unref();
}
setTimeout(() => {
cleanupExpiredOssUploadReservations('startup').catch(error => {
console.error('[OSS直传] 启动清理失败:', error);
});
}, 35 * 1000);
const EPHEMERAL_TOKEN_SECRET = JWT_SECRET;
function signEphemeralToken(payload, expiresInSeconds = 900) {
@@ -2734,6 +2867,7 @@ const fileListLimiter = new RateLimiter({
// 验证码最小请求间隔控制
const CAPTCHA_MIN_INTERVAL = 1000; // 1秒
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
const captchaTicketCache = new TTLCache(CAPTCHA_TTL_MS);
// 验证码防刷中间件
function captchaRateLimitMiddleware(req, res, next) {
@@ -3358,15 +3492,7 @@ async function searchFilesRecursively(storage, startPath, keyword, options = {})
}
function normalizeUploadPath(rawPath) {
const safeRaw = typeof rawPath === 'string' ? rawPath : '/';
if (safeRaw.includes('..') || safeRaw.includes('\x00')) {
return null;
}
const normalized = path.posix.normalize(safeRaw || '/');
if (normalized.includes('..')) {
return null;
}
return normalized === '.' ? '/' : normalized;
return normalizeVirtualPath(typeof rawPath === 'string' ? rawPath : '/');
}
function buildVirtualFilePath(basePath, filename) {
@@ -3590,7 +3716,7 @@ function cleanupOldTempFiles() {
const filePath = path.join(uploadsDir, file);
try {
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > maxAge) {
if (stats.isFile() && now - stats.mtimeMs > maxAge) {
fs.unlinkSync(filePath);
cleaned++;
}
@@ -3689,43 +3815,121 @@ function checkMailRateLimit(req, type = 'mail') {
// ===== 验证码验证辅助函数 =====
function isCaptchaSecretSecure() {
return typeof CAPTCHA_SECRET === 'string' &&
CAPTCHA_SECRET.length >= 32 &&
!DEFAULT_CAPTCHA_SECRETS.includes(CAPTCHA_SECRET) &&
(CAPTCHA_SECRET !== JWT_SECRET || isJwtSecretSecure());
}
function signCaptchaTicket(ticketId) {
return crypto
.createHmac('sha256', CAPTCHA_SECRET)
.update(ticketId)
.digest('hex');
}
function isSafeHexDigest(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
}
function safeEqualHex(left, right) {
if (!isSafeHexDigest(left) || !isSafeHexDigest(right)) return false;
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
}
function buildCaptchaCookieValue(ticketId) {
return `${ticketId}.${signCaptchaTicket(ticketId)}`;
}
function parseCaptchaTicket(rawValue) {
const value = String(rawValue || '');
const separatorIndex = value.lastIndexOf('.');
if (separatorIndex <= 0) return null;
const ticketId = value.slice(0, separatorIndex);
const signature = value.slice(separatorIndex + 1);
if (!/^[a-f0-9]{48}$/i.test(ticketId)) return null;
if (!safeEqualHex(signCaptchaTicket(ticketId), signature)) return null;
return ticketId;
}
function getCaptchaCookieOptions(maxAge = CAPTCHA_COOKIE_MAX_AGE_MS) {
return {
httpOnly: true,
secure: isSecureCookie,
sameSite: sameSiteMode,
maxAge,
path: '/'
};
}
function clearCaptchaTicketCookie(res) {
if (!res) return;
res.clearCookie(CAPTCHA_COOKIE_NAME, {
httpOnly: true,
secure: isSecureCookie,
sameSite: sameSiteMode,
path: '/'
});
}
function issueCaptchaTicket(res, captchaText) {
const ticketId = crypto.randomBytes(24).toString('hex');
captchaTicketCache.set(ticketId, {
captcha: String(captchaText || '').toLowerCase(),
createdAt: Date.now()
}, CAPTCHA_TTL_MS);
res.cookie(CAPTCHA_COOKIE_NAME, buildCaptchaCookieValue(ticketId), getCaptchaCookieOptions());
return ticketId;
}
/**
* 验证验证码
* @param {Object} req - 请求对象
* @param {Object} res - 响应对象
* @param {string} captcha - 用户输入的验证码
* @param {string} logPrefix - 日志前缀
* @returns {{valid: boolean, message?: string}} 验证结果
*/
function verifyCaptcha(req, captcha) {
function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
if (!captcha) {
return { valid: false, message: '请输入验证码' };
}
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
// 调试日志
console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha);
if (!sessionCaptcha || !captchaTime) {
console.log('[验证码验证] 失败: session中无验证码');
const ticketId = parseCaptchaTicket(req.cookies?.[CAPTCHA_COOKIE_NAME]);
if (!ticketId) {
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码票据无效或不存在`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
console.log('[验证码验证] 失败: 验证码已超时');
const captchaRecord = captchaTicketCache.get(ticketId);
console.log(`[${logPrefix}] 正在验证验证码票据:`, ticketId.slice(0, 8));
if (!captchaRecord || !captchaRecord.captcha || !captchaRecord.createdAt) {
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码票据不存在`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
if (captcha.toLowerCase() !== sessionCaptcha) {
console.log('[验证码验证] 失败: 验证码不匹配');
if (Date.now() - captchaRecord.createdAt > CAPTCHA_TTL_MS) {
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
console.log(`[${logPrefix}] 失败: 验证码已超时`);
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
if (String(captcha).toLowerCase() !== captchaRecord.captcha) {
console.log(`[${logPrefix}] 失败: 验证码不匹配`);
return { valid: false, message: '验证码错误' };
}
console.log('[验证码验证] 成功');
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
console.log(`[${logPrefix}] 成功`);
captchaTicketCache.delete(ticketId);
clearCaptchaTicketCookie(res);
return { valid: true };
}
@@ -3797,23 +4001,10 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字
});
// 将验证码文本存储在session中
req.session.captcha = captcha.text.toLowerCase();
req.session.captchaTime = Date.now();
// 保存session后再返回响应修复确保session保存成功
req.session.save((err) => {
if (err) {
console.error('[验证码] Session保存失败:', err);
return res.status(500).json({
success: false,
message: '验证码生成失败'
});
}
console.log('[验证码] 生成成功, SessionID:', req.sessionID);
res.type('svg');
res.send(captcha.data);
});
const ticketId = issueCaptchaTicket(res, captcha.text);
console.log('[验证码] 生成成功, Ticket:', ticketId.slice(0, 8));
res.type('svg');
res.send(captcha.data);
} catch (error) {
console.error('生成验证码失败:', error);
res.status(500).json({
@@ -3907,7 +4098,7 @@ app.post('/api/register',
try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4017,7 +4208,7 @@ app.post('/api/resend-verification', [
try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4108,7 +4299,7 @@ app.post('/api/password/forgot', [
const { email, captcha } = req.body;
try {
// 验证验证码
const captchaResult = verifyCaptcha(req, captcha);
const captchaResult = verifyCaptcha(req, res, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
@@ -4246,45 +4437,14 @@ app.post('/api/login',
});
}
// 验证验证码
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
// 安全:不记录验证码明文
console.log('[登录验证] 正在验证验证码...');
if (!sessionCaptcha || !captchaTime) {
console.log('[登录验证] 验证码不存在于Session中');
const captchaResult = verifyCaptcha(req, res, captcha, '登录验证');
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
message: captchaResult.message,
needCaptcha: true
});
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
console.log('[登录验证] 验证码已超过5分钟');
return res.status(400).json({
success: false,
message: '验证码已过期,请刷新验证码',
needCaptcha: true
});
}
if (captcha.toLowerCase() !== sessionCaptcha) {
console.log('[登录验证] 验证码不匹配');
return res.status(400).json({
success: false,
message: '验证码错误',
needCaptcha: true
});
}
console.log('[登录验证] 验证码验证通过');
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
}
let user = UserDB.findByUsername(username);
@@ -5433,16 +5593,15 @@ app.get('/api/files', authMiddleware, async (req, res) => {
const rawPath = req.query.path || '/';
// 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径
if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) {
// 路径安全验证:在 API 层提前拒绝包含 ..、编码 .. 或空字节的路径
const dirPath = normalizeVirtualPath(rawPath);
if (!dirPath) {
return res.status(400).json({
success: false,
message: '路径包含非法字符'
});
}
// 规范化路径
const dirPath = path.posix.normalize(rawPath);
let storage;
try {
@@ -6604,8 +6763,9 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
});
}
// 路径安全验证:防止目录遍历攻击
if (uploadPath.includes('..') || uploadPath.includes('\x00')) {
// 路径安全验证:防止目录遍历攻击。使用统一虚拟路径规范化,覆盖编码后的 .. 片段。
const normalizedUploadPath = normalizeUploadPath(uploadPath);
if (!normalizedUploadPath) {
return res.status(400).json({
success: false,
message: '上传路径非法'
@@ -6630,7 +6790,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
// 构建对象 Key与 OssStorageClient.getObjectKey 格式一致)
// 格式user_${id}/${path}/${filename}
const sanitizedFilename = sanitizeFilename(filename);
let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
let normalizedPath = normalizedUploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
// 移除开头的斜杠
normalizedPath = normalizedPath.replace(/^\/+/, '');
// 移除结尾的斜杠
@@ -6678,32 +6838,53 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
});
}
const reservationToken = createOssUploadReservationToken();
const tempObjectKey = buildOssTempObjectKey(req.user.id, reservationToken, sanitizedFilename);
const reservationExpiresAt = new Date(Date.now() + OSS_UPLOAD_RESERVATION_TTL_MS);
const reservation = OssUploadReservationDB.create({
reservationToken,
userId: req.user.id,
finalObjectKey: objectKey,
tempObjectKey,
expectedSize: fileSize,
previousSize,
fileHash: fileHash || null,
expiresAt: formatDateTimeForSqlite(reservationExpiresAt)
});
if (!reservation) {
throw new Error('创建上传预留记录失败');
}
const completionToken = signEphemeralToken({
type: 'upload_complete',
userId: req.user.id,
reservationToken,
objectKey,
tempObjectKey,
previousSize,
expectedSize: fileSize,
fileHash: fileHash || null
}, 30 * 60);
}, Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000));
// 创建 PutObject 命令
// 签名只允许写入临时对象;完成确认后由服务端复制到最终路径
const command = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
Key: tempObjectKey,
ContentType: contentType
});
// 生成签名 URL15分钟有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 });
const signedUrl = await getSignedUrl(client, command, { expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS });
res.json({
success: true,
uploadUrl: signedUrl,
objectKey: objectKey,
uploadObjectKey: tempObjectKey,
previousSize,
completionToken,
expiresIn: 900
expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS,
completionExpiresIn: Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000)
});
} catch (error) {
console.error('[OSS签名] 生成上传签名失败:', error);
@@ -6770,7 +6951,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
const completionPayload = completionTokenResult.payload || {};
if (
Number(completionPayload.userId) !== Number(req.user.id) ||
completionPayload.objectKey !== normalizedObjectKey
completionPayload.objectKey !== normalizedObjectKey ||
!completionPayload.reservationToken
) {
return res.status(403).json({
success: false,
@@ -6778,23 +6960,37 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
});
}
const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0
? Number(completionPayload.previousSize)
const reservation = OssUploadReservationDB.findPendingByToken(completionPayload.reservationToken);
if (
!reservation ||
Number(reservation.user_id) !== Number(req.user.id) ||
reservation.final_object_key !== normalizedObjectKey ||
reservation.temp_object_key !== completionPayload.tempObjectKey
) {
return res.status(403).json({
success: false,
message: '上传预留记录不存在、已完成或已过期'
});
}
const previousObjectSize = Number.isFinite(Number(reservation.previous_size)) && Number(reservation.previous_size) >= 0
? Number(reservation.previous_size)
: 0;
const completionFileHash = normalizeFileHash(completionPayload.fileHash);
const expectedSize = Math.max(0, Math.floor(Number(reservation.expected_size) || 0));
const completionFileHash = normalizeFileHash(reservation.file_hash || completionPayload.fileHash);
const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`;
let ossClient;
try {
const { HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
ossClient = createOssClientForUser(req.user);
await ossClient.connect();
const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({
Bucket: ossClient.getBucket(),
Key: normalizedObjectKey
Key: reservation.temp_object_key
}));
const verifiedSize = Number(headResponse.ContentLength || 0);
@@ -6807,6 +7003,19 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
console.warn(`[上传完成] 用户 ${req.user.id} 上报大小(${reportedSize})与实际大小(${verifiedSize})不一致,已使用实际大小`);
}
if (expectedSize <= 0 || verifiedSize !== expectedSize) {
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: reservation.temp_object_key
}));
OssUploadReservationDB.cancel(reservation.reservation_token);
clearOssUsageCache(req.user.id);
return res.status(400).json({
success: false,
message: `上传对象大小校验失败:期望 ${formatFileSize(expectedSize)},实际 ${formatFileSize(verifiedSize)}`
});
}
const deltaSize = verifiedSize - previousObjectSize;
// 二次校验 OSS 配额(防止签名后并发上传导致超限)
@@ -6816,17 +7025,13 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
const projectedUsage = Math.max(0, currentUsage + deltaSize);
if (projectedUsage > ossQuota) {
// 回滚:删除刚上传的对象,避免超配额文件残留
// 回滚:删除临时对象,避免超配额文件残留
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: normalizedObjectKey
Key: reservation.temp_object_key
}));
// 若为覆盖上传,旧对象已被替换,删除后需扣除旧对象体积
if (previousObjectSize > 0) {
await StorageUsageCache.updateUsage(req.user.id, -previousObjectSize);
}
OssUploadReservationDB.cancel(reservation.reservation_token);
clearOssUsageCache(req.user.id);
return res.status(400).json({
@@ -6835,6 +7040,18 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
});
}
await ossClient.s3Client.send(new CopyObjectCommand({
Bucket: ossClient.getBucket(),
CopySource: encodeS3CopySource(ossClient.getBucket(), reservation.temp_object_key),
Key: normalizedObjectKey,
MetadataDirective: 'COPY'
}));
await ossClient.s3Client.send(new DeleteObjectCommand({
Bucket: ossClient.getBucket(),
Key: reservation.temp_object_key
}));
// 更新存储使用量缓存(增量更新,覆盖上传只记录差值)
if (deltaSize !== 0) {
await StorageUsageCache.updateUsage(req.user.id, deltaSize);
@@ -6852,6 +7069,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
objectKey: normalizedObjectKey
});
OssUploadReservationDB.complete(reservation.reservation_token);
console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`);
res.json({
success: true,
@@ -6885,9 +7104,9 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
});
}
// 路径安全验证:防止目录遍历攻击
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
// 路径安全验证:防止目录遍历攻击。必须在 normalize 前拒绝 .. 片段。
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -7123,8 +7342,9 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
}
// 路径安全校验
const normalizedPath = path.posix.normalize(remotePath || '/');
if (normalizedPath.includes('..')) {
const normalizedPath = normalizeUploadPath(remotePath || '/');
if (!normalizedPath) {
safeDeleteFile(req.file.path);
return res.status(400).json({
success: false,
message: '上传路径非法'
@@ -7199,8 +7419,8 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => {
});
}
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -7317,8 +7537,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
}
// 路径安全验证:防止目录遍历攻击
const normalizedPath = path.posix.normalize(filePath);
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
const normalizedPath = normalizeVirtualPath(filePath);
if (!normalizedPath) {
return res.status(400).json({
success: false,
message: '文件路径非法'
@@ -8626,52 +8846,10 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
// 记录下载次数(添加限流保护防止滥用)
app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => {
const { code } = req.params;
// 参数验证code 不能为空
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
return res.status(400).json({
success: false,
message: '无效的分享码'
});
}
try {
const share = ShareDB.findByCode(code);
if (!share) {
return res.status(404).json({
success: false,
message: '分享不存在'
});
}
const accessPolicy = evaluateShareSecurityPolicy(share, req, {
action: 'download',
enforceDownloadLimit: true
});
if (!accessPolicy.allowed) {
return res.status(403).json({
success: false,
message: accessPolicy.message || '下载已受限',
policy_code: accessPolicy.code || 'policy_blocked'
});
}
// 增加下载次数
ShareDB.incrementDownloadCount(code);
res.json({
success: true,
message: '下载统计已记录'
});
} catch (error) {
console.error('记录下载失败:', error);
res.status(500).json({
success: false,
message: '记录下载失败: ' + error.message
});
}
res.status(410).json({
success: false,
message: '下载统计已合并到下载地址签发和文件下载接口'
});
});
// 生成分享文件下载签名 URLOSS 直连下载,公开 API添加限流保护
@@ -8903,6 +9081,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
}
}
ShareDB.incrementDownloadCount(code);
res.json({
success: true,
downloadUrl: signedUrl,
@@ -9225,10 +9405,11 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
});
// 更新系统设置
// 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证
// 敏感系统设置需要管理员当前密码二次确认
app.post('/api/admin/settings',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
(req, res) => {
try {
const {
@@ -9430,6 +9611,7 @@ app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req,
app.post('/api/admin/unified-oss-config',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
[
body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'),
body('region').notEmpty().withMessage('地域不能为空'),
@@ -9571,6 +9753,7 @@ app.post('/api/admin/unified-oss-config/test',
app.delete('/api/admin/unified-oss-config',
authMiddleware,
adminMiddleware,
requirePasswordConfirmation,
(req, res) => {
try {
SettingsDB.clearUnifiedOssConfig();
@@ -9782,16 +9965,16 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击'
});
// 12. Session密钥检查
const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32;
// 12. 验证码票据签名密钥检查
const captchaSecretSecure = isCaptchaSecretSecure();
checks.push({
name: 'Session密钥',
name: '验证码票据签名密钥',
category: 'security',
status: sessionSecure ? 'pass' : 'fail',
message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!',
suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET至少32字符'
status: captchaSecretSecure ? 'pass' : 'fail',
message: captchaSecretSecure ? '验证码票据签名密钥已正确配置' : '验证码票据签名密钥使用默认值或长度不足,存在安全风险!',
suggestion: captchaSecretSecure ? null : '请在.env中设置随机生成的JWT_SECRET或CAPTCHA_SECRET至少32字符'
});
if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical';
if (!captchaSecretSecure && overallStatus !== 'critical') overallStatus = 'critical';
// 统计
const summary = {

View File

@@ -48,6 +48,7 @@ const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
OSS_MULTIPART_MIN_PART_SIZE,
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
);
const OSS_RENAME_RECOVERY_KEYS = new Set();
/**
* 将 OSS/网络错误转换为友好的错误信息
@@ -230,8 +231,10 @@ class LocalStorageClient {
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
const netIncrease = newFileSize - oldFileSize;
let reservedDelta = 0;
if (netIncrease > 0) {
this.checkQuota(netIncrease);
reservedDelta = netIncrease;
}
// 确保目标目录存在
@@ -242,30 +245,37 @@ class LocalStorageClient {
// 使用临时文件+重命名模式,避免文件被占用问题
const tempPath = `${destPath}.uploading_${Date.now()}`;
const backupPath = `${destPath}.backup_${Date.now()}`;
let backupCreated = false;
let destReplaced = false;
try {
// 如果目标文件存在,先删除
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
fs.renameSync(destPath, backupPath);
backupCreated = true;
}
// 优先尝试 rename同文件系统下瞬时完成大文件不再需要逐字节复制
let movedDirectly = false;
try {
fs.renameSync(localPath, destPath);
movedDirectly = true;
destReplaced = true;
} catch (renameErr) {
if (renameErr.code === 'EXDEV') {
// 跨文件系统,回退到 copy + rename
fs.copyFileSync(localPath, tempPath);
fs.renameSync(tempPath, destPath);
destReplaced = true;
} else {
throw renameErr;
}
}
// 更新已使用空间(使用净增量)
if (netIncrease !== 0) {
if (backupCreated) {
try { fs.unlinkSync(backupPath); } catch (_) {}
}
// 正向净增已在写入前原子预留,这里只处理覆盖变小时的扣减。
if (netIncrease < 0) {
this.updateUsedSpace(netIncrease);
}
} catch (error) {
@@ -273,6 +283,17 @@ class LocalStorageClient {
if (fs.existsSync(tempPath)) {
try { fs.unlinkSync(tempPath); } catch (_) {}
}
if (destReplaced && fs.existsSync(destPath)) {
try { fs.unlinkSync(destPath); } catch (_) {}
}
if (backupCreated && fs.existsSync(backupPath) && !fs.existsSync(destPath)) {
try { fs.renameSync(backupPath, destPath); } catch (restoreError) {
console.error(`[本地存储] 恢复旧文件失败: ${restoreError.message}`);
}
}
if (reservedDelta > 0) {
this.updateUsedSpace(-reservedDelta);
}
throw error;
}
}
@@ -507,12 +528,13 @@ class LocalStorageClient {
// 5. 拼接完整路径
const fullPath = path.join(this.basePath, normalized);
// 6. 解析真实路径(处理符号链接)后再次验证
// 6. 解析绝对路径后再次验证
const resolvedBasePath = path.resolve(this.basePath);
const resolvedFullPath = path.resolve(fullPath);
const relativeToBase = path.relative(resolvedBasePath, resolvedFullPath);
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
console.warn('[安全] 检测到路径遍历攻击:', {
input: relativePath,
resolved: resolvedFullPath,
@@ -528,225 +550,26 @@ class LocalStorageClient {
* 检查配额
*/
checkQuota(additionalSize) {
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
if (newUsed > this.user.local_storage_quota) {
const used = this.formatSize(this.user.local_storage_used);
const amount = Math.max(0, Math.trunc(Number(additionalSize) || 0));
if (amount === 0) return;
const updatedUser = UserDB.reserveLocalStorageSpace(this.user.id, amount);
if (!updatedUser) {
const latestUser = UserDB.findById(this.user.id) || this.user;
const used = this.formatSize(latestUser.local_storage_used || 0);
const quota = this.formatSize(this.user.local_storage_quota);
const need = this.formatSize(additionalSize);
const need = this.formatSize(amount);
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
}
this.user.local_storage_used = Number(updatedUser.local_storage_used || 0);
}
/**
* 更新已使用空间
*/
updateUsedSpace(delta) {
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
UserDB.update(this.user.id, { local_storage_used: newUsed });
// 更新内存中的值
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;
}
const updatedUser = UserDB.adjustLocalStorageUsed(this.user.id, delta);
this.user.local_storage_used = Number(updatedUser?.local_storage_used || 0);
}
/**
@@ -928,6 +751,16 @@ class OssStorageClient {
this.s3Client = new S3Client(s3Config);
console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`);
const recoveryKey = `${ossConfig.oss_bucket}:${this.prefix}`;
if (!OSS_RENAME_RECOVERY_KEYS.has(recoveryKey)) {
OSS_RENAME_RECOVERY_KEYS.add(recoveryKey);
this.recoverPendingRenames().catch(error => {
OSS_RENAME_RECOVERY_KEYS.delete(recoveryKey);
console.error('[OSS存储] 重命名恢复任务失败:', error.message);
});
}
return this;
} catch (error) {
console.error(`[OSS存储] 连接失败:`, error.message);
@@ -935,6 +768,113 @@ class OssStorageClient {
}
}
/**
* 恢复未完成的重命名操作。
* 扫描 OSS 中的 .rename_pending_* 标记,回滚 copying 阶段或补删 deleting 阶段。
*/
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 {
const markerKey = marker.Key;
const markerResponse = await this.s3Client.send(new GetObjectCommand({
Bucket: bucket,
Key: markerKey
}));
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
const age = Date.now() - Number(timestamp || 0);
const timeoutMs = 60 * 60 * 1000;
if (age <= timeoutMs) {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase})`);
continue;
}
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
if (phase === 'copying') {
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
await this._deletePrefixObjects(oldPrefix);
} else {
console.warn(`[OSS存储] 未知阶段 ${phase},仅清理标记文件`);
}
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
async _rollbackRename(oldPrefix, newPrefix) {
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
await this._deletePrefixObjects(newPrefixWithSlash);
}
async _deletePrefixObjects(prefix) {
const bucket = this.getBucket();
const safePrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
let continuationToken = null;
let deletedCount = 0;
do {
const listResponse = await this.s3Client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: safePrefix,
ContinuationToken: continuationToken,
MaxKeys: 1000
}));
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
}));
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 已删除前缀 ${safePrefix}${deletedCount} 个对象`);
}
}
/**
* 获取当前使用的 bucket 名称
* @returns {string}
@@ -979,22 +919,27 @@ class OssStorageClient {
throw new Error('路径包含非法字符');
}
// 2. 先进行 URL 解码(防止双重编码绕过)
let decoded = relativePath;
// 2. 仅将 URL 解码结果用于安全检查,不用于生成对象 key。
// 文件名中的字面量 "%2F" 应保留为普通字符,不能变成路径分隔符。
let decodedForSecurity = relativePath;
try {
decoded = decodeURIComponent(relativePath);
decodedForSecurity = decodeURIComponent(relativePath);
} catch (e) {
// 解码失败使用原始值
// 解码失败使用原始值做后续检查
}
// 3. 检查解码后的空字节
if (decoded.includes('\x00')) {
if (decodedForSecurity.includes('\x00')) {
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
throw new Error('路径包含非法字符');
}
if (decodedForSecurity.includes('..')) {
console.warn('[OSS安全] 检测到编码的目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 4. 规范化路径统一使用正斜杠OSS 使用正斜杠作为分隔符)
let normalized = decoded
let normalized = relativePath
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
.replace(/\/+/g, '/'); // 合并多个连续斜杠
@@ -1282,7 +1227,7 @@ class OssStorageClient {
try {
statResult = await this.stat(filePath);
} catch (statError) {
if (statError.message && statResult?.message.includes('不存在')) {
if (statError.message && statError.message.includes('不存在')) {
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
return { size: 0 }; // 文件不存在,返回大小为 0
}
@@ -1292,6 +1237,7 @@ class OssStorageClient {
let totalDeletedSize = 0;
if (statResult.isDirectory) {
const directoryPrefix = key.endsWith('/') ? key : `${key}/`;
// 删除目录:列出所有对象并批量删除
// 使用分页循环处理超过 1000 个对象的情况
let continuationToken = null;
@@ -1301,7 +1247,7 @@ class OssStorageClient {
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key,
Prefix: directoryPrefix,
MaxKeys: MAX_DELETE_BATCH,
ContinuationToken: continuationToken
});
@@ -1336,7 +1282,7 @@ class OssStorageClient {
} while (continuationToken);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
console.log(`[OSS存储] 删除目录: ${directoryPrefix} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
}
return { size: totalDeletedSize };
@@ -1742,7 +1688,7 @@ class OssStorageClient {
const key = this.getObjectKey(filePath);
const bucket = this.getBucket();
const provider = this.getProvider();
const region = this.s3Client.config.region;
const region = String(this.currentConfig?.oss_region || this.user.oss_region || 'us-east-1');
let baseUrl;
if (provider === 'aliyun') {

View File

@@ -0,0 +1,105 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-download-quota-'));
const tempDbPath = path.join(tempDir, 'database.db');
process.env.DATABASE_PATH = tempDbPath;
process.env.ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
process.env.JWT_SECRET = crypto.randomBytes(32).toString('hex');
process.env.REFRESH_SECRET = crypto.randomBytes(32).toString('hex');
process.env.WAL_CHECKPOINT_ENABLED = 'false';
let db;
try {
const { db: loadedDb, UserDB } = require('./database');
const { authMiddleware, generateToken } = require('./auth');
db = loadedDb;
const adminUser = UserDB.findByUsername(process.env.ADMIN_USERNAME || 'admin');
assert(adminUser, '应自动创建默认管理员账号');
assert(
Number(adminUser.download_traffic_quota) === -1,
`默认管理员下载配额应为 -1实际: ${adminUser.download_traffic_quota}`
);
const username = `quota_test_${Date.now()}`;
const userId = UserDB.create({
username,
email: `${username}@example.com`,
password: 'secret123',
is_verified: 1
});
const createdUser = UserDB.findById(userId);
assert(createdUser, '新用户应创建成功');
assert(
Number(createdUser.download_traffic_quota) === -1,
`新用户默认下载配额应为 -1实际: ${createdUser.download_traffic_quota}`
);
const token = generateToken(createdUser);
const req = {
headers: {
authorization: `Bearer ${token}`
},
cookies: {},
ip: '127.0.0.1',
socket: {
remoteAddress: '127.0.0.1'
},
get() {
return 'quota-test-agent';
}
};
let nextCalled = false;
const res = {
statusCode: 200,
payload: null,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.payload = body;
return this;
}
};
authMiddleware(req, res, () => {
nextCalled = true;
});
assert(nextCalled, `authMiddleware 应放行不限流量用户,实际状态码: ${res.statusCode}`);
assert(req.user, 'authMiddleware 应写入 req.user');
assert(
Number(req.user.download_traffic_quota) === -1,
`authMiddleware 中的下载配额应保留 -1实际: ${req.user.download_traffic_quota}`
);
console.log('PASS test_download_quota_defaults');
process.exit(0);
} catch (error) {
console.error('FAIL test_download_quota_defaults');
console.error(error && error.stack ? error.stack : error);
process.exit(1);
} finally {
if (db) {
try {
db.close();
} catch (closeError) {
console.error('关闭测试数据库失败:', closeError.message);
}
}
}

View File

@@ -473,7 +473,7 @@ function testLocalStoragePath() {
return fullPath;
}
const basePath = '/tmp/storage/user_1';
const basePath = path.join(path.resolve('/tmp'), 'storage', 'user_1');
test('正常相对路径应该被接受', () => {
const result = getFullPath(basePath, 'documents/file.txt');
@@ -703,7 +703,7 @@ function testDatabaseFieldWhitelist() {
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',
'upload_api_key', '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'
@@ -730,14 +730,14 @@ function testDatabaseFieldWhitelist() {
const updates = {
username: 'newname',
id: 999, // 尝试修改 ID
is_admin: 1, // 合法字段
is_admin: 1, // 权限字段不允许通过通用更新入口修改
sql_injection: "'; DROP TABLE users; --" // 非法字段
};
const filtered = filterUpdates(updates);
assert.ok(!('id' in filtered));
assert.ok(!('is_admin' in filtered));
assert.ok(!('sql_injection' in filtered));
assert.strictEqual(filtered.username, 'newname');
assert.strictEqual(filtered.is_admin, 1);
});
test('原型污染尝试应该被阻止', () => {

View File

@@ -0,0 +1,420 @@
/**
* Full project audit regression harness.
*
* Starts the backend with an isolated database/storage root and exercises the
* highest-risk public HTTP flows through real routes, cookies and CSRF.
*/
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const BACKEND_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(BACKEND_DIR, 'server.js');
const AUDIT_PREFIX = `audit_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
function randomHex(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex');
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function countRegularFiles(dir) {
if (!fs.existsSync(dir)) return 0;
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile()) count += 1;
}
return count;
}
class CookieJar {
constructor() {
this.cookies = new Map();
}
store(setCookie) {
const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);
for (const raw of list) {
const first = String(raw).split(';')[0];
const idx = first.indexOf('=');
if (idx <= 0) continue;
this.cookies.set(first.slice(0, idx), first.slice(idx + 1));
}
}
header() {
return Array.from(this.cookies.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ');
}
get(name) {
return this.cookies.get(name) || '';
}
}
async function request(baseUrl, jar, method, route, options = {}) {
const url = new URL(route, baseUrl);
const headers = { ...(options.headers || {}) };
const cookieHeader = jar?.header();
if (cookieHeader) headers.Cookie = cookieHeader;
const upperMethod = method.toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(upperMethod) && options.csrf !== false) {
const csrf = jar?.get('csrf_token');
if (csrf) headers['X-CSRF-Token'] = csrf;
}
let body = options.body;
if (options.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.json);
}
const response = await fetch(url, {
method: upperMethod,
headers,
body,
redirect: options.redirect || 'manual'
});
jar?.store(response.headers.getSetCookie ? response.headers.getSetCookie() : response.headers.get('set-cookie'));
const contentType = response.headers.get('content-type') || '';
const buffer = Buffer.from(await response.arrayBuffer());
let data = buffer;
if (contentType.includes('application/json')) {
data = JSON.parse(buffer.toString('utf8') || '{}');
} else if (contentType.includes('text/') || contentType.includes('image/svg')) {
data = buffer.toString('utf8');
}
return {
status: response.status,
headers: response.headers,
data,
raw: buffer
};
}
async function waitForHealth(baseUrl, timeoutMs = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await request(baseUrl, null, 'GET', '/api/health');
if (res.status === 200 && res.data?.success === true) return;
} catch {}
await delay(250);
}
throw new Error('server did not become healthy');
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function run() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-full-audit-'));
const storageRoot = path.join(tempRoot, 'storage');
const dbPath = path.join(tempRoot, 'database.db');
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const uploadsDir = path.join(BACKEND_DIR, 'uploads');
const adminPassword = `${AUDIT_PREFIX}_Pass123!`;
const env = {
...process.env,
PORT: String(port),
NODE_ENV: 'development',
DATABASE_PATH: dbPath,
STORAGE_ROOT: storageRoot,
JWT_SECRET: randomHex(32),
ENCRYPTION_KEY: randomHex(32),
ADMIN_USERNAME: 'admin',
ADMIN_PASSWORD: adminPassword,
PUBLIC_BASE_URL: baseUrl,
ALLOWED_ORIGINS: baseUrl,
COOKIE_SECURE: 'false',
ENABLE_CSRF: 'true',
ENFORCE_HTTPS: 'false',
TRUST_PROXY: 'false',
WAL_CHECKPOINT_ENABLED: 'false'
};
const child = spawn(process.execPath, [SERVER_PATH], {
cwd: BACKEND_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk.toString();
});
child.stderr.on('data', chunk => {
stderr += chunk.toString();
});
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
try {
await waitForHealth(baseUrl);
const jar = new CookieJar();
let userId = 1;
let shareId = null;
let shareCode = '';
let directLinkId = null;
test('public health/config/csrf endpoints are reachable', async () => {
const health = await request(baseUrl, jar, 'GET', '/api/health');
assert.strictEqual(health.status, 200);
assert.strictEqual(health.data.success, true);
const config = await request(baseUrl, jar, 'GET', '/api/config');
assert.strictEqual(config.status, 200);
assert.strictEqual(config.data.success, true);
const csrf = await request(baseUrl, jar, 'GET', '/api/csrf-token');
assert.strictEqual(csrf.status, 200);
assert.ok(csrf.data.csrfToken);
assert.ok(jar.get('csrf_token'));
});
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
const login = await request(baseUrl, jar, 'POST', '/api/login', {
json: { username: 'admin', password: adminPassword }
});
assert.strictEqual(login.status, 200);
assert.strictEqual(login.data.success, true);
assert.ok(jar.get('token'));
assert.ok(jar.get('refreshToken'));
userId = login.data.user.id;
const profile = await request(baseUrl, jar, 'GET', '/api/user/profile');
assert.strictEqual(profile.status, 200);
assert.strictEqual(profile.data.success, true);
assert.strictEqual(profile.data.user.id, userId);
assert.strictEqual(profile.data.user.oss_access_key_secret, undefined);
const csrfBlocked = await request(baseUrl, jar, 'POST', '/api/user/theme', {
csrf: false,
json: { theme: 'light' }
});
assert.strictEqual(csrfBlocked.status, 403);
});
test('admin can move isolated audit user to local storage only', async () => {
const res = await request(baseUrl, jar, 'POST', `/api/admin/users/${userId}/storage-permission`, {
json: {
storage_permission: 'local_only',
local_storage_quota: 10 * 1024 * 1024,
download_traffic_quota: -1,
reset_download_traffic_used: true
}
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.data.success, true);
assert.strictEqual(res.data.user.current_storage_type, 'local');
});
test('file manager rejects unsafe folder names and handles normal local file flow', async () => {
const badMkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
json: { path: '/', folderName: '../bad' }
});
assert.strictEqual(badMkdir.status, 400);
const badList = await request(baseUrl, jar, 'GET', '/api/files?path=/../secret');
assert.strictEqual(badList.status, 400);
const mkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
json: { path: '/', folderName: AUDIT_PREFIX }
});
assert.strictEqual(mkdir.status, 200);
assert.strictEqual(mkdir.data.success, true);
const file = new Blob([`hello ${AUDIT_PREFIX}`], { type: 'text/plain' });
const form = new FormData();
form.append('path', `/${AUDIT_PREFIX}`);
form.append('file', file, 'hello.txt');
const upload = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
assert.strictEqual(upload.status, 200);
assert.strictEqual(upload.data.success, true);
assert.strictEqual(upload.data.path, `/${AUDIT_PREFIX}/hello.txt`);
const list = await request(baseUrl, jar, 'GET', `/api/files?path=/${encodeURIComponent(AUDIT_PREFIX)}`);
assert.strictEqual(list.status, 200);
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
const search = await request(baseUrl, jar, 'GET', `/api/files/search?keyword=hello&path=/${encodeURIComponent(AUDIT_PREFIX)}`);
assert.strictEqual(search.status, 200);
assert.ok(search.data.items.some(item => item.name === 'hello.txt'));
});
test('failed normal upload validation must not leave multer temp files', async () => {
fs.mkdirSync(uploadsDir, { recursive: true });
const before = countRegularFiles(uploadsDir);
const form = new FormData();
form.append('path', '/../blocked');
form.append('file', new Blob(['leak candidate'], { type: 'text/plain' }), 'leak.txt');
const res = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
assert.strictEqual(res.status, 400);
const after = countRegularFiles(uploadsDir);
assert.strictEqual(after, before, `uploads temp leak: before=${before}, after=${after}`);
});
test('download URL/check/download work for local file and reject traversal', async () => {
const traversal = await request(baseUrl, jar, 'GET', '/api/files/download-check?path=/../secret.txt');
assert.strictEqual(traversal.status, 400);
const check = await request(baseUrl, jar, 'GET', `/api/files/download-check?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
assert.strictEqual(check.status, 200);
assert.strictEqual(check.data.success, true);
const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`);
assert.strictEqual(url.status, 400);
assert.match(url.data.message, /OSS/);
const download = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
assert.strictEqual(download.status, 200);
assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX));
});
test('share and direct-link flows preserve path boundaries', async () => {
const createShare = await request(baseUrl, jar, 'POST', '/api/share/create', {
json: {
share_type: 'directory',
file_path: `/${AUDIT_PREFIX}`,
file_name: AUDIT_PREFIX,
password: `${AUDIT_PREFIX}_pw`,
expiry_days: 1,
max_downloads: 5,
device_limit: 'all'
}
});
assert.strictEqual(createShare.status, 200);
assert.strictEqual(createShare.data.success, true);
shareId = createShare.data.share_id;
shareCode = createShare.data.share_code;
const badVerify = await request(baseUrl, new CookieJar(), 'POST', `/api/share/${shareCode}/verify`, {
json: { password: 'wrong' }
});
assert.strictEqual(badVerify.status, 401);
const publicJar = new CookieJar();
const verify = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/verify`, {
json: { password: `${AUDIT_PREFIX}_pw` }
});
assert.strictEqual(verify.status, 200);
assert.strictEqual(verify.data.success, true);
const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
json: { path: '', password: `${AUDIT_PREFIX}_pw` }
});
assert.strictEqual(list.status, 200);
assert.strictEqual(list.data.success, true);
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
const traversal = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
json: { path: '/../database.db', mode: 'download', password: `${AUDIT_PREFIX}_pw` }
});
assert.ok([400, 403, 404].includes(traversal.status));
const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', {
json: {
file_path: `/${AUDIT_PREFIX}/hello.txt`,
file_name: 'hello.txt',
expiry_days: 1
}
});
assert.strictEqual(direct.status, 200);
assert.strictEqual(direct.data.success, true);
directLinkId = direct.data.link_id;
});
test('admin listing/logging endpoints are authenticated and sanitized', async () => {
const noAuth = await request(baseUrl, new CookieJar(), 'GET', '/api/admin/users');
assert.strictEqual(noAuth.status, 401);
const users = await request(baseUrl, jar, 'GET', '/api/admin/users?page=1&pageSize=10');
assert.strictEqual(users.status, 200);
assert.strictEqual(users.data.success, true);
const adminRow = users.data.users.find(user => user.id === userId);
assert.ok(adminRow);
assert.strictEqual(adminRow.password, undefined);
assert.strictEqual(adminRow.oss_access_key_secret, undefined);
const logs = await request(baseUrl, jar, 'GET', '/api/admin/logs?page=1&pageSize=5');
assert.strictEqual(logs.status, 200);
assert.strictEqual(logs.data.success, true);
});
test('cleanup via public APIs succeeds for audit artifacts', async () => {
if (directLinkId) {
const res = await request(baseUrl, jar, 'DELETE', `/api/direct-link/${directLinkId}`);
assert.ok([200, 404].includes(res.status));
}
if (shareId) {
const res = await request(baseUrl, jar, 'DELETE', `/api/share/${shareId}`);
assert.ok([200, 404].includes(res.status));
}
const del = await request(baseUrl, jar, 'POST', '/api/files/delete', {
json: { path: '/', fileName: AUDIT_PREFIX }
});
assert.strictEqual(del.status, 200);
assert.strictEqual(del.data.success, true);
});
const failures = [];
for (const item of tests) {
try {
await item.fn();
console.log(`[PASS] ${item.name}`);
} catch (error) {
failures.push({ name: item.name, error });
console.error(`[FAIL] ${item.name}`);
console.error(error.stack || error.message);
}
}
if (failures.length > 0) {
const summary = failures.map(item => `- ${item.name}: ${item.error.message}`).join('\n');
throw new Error(`full audit regression failed:\n${summary}`);
}
console.log(`PASS full-audit-regression (${tests.length} tests)`);
} catch (error) {
console.error('--- backend stdout tail ---');
console.error(stdout.split(/\r?\n/).slice(-80).join('\n'));
console.error('--- backend stderr tail ---');
console.error(stderr.split(/\r?\n/).slice(-80).join('\n'));
throw error;
} finally {
child.kill('SIGTERM');
await delay(500);
if (!child.killed) child.kill('SIGKILL');
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
run().catch(error => {
console.error(error.stack || error.message);
process.exit(1);
});

View File

@@ -16,6 +16,7 @@
*/
const crypto = require('crypto');
const ENCRYPTION_PREFIX = 'enc:v1:';
/**
* 从环境变量获取加密密钥
@@ -111,8 +112,8 @@ function encryptSecret(plaintext) {
authTag
]);
// 返回 Base64 编码结果
return combined.toString('base64');
// 返回带版本前缀的 Base64 编码结果,避免旧明文被误判为密文。
return ENCRYPTION_PREFIX + combined.toString('base64');
} catch (error) {
console.error('[加密] 加密失败:', error);
throw new Error('数据加密失败: ' + error.message);
@@ -134,24 +135,26 @@ function encryptSecret(plaintext) {
* // 输出: 'my-secret-key'
*/
function decryptSecret(ciphertext) {
// 如果是 null 或 undefined直接返回
if (!ciphertext) {
return ciphertext;
}
const rawValue = String(ciphertext);
const hasPrefix = rawValue.startsWith(ENCRYPTION_PREFIX);
const encodedValue = hasPrefix ? rawValue.slice(ENCRYPTION_PREFIX.length) : rawValue;
if (!hasPrefix && !isEncrypted(encodedValue)) {
if (/[+/=]/.test(rawValue)) {
throw new Error('数据解密失败: 疑似密文格式无效');
}
console.warn('[加密] 检测到未加密的旧密钥,建议重新保存以完成加密');
return rawValue;
}
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');
const combined = Buffer.from(encodedValue, 'base64');
// 提取各部分
// IV: 前 12 字节
@@ -175,13 +178,9 @@ function decryptSecret(ciphertext) {
return decrypted;
} catch (error) {
// 如果解密失败,可能是旧数据(明文),直接返回
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
// 在开发环境抛出错误,生产环境尝试返回原值
if (process.env.NODE_ENV === 'production') {
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
return ciphertext;
if (!hasPrefix && !/[+/=]/.test(rawValue)) {
console.warn('[加密] 旧格式数据解密失败,按未加密旧密钥处理:', error.message);
return rawValue;
}
throw new Error('数据解密失败: ' + error.message);
@@ -240,6 +239,10 @@ function isEncrypted(data) {
return false;
}
const encodedValue = data.startsWith(ENCRYPTION_PREFIX)
? data.slice(ENCRYPTION_PREFIX.length)
: data;
// 加密后的数据特征:
// 1. 是有效的 Base64
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符IV + authTag 的 Base64
@@ -247,7 +250,11 @@ function isEncrypted(data) {
try {
// 尝试解码 Base64
const buffer = Buffer.from(data, 'base64');
if (!/^[A-Za-z0-9+/=]+$/.test(encodedValue) || encodedValue.length % 4 !== 0) {
return false;
}
const buffer = Buffer.from(encodedValue, 'base64');
// 检查长度(至少包含 IV + authTag
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节