diff --git a/README.md b/README.md index a50e814..67d0c9b 100644 --- a/README.md +++ b/README.md @@ -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% diff --git a/backend/.env.example b/backend/.env.example index 0fdb8c2..c3146ea 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 # ============================================ # 开发调试配置 diff --git a/backend/auth.js b/backend/auth.js index ec06570..0f853bd 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -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, diff --git a/backend/database.js b/backend/database.js index 0f5461e..aedf71e 100644 --- a/backend/database.js +++ b/backend/database.js @@ -40,6 +40,23 @@ const db = new Database(dbPath); const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB +function normalizeStoredDownloadTrafficQuota(rawQuota) { + if (rawQuota === null || rawQuota === undefined || rawQuota === '') { + return -1; // 默认不限下载 + } + + const parsedQuota = Number(rawQuota); + if (!Number.isFinite(parsedQuota)) { + return -1; + } + + if (parsedQuota < 0) { + return -1; // -1 表示不限流量 + } + + return Math.max(0, Math.floor(parsedQuota)); // 0 表示禁止下载 +} + // ===== 性能优化配置(P0 优先级修复) ===== // 1. 启用 WAL 模式(Write-Ahead Logging) @@ -507,6 +524,26 @@ function initDatabase() { ) `); + // OSS 直传临时对象登记表:直传必须完成服务端确认后才进入用户目录 + db.exec(` + CREATE TABLE IF NOT EXISTS oss_upload_reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_token TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + final_object_key TEXT NOT NULL, + temp_object_key TEXT NOT NULL, + expected_size INTEGER NOT NULL DEFAULT 0, + previous_size INTEGER NOT NULL DEFAULT 0, + file_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending/completed/expired/cancelled + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + completed_at DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + // 在线设备会话(用于设备管理和强制下线) db.exec(` CREATE TABLE IF NOT EXISTS user_device_sessions ( @@ -575,6 +612,12 @@ function initDatabase() { CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash ON upload_sessions(user_id, file_hash, file_size); + -- OSS 直传临时对象索引 + CREATE INDEX IF NOT EXISTS idx_oss_upload_reservations_status_expires + ON oss_upload_reservations(status, expires_at); + CREATE INDEX IF NOT EXISTS idx_oss_upload_reservations_user_status + ON oss_upload_reservations(user_id, status, expires_at); + -- 在线设备会话索引 CREATE INDEX IF NOT EXISTS idx_user_device_sessions_user_active ON user_device_sessions(user_id, revoked_at, expires_at, last_active_at); @@ -615,14 +658,22 @@ function createDefaultAdmin() { // 从环境变量读取管理员账号密码,如果没有则使用默认值 const adminUsername = process.env.ADMIN_USERNAME || 'admin'; const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; + const defaultAdminPasswords = new Set(['admin123', 'password', '123456', '12345678', 'change-this-admin-password']); + + if (process.env.NODE_ENV === 'production' && defaultAdminPasswords.has(String(adminPassword).trim())) { + console.error('[安全] 生产环境禁止使用默认管理员密码,请设置 ADMIN_PASSWORD'); + process.exit(1); + } const hashedPassword = bcrypt.hashSync(adminPassword, 10); db.prepare(` INSERT INTO users ( username, email, password, - is_admin, is_active, has_oss_config, is_verified - ) VALUES (?, ?, ?, ?, ?, ?, ?) + is_admin, is_active, has_oss_config, is_verified, + download_traffic_quota, download_traffic_used, + download_traffic_reset_cycle, download_traffic_last_reset_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( adminUsername, `${adminUsername}@example.com`, @@ -630,7 +681,11 @@ function createDefaultAdmin() { 1, 1, 0, // 管理员不需要OSS配置 - 1 // 管理员默认已验证 + 1, // 管理员默认已验证 + -1, // 默认不限下载 + 0, + 'none', + null ); console.log('默认管理员账号已创建'); @@ -658,10 +713,15 @@ const UserDB = { username, email, password, oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint, has_oss_config, - is_verified, verification_token, verification_expires_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_verified, verification_token, verification_expires_at, + storage_permission, current_storage_type, + download_traffic_quota, download_traffic_used, + download_traffic_reset_cycle, download_traffic_last_reset_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); + const downloadTrafficQuota = normalizeStoredDownloadTrafficQuota(userData.download_traffic_quota); + const result = stmt.run( userData.username, userData.email, @@ -675,7 +735,13 @@ const UserDB = { hasOssConfig, userData.is_verified !== undefined ? userData.is_verified : 0, hashedVerificationToken, - userData.verification_expires_at || null + userData.verification_expires_at || null, + userData.storage_permission || 'oss_only', + userData.current_storage_type || 'oss', + downloadTrafficQuota, + 0, + 'none', + null ); return result.lastInsertRowid; @@ -751,9 +817,8 @@ const UserDB = { 'theme_preference': 'string', // 数值类型字段 - 'is_admin': 'number', 'is_active': 'number', - 'is_banned': 'is_banned', + 'is_banned': 'number', 'has_oss_config': 'number', 'is_verified': 'number', 'local_storage_quota': 'number', @@ -808,7 +873,6 @@ const UserDB = { // API 密钥和权限字段 'upload_api_key': 'upload_api_key', - 'is_admin': 'is_admin', 'is_active': 'is_active', 'is_banned': 'is_banned', 'has_oss_config': 'has_oss_config', @@ -852,7 +916,7 @@ const UserDB = { // 检查数据库中是否有 FIELD_MAP 未定义的字段(可选) for (const dbField of dbFields) { - if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) { + if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at', 'is_admin'].includes(dbField)) { extraFields.push(dbField); } } @@ -894,7 +958,6 @@ const UserDB = { // API 密钥和权限字段 'upload_api_key': 'upload_api_key', - 'is_admin': 'is_admin', 'is_active': 'is_active', 'is_banned': 'is_banned', 'has_oss_config': 'has_oss_config', @@ -968,7 +1031,7 @@ const UserDB = { 'download_traffic_quota_expires_at': 'string', 'download_traffic_reset_cycle': 'string', 'download_traffic_last_reset_at': 'string', - 'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number', + 'is_active': 'number', 'is_banned': 'number', 'has_oss_config': 'number', 'is_verified': 'number', 'local_storage_quota': 'number', 'local_storage_used': 'number', 'oss_storage_quota': 'number', 'download_traffic_quota': 'number', @@ -1014,6 +1077,43 @@ const UserDB = { return result; }, + adjustLocalStorageUsed(id, delta) { + const amount = Number(delta); + if (!Number.isFinite(amount) || amount === 0) { + return this.findById(id); + } + + db.prepare(` + UPDATE users + SET local_storage_used = MAX(COALESCE(local_storage_used, 0) + ?, 0), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(Math.trunc(amount), id); + + return this.findById(id); + }, + + reserveLocalStorageSpace(id, additionalSize) { + const amount = Math.max(0, Math.trunc(Number(additionalSize) || 0)); + if (amount === 0) { + return this.findById(id); + } + + const stmt = db.prepare(` + UPDATE users + SET local_storage_used = COALESCE(local_storage_used, 0) + ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + AND COALESCE(local_storage_used, 0) + ? <= COALESCE(local_storage_quota, 0) + `); + const result = stmt.run(amount, id, amount); + if (result.changes === 0) { + return null; + } + + return this.findById(id); + }, + // 获取所有用户 getAll(filters = {}) { let query = 'SELECT * FROM users WHERE 1=1'; @@ -1278,7 +1378,7 @@ const ShareDB = { if (attempts > 10) { shareCode = this.generateShareCode(10); // 增加长度 } - } while (this.findByCode(shareCode) && attempts < 20); + } while (this.findAnyByCode(shareCode) && attempts < 20); // 计算过期时间 let expiresAt = null; @@ -1376,6 +1476,10 @@ const ShareDB = { return result; }, + findAnyByCode(shareCode) { + return db.prepare('SELECT * FROM shares WHERE share_code = ?').get(shareCode); + }, + // 根据ID查找 findById(id) { return db.prepare('SELECT * FROM shares WHERE id = ?').get(id); @@ -1396,6 +1500,7 @@ const ShareDB = { AND share_type = ? AND share_path = ? AND COALESCE(storage_type, 'oss') = ? + AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime')) ORDER BY id DESC LIMIT 1 `).get( @@ -1557,6 +1662,7 @@ const DirectLinkDB = { WHERE user_id = ? AND file_path = ? AND COALESCE(storage_type, 'oss') = ? + AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime')) ORDER BY id DESC LIMIT 1 `).get( @@ -1691,7 +1797,17 @@ const SettingsDB = { * 删除统一的 OSS 配置 */ clearUnifiedOssConfig() { - db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run(); + db.prepare(` + DELETE FROM system_settings + WHERE key IN ( + 'oss_provider', + 'oss_region', + 'oss_access_key_id', + 'oss_access_key_secret', + 'oss_bucket', + 'oss_endpoint' + ) + `).run(); console.log('[系统设置] 统一 OSS 配置已清除'); }, @@ -1721,12 +1837,13 @@ const VerificationDB = { const row = db.prepare(` SELECT * FROM users WHERE verification_token = ? - AND ( - verification_expires_at IS NULL - OR verification_expires_at = '' - OR verification_expires_at > strftime('%s','now')*1000 -- 数值时间戳(ms) - OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 - ) + AND verification_expires_at IS NOT NULL + AND verification_expires_at != '' + AND CASE + WHEN typeof(verification_expires_at) IN ('integer', 'real') + THEN verification_expires_at > strftime('%s','now') * 1000 + ELSE datetime(verification_expires_at) > datetime('now','localtime') + END AND is_verified = 0 `).get(hashedToken); if (!row) return null; @@ -1757,10 +1874,14 @@ const PasswordResetTokenDB = { const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); const row = db.prepare(` SELECT * FROM password_reset_tokens - WHERE token = ? AND used = 0 AND ( - expires_at > strftime('%s','now')*1000 -- 数值时间戳 - OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 - ) + WHERE token = ? AND used = 0 + AND expires_at IS NOT NULL + AND expires_at != '' + AND CASE + WHEN typeof(expires_at) IN ('integer', 'real') + THEN expires_at > strftime('%s','now') * 1000 + ELSE datetime(expires_at) > datetime('now','localtime') + END `).get(hashedToken); if (!row) return null; // 立即标记为已使用(防止重复使用) @@ -1960,7 +2081,7 @@ function migrateDownloadTrafficFields() { if (!hasDownloadTrafficQuota) { console.log('[数据库迁移] 添加 download_traffic_quota 字段...'); - db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT 0'); + db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT -1'); console.log('[数据库迁移] ✓ download_traffic_quota 字段已添加'); } @@ -1988,10 +2109,10 @@ function migrateDownloadTrafficFields() { console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加'); } - // 统一策略:download_traffic_quota 为空时回填为 0(0 表示禁止下载,-1 表示不限流量) + // 未设置下载配额的旧记录默认回填为不限流量,避免升级后默认无法下载 const quotaBackfillResult = db.prepare(` UPDATE users - SET download_traffic_quota = 0 + SET download_traffic_quota = -1 WHERE download_traffic_quota IS NULL `).run(); @@ -2009,6 +2130,53 @@ function migrateDownloadTrafficFields() { console.log(`[数据库迁移] ✓ 不限流量配额已标准化为 -1: ${quotaUnlimitedNormalizeResult.changes} 条记录`); } + const legacyRepairMarkerKey = 'download_traffic_zero_default_repaired_v1'; + if (SettingsDB.get(legacyRepairMarkerKey) !== 'true') { + const legacyRepairCandidates = db.prepare(` + SELECT u.id, u.username + FROM users u + WHERE COALESCE(u.download_traffic_quota, 0) = 0 + AND NOT EXISTS ( + SELECT 1 + FROM system_logs l + WHERE l.action = 'update_user_storage_and_traffic' + AND l.details LIKE '%"targetUserId":' || u.id || ',%' + ) + `).all(); + + if (legacyRepairCandidates.length > 0) { + const repairLegacyZeroQuotaDefaults = db.transaction((candidates) => { + const stmt = db.prepare(` + UPDATE users + SET download_traffic_quota = -1 + WHERE id = ? AND download_traffic_quota = 0 + `); + + let repaired = 0; + for (const candidate of candidates) { + const result = stmt.run(candidate.id); + repaired += Number(result?.changes || 0); + } + return repaired; + }); + + const repairedCount = repairLegacyZeroQuotaDefaults(legacyRepairCandidates); + if (repairedCount > 0) { + const repairedUsers = legacyRepairCandidates + .slice(0, 8) + .map(item => `${item.username}(#${item.id})`) + .join(', '); + const suffix = legacyRepairCandidates.length > 8 ? ' ...' : ''; + console.log( + `[数据库迁移] ✓ 已修复旧版默认下载配额为 0 的遗留数据: ${repairedCount} 条记录` + + ` (${repairedUsers}${suffix})` + ); + } + } + + SettingsDB.set(legacyRepairMarkerKey, 'true'); + } + const usedBackfillResult = db.prepare(` UPDATE users SET download_traffic_used = 0 @@ -2645,6 +2813,120 @@ const UploadSessionDB = { } }; +const OssUploadReservationDB = { + create({ + reservationToken, + userId, + finalObjectKey, + tempObjectKey, + expectedSize, + previousSize = 0, + fileHash = null, + expiresAt + }) { + const token = typeof reservationToken === 'string' ? reservationToken.trim() : ''; + const uid = Number(userId); + const expected = Math.max(0, Math.floor(Number(expectedSize) || 0)); + const previous = Math.max(0, Math.floor(Number(previousSize) || 0)); + const finalKey = typeof finalObjectKey === 'string' ? finalObjectKey.trim() : ''; + const tempKey = typeof tempObjectKey === 'string' ? tempObjectKey.trim() : ''; + const hash = typeof fileHash === 'string' && fileHash.trim() ? fileHash.trim() : null; + const expiresAtValue = typeof expiresAt === 'string' ? expiresAt : null; + + if (!token || !Number.isFinite(uid) || uid <= 0 || !finalKey || !tempKey || expected <= 0 || !expiresAtValue) { + return null; + } + + const result = db.prepare(` + INSERT INTO oss_upload_reservations ( + reservation_token, user_id, final_object_key, temp_object_key, + expected_size, previous_size, file_hash, status, expires_at, + created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, 'pending', ?, + datetime('now', 'localtime'), datetime('now', 'localtime') + ) + `).run(token, Math.floor(uid), finalKey, tempKey, expected, previous, hash, expiresAtValue); + + return db.prepare('SELECT * FROM oss_upload_reservations WHERE id = ?').get(result.lastInsertRowid); + }, + + findPendingByToken(reservationToken) { + const token = typeof reservationToken === 'string' ? reservationToken.trim() : ''; + if (!token) return null; + + return db.prepare(` + SELECT * + FROM oss_upload_reservations + WHERE reservation_token = ? + AND status = 'pending' + AND expires_at > datetime('now', 'localtime') + LIMIT 1 + `).get(token); + }, + + complete(reservationToken) { + const token = typeof reservationToken === 'string' ? reservationToken.trim() : ''; + if (!token) return { changes: 0 }; + + return db.prepare(` + UPDATE oss_upload_reservations + SET status = 'completed', + completed_at = datetime('now', 'localtime'), + updated_at = datetime('now', 'localtime') + WHERE reservation_token = ? + AND status = 'pending' + `).run(token); + }, + + cancel(reservationToken) { + const token = typeof reservationToken === 'string' ? reservationToken.trim() : ''; + if (!token) return { changes: 0 }; + + return db.prepare(` + UPDATE oss_upload_reservations + SET status = 'cancelled', + updated_at = datetime('now', 'localtime') + WHERE reservation_token = ? + AND status = 'pending' + `).run(token); + }, + + listExpiredPending(limit = 100) { + const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100))); + return db.prepare(` + SELECT * + FROM oss_upload_reservations + WHERE status = 'pending' + AND expires_at <= datetime('now', 'localtime') + ORDER BY expires_at ASC, id ASC + LIMIT ? + `).all(safeLimit); + }, + + expire(reservationToken) { + const token = typeof reservationToken === 'string' ? reservationToken.trim() : ''; + if (!token) return { changes: 0 }; + + return db.prepare(` + UPDATE oss_upload_reservations + SET status = 'expired', + updated_at = datetime('now', 'localtime') + WHERE reservation_token = ? + AND status = 'pending' + `).run(token); + }, + + cleanupFinalizedHistory(keepDays = 7) { + const days = Math.min(365, Math.max(1, Math.floor(Number(keepDays) || 7))); + return db.prepare(` + DELETE FROM oss_upload_reservations + WHERE status IN ('completed', 'expired', 'cancelled') + AND updated_at < datetime('now', 'localtime', '-' || ? || ' days') + `).run(days); + } +}; + const DeviceSessionDB = { _normalizeSessionId(sessionId) { return typeof sessionId === 'string' ? sessionId.trim() : ''; @@ -3190,13 +3472,13 @@ const TransactionDB = { // 初始化数据库 initDatabase(); -createDefaultAdmin(); initDefaultSettings(); migrateToV2(); // 执行数据库迁移 migrateThemePreference(); // 主题偏好迁移 migrateToOss(); // SFTP → OSS 迁移 migrateOssQuotaField(); // OSS 配额字段迁移 migrateDownloadTrafficFields(); // 下载流量字段迁移 +createDefaultAdmin(); module.exports = { db, @@ -3209,6 +3491,7 @@ module.exports = { DownloadTrafficReportDB, DownloadTrafficReservationDB, UploadSessionDB, + OssUploadReservationDB, DeviceSessionDB, FileHashIndexDB, DownloadTrafficIngestDB, diff --git a/backend/fix_expires_at_format.js b/backend/fix_expires_at_format.js index c039db7..e621078 100644 --- a/backend/fix_expires_at_format.js +++ b/backend/fix_expires_at_format.js @@ -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++; diff --git a/backend/package-lock.json b/backend/package-lock.json index afec4cf..16ba067 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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": { diff --git a/backend/package.json b/backend/package.json index a156713..2e7678e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/server.js b/backend/server.js index ad9b0d1..b349d24 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,7 +4,6 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser'); -const session = require('express-session'); const svgCaptcha = require('svg-captcha'); const multer = require('multer'); const nodemailer = require('nodemailer'); @@ -74,6 +73,7 @@ const { DownloadTrafficReportDB, DownloadTrafficReservationDB, UploadSessionDB, + OssUploadReservationDB, DeviceSessionDB, FileHashIndexDB, DownloadTrafficIngestDB, @@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max( 10, Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) ); -const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.28'; +const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.31'; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase(); const DEFAULT_DESKTOP_INSTALLER_SIZE = Math.max(0, Number(process.env.DESKTOP_INSTALLER_SIZE || 0)); @@ -124,6 +124,16 @@ const DESKTOP_INSTALLER_SHA256_PATTERN = /^[a-f0-9]{64}$/; const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB +const OSS_DIRECT_UPLOAD_TTL_SECONDS = Math.max( + 60, + Math.min(3600, Number(process.env.OSS_DIRECT_UPLOAD_TTL_SECONDS || 900)) +); +const OSS_UPLOAD_RESERVATION_TTL_MS = Math.max( + 30 * 60 * 1000, + Number(process.env.OSS_UPLOAD_RESERVATION_TTL_MS || (45 * 60 * 1000)), + OSS_DIRECT_UPLOAD_TTL_SECONDS * 1000 +); +const OSS_UPLOAD_TEMP_PREFIX = '__wanwan_tmp_uploads'; const GLOBAL_SEARCH_DEFAULT_LIMIT = Number(process.env.GLOBAL_SEARCH_DEFAULT_LIMIT || 80); const GLOBAL_SEARCH_MAX_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200); const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000); @@ -150,6 +160,17 @@ const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase() const SHOULD_USE_SECURE_COOKIES = COOKIE_SECURE_MODE === 'true' || (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); +const CAPTCHA_COOKIE_NAME = 'captcha.ticket'; +const CAPTCHA_TTL_MS = 5 * 60 * 1000; +const CAPTCHA_COOKIE_MAX_AGE_MS = CAPTCHA_TTL_MS; +const CAPTCHA_SECRET = process.env.CAPTCHA_SECRET || JWT_SECRET; +const DEFAULT_CAPTCHA_SECRETS = [ + 'your-session-secret-change-in-production', + 'session-secret-change-me', + 'your-captcha-secret-change-in-production' +]; +const isSecureCookie = SHOULD_USE_SECURE_COOKIES; +const sameSiteMode = isSecureCookie ? 'none' : 'lax'; function normalizeVersion(rawVersion, fallback = '0.0.0') { const value = String(rawVersion || '').trim(); @@ -421,9 +442,8 @@ function getSecureBaseUrl(req) { return `${getProtocol(req)}://${req.get('host')}`; } - // 生产环境没有配置时,记录警告并使用请求的 Host(不推荐) - console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!'); - return `${getProtocol(req)}://${req.get('host')}`; + // 生产环境绝不使用请求 Host 生成外部链接,避免 Host Header 注入。 + throw new Error('生产环境必须配置 PUBLIC_BASE_URL 或 ALLOWED_HOSTS'); } // ===== 安全配置:信任代理 ===== @@ -522,7 +542,7 @@ function applySecurityHeaders(req, res) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } // 内容安全策略 - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';"); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';"); // 隐藏X-Powered-By res.removeHeader('X-Powered-By'); } @@ -631,44 +651,6 @@ app.use((req, res, next) => { return next(); }); -// Session配置(用于验证码) -const isSecureCookie = SHOULD_USE_SECURE_COOKIES; -const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 - -// 安全检查:Session密钥配置 -const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production'; -const DEFAULT_SESSION_SECRETS = [ - 'your-session-secret-change-in-production', - 'session-secret-change-me' -]; - -if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) { - const sessionWarnMsg = ` -[安全警告] SESSION_SECRET 使用默认值,存在安全风险! -请在 .env 文件中设置随机生成的 SESSION_SECRET -生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -`; - if (process.env.NODE_ENV === 'production') { - console.error(sessionWarnMsg); - throw new Error('生产环境必须设置 SESSION_SECRET!'); - } else { - console.warn(sessionWarnMsg); - } -} - -app.use(session({ - secret: SESSION_SECRET, - resave: false, - saveUninitialized: false, // 仅在写入 session 时创建,减少无效会话 - name: 'captcha.sid', // 自定义session cookie名称 - cookie: { - secure: isSecureCookie, - httpOnly: true, - sameSite: sameSiteMode, - maxAge: 10 * 60 * 1000 // 10分钟 - } -})); - // 安全响应头中间件 app.use((req, res, next) => { applySecurityHeaders(req, res); @@ -861,16 +843,32 @@ function isFileExtensionSafe(filename) { // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { + const sensitiveBodyFields = new Set([ + 'password', + 'current_password', + 'new_password', + 'admin_password', + 'api_key', + 'access_key_secret', + 'oss_access_key_secret', + 'smtp_password', + 'token', + 'refreshToken' + ]); + // 递归过滤所有字符串字段 - function sanitizeObject(obj) { + function sanitizeObject(obj, fieldName = '') { if (typeof obj === 'string') { + if (sensitiveBodyFields.has(fieldName)) { + return obj; + } return sanitizeInput(obj); } else if (Array.isArray(obj)) { - return obj.map(item => sanitizeObject(item)); + return obj.map(item => sanitizeObject(item, fieldName)); } else if (obj && typeof obj === 'object') { const sanitized = {}; for (const [key, value] of Object.entries(obj)) { - sanitized[key] = sanitizeObject(value); + sanitized[key] = sanitizeObject(value, key); } return sanitized; } @@ -1419,6 +1417,53 @@ function handleDownloadSecurityBlock(req, res, blockResult, options = {}) { }); } +function requirePasswordConfirmation(req, res, next) { + try { + const password = String( + req.body?.current_password || + req.body?.admin_password || + req.body?.password_confirmation || + '' + ); + + if (!password) { + return res.status(403).json({ + success: false, + message: '需要输入当前管理员密码进行确认', + requirePasswordConfirmation: true + }); + } + + const user = UserDB.findById(req.user?.id); + if (!user || !user.password) { + return res.status(403).json({ + success: false, + message: '管理员账号不存在或状态异常' + }); + } + + const ok = require('bcryptjs').compareSync(password, user.password); + if (!ok) { + logAuth(req, 'admin_password_confirmation_failed', '管理员敏感操作密码确认失败', { + userId: req.user?.id + }, 'warning'); + return res.status(403).json({ + success: false, + message: '管理员密码确认失败', + requirePasswordConfirmation: true + }); + } + + next(); + } catch (error) { + console.error('[安全] 管理员密码确认失败:', error); + res.status(500).json({ + success: false, + message: '管理员密码确认失败,请稍后重试' + }); + } +} + function sendPlainTextError(res, statusCode, message) { return res.status(statusCode).type('text/plain; charset=utf-8').send(message); } @@ -1454,6 +1499,26 @@ function formatDateTimeForSqlite(date = new Date()) { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } +function createOssUploadReservationToken() { + return crypto.randomBytes(24).toString('hex'); +} + +function buildOssTempObjectKey(userId, reservationToken, filename) { + const safeUserId = Math.max(0, Math.floor(Number(userId) || 0)); + const safeToken = String(reservationToken || '').replace(/[^a-f0-9]/gi, '').slice(0, 64); + const safeName = sanitizeFilename(filename || 'upload.bin') || 'upload.bin'; + return `${OSS_UPLOAD_TEMP_PREFIX}/user_${safeUserId}/${safeToken}/${safeName}`; +} + +function encodeS3CopySource(bucket, key) { + const encodedBucket = encodeURIComponent(String(bucket || '')); + const encodedKey = String(key || '') + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + return `${encodedBucket}/${encodedKey}`; +} + function getDateKeyFromDate(date = new Date()) { const target = date instanceof Date ? date : new Date(date); if (Number.isNaN(target.getTime())) { @@ -2227,10 +2292,14 @@ function buildStorageUserContext(user, overrides = {}) { return user; } + const directSecret = user.oss_access_key_secret; const storageUser = { ...user, ...overrides }; + if (!storageUser.oss_access_key_secret && directSecret) { + storageUser.oss_access_key_secret = directSecret; + } if (typeof storageUser.oss_access_key_secret === 'string' && storageUser.oss_access_key_secret) { try { @@ -2270,6 +2339,70 @@ function createS3ClientContextForUser(user, overrides = {}) { }; } +async function cleanupExpiredOssUploadReservations(trigger = 'interval') { + const rows = OssUploadReservationDB.listExpiredPending(200); + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + let cleaned = 0; + + for (const row of rows) { + try { + const user = UserDB.findById(row.user_id); + if (!user) { + OssUploadReservationDB.expire(row.reservation_token); + cleaned += 1; + continue; + } + + const ossClient = createOssClientForUser(user); + await ossClient.connect(); + try { + await ossClient.s3Client.send(new DeleteObjectCommand({ + Bucket: ossClient.getBucket(), + Key: row.temp_object_key + })); + } catch (deleteError) { + const statusCode = deleteError?.$metadata?.httpStatusCode; + if (deleteError?.name !== 'NotFound' && deleteError?.name !== 'NoSuchKey' && statusCode !== 404) { + throw deleteError; + } + } + + OssUploadReservationDB.expire(row.reservation_token); + cleaned += 1; + } catch (error) { + console.error(`[OSS直传] 清理过期临时对象失败: reservation=${row.id}, user=${row.user_id}`, error); + } + } + + const deletedHistory = OssUploadReservationDB.cleanupFinalizedHistory(7); + if (cleaned > 0 || Number(deletedHistory?.changes || 0) > 0) { + console.log( + `[OSS直传] 清理完成 (trigger=${trigger}) ` + + `expired=${cleaned}, deleted_history=${Number(deletedHistory?.changes || 0)}` + ); + } +} + +const ossUploadReservationSweepTimer = setInterval(() => { + cleanupExpiredOssUploadReservations('interval').catch(error => { + console.error('[OSS直传] 定期清理失败:', error); + }); +}, 5 * 60 * 1000); + +if (ossUploadReservationSweepTimer && typeof ossUploadReservationSweepTimer.unref === 'function') { + ossUploadReservationSweepTimer.unref(); +} + +setTimeout(() => { + cleanupExpiredOssUploadReservations('startup').catch(error => { + console.error('[OSS直传] 启动清理失败:', error); + }); +}, 35 * 1000); + const EPHEMERAL_TOKEN_SECRET = JWT_SECRET; function signEphemeralToken(payload, expiresInSeconds = 900) { @@ -2734,6 +2867,7 @@ const fileListLimiter = new RateLimiter({ // 验证码最小请求间隔控制 const CAPTCHA_MIN_INTERVAL = 1000; // 1秒 const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理 +const captchaTicketCache = new TTLCache(CAPTCHA_TTL_MS); // 验证码防刷中间件 function captchaRateLimitMiddleware(req, res, next) { @@ -3358,15 +3492,7 @@ async function searchFilesRecursively(storage, startPath, keyword, options = {}) } function normalizeUploadPath(rawPath) { - const safeRaw = typeof rawPath === 'string' ? rawPath : '/'; - if (safeRaw.includes('..') || safeRaw.includes('\x00')) { - return null; - } - const normalized = path.posix.normalize(safeRaw || '/'); - if (normalized.includes('..')) { - return null; - } - return normalized === '.' ? '/' : normalized; + return normalizeVirtualPath(typeof rawPath === 'string' ? rawPath : '/'); } function buildVirtualFilePath(basePath, filename) { @@ -3590,7 +3716,7 @@ function cleanupOldTempFiles() { const filePath = path.join(uploadsDir, file); try { const stats = fs.statSync(filePath); - if (now - stats.mtimeMs > maxAge) { + if (stats.isFile() && now - stats.mtimeMs > maxAge) { fs.unlinkSync(filePath); cleaned++; } @@ -3689,43 +3815,121 @@ function checkMailRateLimit(req, type = 'mail') { // ===== 验证码验证辅助函数 ===== +function isCaptchaSecretSecure() { + return typeof CAPTCHA_SECRET === 'string' && + CAPTCHA_SECRET.length >= 32 && + !DEFAULT_CAPTCHA_SECRETS.includes(CAPTCHA_SECRET) && + (CAPTCHA_SECRET !== JWT_SECRET || isJwtSecretSecure()); +} + +function signCaptchaTicket(ticketId) { + return crypto + .createHmac('sha256', CAPTCHA_SECRET) + .update(ticketId) + .digest('hex'); +} + +function isSafeHexDigest(value) { + return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value); +} + +function safeEqualHex(left, right) { + if (!isSafeHexDigest(left) || !isSafeHexDigest(right)) return false; + return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex')); +} + +function buildCaptchaCookieValue(ticketId) { + return `${ticketId}.${signCaptchaTicket(ticketId)}`; +} + +function parseCaptchaTicket(rawValue) { + const value = String(rawValue || ''); + const separatorIndex = value.lastIndexOf('.'); + if (separatorIndex <= 0) return null; + + const ticketId = value.slice(0, separatorIndex); + const signature = value.slice(separatorIndex + 1); + if (!/^[a-f0-9]{48}$/i.test(ticketId)) return null; + if (!safeEqualHex(signCaptchaTicket(ticketId), signature)) return null; + + return ticketId; +} + +function getCaptchaCookieOptions(maxAge = CAPTCHA_COOKIE_MAX_AGE_MS) { + return { + httpOnly: true, + secure: isSecureCookie, + sameSite: sameSiteMode, + maxAge, + path: '/' + }; +} + +function clearCaptchaTicketCookie(res) { + if (!res) return; + res.clearCookie(CAPTCHA_COOKIE_NAME, { + httpOnly: true, + secure: isSecureCookie, + sameSite: sameSiteMode, + path: '/' + }); +} + +function issueCaptchaTicket(res, captchaText) { + const ticketId = crypto.randomBytes(24).toString('hex'); + captchaTicketCache.set(ticketId, { + captcha: String(captchaText || '').toLowerCase(), + createdAt: Date.now() + }, CAPTCHA_TTL_MS); + res.cookie(CAPTCHA_COOKIE_NAME, buildCaptchaCookieValue(ticketId), getCaptchaCookieOptions()); + return ticketId; +} + /** * 验证验证码 * @param {Object} req - 请求对象 + * @param {Object} res - 响应对象 * @param {string} captcha - 用户输入的验证码 + * @param {string} logPrefix - 日志前缀 * @returns {{valid: boolean, message?: string}} 验证结果 */ -function verifyCaptcha(req, captcha) { +function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') { if (!captcha) { return { valid: false, message: '请输入验证码' }; } - const sessionCaptcha = req.session.captcha; - const captchaTime = req.session.captchaTime; - - // 调试日志 - console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha); - - if (!sessionCaptcha || !captchaTime) { - console.log('[验证码验证] 失败: session中无验证码'); + const ticketId = parseCaptchaTicket(req.cookies?.[CAPTCHA_COOKIE_NAME]); + if (!ticketId) { + clearCaptchaTicketCookie(res); + console.log(`[${logPrefix}] 失败: 验证码票据无效或不存在`); return { valid: false, message: '验证码已过期,请刷新验证码' }; } - // 验证码有效期5分钟 - if (Date.now() - captchaTime > 5 * 60 * 1000) { - console.log('[验证码验证] 失败: 验证码已超时'); + const captchaRecord = captchaTicketCache.get(ticketId); + console.log(`[${logPrefix}] 正在验证验证码票据:`, ticketId.slice(0, 8)); + + if (!captchaRecord || !captchaRecord.captcha || !captchaRecord.createdAt) { + captchaTicketCache.delete(ticketId); + clearCaptchaTicketCookie(res); + console.log(`[${logPrefix}] 失败: 验证码票据不存在`); return { valid: false, message: '验证码已过期,请刷新验证码' }; } - if (captcha.toLowerCase() !== sessionCaptcha) { - console.log('[验证码验证] 失败: 验证码不匹配'); + if (Date.now() - captchaRecord.createdAt > CAPTCHA_TTL_MS) { + captchaTicketCache.delete(ticketId); + clearCaptchaTicketCookie(res); + console.log(`[${logPrefix}] 失败: 验证码已超时`); + return { valid: false, message: '验证码已过期,请刷新验证码' }; + } + + if (String(captcha).toLowerCase() !== captchaRecord.captcha) { + console.log(`[${logPrefix}] 失败: 验证码不匹配`); return { valid: false, message: '验证码错误' }; } - console.log('[验证码验证] 成功'); - // 验证通过后清除session中的验证码 - delete req.session.captcha; - delete req.session.captchaTime; + console.log(`[${logPrefix}] 成功`); + captchaTicketCache.delete(ticketId); + clearCaptchaTicketCookie(res); return { valid: true }; } @@ -3797,23 +4001,10 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字 }); - // 将验证码文本存储在session中 - req.session.captcha = captcha.text.toLowerCase(); - req.session.captchaTime = Date.now(); - - // 保存session后再返回响应(修复:确保session保存成功) - req.session.save((err) => { - if (err) { - console.error('[验证码] Session保存失败:', err); - return res.status(500).json({ - success: false, - message: '验证码生成失败' - }); - } - console.log('[验证码] 生成成功, SessionID:', req.sessionID); - res.type('svg'); - res.send(captcha.data); - }); + const ticketId = issueCaptchaTicket(res, captcha.text); + console.log('[验证码] 生成成功, Ticket:', ticketId.slice(0, 8)); + res.type('svg'); + res.send(captcha.data); } catch (error) { console.error('生成验证码失败:', error); res.status(500).json({ @@ -3907,7 +4098,7 @@ app.post('/api/register', try { // 验证验证码 const { captcha } = req.body; - const captchaResult = verifyCaptcha(req, captcha); + const captchaResult = verifyCaptcha(req, res, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, @@ -4017,7 +4208,7 @@ app.post('/api/resend-verification', [ try { // 验证验证码 const { captcha } = req.body; - const captchaResult = verifyCaptcha(req, captcha); + const captchaResult = verifyCaptcha(req, res, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, @@ -4108,7 +4299,7 @@ app.post('/api/password/forgot', [ const { email, captcha } = req.body; try { // 验证验证码 - const captchaResult = verifyCaptcha(req, captcha); + const captchaResult = verifyCaptcha(req, res, captcha); if (!captchaResult.valid) { return res.status(400).json({ success: false, @@ -4246,45 +4437,14 @@ app.post('/api/login', }); } - // 验证验证码 - const sessionCaptcha = req.session.captcha; - const captchaTime = req.session.captchaTime; - - // 安全:不记录验证码明文 - console.log('[登录验证] 正在验证验证码...'); - - if (!sessionCaptcha || !captchaTime) { - console.log('[登录验证] 验证码不存在于Session中'); + const captchaResult = verifyCaptcha(req, res, captcha, '登录验证'); + if (!captchaResult.valid) { return res.status(400).json({ success: false, - message: '验证码已过期,请刷新验证码', + message: captchaResult.message, needCaptcha: true }); } - - // 验证码有效期5分钟 - if (Date.now() - captchaTime > 5 * 60 * 1000) { - console.log('[登录验证] 验证码已超过5分钟'); - return res.status(400).json({ - success: false, - message: '验证码已过期,请刷新验证码', - needCaptcha: true - }); - } - - if (captcha.toLowerCase() !== sessionCaptcha) { - console.log('[登录验证] 验证码不匹配'); - return res.status(400).json({ - success: false, - message: '验证码错误', - needCaptcha: true - }); - } - - console.log('[登录验证] 验证码验证通过'); - // 验证通过后清除session中的验证码 - delete req.session.captcha; - delete req.session.captchaTime; } let user = UserDB.findByUsername(username); @@ -5433,16 +5593,15 @@ app.get('/api/files', authMiddleware, async (req, res) => { const rawPath = req.query.path || '/'; - // 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径 - if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) { + // 路径安全验证:在 API 层提前拒绝包含 ..、编码 .. 或空字节的路径 + const dirPath = normalizeVirtualPath(rawPath); + if (!dirPath) { return res.status(400).json({ success: false, message: '路径包含非法字符' }); } - // 规范化路径 - const dirPath = path.posix.normalize(rawPath); let storage; try { @@ -6604,8 +6763,9 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { }); } - // 路径安全验证:防止目录遍历攻击 - if (uploadPath.includes('..') || uploadPath.includes('\x00')) { + // 路径安全验证:防止目录遍历攻击。使用统一虚拟路径规范化,覆盖编码后的 .. 片段。 + const normalizedUploadPath = normalizeUploadPath(uploadPath); + if (!normalizedUploadPath) { return res.status(400).json({ success: false, message: '上传路径非法' @@ -6630,7 +6790,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { // 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致) // 格式:user_${id}/${path}/${filename} const sanitizedFilename = sanitizeFilename(filename); - let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/'); + let normalizedPath = normalizedUploadPath.replace(/\\/g, '/').replace(/\/+/g, '/'); // 移除开头的斜杠 normalizedPath = normalizedPath.replace(/^\/+/, ''); // 移除结尾的斜杠 @@ -6678,32 +6838,53 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { }); } + const reservationToken = createOssUploadReservationToken(); + const tempObjectKey = buildOssTempObjectKey(req.user.id, reservationToken, sanitizedFilename); + const reservationExpiresAt = new Date(Date.now() + OSS_UPLOAD_RESERVATION_TTL_MS); + const reservation = OssUploadReservationDB.create({ + reservationToken, + userId: req.user.id, + finalObjectKey: objectKey, + tempObjectKey, + expectedSize: fileSize, + previousSize, + fileHash: fileHash || null, + expiresAt: formatDateTimeForSqlite(reservationExpiresAt) + }); + + if (!reservation) { + throw new Error('创建上传预留记录失败'); + } + const completionToken = signEphemeralToken({ type: 'upload_complete', userId: req.user.id, + reservationToken, objectKey, + tempObjectKey, previousSize, expectedSize: fileSize, fileHash: fileHash || null - }, 30 * 60); + }, Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000)); - // 创建 PutObject 命令 + // 签名只允许写入临时对象;完成确认后由服务端复制到最终路径 const command = new PutObjectCommand({ Bucket: bucket, - Key: objectKey, + Key: tempObjectKey, ContentType: contentType }); - // 生成签名 URL(15分钟有效) - const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 }); + const signedUrl = await getSignedUrl(client, command, { expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS }); res.json({ success: true, uploadUrl: signedUrl, objectKey: objectKey, + uploadObjectKey: tempObjectKey, previousSize, completionToken, - expiresIn: 900 + expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS, + completionExpiresIn: Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000) }); } catch (error) { console.error('[OSS签名] 生成上传签名失败:', error); @@ -6770,7 +6951,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { const completionPayload = completionTokenResult.payload || {}; if ( Number(completionPayload.userId) !== Number(req.user.id) || - completionPayload.objectKey !== normalizedObjectKey + completionPayload.objectKey !== normalizedObjectKey || + !completionPayload.reservationToken ) { return res.status(403).json({ success: false, @@ -6778,23 +6960,37 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { }); } - const previousObjectSize = Number.isFinite(Number(completionPayload.previousSize)) && Number(completionPayload.previousSize) >= 0 - ? Number(completionPayload.previousSize) + const reservation = OssUploadReservationDB.findPendingByToken(completionPayload.reservationToken); + if ( + !reservation || + Number(reservation.user_id) !== Number(req.user.id) || + reservation.final_object_key !== normalizedObjectKey || + reservation.temp_object_key !== completionPayload.tempObjectKey + ) { + return res.status(403).json({ + success: false, + message: '上传预留记录不存在、已完成或已过期' + }); + } + + const previousObjectSize = Number.isFinite(Number(reservation.previous_size)) && Number(reservation.previous_size) >= 0 + ? Number(reservation.previous_size) : 0; - const completionFileHash = normalizeFileHash(completionPayload.fileHash); + const expectedSize = Math.max(0, Math.floor(Number(reservation.expected_size) || 0)); + const completionFileHash = normalizeFileHash(reservation.file_hash || completionPayload.fileHash); const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`; let ossClient; try { - const { HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); + const { HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); ossClient = createOssClientForUser(req.user); await ossClient.connect(); const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({ Bucket: ossClient.getBucket(), - Key: normalizedObjectKey + Key: reservation.temp_object_key })); const verifiedSize = Number(headResponse.ContentLength || 0); @@ -6807,6 +7003,19 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { console.warn(`[上传完成] 用户 ${req.user.id} 上报大小(${reportedSize})与实际大小(${verifiedSize})不一致,已使用实际大小`); } + if (expectedSize <= 0 || verifiedSize !== expectedSize) { + await ossClient.s3Client.send(new DeleteObjectCommand({ + Bucket: ossClient.getBucket(), + Key: reservation.temp_object_key + })); + OssUploadReservationDB.cancel(reservation.reservation_token); + clearOssUsageCache(req.user.id); + return res.status(400).json({ + success: false, + message: `上传对象大小校验失败:期望 ${formatFileSize(expectedSize)},实际 ${formatFileSize(verifiedSize)}` + }); + } + const deltaSize = verifiedSize - previousObjectSize; // 二次校验 OSS 配额(防止签名后并发上传导致超限) @@ -6816,17 +7025,13 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { const projectedUsage = Math.max(0, currentUsage + deltaSize); if (projectedUsage > ossQuota) { - // 回滚:删除刚上传的对象,避免超配额文件残留 + // 回滚:删除临时对象,避免超配额文件残留 await ossClient.s3Client.send(new DeleteObjectCommand({ Bucket: ossClient.getBucket(), - Key: normalizedObjectKey + Key: reservation.temp_object_key })); - // 若为覆盖上传,旧对象已被替换,删除后需扣除旧对象体积 - if (previousObjectSize > 0) { - await StorageUsageCache.updateUsage(req.user.id, -previousObjectSize); - } - + OssUploadReservationDB.cancel(reservation.reservation_token); clearOssUsageCache(req.user.id); return res.status(400).json({ @@ -6835,6 +7040,18 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { }); } + await ossClient.s3Client.send(new CopyObjectCommand({ + Bucket: ossClient.getBucket(), + CopySource: encodeS3CopySource(ossClient.getBucket(), reservation.temp_object_key), + Key: normalizedObjectKey, + MetadataDirective: 'COPY' + })); + + await ossClient.s3Client.send(new DeleteObjectCommand({ + Bucket: ossClient.getBucket(), + Key: reservation.temp_object_key + })); + // 更新存储使用量缓存(增量更新,覆盖上传只记录差值) if (deltaSize !== 0) { await StorageUsageCache.updateUsage(req.user.id, deltaSize); @@ -6852,6 +7069,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { objectKey: normalizedObjectKey }); + OssUploadReservationDB.complete(reservation.reservation_token); + console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`); res.json({ success: true, @@ -6885,9 +7104,9 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { }); } - // 路径安全验证:防止目录遍历攻击 - const normalizedPath = path.posix.normalize(filePath); - if (normalizedPath.includes('..') || filePath.includes('\x00')) { + // 路径安全验证:防止目录遍历攻击。必须在 normalize 前拒绝 .. 片段。 + const normalizedPath = normalizeVirtualPath(filePath); + if (!normalizedPath) { return res.status(400).json({ success: false, message: '文件路径非法' @@ -7123,8 +7342,9 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) } // 路径安全校验 - const normalizedPath = path.posix.normalize(remotePath || '/'); - if (normalizedPath.includes('..')) { + const normalizedPath = normalizeUploadPath(remotePath || '/'); + if (!normalizedPath) { + safeDeleteFile(req.file.path); return res.status(400).json({ success: false, message: '上传路径非法' @@ -7199,8 +7419,8 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => { }); } - const normalizedPath = path.posix.normalize(filePath); - if (normalizedPath.includes('..') || filePath.includes('\x00')) { + const normalizedPath = normalizeVirtualPath(filePath); + if (!normalizedPath) { return res.status(400).json({ success: false, message: '文件路径非法' @@ -7317,8 +7537,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { } // 路径安全验证:防止目录遍历攻击 - const normalizedPath = path.posix.normalize(filePath); - if (normalizedPath.includes('..') || filePath.includes('\x00')) { + const normalizedPath = normalizeVirtualPath(filePath); + if (!normalizedPath) { return res.status(400).json({ success: false, message: '文件路径非法' @@ -8626,52 +8846,10 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => // 记录下载次数(添加限流保护防止滥用) app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => { - const { code } = req.params; - - // 参数验证:code 不能为空 - if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) { - return res.status(400).json({ - success: false, - message: '无效的分享码' - }); - } - - try { - const share = ShareDB.findByCode(code); - - if (!share) { - return res.status(404).json({ - success: false, - message: '分享不存在' - }); - } - - const accessPolicy = evaluateShareSecurityPolicy(share, req, { - action: 'download', - enforceDownloadLimit: true - }); - if (!accessPolicy.allowed) { - return res.status(403).json({ - success: false, - message: accessPolicy.message || '下载已受限', - policy_code: accessPolicy.code || 'policy_blocked' - }); - } - - // 增加下载次数 - ShareDB.incrementDownloadCount(code); - - res.json({ - success: true, - message: '下载统计已记录' - }); - } catch (error) { - console.error('记录下载失败:', error); - res.status(500).json({ - success: false, - message: '记录下载失败: ' + error.message - }); - } + res.status(410).json({ + success: false, + message: '下载统计已合并到下载地址签发和文件下载接口' + }); }); // 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护) @@ -8903,6 +9081,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } } + ShareDB.incrementDownloadCount(code); + res.json({ success: true, downloadUrl: signedUrl, @@ -9225,10 +9405,11 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { }); // 更新系统设置 -// 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证 +// 敏感系统设置需要管理员当前密码二次确认 app.post('/api/admin/settings', authMiddleware, adminMiddleware, + requirePasswordConfirmation, (req, res) => { try { const { @@ -9430,6 +9611,7 @@ app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req, app.post('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, + requirePasswordConfirmation, [ body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), body('region').notEmpty().withMessage('地域不能为空'), @@ -9571,6 +9753,7 @@ app.post('/api/admin/unified-oss-config/test', app.delete('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, + requirePasswordConfirmation, (req, res) => { try { SettingsDB.clearUnifiedOssConfig(); @@ -9782,16 +9965,16 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击' }); - // 12. Session密钥检查 - const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32; + // 12. 验证码票据签名密钥检查 + const captchaSecretSecure = isCaptchaSecretSecure(); checks.push({ - name: 'Session密钥', + name: '验证码票据签名密钥', category: 'security', - status: sessionSecure ? 'pass' : 'fail', - message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!', - suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET,至少32字符' + status: captchaSecretSecure ? 'pass' : 'fail', + message: captchaSecretSecure ? '验证码票据签名密钥已正确配置' : '验证码票据签名密钥使用默认值或长度不足,存在安全风险!', + suggestion: captchaSecretSecure ? null : '请在.env中设置随机生成的JWT_SECRET或CAPTCHA_SECRET,至少32字符' }); - if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical'; + if (!captchaSecretSecure && overallStatus !== 'critical') overallStatus = 'critical'; // 统计 const summary = { diff --git a/backend/storage.js b/backend/storage.js index b19ae32..6e7da5d 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -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') { diff --git a/backend/test_download_quota_defaults.js b/backend/test_download_quota_defaults.js new file mode 100644 index 0000000..79eeae6 --- /dev/null +++ b/backend/test_download_quota_defaults.js @@ -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); + } + } +} diff --git a/backend/tests/boundary-tests.js b/backend/tests/boundary-tests.js index c9bdb67..df14aa4 100644 --- a/backend/tests/boundary-tests.js +++ b/backend/tests/boundary-tests.js @@ -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('原型污染尝试应该被阻止', () => { diff --git a/backend/tests/full-audit-regression.js b/backend/tests/full-audit-regression.js new file mode 100644 index 0000000..ce43271 --- /dev/null +++ b/backend/tests/full-audit-regression.js @@ -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); +}); diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index b34d122..5c001c4 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -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字节 diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json index 124c357..0ff5126 100644 --- a/desktop-client/package-lock.json +++ b/desktop-client/package-lock.json @@ -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": { diff --git a/desktop-client/package.json b/desktop-client/package.json index 676521c..c8c3084 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,7 +1,7 @@ { "name": "desktop-client", "private": true, - "version": "0.1.30", + "version": "0.1.31", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index 4276285..f44bb47 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "desktop-client" -version = "0.1.30" +version = "0.1.31" dependencies = [ "reqwest 0.12.28", "rusqlite", diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index cf5bafb..55a16e8 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-client" -version = "0.1.30" +version = "0.1.31" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 9826380..c21d6fe 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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 { + 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 #[tauri::command] fn api_launch_installer(installer_path: String) -> Result { - 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 Result { - 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; diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 5fa3d26..7908239 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -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": { diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 509c4ae..f125665 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -166,7 +166,7 @@ const syncState = reactive({ nextRunAt: "", }); const updateState = reactive({ - currentVersion: "0.1.30", + currentVersion: "0.1.31", latestVersion: "", available: false, mandatory: false, diff --git a/docker-compose.yml b/docker-compose.yml index 6bb22c3..f1082e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/app.html b/frontend/app.html index 9584963..845ea84 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -1778,8 +1778,8 @@
- - + +
@@ -1986,7 +1986,7 @@
-
+
拖放文件到这里上传
@@ -3041,8 +3041,8 @@
- - + +
-
-
正在上传文件
+
{{ uploadPhase || '正在上传文件' }}
{{ uploadingFileName }}
{{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }}
diff --git a/frontend/app.js b/frontend/app.js index dd078c3..c9b46c2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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) => `${match}`); + 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 += `${this.escapeHtml(rawText.slice(index, index + search.length))}`; + 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); diff --git a/frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe b/frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe new file mode 100644 index 0000000..0a695a9 Binary files /dev/null and b/frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe differ diff --git a/frontend/reset-password.html b/frontend/reset-password.html index 9447288..645a599 100644 --- a/frontend/reset-password.html +++ b/frontend/reset-password.html @@ -292,8 +292,8 @@
-
密码长度至少6位
+ placeholder="请输入新密码" required minlength="8" maxlength="128"> +
密码长度8-128位,且包含字母、数字、特殊字符中的至少两种
@@ -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 = ' 处理中...'; 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 diff --git a/frontend/share.html b/frontend/share.html index ce44ebe..694d57e 100644 --- a/frontend/share.html +++ b/frontend/share.html @@ -1040,6 +1040,13 @@ | 有效期:

+ +
@@ -1083,7 +1093,7 @@
    -
  • +
  • @@ -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; diff --git a/frontend/verify.html b/frontend/verify.html index 3bed03f..6145b2a 100644 --- a/frontend/verify.html +++ b/frontend/verify.html @@ -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)}`); diff --git a/install.sh b/install.sh index 3ceaa21..1282340 100644 --- a/install.sh +++ b/install.sh @@ -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 # 自动生成随机加密密钥