fix: harden cloud storage security
This commit is contained in:
@@ -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
|
||||
|
||||
# ============================================
|
||||
# 开发调试配置
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 为空时回填为 0(0 表示禁止下载,-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,
|
||||
|
||||
@@ -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++;
|
||||
|
||||
304
backend/package-lock.json
generated
304
backend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// 生成签名 URL(15分钟有效)
|
||||
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: '下载统计已合并到下载地址签发和文件下载接口'
|
||||
});
|
||||
});
|
||||
|
||||
// 生成分享文件下载签名 URL(OSS 直连下载,公开 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 = {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
105
backend/test_download_quota_defaults.js
Normal file
105
backend/test_download_quota_defaults.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('原型污染尝试应该被阻止', () => {
|
||||
|
||||
420
backend/tests/full-audit-regression.js
Normal file
420
backend/tests/full-audit-regression.js
Normal 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);
|
||||
});
|
||||
@@ -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字节
|
||||
|
||||
Reference in New Issue
Block a user