fix: harden cloud storage security

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

View File

@@ -54,11 +54,11 @@
- 支持SMTP配置
- 邮件模板可自定义
#### 🖥️ 桌面上传工具
- 拖拽上传,简单易用
- 实时显示上传进度
- 自动配置,无需手动设置
- 支持大文件上传
#### 🖥️ 桌面客户端
- Tauri + Vue 桌面端,默认对接网页端账号体系
- 支持文件浏览、搜索、上传、下载、分享/直链管理
- 支持断点下载、本地目录同步和客户端更新检测
- Windows 安装包可通过 `/api/client/desktop-update` 发布和下载
#### ⚡ 一键部署
- 全自动安装脚本install.sh
@@ -123,10 +123,10 @@ docker-compose logs -f
部署完成后,访问系统:
- **访问地址**: http://你的服务器IP
- **默认管理员账号**:
- 用户名: `admin`
- 密码: `admin123`
- ⚠️ **请立即登录并修改密码!**
- **管理员账号**:
- 安装脚本会要求你设置管理员用户名和强密码
- 密码至少 8 位,并且需要包含字母、数字、特殊字符中的至少两类
- 生产环境禁止使用 `admin123``12345678` 等默认弱密码
## 📖 使用指南
@@ -210,12 +210,12 @@ SMTP密码: 你的授权码
3. 复制分享链接发送给他人
4. 在"我的分享"中管理所有分享链接
### 使用桌面上传工具
### 使用桌面客户端
1. 进入"上传工具"页面
2. 下载适合你系统的上传工具
3. 输入服务器地址和API密钥
4. 拖拽文件即可上传
1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息
2. 安装后使用网页端账号登录
3. 在客户端中浏览文件、上传/下载、创建分享或直链
4. 如需发布新版本,将安装包放到 `frontend/downloads/` 并更新后台桌面端版本配置
## 📁 项目结构
@@ -244,11 +244,10 @@ vue-driven-cloud-storage/
│ ├── nginx.conf # 反向代理配置
│ └── nginx.conf.example # 配置模板
├── upload-tool/ # 桌面上传工具
│ ├── upload_tool.py # Python 上传工具源码
│ ├── requirements.txt # Python 依赖
── build.bat # Windows 打包脚本
│ └── build.sh # Linux/Mac 打包脚本
├── desktop-client/ # Tauri 桌面客户端
│ ├── src/ # Vue/TypeScript 前端
│ ├── src-tauri/ # Rust/Tauri 原生能力
── package.json # 桌面端依赖和构建脚本
├── install.sh # 一键安装脚本
├── docker-compose.yml # Docker 编排文件
@@ -267,7 +266,6 @@ vue-driven-cloud-storage/
- **bcrypt** - 密码加密
- **nodemailer** - 邮件发送
- **svg-captcha** - 验证码生成
- **express-session** - Session 管理
### 前端技术
- **Vue.js 3** - 渐进式 JavaScript 框架
@@ -286,7 +284,7 @@ vue-driven-cloud-storage/
### 认证与授权
- ✅ bcrypt 密码加密10轮盐值
- ✅ JWT 令牌认证
-Session 安全管理
-HttpOnly Cookie + CSRF 双提交令牌
- ✅ CORS 跨域配置
- ✅ SQL 注入防护(参数化查询)
- ✅ XSS 防护(输入过滤)
@@ -345,7 +343,7 @@ sudo cp /var/www/vue-driven-cloud-storage/backend/data/database.db \
# 备份上传文件(本地存储模式)
sudo tar -czf /backup/uploads-$(date +%Y%m%d).tar.gz \
/var/www/vue-driven-cloud-storage/backend/uploads/
/var/www/vue-driven-cloud-storage/backend/storage/
```
### 更新系统
@@ -396,7 +394,7 @@ A: 登录后进入"管理面板" → "存储管理",点击切换按钮即可
A: 点击登录页的"忘记密码",通过邮箱重置密码。如未配置邮箱,需要手动重置数据库。
**Q: 上传文件大小限制是多少?**
A: 默认限制 5GB可在 Nginx 配置中修改 `client_max_body_size`
A: 当前默认限制 10GB可在系统设置和 Nginx 配置中同步调整
### 故障排查
@@ -424,6 +422,16 @@ A: 默认限制 5GB可在 Nginx 配置中修改 `client_max_body_size`。
## 📝 更新日志
### v3.1.1 (2026-06-13)
- 🔐 统一文件虚拟路径校验,修复列表、上传、下载、分享相关路径边界问题
- 🔐 上传参数校验失败时清理临时文件,避免失败上传残留
- 🔐 密码策略统一为 8-128 字符且至少两类字符,安装脚本和前端提示同步更新
- 🔐 邮箱验证和重置密码链接使用后清理 URL token
- 🔐 分享页外部下载窗口增加 `noopener,noreferrer`
- 🧪 新增隔离 API 审计回归脚本覆盖登录、CSRF、文件、分享、直链和管理端脱敏
- 🛠️ 清理已废弃的 `SESSION_SECRET` / `express-session` 文档和安装脚本残留
- 🖥️ 明确桌面客户端更新接口和 Windows 安装包发布流程
### v3.1.0 (2025-01-18)
- 🚀 **重大架构优化**OSS 直连上传下载(不经过后端)
- 上传速度提升 50%,服务器流量节省 50%

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -473,7 +473,7 @@ function testLocalStoragePath() {
return fullPath;
}
const basePath = '/tmp/storage/user_1';
const basePath = path.join(path.resolve('/tmp'), 'storage', 'user_1');
test('正常相对路径应该被接受', () => {
const result = getFullPath(basePath, 'documents/file.txt');
@@ -703,7 +703,7 @@ function testDatabaseFieldWhitelist() {
const ALLOWED_FIELDS = [
'username', 'email', 'password',
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
'upload_api_key', 'is_active', 'is_banned', 'has_oss_config',
'is_verified', 'verification_token', 'verification_expires_at',
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
'theme_preference'
@@ -730,14 +730,14 @@ function testDatabaseFieldWhitelist() {
const updates = {
username: 'newname',
id: 999, // 尝试修改 ID
is_admin: 1, // 合法字段
is_admin: 1, // 权限字段不允许通过通用更新入口修改
sql_injection: "'; DROP TABLE users; --" // 非法字段
};
const filtered = filterUpdates(updates);
assert.ok(!('id' in filtered));
assert.ok(!('is_admin' in filtered));
assert.ok(!('sql_injection' in filtered));
assert.strictEqual(filtered.username, 'newname');
assert.strictEqual(filtered.is_admin, 1);
});
test('原型污染尝试应该被阻止', () => {

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "desktop-client",
"version": "0.1.29",
"version": "0.1.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "desktop-client",
"version": "0.1.29",
"version": "0.1.31",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
@@ -516,9 +516,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
"cpu": [
"arm"
],
@@ -530,9 +530,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
"cpu": [
"arm64"
],
@@ -544,9 +544,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
"cpu": [
"arm64"
],
@@ -558,9 +558,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
"cpu": [
"x64"
],
@@ -572,9 +572,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
"cpu": [
"arm64"
],
@@ -586,9 +586,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
"cpu": [
"x64"
],
@@ -600,9 +600,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
"cpu": [
"arm"
],
@@ -614,9 +614,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
"cpu": [
"arm"
],
@@ -628,9 +628,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
"cpu": [
"arm64"
],
@@ -642,9 +642,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
"cpu": [
"arm64"
],
@@ -656,9 +656,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
"cpu": [
"loong64"
],
@@ -670,9 +670,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
"cpu": [
"loong64"
],
@@ -684,9 +684,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
"cpu": [
"ppc64"
],
@@ -698,9 +698,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
"cpu": [
"ppc64"
],
@@ -712,9 +712,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
"cpu": [
"riscv64"
],
@@ -726,9 +726,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
"cpu": [
"riscv64"
],
@@ -740,9 +740,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
"cpu": [
"s390x"
],
@@ -754,9 +754,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
"cpu": [
"x64"
],
@@ -768,9 +768,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
"cpu": [
"x64"
],
@@ -782,9 +782,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
"cpu": [
"x64"
],
@@ -796,9 +796,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
"cpu": [
"arm64"
],
@@ -810,9 +810,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
"cpu": [
"arm64"
],
@@ -824,9 +824,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
"cpu": [
"ia32"
],
@@ -838,9 +838,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
"cpu": [
"x64"
],
@@ -852,9 +852,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
"cpu": [
"x64"
],
@@ -1111,9 +1111,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
@@ -1311,9 +1311,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==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1446,13 +1446,13 @@
}
},
"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==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -1469,9 +1469,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -1500,9 +1500,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1513,9 +1513,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
@@ -1532,7 +1532,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1541,13 +1541,13 @@
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -1557,31 +1557,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.61.1",
"@rollup/rollup-android-arm64": "4.61.1",
"@rollup/rollup-darwin-arm64": "4.61.1",
"@rollup/rollup-darwin-x64": "4.61.1",
"@rollup/rollup-freebsd-arm64": "4.61.1",
"@rollup/rollup-freebsd-x64": "4.61.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
"@rollup/rollup-linux-arm64-musl": "4.61.1",
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
"@rollup/rollup-linux-loong64-musl": "4.61.1",
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
"@rollup/rollup-linux-x64-gnu": "4.61.1",
"@rollup/rollup-linux-x64-musl": "4.61.1",
"@rollup/rollup-openbsd-x64": "4.61.1",
"@rollup/rollup-openharmony-arm64": "4.61.1",
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
"@rollup/rollup-win32-x64-gnu": "4.61.1",
"@rollup/rollup-win32-x64-msvc": "4.61.1",
"fsevents": "~2.3.2"
}
},
@@ -1626,9 +1626,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "desktop-client",
"private": true,
"version": "0.1.30",
"version": "0.1.31",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -693,7 +693,7 @@ dependencies = [
[[package]]
name = "desktop-client"
version = "0.1.30"
version = "0.1.31"
dependencies = [
"reqwest 0.12.28",
"rusqlite",

View File

@@ -1,6 +1,6 @@
[package]
name = "desktop-client"
version = "0.1.30"
version = "0.1.31"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

View File

@@ -1,4 +1,4 @@
use reqwest::Method;
use reqwest::{Method, Url};
use reqwest::StatusCode;
use rusqlite::{params, Connection};
use serde::Serialize;
@@ -454,6 +454,49 @@ fn is_update_installer_file_name(file_name: &str) -> bool {
lower.starts_with("wanwan-cloud-desktop_v") || file_name.trim().starts_with("玩玩云_v")
}
fn ensure_http_download_url(raw_url: &str) -> Result<(), String> {
let parsed = Url::parse(raw_url).map_err(|_| "下载地址格式无效".to_string())?;
match parsed.scheme() {
"http" | "https" => Ok(()),
_ => Err("仅允许 HTTP/HTTPS 下载地址".to_string()),
}
}
fn validate_update_installer_path(installer_path: &str) -> Result<PathBuf, String> {
let path_text = installer_path.trim();
if path_text.is_empty() {
return Err("安装包路径不能为空".to_string());
}
let installer = PathBuf::from(path_text);
if !installer.exists() {
return Err("安装包不存在,请重新下载".to_string());
}
if !installer.is_file() {
return Err("安装包路径无效".to_string());
}
let file_name = installer
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "安装包文件名无效".to_string())?;
if !is_update_installer_file_name(file_name) {
return Err("仅允许启动玩玩云官方更新安装包".to_string());
}
let download_dir = resolve_download_dir()
.canonicalize()
.map_err(|err| format!("读取下载目录失败: {}", err))?;
let canonical_installer = installer
.canonicalize()
.map_err(|err| format!("读取安装包路径失败: {}", err))?;
if !canonical_installer.starts_with(&download_dir) {
return Err("安装包必须位于系统下载目录内".to_string());
}
Ok(canonical_installer)
}
fn cleanup_old_update_installers(
download_dir: &Path,
keep_file_name: &str,
@@ -1192,6 +1235,7 @@ async fn api_native_download(
if trimmed_url.is_empty() {
return Err("下载地址不能为空".to_string());
}
ensure_http_download_url(&trimmed_url)?;
let preferred_name = file_name
.as_deref()
@@ -1418,18 +1462,8 @@ fn api_compute_file_sha256(file_path: String) -> Result<BridgeResponse, String>
#[tauri::command]
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
let path_text = installer_path.trim().to_string();
if path_text.is_empty() {
return Err("安装包路径不能为空".to_string());
}
let installer = PathBuf::from(&path_text);
if !installer.exists() {
return Err("安装包不存在,请重新下载".to_string());
}
if !installer.is_file() {
return Err("安装包路径无效".to_string());
}
let installer = validate_update_installer_path(&installer_path)?;
let path_text = installer.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let spawn_result = Command::new(&installer).spawn();
@@ -1456,18 +1490,8 @@ fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String
#[tauri::command]
fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeResponse, String> {
let path_text = installer_path.trim().to_string();
if path_text.is_empty() {
return Err("安装包路径不能为空".to_string());
}
let installer = PathBuf::from(&path_text);
if !installer.exists() {
return Err("安装包不存在,请重新下载".to_string());
}
if !installer.is_file() {
return Err("安装包路径无效".to_string());
}
let installer = validate_update_installer_path(&installer_path)?;
let path_text = installer.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
let windows_log_file_path: String;

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "玩玩云",
"version": "0.1.30",
"version": "0.1.31",
"identifier": "cn.workyai.wanwancloud.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -20,7 +20,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* http://localhost:* https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}
},
"bundle": {

View File

@@ -166,7 +166,7 @@ const syncState = reactive({
nextRunAt: "",
});
const updateState = reactive({
currentVersion: "0.1.30",
currentVersion: "0.1.31",
latestVersion: "",
available: false,
mandatory: false,

View File

@@ -23,9 +23,9 @@ services:
- NODE_ENV=production
- PORT=40001
# 以下配置建议通过 .env 文件或环境变量设置
# - JWT_SECRET=your-secret-key
# - JWT_SECRET=<至少32字符的强随机密钥>
# - ADMIN_USERNAME=admin
# - ADMIN_PASSWORD=admin123
# - ADMIN_PASSWORD=<至少8位且至少包含两类字符的强密码>
env_file:
- ./backend/.env
volumes:

View File

@@ -1778,8 +1778,8 @@
<input type="email" class="form-input" v-model="registerForm.email" required>
</div>
<div class="form-group">
<label class="form-label">密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="registerForm.password" required minlength="6">
<label class="form-label">密码 (8-128字符至少两类字符)</label>
<input type="password" class="form-input" v-model="registerForm.password" required minlength="8" maxlength="128">
</div>
<div class="form-group">
<label class="form-label">验证码</label>
@@ -1986,7 +1986,7 @@
</div>
</div>
<!-- 拖拽提示层 -->
<div v-if="isDragging && storageType === 'local'" class="drag-drop-overlay">
<div v-if="isDragging" class="drag-drop-overlay">
<div class="drag-drop-content">
<i class="fas fa-cloud-upload-alt" style="font-size: 64px; color: #667eea; margin-bottom: 20px;"></i>
<div style="font-size: 24px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px;">拖放文件到这里上传</div>
@@ -3041,8 +3041,8 @@
<input type="password" class="form-input" v-model="changePasswordForm.current_password" placeholder="输入当前密码" required>
</div>
<div class="form-group">
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
<label class="form-label">新密码 (8-128字符至少两类字符)</label>
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
</div>
<button type="submit" class="btn btn-primary" :disabled="passwordChanging">
<i :class="passwordChanging ? 'fas fa-spinner fa-spin' : 'fas fa-key'"></i> {{ passwordChanging ? '修改中...' : '修改密码' }}
@@ -4252,8 +4252,8 @@
重置链接已验证,请输入新密码
</p>
<div class="form-group">
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
<label class="form-label">新密码 (8-128字符至少两类字符)</label>
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="submitResetPassword" :disabled="passwordResetting" style="flex: 1;">
@@ -4392,12 +4392,12 @@
</div>
<!-- 上传进度条 -->
<div v-if="uploadProgress > 0 && uploadProgress < 100"
<div v-if="uploadingFileName && uploadProgress > 0"
class="upload-progress-panel">
<div class="upload-progress-header">
<i class="fas fa-cloud-upload-alt upload-progress-icon"></i>
<div class="upload-progress-meta">
<div class="upload-progress-title">正在上传文件</div>
<div class="upload-progress-title">{{ uploadPhase || '正在上传文件' }}</div>
<div class="upload-progress-name">{{ uploadingFileName }}</div>
<div v-if="totalBytes > 0" class="upload-progress-size">{{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }}</div>
</div>

View File

@@ -163,7 +163,10 @@ createApp({
uploadedBytes: 0,
totalBytes: 0,
uploadingFileName: '',
uploadPhase: '',
isDragging: false,
fileLoadRequestId: 0,
inspectionLoadRequestId: 0,
modalMouseDownTarget: null, // 模态框鼠标按下的目标
// 全局搜索(文件页)
@@ -723,6 +726,36 @@ createApp({
};
},
validateAccountPassword(password) {
const value = String(password || '');
if (value.length < 8) {
return { valid: false, message: '密码至少8个字符' };
}
if (value.length > 128) {
return { valid: false, message: '密码不能超过128个字符' };
}
const typeCount = [
/[a-zA-Z]/.test(value),
/[0-9]/.test(value),
/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(value)
].filter(Boolean).length;
if (typeCount < 2) {
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
}
const weakPasswords = [
'password', '12345678', '123456789', 'qwerty123', 'admin123',
'letmein', 'welcome', 'monkey', 'dragon', 'master'
];
if (weakPasswords.includes(value.toLowerCase())) {
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
}
return { valid: true };
},
// 创建防抖版本的 loadUserProfile延迟2秒避免频繁请求
debouncedLoadUserProfile() {
if (!this._debouncedLoadUserProfile) {
@@ -1001,8 +1034,7 @@ handleDragLeave(e) {
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
await this.uploadFile(file);
await this.uploadFiles(files);
}
},
@@ -1031,6 +1063,27 @@ handleDragLeave(e) {
return generated;
},
getPersistableUser(user) {
if (!user || typeof user !== 'object') return null;
const {
oss_access_key_id,
oss_access_key_secret,
oss_provider,
oss_region,
oss_bucket,
oss_endpoint,
...safeUser
} = user;
return safeUser;
},
persistUser(user = this.user) {
const safeUser = this.getPersistableUser(user);
if (safeUser) {
localStorage.setItem('user', JSON.stringify(safeUser));
}
},
buildLoginClientMeta() {
const platform = navigator.platform || '未知平台';
const name = `${navigator.userAgent?.includes('Mobile') ? '移动端网页' : '网页端'} · ${platform || '未知平台'}`;
@@ -1121,7 +1174,7 @@ handleDragLeave(e) {
// 保存用户信息到localStorage非敏感信息用于页面刷新后恢复
// 注意token 通过 HttpOnly Cookie 传递,不再存储在 localStorage
localStorage.setItem('user', JSON.stringify(this.user));
this.persistUser();
// 启动token自动刷新在过期前5分钟刷新
const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000);
@@ -1152,29 +1205,26 @@ handleDragLeave(e) {
this.loadUserTheme();
// 管理员直接跳转到管理后台
if (this.user.is_admin) {
this.currentView = 'admin';
this.switchView('admin', true);
}
// 普通用户:检查存储权限
else {
// 如果用户可以使用本地存储,直接进入文件页面
if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') {
this.currentView = 'files';
this.loadFiles('/');
this.switchView('files', true);
}
// 如果仅OSS模式需要检查是否配置了OSS包括系统级统一配置
else if (this.storagePermission === 'oss_only') {
if (this.user?.oss_config_source !== 'none') {
this.currentView = 'files';
this.loadFiles('/');
this.switchView('files', true);
} else {
this.currentView = 'settings';
this.switchView('settings', true);
this.showToast('info', '欢迎', '请先配置您的OSS服务');
this.openOssConfigModal();
}
} else {
// 默认行为:跳转到文件页面
this.currentView = 'files';
this.loadFiles('/');
this.switchView('files', true);
}
}
}
@@ -1298,6 +1348,13 @@ handleDragLeave(e) {
async handleRegister() {
this.errorMessage = '';
this.successMessage = '';
const passwordCheck = this.validateAccountPassword(this.registerForm.password);
if (!passwordCheck.valid) {
this.errorMessage = passwordCheck.message;
return;
}
this.registerLoading = true;
try {
@@ -1350,7 +1407,7 @@ handleDragLeave(e) {
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
return;
}
if (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '') {
if (!this.user?.has_oss_config && (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '')) {
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
return;
}
@@ -1392,8 +1449,7 @@ handleDragLeave(e) {
this.showOssConfigModal = false;
// 刷新到文件页面
this.currentView = 'files';
this.loadFiles('/');
this.switchView('files', true);
// 显示成功提示
this.showToast('success', '配置成功', 'OSS存储配置已保存');
@@ -1470,7 +1526,7 @@ handleDragLeave(e) {
// 更新用户信息(后端已通过 Cookie 更新 token
if (response.data.user) {
this.user = response.data.user;
localStorage.setItem('user', JSON.stringify(response.data.user));
this.persistUser(response.data.user);
}
// 延迟后重新登录
@@ -1487,8 +1543,9 @@ handleDragLeave(e) {
return;
}
if (this.changePasswordForm.new_password.length < 6) {
this.showToast('warning', '提示', '新密码至少6个字符');
const passwordCheck = this.validateAccountPassword(this.changePasswordForm.new_password);
if (!passwordCheck.valid) {
this.showToast('warning', '提示', passwordCheck.message);
return;
}
@@ -1554,7 +1611,7 @@ handleDragLeave(e) {
this.showToast('success', '成功', '用户名修改成功!');
// 更新本地用户信息
this.user.username = this.usernameForm.newUsername;
localStorage.setItem('user', JSON.stringify(this.user));
this.persistUser();
this.usernameForm.newUsername = '';
}
} catch (error) {
@@ -1576,7 +1633,7 @@ handleDragLeave(e) {
// 更新本地用户信息
if (response.data.user) {
this.user = response.data.user;
localStorage.setItem('user', JSON.stringify(this.user));
this.persistUser();
}
}
} catch (error) {
@@ -1637,7 +1694,7 @@ handleDragLeave(e) {
this.isLoggedIn = true;
// 更新localStorage中的用户信息非敏感信息
localStorage.setItem('user', JSON.stringify(this.user));
this.persistUser();
// 从最新的用户信息初始化存储相关字段
this.storagePermission = this.user.storage_permission || 'oss_only';
@@ -1774,16 +1831,22 @@ handleDragLeave(e) {
// ===== 文件管理 =====
async loadFiles(path) {
const requestId = ++this.fileLoadRequestId;
const targetPath = path || '/';
this.loading = true;
// 确保路径不为undefined
this.currentPath = path || '/';
this.currentPath = targetPath;
this.globalSearchVisible = false;
try {
const response = await axios.get(`${this.apiBase}/api/files`, {
params: { path }
params: { path: targetPath }
});
if (requestId !== this.fileLoadRequestId || this.currentPath !== targetPath) {
return;
}
if (response.data.success) {
this.files = response.data.items;
this.thumbnailLoadErrors = {};
@@ -1805,6 +1868,9 @@ handleDragLeave(e) {
}
}
} catch (error) {
if (requestId !== this.fileLoadRequestId) {
return;
}
console.error('加载文件失败:', error);
this.showToast('error', '加载失败', error.response?.data?.message || error.message);
@@ -1812,7 +1878,9 @@ handleDragLeave(e) {
this.logout();
}
} finally {
this.loading = false;
if (requestId === this.fileLoadRequestId) {
this.loading = false;
}
}
},
@@ -2246,11 +2314,17 @@ handleDragLeave(e) {
// 长按取消(移动端)
handleLongPressEnd() {
const wasTriggered = this.longPressTriggered;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.longPressFile = null;
if (wasTriggered) {
setTimeout(() => {
this.longPressTriggered = false;
}, 350);
}
},
// 从菜单执行操作
@@ -2381,14 +2455,7 @@ handleDragLeave(e) {
}
}
// 本地存储模式:返回同步的下载 URL
// OSS 模式下缩略图功能暂不支持(需要预签名 URL建议点击文件预览
if (this.storageType !== 'oss') {
const filePath = this.getCurrentFilePath(file);
return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`;
}
// OSS 模式暂不支持同步缩略图,返回 null
// 不用下载接口做缩略图,避免浏览网格时消耗下载流量配额。
return null;
},
@@ -2482,6 +2549,8 @@ handleDragLeave(e) {
const link = document.createElement('a');
link.href = this.currentMediaUrl;
link.setAttribute('download', this.currentMediaName);
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -2521,7 +2590,7 @@ handleDragLeave(e) {
openShareFileModal(file) {
this.shareFileForm.fileName = file.name;
this.shareFileForm.filePath = this.currentPath === '/'
? file.name
? `/${file.name}`
: `${this.currentPath}/${file.name}`;
this.shareFileForm.isDirectory = file.isDirectory; // 设置是否为文件夹
this.shareFileForm.enablePassword = false;
@@ -2746,23 +2815,26 @@ handleDragLeave(e) {
// ===== 文件上传 =====
handleFileSelect(event) {
async handleFileSelect(event) {
const files = event.target.files;
if (files && files.length > 0) {
// 支持多文件上传
Array.from(files).forEach(file => {
this.uploadFile(file);
});
await this.uploadFiles(files);
// 清空input允许重复上传相同文件
event.target.value = '';
}
},
handleFileDrop(event) {
async handleFileDrop(event) {
this.isDragging = false;
const file = event.dataTransfer.files[0];
if (file) {
this.uploadFile(file);
await this.uploadFiles(event.dataTransfer.files);
},
async uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean);
if (files.length === 0) return;
for (const file of files) {
await this.uploadFile(file);
}
},
@@ -2784,6 +2856,7 @@ handleDragLeave(e) {
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = file.size;
this.uploadPhase = '准备上传';
try {
const fileHash = await this.computeQuickFileHash(file);
@@ -2793,6 +2866,7 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
return;
@@ -2816,6 +2890,7 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
this.showToast('error', '上传失败', error.message || '上传失败,请重试');
}
@@ -2934,6 +3009,7 @@ handleDragLeave(e) {
'Content-Type': file.type || 'application/octet-stream'
},
onUploadProgress: (progressEvent) => {
this.uploadPhase = '上传中';
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.uploadedBytes = progressEvent.loaded;
this.totalBytes = progressEvent.total;
@@ -2942,6 +3018,7 @@ handleDragLeave(e) {
});
// 3. 通知后端上传完成
this.uploadPhase = '服务端确认中';
await axios.post(`${this.apiBase}/api/files/upload-complete`, {
objectKey: signData.objectKey,
size: file.size,
@@ -2957,6 +3034,7 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
// 6. 刷新文件列表和空间统计
await this.loadFiles(this.currentPath);
@@ -3006,6 +3084,7 @@ handleDragLeave(e) {
if (uploadedBytes > 0 && file.size > 0) {
this.uploadedBytes = uploadedBytes;
this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100));
this.uploadPhase = '上传中';
}
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
@@ -3037,8 +3116,10 @@ handleDragLeave(e) {
this.uploadProgress = file.size > 0
? Math.min(100, Math.round((uploadedBytes / file.size) * 100))
: 0;
this.uploadPhase = this.uploadProgress >= 100 ? '服务端合并中' : '上传中';
}
this.uploadPhase = '服务端合并中';
const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, {
session_id: sessionId
});
@@ -3052,6 +3133,7 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
return true;
@@ -3078,6 +3160,7 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
return true;
}
@@ -3092,6 +3175,7 @@ handleDragLeave(e) {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30 * 60 * 1000,
onUploadProgress: (progressEvent) => {
this.uploadPhase = '上传中';
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.uploadedBytes = progressEvent.loaded;
this.totalBytes = progressEvent.total;
@@ -3104,8 +3188,16 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
} else {
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
this.uploadPhase = '';
throw new Error(response.data.message || '上传失败');
}
return true;
},
@@ -3702,8 +3794,14 @@ handleDragLeave(e) {
},
async submitResetPassword() {
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password || this.resetPasswordForm.new_password.length < 6) {
this.showToast('error', '错误', '请输入有效的重置链接和新密码至少6位');
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password) {
this.showToast('error', '错误', '请输入有效的重置链接和新密码');
return;
}
const passwordCheck = this.validateAccountPassword(this.resetPasswordForm.new_password);
if (!passwordCheck.valid) {
this.showToast('error', '错误', passwordCheck.message);
return;
}
this.passwordResetting = true;
@@ -3735,25 +3833,36 @@ handleDragLeave(e) {
},
async loadUserFiles(path) {
const requestId = ++this.inspectionLoadRequestId;
const targetPath = path || '/';
this.inspectionLoading = true;
this.inspectionPath = path;
this.inspectionPath = targetPath;
try {
const response = await axios.get(
`${this.apiBase}/api/admin/users/${this.inspectionUser.id}/files`,
{
params: { path }
params: { path: targetPath }
}
);
if (requestId !== this.inspectionLoadRequestId || this.inspectionPath !== targetPath) {
return;
}
if (response.data.success) {
this.inspectionFiles = response.data.items;
}
} catch (error) {
if (requestId !== this.inspectionLoadRequestId) {
return;
}
console.error('加载用户文件失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '加载文件失败');
} finally {
this.inspectionLoading = false;
if (requestId === this.inspectionLoadRequestId) {
this.inspectionLoading = false;
}
}
},
@@ -4023,16 +4132,13 @@ handleDragLeave(e) {
},
openOssConfigModal() {
// 只有管理员才能配置OSS
if (!this.user?.is_admin) {
this.showToast('error', '权限不足', '只有管理员才能配置OSS服务');
if (!this.user) {
this.showToast('error', '请先登录', '登录后才能配置 OSS');
return;
}
this.showOssGuideModal = false;
this.showOssConfigModal = true;
if (this.user && !this.user.is_admin) {
this.loadOssConfig();
}
this.loadOssConfig();
},
closeOssConfigModal() {
@@ -4075,6 +4181,7 @@ handleDragLeave(e) {
// 切换到管理后台时,重新加载用户列表、健康检测和系统日志
if (this.user && this.user.is_admin) {
this.loadUsers();
this.loadSystemSettings();
this.loadServerStorageStats();
if (this.adminTab === 'monitor') {
this.initMonitorTab();
@@ -4087,6 +4194,7 @@ handleDragLeave(e) {
case 'settings':
this.loadOnlineDevices();
if (this.user && !this.user.is_admin) {
this.loadOssConfig();
this.loadDownloadTrafficReport();
}
break;
@@ -4360,11 +4468,25 @@ handleDragLeave(e) {
},
getHighlightedText(value, keyword) {
const text = this.escapeHtml(value || '-');
const rawText = String(value || '-');
const search = String(keyword || '').trim();
if (!search) return text;
const reg = new RegExp(this.escapeRegExp(search), 'ig');
return text.replace(reg, (match) => `<mark class="admin-search-hit">${match}</mark>`);
if (!search) return this.escapeHtml(rawText);
const lowerText = rawText.toLowerCase();
const lowerSearch = search.toLowerCase();
let cursor = 0;
let output = '';
let index = lowerText.indexOf(lowerSearch, cursor);
while (index !== -1) {
output += this.escapeHtml(rawText.slice(cursor, index));
output += `<mark class="admin-search-hit">${this.escapeHtml(rawText.slice(index, index + search.length))}</mark>`;
cursor = index + search.length;
index = lowerText.indexOf(lowerSearch, cursor);
}
output += this.escapeHtml(rawText.slice(cursor));
return output;
},
formatBytes(bytes) {
@@ -4566,9 +4688,14 @@ handleDragLeave(e) {
async updateSystemSettings() {
try {
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
const currentPassword = window.prompt('请输入当前管理员密码以确认修改系统设置');
if (currentPassword === null) {
return;
}
const payload = {
max_upload_size: maxUploadSize,
current_password: currentPassword,
download_security: {
enabled: !!this.systemSettings.downloadSecurity.enabled,
same_ip_same_file: {
@@ -4610,7 +4737,7 @@ handleDragLeave(e) {
}
} catch (error) {
console.error('更新系统设置失败:', error);
this.showToast('error', '错误', '更新系统设置失败');
this.showToast('error', '错误', error.response?.data?.message || '更新系统设置失败');
}
},
@@ -4966,7 +5093,7 @@ handleDragLeave(e) {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
?.substring('csrf_token='.length);
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
config.headers['X-CSRF-Token'] = csrfToken;
@@ -5017,12 +5144,18 @@ handleDragLeave(e) {
// 设置axios响应拦截器处理401错误token过期/失效)
axios.interceptors.response.use(
response => response,
error => {
async error => {
if (error.response && error.response.status === 401) {
// 排除登录接口本身的401密码错误等
const isLoginApi = error.config?.url?.includes('/api/login');
if (!isLoginApi && this.isLoggedIn) {
console.warn('[认证] 收到401响应Token已失效');
const isRefreshApi = error.config?.url?.includes('/api/refresh');
if (!isLoginApi && !isRefreshApi && this.isLoggedIn && !error.config?._retry) {
console.warn('[认证] 收到401响应尝试刷新Token');
error.config._retry = true;
const refreshed = await this.doRefreshToken();
if (refreshed) {
return axios(error.config);
}
this.handleTokenExpired();
this.showToast('warning', '登录已过期', '请重新登录');
}
@@ -5049,21 +5182,6 @@ handleDragLeave(e) {
watch: {
currentView(newView) {
if (newView === 'shares') {
this.refreshShareResources();
} else if (newView === 'admin' && this.user?.is_admin) {
this.loadUsers();
this.loadSystemSettings();
this.loadServerStorageStats();
} else if (newView === 'settings' && this.user && !this.user.is_admin) {
// 普通用户进入设置页面时加载OSS配置
this.loadOssConfig();
this.loadDownloadTrafficReport();
this.loadOnlineDevices();
} else if (newView === 'settings') {
this.loadOnlineDevices();
}
// 记住最后停留的视图(需合法且已登录)
if (this.isLoggedIn && this.isViewAllowed(newView)) {
localStorage.setItem('lastView', newView);

View File

@@ -292,8 +292,8 @@
<div class="form-group">
<label class="form-label">新密码</label>
<input type="password" id="password" class="form-input"
placeholder="请输入新密码" required minlength="6">
<div class="password-hint">密码长度至少6位</div>
placeholder="请输入新密码" required minlength="8" maxlength="128">
<div class="password-hint">密码长度8-128位且包含字母、数字、特殊字符中的至少两种</div>
</div>
<div class="form-group">
@@ -347,6 +347,28 @@
return url.searchParams.get(name);
}
function getCookie(name) {
const prefix = `${name}=`;
const row = document.cookie
.split('; ')
.find(item => item.startsWith(prefix));
return row ? decodeURIComponent(row.substring(prefix.length)) : '';
}
async function ensureCsrfToken() {
let token = getCookie('csrf_token');
if (token) return token;
try {
const res = await fetch('/api/csrf-token', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
token = data.csrfToken || data.token || getCookie('csrf_token');
} catch (error) {
console.warn('[CSRF] 获取 token 失败:', error.message);
}
return token || getCookie('csrf_token');
}
// 显示指定区块
function showSection(id) {
['loading', 'error', 'form', 'success'].forEach(s => {
@@ -363,6 +385,35 @@
alert.classList.remove('hidden');
}
function validateAccountPassword(password) {
const value = String(password || '');
if (value.length < 8) {
return { valid: false, message: '密码至少8个字符' };
}
if (value.length > 128) {
return { valid: false, message: '密码不能超过128个字符' };
}
const typeCount = [
/[a-zA-Z]/.test(value),
/[0-9]/.test(value),
/[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(value)
].filter(Boolean).length;
if (typeCount < 2) {
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
}
const weakPasswords = [
'password', '12345678', '123456789', 'qwerty123', 'admin123',
'letmein', 'welcome', 'monkey', 'dragon', 'master'
];
if (weakPasswords.includes(value.toLowerCase())) {
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
}
return { valid: true };
}
// 验证token
async function validateToken() {
resetToken = getParam('resetToken') || getParam('token');
@@ -373,6 +424,11 @@
return;
}
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('resetToken');
cleanUrl.searchParams.delete('token');
window.history.replaceState({}, document.title, cleanUrl.pathname + cleanUrl.search + cleanUrl.hash);
// Token存在显示表单
showSection('form');
}
@@ -386,8 +442,9 @@
const submitBtn = document.getElementById('submitBtn');
// 验证
if (password.length < 6) {
showFormAlert('error', '密码长度至少6位');
const passwordCheck = validateAccountPassword(password);
if (!passwordCheck.valid) {
showFormAlert('error', passwordCheck.message);
return;
}
@@ -401,9 +458,14 @@
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
try {
const csrfToken = await ensureCsrfToken();
const res = await fetch('/api/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: JSON.stringify({
token: resetToken,
new_password: password

View File

@@ -1040,6 +1040,13 @@
<span v-else> | 有效期: <strong class="share-expire-time valid">永久有效</strong></span>
</p>
<div v-if="shareInfo.share_type !== 'file'" class="share-meta-bar" style="justify-content: space-between;">
<span>当前位置:/{{ currentPath || '' }}</span>
<button v-if="currentPath" class="btn btn-secondary" style="width: auto; padding: 8px 14px;" @click="goParentDirectory">
<i class="fas fa-arrow-left"></i> 返回上级
</button>
</div>
<!-- 视图切换按钮 (多文件时才显示) -->
<div v-if="files.length > 1" class="view-controls">
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
@@ -1061,6 +1068,9 @@
<i class="single-file-icon fas" :class="getFileIcon(viewingFile || files[0])" :style="getIconColor(viewingFile || files[0])"></i>
<div class="single-file-name">{{ (viewingFile || files[0]).name }}</div>
<div class="single-file-size">{{ (viewingFile || files[0]).sizeFormatted }}</div>
<button v-if="(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="enterDirectory(viewingFile || files[0])">
<i class="fas fa-folder-open"></i> 进入文件夹
</button>
<button v-if="!(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="downloadFile(viewingFile || files[0])">
<i class="fas fa-download"></i> 下载文件
</button>
@@ -1083,7 +1093,7 @@
<!-- 列表视图 -->
<ul v-else-if="!viewingFile" class="file-list">
<li v-for="file in files" :key="file.name" class="file-item">
<li v-for="file in files" :key="file.name" class="file-item" @click="handleFileClick(file)" style="cursor: pointer;">
<div class="file-info">
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
<div class="file-name-container">
@@ -1126,6 +1136,7 @@
shareNotFound: false,
shareInfo: null,
files: [],
currentPath: '',
loading: true,
errorMessage: '',
downloadAlertMessage: '',
@@ -1196,6 +1207,8 @@
async verifyShare() {
this.errorMessage = '';
this.downloadAlertMessage = '';
this.currentPath = '';
this.viewingFile = null;
this.loading = true;
try {
@@ -1210,40 +1223,45 @@
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
if (response.data.file) {
this.files = [response.data.file];
this.loading = false;
} else {
// 目录分享,需要加载文件列表
await this.loadFiles();
}
} else {
this.errorMessage = response.data.message || '验证失败';
}
} catch (error) {
// 404错误 - 分享不存在
if (error.response?.status === 404) {
this.shareNotFound = true;
this.loading = false;
}
// 需要密码
else if (error.response?.data?.needPassword) {
this.needPassword = true;
this.loading = false;
}
// 其他错误
else {
this.errorMessage = error.response?.data?.message || '验证失败';
this.loading = false;
}
} finally {
this.loading = false;
}
},
async loadFiles() {
async loadFiles(path = this.currentPath || '') {
this.loading = true;
this.currentPath = String(path || '').replace(/^\/+|\/+$/g, '');
this.viewingFile = null;
try {
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
password: this.password,
path: ''
path: this.currentPath
});
if (response.data.success) {
this.files = response.data.items;
} else {
this.errorMessage = response.data.message || '加载文件失败';
}
} catch (error) {
console.error('加载文件失败:', error);
@@ -1255,10 +1273,32 @@
// 处理文件点击 - 显示文件详情页面
handleFileClick(file) {
// 所有文件类型都显示详情页面(分享页面不提供媒体预览)
if (file?.isDirectory) {
this.enterDirectory(file);
return;
}
this.viewFileDetail(file);
},
joinSharePath(basePath, name) {
return [basePath, name]
.map(part => String(part || '').replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.join('/');
},
enterDirectory(file) {
if (!file?.isDirectory) return;
this.loadFiles(this.joinSharePath(this.currentPath, file.name));
},
goParentDirectory() {
if (!this.currentPath) return;
const parts = this.currentPath.split('/').filter(Boolean);
parts.pop();
this.loadFiles(parts.join('/'));
},
// 查看文件详情(放大显示)
viewFileDetail(file) {
this.viewingFile = file;
@@ -1279,8 +1319,9 @@
filePath = this.shareInfo.share_path;
} else {
// 目录分享,组合路径
const basePath = this.shareInfo.share_path;
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
const basePath = String(this.shareInfo.share_path || '/').replace(/\/+$/g, '') || '/';
const relativePath = this.joinSharePath(this.currentPath, file.name);
filePath = basePath === '/' ? `/${relativePath}` : `${basePath}/${relativePath}`;
}
try {
@@ -1295,11 +1336,10 @@
// OSS 直连下载:新窗口打开
console.log("[分享下载] OSS 直连下载");
// 仅直连下载需要单独记录下载次数(本地代理下载在后端接口内已计数)
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
.catch(err => console.error('记录下载次数失败:', err));
window.open(data.downloadUrl, '_blank');
const downloadWindow = window.open(data.downloadUrl, '_blank', 'noopener,noreferrer');
if (downloadWindow) {
downloadWindow.opener = null;
}
} else {
// 本地存储:通过后端下载
console.log("[分享下载] 后端代理下载");
@@ -1474,7 +1514,7 @@
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
?.substring('csrf_token='.length);
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
config.headers['X-CSRF-Token'] = csrfToken;

View File

@@ -265,6 +265,11 @@
return;
}
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('verifyToken');
cleanUrl.searchParams.delete('token');
window.history.replaceState({}, document.title, cleanUrl.pathname + cleanUrl.search + cleanUrl.hash);
try {
const res = await fetch(`/api/verify-email?token=${encodeURIComponent(token)}`);

View File

@@ -2033,10 +2033,18 @@ configure_admin_account() {
done
while true; do
read -s -p "管理员密码(至少6位: " ADMIN_PASSWORD < /dev/tty
read -s -p "管理员密码(至少8位且至少包含字母/数字/特殊字符中的两类: " ADMIN_PASSWORD < /dev/tty
echo ""
if [[ ${#ADMIN_PASSWORD} -lt 6 ]]; then
print_error "密码至少6个字符"
if [[ ${#ADMIN_PASSWORD} -lt 8 ]]; then
print_error "密码至少8个字符"
continue
fi
local type_count=0
[[ "$ADMIN_PASSWORD" =~ [A-Za-z] ]] && ((type_count++))
[[ "$ADMIN_PASSWORD" =~ [0-9] ]] && ((type_count++))
[[ "$ADMIN_PASSWORD" =~ [^A-Za-z0-9] ]] && ((type_count++))
if [[ ${type_count} -lt 2 ]]; then
print_error "密码必须包含字母、数字、特殊字符中的至少两种"
continue
fi
@@ -2111,9 +2119,6 @@ create_env_file() {
# 生成随机JWT密钥
JWT_SECRET=$(openssl rand -base64 32)
# 生成随机Session密钥
SESSION_SECRET=$(openssl rand -hex 32)
# 生成随机加密密钥用于加密OSS等敏感信息
ENCRYPTION_KEY=$(openssl rand -hex 32)
@@ -2162,9 +2167,6 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD}
# JWT密钥
JWT_SECRET=${JWT_SECRET}
# Session密钥用于会话管理
SESSION_SECRET=${SESSION_SECRET}
# 加密密钥用于加密OSS Access Key Secret等敏感信息
# 重要:此密钥必须配置,否则服务无法启动
ENCRYPTION_KEY=${ENCRYPTION_KEY}
@@ -3730,16 +3732,6 @@ update_patch_env() {
print_info ".env 已包含 TRUST_PROXY保持不变"
fi
# 检查 SESSION_SECRET会话安全配置生产环境必需
if ! grep -q "^SESSION_SECRET=" "${PROJECT_DIR}/backend/.env"; then
# 自动生成随机 Session 密钥
NEW_SESSION_SECRET=$(openssl rand -hex 32)
echo "SESSION_SECRET=${NEW_SESSION_SECRET}" >> "${PROJECT_DIR}/backend/.env"
print_warning "已为现有 .env 补充 SESSION_SECRET已自动生成安全密钥"
else
print_info ".env 已包含 SESSION_SECRET保持不变"
fi
# 检查 ENCRYPTION_KEY加密密钥用于加密OSS等敏感信息必需
if ! grep -q "^ENCRYPTION_KEY=" "${PROJECT_DIR}/backend/.env"; then
# 自动生成随机加密密钥