fix: harden cloud storage security
This commit is contained in:
54
README.md
54
README.md
@@ -54,11 +54,11 @@
|
|||||||
- 支持SMTP配置
|
- 支持SMTP配置
|
||||||
- 邮件模板可自定义
|
- 邮件模板可自定义
|
||||||
|
|
||||||
#### 🖥️ 桌面上传工具
|
#### 🖥️ 桌面客户端
|
||||||
- 拖拽上传,简单易用
|
- Tauri + Vue 桌面端,默认对接网页端账号体系
|
||||||
- 实时显示上传进度
|
- 支持文件浏览、搜索、上传、下载、分享/直链管理
|
||||||
- 自动配置,无需手动设置
|
- 支持断点下载、本地目录同步和客户端更新检测
|
||||||
- 支持大文件上传
|
- Windows 安装包可通过 `/api/client/desktop-update` 发布和下载
|
||||||
|
|
||||||
#### ⚡ 一键部署
|
#### ⚡ 一键部署
|
||||||
- 全自动安装脚本(install.sh)
|
- 全自动安装脚本(install.sh)
|
||||||
@@ -123,10 +123,10 @@ docker-compose logs -f
|
|||||||
部署完成后,访问系统:
|
部署完成后,访问系统:
|
||||||
|
|
||||||
- **访问地址**: http://你的服务器IP
|
- **访问地址**: http://你的服务器IP
|
||||||
- **默认管理员账号**:
|
- **管理员账号**:
|
||||||
- 用户名: `admin`
|
- 安装脚本会要求你设置管理员用户名和强密码
|
||||||
- 密码: `admin123`
|
- 密码至少 8 位,并且需要包含字母、数字、特殊字符中的至少两类
|
||||||
- ⚠️ **请立即登录并修改密码!**
|
- 生产环境禁止使用 `admin123`、`12345678` 等默认弱密码
|
||||||
|
|
||||||
## 📖 使用指南
|
## 📖 使用指南
|
||||||
|
|
||||||
@@ -210,12 +210,12 @@ SMTP密码: 你的授权码
|
|||||||
3. 复制分享链接发送给他人
|
3. 复制分享链接发送给他人
|
||||||
4. 在"我的分享"中管理所有分享链接
|
4. 在"我的分享"中管理所有分享链接
|
||||||
|
|
||||||
### 使用桌面上传工具
|
### 使用桌面客户端
|
||||||
|
|
||||||
1. 进入"上传工具"页面
|
1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息
|
||||||
2. 下载适合你系统的上传工具
|
2. 安装后使用网页端账号登录
|
||||||
3. 输入服务器地址和API密钥
|
3. 在客户端中浏览文件、上传/下载、创建分享或直链
|
||||||
4. 拖拽文件即可上传
|
4. 如需发布新版本,将安装包放到 `frontend/downloads/` 并更新后台桌面端版本配置
|
||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
@@ -244,11 +244,10 @@ vue-driven-cloud-storage/
|
|||||||
│ ├── nginx.conf # 反向代理配置
|
│ ├── nginx.conf # 反向代理配置
|
||||||
│ └── nginx.conf.example # 配置模板
|
│ └── nginx.conf.example # 配置模板
|
||||||
│
|
│
|
||||||
├── upload-tool/ # 桌面上传工具
|
├── desktop-client/ # Tauri 桌面客户端
|
||||||
│ ├── upload_tool.py # Python 上传工具源码
|
│ ├── src/ # Vue/TypeScript 前端
|
||||||
│ ├── requirements.txt # Python 依赖
|
│ ├── src-tauri/ # Rust/Tauri 原生能力
|
||||||
│ ├── build.bat # Windows 打包脚本
|
│ └── package.json # 桌面端依赖和构建脚本
|
||||||
│ └── build.sh # Linux/Mac 打包脚本
|
|
||||||
│
|
│
|
||||||
├── install.sh # 一键安装脚本
|
├── install.sh # 一键安装脚本
|
||||||
├── docker-compose.yml # Docker 编排文件
|
├── docker-compose.yml # Docker 编排文件
|
||||||
@@ -267,7 +266,6 @@ vue-driven-cloud-storage/
|
|||||||
- **bcrypt** - 密码加密
|
- **bcrypt** - 密码加密
|
||||||
- **nodemailer** - 邮件发送
|
- **nodemailer** - 邮件发送
|
||||||
- **svg-captcha** - 验证码生成
|
- **svg-captcha** - 验证码生成
|
||||||
- **express-session** - Session 管理
|
|
||||||
|
|
||||||
### 前端技术
|
### 前端技术
|
||||||
- **Vue.js 3** - 渐进式 JavaScript 框架
|
- **Vue.js 3** - 渐进式 JavaScript 框架
|
||||||
@@ -286,7 +284,7 @@ vue-driven-cloud-storage/
|
|||||||
### 认证与授权
|
### 认证与授权
|
||||||
- ✅ bcrypt 密码加密(10轮盐值)
|
- ✅ bcrypt 密码加密(10轮盐值)
|
||||||
- ✅ JWT 令牌认证
|
- ✅ JWT 令牌认证
|
||||||
- ✅ Session 安全管理
|
- ✅ HttpOnly Cookie + CSRF 双提交令牌
|
||||||
- ✅ CORS 跨域配置
|
- ✅ CORS 跨域配置
|
||||||
- ✅ SQL 注入防护(参数化查询)
|
- ✅ SQL 注入防护(参数化查询)
|
||||||
- ✅ XSS 防护(输入过滤)
|
- ✅ 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 \
|
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: 点击登录页的"忘记密码",通过邮箱重置密码。如未配置邮箱,需要手动重置数据库。
|
A: 点击登录页的"忘记密码",通过邮箱重置密码。如未配置邮箱,需要手动重置数据库。
|
||||||
|
|
||||||
**Q: 上传文件大小限制是多少?**
|
**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)
|
### v3.1.0 (2025-01-18)
|
||||||
- 🚀 **重大架构优化**:OSS 直连上传下载(不经过后端)
|
- 🚀 **重大架构优化**:OSS 直连上传下载(不经过后端)
|
||||||
- 上传速度提升 50%,服务器流量节省 50%
|
- 上传速度提升 50%,服务器流量节省 50%
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ NODE_ENV=production
|
|||||||
|
|
||||||
# 强制HTTPS访问(生产环境建议开启)
|
# 强制HTTPS访问(生产环境建议开启)
|
||||||
# 设置为 true 时,仅接受 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监听的端口,用于生成分享链接)
|
# 公开访问端口(nginx监听的端口,用于生成分享链接)
|
||||||
# 标准端口(80/443)可不配置
|
# 标准端口(80/443)可不配置
|
||||||
@@ -33,20 +41,20 @@ PUBLIC_PORT=80
|
|||||||
# 加密密钥(必须配置!)
|
# 加密密钥(必须配置!)
|
||||||
# 用于加密 OSS Access Key Secret 等敏感数据
|
# 用于加密 OSS Access Key Secret 等敏感数据
|
||||||
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# 生成方法: 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密钥(必须修改!)
|
# JWT密钥(必须修改!)
|
||||||
# 生成方法: openssl rand -base64 32
|
# 生成方法: openssl rand -base64 32
|
||||||
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# 或使用: 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 Token 密钥(可选,默认使用 JWT_SECRET 派生)
|
||||||
# 建议生产环境设置独立的密钥
|
# 建议生产环境设置独立的密钥
|
||||||
# REFRESH_SECRET=your-refresh-secret-key
|
# REFRESH_SECRET=REPLACE_WITH_SEPARATE_RANDOM_REFRESH_SECRET
|
||||||
|
|
||||||
# 管理员账号配置(首次启动时创建)
|
# 管理员账号配置(首次启动时创建)
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=REPLACE_WITH_STRONG_ADMIN_PASSWORD
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# CORS 跨域配置(重要!)
|
# CORS 跨域配置(重要!)
|
||||||
@@ -69,17 +77,17 @@ ADMIN_PASSWORD=admin123
|
|||||||
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
|
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
|
||||||
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
|
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
|
||||||
#
|
#
|
||||||
ALLOWED_ORIGINS=
|
ALLOWED_ORIGINS=https://your-domain.example
|
||||||
|
|
||||||
# Cookie 安全配置
|
# Cookie 安全配置
|
||||||
# 使用 HTTPS 时必须设置为 true
|
# 使用 HTTPS 时必须设置为 true
|
||||||
# HTTP 环境设置为 false
|
# HTTP 环境设置为 false
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=true
|
||||||
|
|
||||||
# CSRF 防护配置
|
# CSRF 防护配置
|
||||||
# 启用 CSRF 保护(建议生产环境开启)
|
# 启用 CSRF 保护(建议生产环境开启)
|
||||||
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
|
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
|
||||||
ENABLE_CSRF=false
|
ENABLE_CSRF=true
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 反向代理配置(Nginx/Cloudflare等)
|
# 反向代理配置(Nginx/Cloudflare等)
|
||||||
@@ -97,7 +105,7 @@ ENABLE_CSRF=false
|
|||||||
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
|
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
|
||||||
# 必须配置 TRUST_PROXY=1,否则后端无法正确识别HTTPS请求
|
# 必须配置 TRUST_PROXY=1,否则后端无法正确识别HTTPS请求
|
||||||
#
|
#
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=1
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 存储配置
|
# 存储配置
|
||||||
@@ -126,15 +134,11 @@ STORAGE_ROOT=./storage
|
|||||||
# OSS_ENDPOINT= # 自定义 Endpoint(可选)
|
# OSS_ENDPOINT= # 自定义 Endpoint(可选)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Session 配置
|
# 验证码票据配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# Session 密钥(用于验证码等功能)
|
# 验证码票据签名密钥(可选;默认复用 JWT_SECRET)
|
||||||
# 默认使用随机生成的密钥
|
# CAPTCHA_SECRET=replace-with-random-32-byte-hex
|
||||||
# SESSION_SECRET=your-session-secret
|
|
||||||
|
|
||||||
# Session 过期时间(毫秒),默认 30 分钟
|
|
||||||
# SESSION_MAX_AGE=1800000
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 开发调试配置
|
# 开发调试配置
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { UserDB, DeviceSessionDB } = require('./database');
|
const { UserDB, DeviceSessionDB, SystemLogDB } = require('./database');
|
||||||
const { decryptSecret } = require('./utils/encryption');
|
const { decryptSecret } = require('./utils/encryption');
|
||||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const DEFAULT_OSS_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密钥(必须在环境变量中设置)
|
// JWT密钥(必须在环境变量中设置)
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||||
// Refresh Token密钥(使用不同的密钥)
|
// Refresh Token密钥(使用不同的密钥)
|
||||||
@@ -17,7 +34,8 @@ const REFRESH_TOKEN_EXPIRES = '7d'; // Refresh token 7天
|
|||||||
// 安全检查:验证JWT密钥配置
|
// 安全检查:验证JWT密钥配置
|
||||||
const DEFAULT_SECRETS = [
|
const DEFAULT_SECRETS = [
|
||||||
'your-secret-key-change-in-production',
|
'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 验证逻辑
|
// 安全修复:增强 JWT_SECRET 验证逻辑
|
||||||
@@ -234,10 +252,7 @@ function authMiddleware(req, res, next) {
|
|||||||
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
|
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
|
||||||
? rawOssQuota
|
? rawOssQuota
|
||||||
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
|
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
|
||||||
const rawDownloadTrafficQuota = Number(user.download_traffic_quota);
|
const effectiveDownloadTrafficQuota = normalizeDownloadTrafficQuotaForAuth(user.download_traffic_quota);
|
||||||
const effectiveDownloadTrafficQuota = Number.isFinite(rawDownloadTrafficQuota) && rawDownloadTrafficQuota > 0
|
|
||||||
? Math.floor(rawDownloadTrafficQuota)
|
|
||||||
: 0; // 0 表示不限流量
|
|
||||||
const rawDownloadTrafficUsed = Number(user.download_traffic_used);
|
const rawDownloadTrafficUsed = Number(user.download_traffic_used);
|
||||||
const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0
|
const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0
|
||||||
? Math.floor(rawDownloadTrafficUsed)
|
? Math.floor(rawDownloadTrafficUsed)
|
||||||
@@ -257,8 +272,6 @@ function authMiddleware(req, res, next) {
|
|||||||
oss_provider: user.oss_provider,
|
oss_provider: user.oss_provider,
|
||||||
oss_region: user.oss_region,
|
oss_region: user.oss_region,
|
||||||
oss_access_key_id: user.oss_access_key_id,
|
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_bucket: user.oss_bucket,
|
||||||
oss_endpoint: user.oss_endpoint,
|
oss_endpoint: user.oss_endpoint,
|
||||||
// 存储相关字段
|
// 存储相关字段
|
||||||
@@ -276,6 +289,12 @@ function authMiddleware(req, res, next) {
|
|||||||
// 主题偏好
|
// 主题偏好
|
||||||
theme_preference: user.theme_preference || null
|
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.authSessionId = sessionId || null;
|
||||||
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
||||||
|
|
||||||
@@ -321,14 +340,21 @@ function adminMiddleware(req, res, next) {
|
|||||||
* );
|
* );
|
||||||
*/
|
*/
|
||||||
function requirePasswordConfirmation(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) {
|
if (!password) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '执行此操作需要验证密码',
|
message: '执行此操作需要验证密码',
|
||||||
require_password: true
|
require_password: true,
|
||||||
|
requirePasswordConfirmation: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +381,6 @@ function requirePasswordConfirmation(req, res, next) {
|
|||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
// 记录安全日志:密码验证失败
|
// 记录安全日志:密码验证失败
|
||||||
SystemLogDB = require('./database').SystemLogDB;
|
|
||||||
SystemLogDB.log({
|
SystemLogDB.log({
|
||||||
level: SystemLogDB.LEVELS.WARN,
|
level: SystemLogDB.LEVELS.WARN,
|
||||||
category: SystemLogDB.CATEGORIES.SECURITY,
|
category: SystemLogDB.CATEGORIES.SECURITY,
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ const db = new Database(dbPath);
|
|||||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const DEFAULT_OSS_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 优先级修复) =====
|
// ===== 性能优化配置(P0 优先级修复) =====
|
||||||
|
|
||||||
// 1. 启用 WAL 模式(Write-Ahead Logging)
|
// 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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS user_device_sessions (
|
CREATE TABLE IF NOT EXISTS user_device_sessions (
|
||||||
@@ -575,6 +612,12 @@ function initDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
||||||
ON upload_sessions(user_id, file_hash, file_size);
|
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
|
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_user_active
|
||||||
ON user_device_sessions(user_id, revoked_at, expires_at, last_active_at);
|
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 adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
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);
|
const hashedPassword = bcrypt.hashSync(adminPassword, 10);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
username, email, password,
|
username, email, password,
|
||||||
is_admin, is_active, has_oss_config, is_verified
|
is_admin, is_active, has_oss_config, is_verified,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
download_traffic_quota, download_traffic_used,
|
||||||
|
download_traffic_reset_cycle, download_traffic_last_reset_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
adminUsername,
|
adminUsername,
|
||||||
`${adminUsername}@example.com`,
|
`${adminUsername}@example.com`,
|
||||||
@@ -630,7 +681,11 @@ function createDefaultAdmin() {
|
|||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
0, // 管理员不需要OSS配置
|
0, // 管理员不需要OSS配置
|
||||||
1 // 管理员默认已验证
|
1, // 管理员默认已验证
|
||||||
|
-1, // 默认不限下载
|
||||||
|
0,
|
||||||
|
'none',
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('默认管理员账号已创建');
|
console.log('默认管理员账号已创建');
|
||||||
@@ -658,10 +713,15 @@ const UserDB = {
|
|||||||
username, email, password,
|
username, email, password,
|
||||||
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
|
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
|
||||||
has_oss_config,
|
has_oss_config,
|
||||||
is_verified, verification_token, verification_expires_at
|
is_verified, verification_token, verification_expires_at,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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(
|
const result = stmt.run(
|
||||||
userData.username,
|
userData.username,
|
||||||
userData.email,
|
userData.email,
|
||||||
@@ -675,7 +735,13 @@ const UserDB = {
|
|||||||
hasOssConfig,
|
hasOssConfig,
|
||||||
userData.is_verified !== undefined ? userData.is_verified : 0,
|
userData.is_verified !== undefined ? userData.is_verified : 0,
|
||||||
hashedVerificationToken,
|
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;
|
return result.lastInsertRowid;
|
||||||
@@ -751,9 +817,8 @@ const UserDB = {
|
|||||||
'theme_preference': 'string',
|
'theme_preference': 'string',
|
||||||
|
|
||||||
// 数值类型字段
|
// 数值类型字段
|
||||||
'is_admin': 'number',
|
|
||||||
'is_active': 'number',
|
'is_active': 'number',
|
||||||
'is_banned': 'is_banned',
|
'is_banned': 'number',
|
||||||
'has_oss_config': 'number',
|
'has_oss_config': 'number',
|
||||||
'is_verified': 'number',
|
'is_verified': 'number',
|
||||||
'local_storage_quota': 'number',
|
'local_storage_quota': 'number',
|
||||||
@@ -808,7 +873,6 @@ const UserDB = {
|
|||||||
|
|
||||||
// API 密钥和权限字段
|
// API 密钥和权限字段
|
||||||
'upload_api_key': 'upload_api_key',
|
'upload_api_key': 'upload_api_key',
|
||||||
'is_admin': 'is_admin',
|
|
||||||
'is_active': 'is_active',
|
'is_active': 'is_active',
|
||||||
'is_banned': 'is_banned',
|
'is_banned': 'is_banned',
|
||||||
'has_oss_config': 'has_oss_config',
|
'has_oss_config': 'has_oss_config',
|
||||||
@@ -852,7 +916,7 @@ const UserDB = {
|
|||||||
|
|
||||||
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
|
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
|
||||||
for (const dbField of dbFields) {
|
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);
|
extraFields.push(dbField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -894,7 +958,6 @@ const UserDB = {
|
|||||||
|
|
||||||
// API 密钥和权限字段
|
// API 密钥和权限字段
|
||||||
'upload_api_key': 'upload_api_key',
|
'upload_api_key': 'upload_api_key',
|
||||||
'is_admin': 'is_admin',
|
|
||||||
'is_active': 'is_active',
|
'is_active': 'is_active',
|
||||||
'is_banned': 'is_banned',
|
'is_banned': 'is_banned',
|
||||||
'has_oss_config': 'has_oss_config',
|
'has_oss_config': 'has_oss_config',
|
||||||
@@ -968,7 +1031,7 @@ const UserDB = {
|
|||||||
'download_traffic_quota_expires_at': 'string',
|
'download_traffic_quota_expires_at': 'string',
|
||||||
'download_traffic_reset_cycle': 'string',
|
'download_traffic_reset_cycle': 'string',
|
||||||
'download_traffic_last_reset_at': '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',
|
'has_oss_config': 'number', 'is_verified': 'number',
|
||||||
'local_storage_quota': 'number', 'local_storage_used': 'number',
|
'local_storage_quota': 'number', 'local_storage_used': 'number',
|
||||||
'oss_storage_quota': 'number', 'download_traffic_quota': 'number',
|
'oss_storage_quota': 'number', 'download_traffic_quota': 'number',
|
||||||
@@ -1014,6 +1077,43 @@ const UserDB = {
|
|||||||
return result;
|
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 = {}) {
|
getAll(filters = {}) {
|
||||||
let query = 'SELECT * FROM users WHERE 1=1';
|
let query = 'SELECT * FROM users WHERE 1=1';
|
||||||
@@ -1278,7 +1378,7 @@ const ShareDB = {
|
|||||||
if (attempts > 10) {
|
if (attempts > 10) {
|
||||||
shareCode = this.generateShareCode(10); // 增加长度
|
shareCode = this.generateShareCode(10); // 增加长度
|
||||||
}
|
}
|
||||||
} while (this.findByCode(shareCode) && attempts < 20);
|
} while (this.findAnyByCode(shareCode) && attempts < 20);
|
||||||
|
|
||||||
// 计算过期时间
|
// 计算过期时间
|
||||||
let expiresAt = null;
|
let expiresAt = null;
|
||||||
@@ -1376,6 +1476,10 @@ const ShareDB = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
findAnyByCode(shareCode) {
|
||||||
|
return db.prepare('SELECT * FROM shares WHERE share_code = ?').get(shareCode);
|
||||||
|
},
|
||||||
|
|
||||||
// 根据ID查找
|
// 根据ID查找
|
||||||
findById(id) {
|
findById(id) {
|
||||||
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
|
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
|
||||||
@@ -1396,6 +1500,7 @@ const ShareDB = {
|
|||||||
AND share_type = ?
|
AND share_type = ?
|
||||||
AND share_path = ?
|
AND share_path = ?
|
||||||
AND COALESCE(storage_type, 'oss') = ?
|
AND COALESCE(storage_type, 'oss') = ?
|
||||||
|
AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime'))
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(
|
`).get(
|
||||||
@@ -1557,6 +1662,7 @@ const DirectLinkDB = {
|
|||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND file_path = ?
|
AND file_path = ?
|
||||||
AND COALESCE(storage_type, 'oss') = ?
|
AND COALESCE(storage_type, 'oss') = ?
|
||||||
|
AND (expires_at IS NULL OR expires_at > datetime('now', 'localtime'))
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(
|
`).get(
|
||||||
@@ -1691,7 +1797,17 @@ const SettingsDB = {
|
|||||||
* 删除统一的 OSS 配置
|
* 删除统一的 OSS 配置
|
||||||
*/
|
*/
|
||||||
clearUnifiedOssConfig() {
|
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 配置已清除');
|
console.log('[系统设置] 统一 OSS 配置已清除');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1721,12 +1837,13 @@ const VerificationDB = {
|
|||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT * FROM users
|
SELECT * FROM users
|
||||||
WHERE verification_token = ?
|
WHERE verification_token = ?
|
||||||
AND (
|
AND verification_expires_at IS NOT NULL
|
||||||
verification_expires_at IS NULL
|
AND verification_expires_at != ''
|
||||||
OR verification_expires_at = ''
|
AND CASE
|
||||||
OR verification_expires_at > strftime('%s','now')*1000 -- 数值时间戳(ms)
|
WHEN typeof(verification_expires_at) IN ('integer', 'real')
|
||||||
OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
|
THEN verification_expires_at > strftime('%s','now') * 1000
|
||||||
)
|
ELSE datetime(verification_expires_at) > datetime('now','localtime')
|
||||||
|
END
|
||||||
AND is_verified = 0
|
AND is_verified = 0
|
||||||
`).get(hashedToken);
|
`).get(hashedToken);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -1757,10 +1874,14 @@ const PasswordResetTokenDB = {
|
|||||||
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT * FROM password_reset_tokens
|
SELECT * FROM password_reset_tokens
|
||||||
WHERE token = ? AND used = 0 AND (
|
WHERE token = ? AND used = 0
|
||||||
expires_at > strftime('%s','now')*1000 -- 数值时间戳
|
AND expires_at IS NOT NULL
|
||||||
OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
|
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);
|
`).get(hashedToken);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
// 立即标记为已使用(防止重复使用)
|
// 立即标记为已使用(防止重复使用)
|
||||||
@@ -1960,7 +2081,7 @@ function migrateDownloadTrafficFields() {
|
|||||||
|
|
||||||
if (!hasDownloadTrafficQuota) {
|
if (!hasDownloadTrafficQuota) {
|
||||||
console.log('[数据库迁移] 添加 download_traffic_quota 字段...');
|
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 字段已添加');
|
console.log('[数据库迁移] ✓ download_traffic_quota 字段已添加');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,10 +2109,10 @@ function migrateDownloadTrafficFields() {
|
|||||||
console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加');
|
console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一策略:download_traffic_quota 为空时回填为 0(0 表示禁止下载,-1 表示不限流量)
|
// 未设置下载配额的旧记录默认回填为不限流量,避免升级后默认无法下载
|
||||||
const quotaBackfillResult = db.prepare(`
|
const quotaBackfillResult = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET download_traffic_quota = 0
|
SET download_traffic_quota = -1
|
||||||
WHERE download_traffic_quota IS NULL
|
WHERE download_traffic_quota IS NULL
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
@@ -2009,6 +2130,53 @@ function migrateDownloadTrafficFields() {
|
|||||||
console.log(`[数据库迁移] ✓ 不限流量配额已标准化为 -1: ${quotaUnlimitedNormalizeResult.changes} 条记录`);
|
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(`
|
const usedBackfillResult = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET download_traffic_used = 0
|
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 = {
|
const DeviceSessionDB = {
|
||||||
_normalizeSessionId(sessionId) {
|
_normalizeSessionId(sessionId) {
|
||||||
return typeof sessionId === 'string' ? sessionId.trim() : '';
|
return typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||||
@@ -3190,13 +3472,13 @@ const TransactionDB = {
|
|||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
initDatabase();
|
initDatabase();
|
||||||
createDefaultAdmin();
|
|
||||||
initDefaultSettings();
|
initDefaultSettings();
|
||||||
migrateToV2(); // 执行数据库迁移
|
migrateToV2(); // 执行数据库迁移
|
||||||
migrateThemePreference(); // 主题偏好迁移
|
migrateThemePreference(); // 主题偏好迁移
|
||||||
migrateToOss(); // SFTP → OSS 迁移
|
migrateToOss(); // SFTP → OSS 迁移
|
||||||
migrateOssQuotaField(); // OSS 配额字段迁移
|
migrateOssQuotaField(); // OSS 配额字段迁移
|
||||||
migrateDownloadTrafficFields(); // 下载流量字段迁移
|
migrateDownloadTrafficFields(); // 下载流量字段迁移
|
||||||
|
createDefaultAdmin();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
db,
|
db,
|
||||||
@@ -3209,6 +3491,7 @@ module.exports = {
|
|||||||
DownloadTrafficReportDB,
|
DownloadTrafficReportDB,
|
||||||
DownloadTrafficReservationDB,
|
DownloadTrafficReservationDB,
|
||||||
UploadSessionDB,
|
UploadSessionDB,
|
||||||
|
OssUploadReservationDB,
|
||||||
DeviceSessionDB,
|
DeviceSessionDB,
|
||||||
FileHashIndexDB,
|
FileHashIndexDB,
|
||||||
DownloadTrafficIngestDB,
|
DownloadTrafficIngestDB,
|
||||||
|
|||||||
@@ -19,8 +19,20 @@ shares.forEach(share => {
|
|||||||
|
|
||||||
// 如果是ISO格式(包含T和Z),需要转换
|
// 如果是ISO格式(包含T和Z),需要转换
|
||||||
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
|
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
|
||||||
// 转换为 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
|
const parsed = new Date(oldFormat);
|
||||||
const newFormat = oldFormat.replace('T', ' ').replace(/\.\d+Z$/, '');
|
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);
|
updateStmt.run(newFormat, share.id);
|
||||||
fixed++;
|
fixed++;
|
||||||
|
|||||||
304
backend/package-lock.json
generated
304
backend/package-lock.json
generated
@@ -18,7 +18,6 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
|
||||||
"express-validator": "^7.3.0",
|
"express-validator": "^7.3.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
@@ -940,13 +939,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/xml-builder": {
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
"version": "3.972.4",
|
"version": "3.972.29",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz",
|
||||||
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
|
"integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.12.0",
|
"@smithy/types": "^4.14.3",
|
||||||
"fast-xml-parser": "5.3.4",
|
"fast-xml-parser": "5.7.3",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -979,6 +978,18 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -1477,9 +1488,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/types": {
|
"node_modules/@smithy/types": {
|
||||||
"version": "4.12.0",
|
"version": "4.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz",
|
||||||
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
|
"integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -1784,6 +1795,18 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -1960,9 +1983,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -1973,7 +1996,7 @@
|
|||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"raw-body": "~2.5.3",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
@@ -1990,9 +2013,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -2450,9 +2473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -2513,14 +2536,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -2539,7 +2562,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -2558,31 +2581,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||||
@@ -2602,10 +2600,10 @@
|
|||||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "5.3.4",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2614,7 +2612,26 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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": {
|
"bin": {
|
||||||
"fxparser": "src/cli/cli.js"
|
"fxparser": "src/cli/cli.js"
|
||||||
@@ -2838,9 +2855,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -3115,9 +3132,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
@@ -3250,12 +3267,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -3282,18 +3299,6 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -3307,21 +3312,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.6",
|
"type-is": "^1.6.18"
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.18",
|
|
||||||
"xtend": "^4.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.16.0"
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
@@ -3352,9 +3358,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -3390,9 +3396,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3419,9 +3425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/minimatch": {
|
"node_modules/nodemon/node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3480,15 +3486,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -3525,6 +3522,21 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -3551,15 +3563,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3641,9 +3653,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -3655,15 +3667,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -3753,9 +3756,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
@@ -3888,14 +3891,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -3907,13 +3910,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4172,16 +4175,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.1.2",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz",
|
||||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
"integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anynum": "^1.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
@@ -4345,18 +4351,6 @@
|
|||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -4518,13 +4512,19 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xml-naming": {
|
||||||
"version": "4.0.2",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zip-stream": {
|
"node_modules/zip-stream": {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.18.2",
|
|
||||||
"express-validator": "^7.3.0",
|
"express-validator": "^7.3.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ require('dotenv').config();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const session = require('express-session');
|
|
||||||
const svgCaptcha = require('svg-captcha');
|
const svgCaptcha = require('svg-captcha');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
@@ -74,6 +73,7 @@ const {
|
|||||||
DownloadTrafficReportDB,
|
DownloadTrafficReportDB,
|
||||||
DownloadTrafficReservationDB,
|
DownloadTrafficReservationDB,
|
||||||
UploadSessionDB,
|
UploadSessionDB,
|
||||||
|
OssUploadReservationDB,
|
||||||
DeviceSessionDB,
|
DeviceSessionDB,
|
||||||
FileHashIndexDB,
|
FileHashIndexDB,
|
||||||
DownloadTrafficIngestDB,
|
DownloadTrafficIngestDB,
|
||||||
@@ -112,7 +112,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
|
|||||||
10,
|
10,
|
||||||
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
|
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_URL = process.env.DESKTOP_INSTALLER_URL || '';
|
||||||
const DEFAULT_DESKTOP_INSTALLER_SHA256 = String(process.env.DESKTOP_INSTALLER_SHA256 || '').trim().toLowerCase();
|
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));
|
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_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_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 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_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_LIMIT = Number(process.env.GLOBAL_SEARCH_MAX_LIMIT || 200);
|
||||||
const GLOBAL_SEARCH_MAX_SCANNED_NODES = Number(process.env.GLOBAL_SEARCH_MAX_SCANNED_NODES || 4000);
|
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 =
|
const SHOULD_USE_SECURE_COOKIES =
|
||||||
COOKIE_SECURE_MODE === 'true' ||
|
COOKIE_SECURE_MODE === 'true' ||
|
||||||
(process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false');
|
(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') {
|
function normalizeVersion(rawVersion, fallback = '0.0.0') {
|
||||||
const value = String(rawVersion || '').trim();
|
const value = String(rawVersion || '').trim();
|
||||||
@@ -421,9 +442,8 @@ function getSecureBaseUrl(req) {
|
|||||||
return `${getProtocol(req)}://${req.get('host')}`;
|
return `${getProtocol(req)}://${req.get('host')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生产环境没有配置时,记录警告并使用请求的 Host(不推荐)
|
// 生产环境绝不使用请求 Host 生成外部链接,避免 Host Header 注入。
|
||||||
console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!');
|
throw new Error('生产环境必须配置 PUBLIC_BASE_URL 或 ALLOWED_HOSTS');
|
||||||
return `${getProtocol(req)}://${req.get('host')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 安全配置:信任代理 =====
|
// ===== 安全配置:信任代理 =====
|
||||||
@@ -522,7 +542,7 @@ function applySecurityHeaders(req, res) {
|
|||||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
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
|
// 隐藏X-Powered-By
|
||||||
res.removeHeader('X-Powered-By');
|
res.removeHeader('X-Powered-By');
|
||||||
}
|
}
|
||||||
@@ -631,44 +651,6 @@ app.use((req, res, next) => {
|
|||||||
return 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) => {
|
app.use((req, res, next) => {
|
||||||
applySecurityHeaders(req, res);
|
applySecurityHeaders(req, res);
|
||||||
@@ -861,16 +843,32 @@ function isFileExtensionSafe(filename) {
|
|||||||
// 应用XSS过滤到所有POST/PUT请求的body
|
// 应用XSS过滤到所有POST/PUT请求的body
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if ((req.method === 'POST' || req.method === 'PUT') && req.body) {
|
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 (typeof obj === 'string') {
|
||||||
|
if (sensitiveBodyFields.has(fieldName)) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
return sanitizeInput(obj);
|
return sanitizeInput(obj);
|
||||||
} else if (Array.isArray(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') {
|
} else if (obj && typeof obj === 'object') {
|
||||||
const sanitized = {};
|
const sanitized = {};
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
sanitized[key] = sanitizeObject(value);
|
sanitized[key] = sanitizeObject(value, key);
|
||||||
}
|
}
|
||||||
return sanitized;
|
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) {
|
function sendPlainTextError(res, statusCode, message) {
|
||||||
return res.status(statusCode).type('text/plain; charset=utf-8').send(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}`;
|
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()) {
|
function getDateKeyFromDate(date = new Date()) {
|
||||||
const target = date instanceof Date ? date : new Date(date);
|
const target = date instanceof Date ? date : new Date(date);
|
||||||
if (Number.isNaN(target.getTime())) {
|
if (Number.isNaN(target.getTime())) {
|
||||||
@@ -2227,10 +2292,14 @@ function buildStorageUserContext(user, overrides = {}) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const directSecret = user.oss_access_key_secret;
|
||||||
const storageUser = {
|
const storageUser = {
|
||||||
...user,
|
...user,
|
||||||
...overrides
|
...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) {
|
if (typeof storageUser.oss_access_key_secret === 'string' && storageUser.oss_access_key_secret) {
|
||||||
try {
|
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;
|
const EPHEMERAL_TOKEN_SECRET = JWT_SECRET;
|
||||||
|
|
||||||
function signEphemeralToken(payload, expiresInSeconds = 900) {
|
function signEphemeralToken(payload, expiresInSeconds = 900) {
|
||||||
@@ -2734,6 +2867,7 @@ const fileListLimiter = new RateLimiter({
|
|||||||
// 验证码最小请求间隔控制
|
// 验证码最小请求间隔控制
|
||||||
const CAPTCHA_MIN_INTERVAL = 1000; // 1秒
|
const CAPTCHA_MIN_INTERVAL = 1000; // 1秒
|
||||||
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
|
const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理
|
||||||
|
const captchaTicketCache = new TTLCache(CAPTCHA_TTL_MS);
|
||||||
|
|
||||||
// 验证码防刷中间件
|
// 验证码防刷中间件
|
||||||
function captchaRateLimitMiddleware(req, res, next) {
|
function captchaRateLimitMiddleware(req, res, next) {
|
||||||
@@ -3358,15 +3492,7 @@ async function searchFilesRecursively(storage, startPath, keyword, options = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUploadPath(rawPath) {
|
function normalizeUploadPath(rawPath) {
|
||||||
const safeRaw = typeof rawPath === 'string' ? rawPath : '/';
|
return normalizeVirtualPath(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVirtualFilePath(basePath, filename) {
|
function buildVirtualFilePath(basePath, filename) {
|
||||||
@@ -3590,7 +3716,7 @@ function cleanupOldTempFiles() {
|
|||||||
const filePath = path.join(uploadsDir, file);
|
const filePath = path.join(uploadsDir, file);
|
||||||
try {
|
try {
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
if (now - stats.mtimeMs > maxAge) {
|
if (stats.isFile() && now - stats.mtimeMs > maxAge) {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
cleaned++;
|
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} req - 请求对象
|
||||||
|
* @param {Object} res - 响应对象
|
||||||
* @param {string} captcha - 用户输入的验证码
|
* @param {string} captcha - 用户输入的验证码
|
||||||
|
* @param {string} logPrefix - 日志前缀
|
||||||
* @returns {{valid: boolean, message?: string}} 验证结果
|
* @returns {{valid: boolean, message?: string}} 验证结果
|
||||||
*/
|
*/
|
||||||
function verifyCaptcha(req, captcha) {
|
function verifyCaptcha(req, res, captcha, logPrefix = '验证码验证') {
|
||||||
if (!captcha) {
|
if (!captcha) {
|
||||||
return { valid: false, message: '请输入验证码' };
|
return { valid: false, message: '请输入验证码' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionCaptcha = req.session.captcha;
|
const ticketId = parseCaptchaTicket(req.cookies?.[CAPTCHA_COOKIE_NAME]);
|
||||||
const captchaTime = req.session.captchaTime;
|
if (!ticketId) {
|
||||||
|
clearCaptchaTicketCookie(res);
|
||||||
// 调试日志
|
console.log(`[${logPrefix}] 失败: 验证码票据无效或不存在`);
|
||||||
console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha);
|
|
||||||
|
|
||||||
if (!sessionCaptcha || !captchaTime) {
|
|
||||||
console.log('[验证码验证] 失败: session中无验证码');
|
|
||||||
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证码有效期5分钟
|
const captchaRecord = captchaTicketCache.get(ticketId);
|
||||||
if (Date.now() - captchaTime > 5 * 60 * 1000) {
|
console.log(`[${logPrefix}] 正在验证验证码票据:`, ticketId.slice(0, 8));
|
||||||
console.log('[验证码验证] 失败: 验证码已超时');
|
|
||||||
|
if (!captchaRecord || !captchaRecord.captcha || !captchaRecord.createdAt) {
|
||||||
|
captchaTicketCache.delete(ticketId);
|
||||||
|
clearCaptchaTicketCookie(res);
|
||||||
|
console.log(`[${logPrefix}] 失败: 验证码票据不存在`);
|
||||||
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
return { valid: false, message: '验证码已过期,请刷新验证码' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (captcha.toLowerCase() !== sessionCaptcha) {
|
if (Date.now() - captchaRecord.createdAt > CAPTCHA_TTL_MS) {
|
||||||
console.log('[验证码验证] 失败: 验证码不匹配');
|
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: '验证码错误' };
|
return { valid: false, message: '验证码错误' };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[验证码验证] 成功');
|
console.log(`[${logPrefix}] 成功`);
|
||||||
// 验证通过后清除session中的验证码
|
captchaTicketCache.delete(ticketId);
|
||||||
delete req.session.captcha;
|
clearCaptchaTicketCookie(res);
|
||||||
delete req.session.captchaTime;
|
|
||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
@@ -3797,23 +4001,10 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
|
|||||||
charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字
|
charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将验证码文本存储在session中
|
const ticketId = issueCaptchaTicket(res, captcha.text);
|
||||||
req.session.captcha = captcha.text.toLowerCase();
|
console.log('[验证码] 生成成功, Ticket:', ticketId.slice(0, 8));
|
||||||
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.type('svg');
|
||||||
res.send(captcha.data);
|
res.send(captcha.data);
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('生成验证码失败:', error);
|
console.error('生成验证码失败:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -3907,7 +4098,7 @@ app.post('/api/register',
|
|||||||
try {
|
try {
|
||||||
// 验证验证码
|
// 验证验证码
|
||||||
const { captcha } = req.body;
|
const { captcha } = req.body;
|
||||||
const captchaResult = verifyCaptcha(req, captcha);
|
const captchaResult = verifyCaptcha(req, res, captcha);
|
||||||
if (!captchaResult.valid) {
|
if (!captchaResult.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -4017,7 +4208,7 @@ app.post('/api/resend-verification', [
|
|||||||
try {
|
try {
|
||||||
// 验证验证码
|
// 验证验证码
|
||||||
const { captcha } = req.body;
|
const { captcha } = req.body;
|
||||||
const captchaResult = verifyCaptcha(req, captcha);
|
const captchaResult = verifyCaptcha(req, res, captcha);
|
||||||
if (!captchaResult.valid) {
|
if (!captchaResult.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -4108,7 +4299,7 @@ app.post('/api/password/forgot', [
|
|||||||
const { email, captcha } = req.body;
|
const { email, captcha } = req.body;
|
||||||
try {
|
try {
|
||||||
// 验证验证码
|
// 验证验证码
|
||||||
const captchaResult = verifyCaptcha(req, captcha);
|
const captchaResult = verifyCaptcha(req, res, captcha);
|
||||||
if (!captchaResult.valid) {
|
if (!captchaResult.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -4246,45 +4437,14 @@ app.post('/api/login',
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证验证码
|
const captchaResult = verifyCaptcha(req, res, captcha, '登录验证');
|
||||||
const sessionCaptcha = req.session.captcha;
|
if (!captchaResult.valid) {
|
||||||
const captchaTime = req.session.captchaTime;
|
|
||||||
|
|
||||||
// 安全:不记录验证码明文
|
|
||||||
console.log('[登录验证] 正在验证验证码...');
|
|
||||||
|
|
||||||
if (!sessionCaptcha || !captchaTime) {
|
|
||||||
console.log('[登录验证] 验证码不存在于Session中');
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '验证码已过期,请刷新验证码',
|
message: captchaResult.message,
|
||||||
needCaptcha: true
|
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);
|
let user = UserDB.findByUsername(username);
|
||||||
@@ -5433,16 +5593,15 @@ app.get('/api/files', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
const rawPath = req.query.path || '/';
|
const rawPath = req.query.path || '/';
|
||||||
|
|
||||||
// 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径
|
// 路径安全验证:在 API 层提前拒绝包含 ..、编码 .. 或空字节的路径
|
||||||
if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) {
|
const dirPath = normalizeVirtualPath(rawPath);
|
||||||
|
if (!dirPath) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '路径包含非法字符'
|
message: '路径包含非法字符'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规范化路径
|
|
||||||
const dirPath = path.posix.normalize(rawPath);
|
|
||||||
let storage;
|
let storage;
|
||||||
|
|
||||||
try {
|
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({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '上传路径非法'
|
message: '上传路径非法'
|
||||||
@@ -6630,7 +6790,7 @@ app.get('/api/files/upload-signature', authMiddleware, async (req, res) => {
|
|||||||
// 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致)
|
// 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致)
|
||||||
// 格式:user_${id}/${path}/${filename}
|
// 格式:user_${id}/${path}/${filename}
|
||||||
const sanitizedFilename = sanitizeFilename(filename);
|
const sanitizedFilename = sanitizeFilename(filename);
|
||||||
let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
|
let normalizedPath = normalizedUploadPath.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||||
// 移除开头的斜杠
|
// 移除开头的斜杠
|
||||||
normalizedPath = normalizedPath.replace(/^\/+/, '');
|
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({
|
const completionToken = signEphemeralToken({
|
||||||
type: 'upload_complete',
|
type: 'upload_complete',
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
|
reservationToken,
|
||||||
objectKey,
|
objectKey,
|
||||||
|
tempObjectKey,
|
||||||
previousSize,
|
previousSize,
|
||||||
expectedSize: fileSize,
|
expectedSize: fileSize,
|
||||||
fileHash: fileHash || null
|
fileHash: fileHash || null
|
||||||
}, 30 * 60);
|
}, Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000));
|
||||||
|
|
||||||
// 创建 PutObject 命令
|
// 签名只允许写入临时对象;完成确认后由服务端复制到最终路径
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: objectKey,
|
Key: tempObjectKey,
|
||||||
ContentType: contentType
|
ContentType: contentType
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成签名 URL(15分钟有效)
|
const signedUrl = await getSignedUrl(client, command, { expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS });
|
||||||
const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 });
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
uploadUrl: signedUrl,
|
uploadUrl: signedUrl,
|
||||||
objectKey: objectKey,
|
objectKey: objectKey,
|
||||||
|
uploadObjectKey: tempObjectKey,
|
||||||
previousSize,
|
previousSize,
|
||||||
completionToken,
|
completionToken,
|
||||||
expiresIn: 900
|
expiresIn: OSS_DIRECT_UPLOAD_TTL_SECONDS,
|
||||||
|
completionExpiresIn: Math.ceil(OSS_UPLOAD_RESERVATION_TTL_MS / 1000)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OSS签名] 生成上传签名失败:', error);
|
console.error('[OSS签名] 生成上传签名失败:', error);
|
||||||
@@ -6770,7 +6951,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
|
|||||||
const completionPayload = completionTokenResult.payload || {};
|
const completionPayload = completionTokenResult.payload || {};
|
||||||
if (
|
if (
|
||||||
Number(completionPayload.userId) !== Number(req.user.id) ||
|
Number(completionPayload.userId) !== Number(req.user.id) ||
|
||||||
completionPayload.objectKey !== normalizedObjectKey
|
completionPayload.objectKey !== normalizedObjectKey ||
|
||||||
|
!completionPayload.reservationToken
|
||||||
) {
|
) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
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
|
const reservation = OssUploadReservationDB.findPendingByToken(completionPayload.reservationToken);
|
||||||
? Number(completionPayload.previousSize)
|
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;
|
: 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)}`;
|
const virtualFilePath = `/${normalizedObjectKey.slice(expectedPrefix.length)}`;
|
||||||
|
|
||||||
let ossClient;
|
let ossClient;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
const { HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
ossClient = createOssClientForUser(req.user);
|
ossClient = createOssClientForUser(req.user);
|
||||||
await ossClient.connect();
|
await ossClient.connect();
|
||||||
|
|
||||||
const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({
|
const headResponse = await ossClient.s3Client.send(new HeadObjectCommand({
|
||||||
Bucket: ossClient.getBucket(),
|
Bucket: ossClient.getBucket(),
|
||||||
Key: normalizedObjectKey
|
Key: reservation.temp_object_key
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const verifiedSize = Number(headResponse.ContentLength || 0);
|
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})不一致,已使用实际大小`);
|
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;
|
const deltaSize = verifiedSize - previousObjectSize;
|
||||||
|
|
||||||
// 二次校验 OSS 配额(防止签名后并发上传导致超限)
|
// 二次校验 OSS 配额(防止签名后并发上传导致超限)
|
||||||
@@ -6816,17 +7025,13 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
|
|||||||
const projectedUsage = Math.max(0, currentUsage + deltaSize);
|
const projectedUsage = Math.max(0, currentUsage + deltaSize);
|
||||||
|
|
||||||
if (projectedUsage > ossQuota) {
|
if (projectedUsage > ossQuota) {
|
||||||
// 回滚:删除刚上传的对象,避免超配额文件残留
|
// 回滚:删除临时对象,避免超配额文件残留
|
||||||
await ossClient.s3Client.send(new DeleteObjectCommand({
|
await ossClient.s3Client.send(new DeleteObjectCommand({
|
||||||
Bucket: ossClient.getBucket(),
|
Bucket: ossClient.getBucket(),
|
||||||
Key: normalizedObjectKey
|
Key: reservation.temp_object_key
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 若为覆盖上传,旧对象已被替换,删除后需扣除旧对象体积
|
OssUploadReservationDB.cancel(reservation.reservation_token);
|
||||||
if (previousObjectSize > 0) {
|
|
||||||
await StorageUsageCache.updateUsage(req.user.id, -previousObjectSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOssUsageCache(req.user.id);
|
clearOssUsageCache(req.user.id);
|
||||||
|
|
||||||
return res.status(400).json({
|
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) {
|
if (deltaSize !== 0) {
|
||||||
await StorageUsageCache.updateUsage(req.user.id, deltaSize);
|
await StorageUsageCache.updateUsage(req.user.id, deltaSize);
|
||||||
@@ -6852,6 +7069,8 @@ app.post('/api/files/upload-complete', authMiddleware, async (req, res) => {
|
|||||||
objectKey: normalizedObjectKey
|
objectKey: normalizedObjectKey
|
||||||
});
|
});
|
||||||
|
|
||||||
|
OssUploadReservationDB.complete(reservation.reservation_token);
|
||||||
|
|
||||||
console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`);
|
console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${verifiedSize} 字节, 增量: ${deltaSize} 字节`);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -6885,9 +7104,9 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径安全验证:防止目录遍历攻击
|
// 路径安全验证:防止目录遍历攻击。必须在 normalize 前拒绝 .. 片段。
|
||||||
const normalizedPath = path.posix.normalize(filePath);
|
const normalizedPath = normalizeVirtualPath(filePath);
|
||||||
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
|
if (!normalizedPath) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '文件路径非法'
|
message: '文件路径非法'
|
||||||
@@ -7123,8 +7342,9 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 路径安全校验
|
// 路径安全校验
|
||||||
const normalizedPath = path.posix.normalize(remotePath || '/');
|
const normalizedPath = normalizeUploadPath(remotePath || '/');
|
||||||
if (normalizedPath.includes('..')) {
|
if (!normalizedPath) {
|
||||||
|
safeDeleteFile(req.file.path);
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '上传路径非法'
|
message: '上传路径非法'
|
||||||
@@ -7199,8 +7419,8 @@ app.get('/api/files/download-check', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = path.posix.normalize(filePath);
|
const normalizedPath = normalizeVirtualPath(filePath);
|
||||||
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
|
if (!normalizedPath) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '文件路径非法'
|
message: '文件路径非法'
|
||||||
@@ -7317,8 +7537,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 路径安全验证:防止目录遍历攻击
|
// 路径安全验证:防止目录遍历攻击
|
||||||
const normalizedPath = path.posix.normalize(filePath);
|
const normalizedPath = normalizeVirtualPath(filePath);
|
||||||
if (normalizedPath.includes('..') || filePath.includes('\x00')) {
|
if (!normalizedPath) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '文件路径非法'
|
message: '文件路径非法'
|
||||||
@@ -8626,52 +8846,10 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
|
|||||||
|
|
||||||
// 记录下载次数(添加限流保护防止滥用)
|
// 记录下载次数(添加限流保护防止滥用)
|
||||||
app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => {
|
app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => {
|
||||||
const { code } = req.params;
|
res.status(410).json({
|
||||||
|
|
||||||
// 参数验证:code 不能为空
|
|
||||||
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
success: false,
|
||||||
message: '无效的分享码'
|
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护)
|
// 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护)
|
||||||
@@ -8903,6 +9081,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ShareDB.incrementDownloadCount(code);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
downloadUrl: signedUrl,
|
downloadUrl: signedUrl,
|
||||||
@@ -9225,10 +9405,11 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新系统设置
|
// 更新系统设置
|
||||||
// 注意:已移除 requirePasswordConfirmation 中间件,依赖管理员登录认证
|
// 敏感系统设置需要管理员当前密码二次确认
|
||||||
app.post('/api/admin/settings',
|
app.post('/api/admin/settings',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
adminMiddleware,
|
adminMiddleware,
|
||||||
|
requirePasswordConfirmation,
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -9430,6 +9611,7 @@ app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req,
|
|||||||
app.post('/api/admin/unified-oss-config',
|
app.post('/api/admin/unified-oss-config',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
adminMiddleware,
|
adminMiddleware,
|
||||||
|
requirePasswordConfirmation,
|
||||||
[
|
[
|
||||||
body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'),
|
body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'),
|
||||||
body('region').notEmpty().withMessage('地域不能为空'),
|
body('region').notEmpty().withMessage('地域不能为空'),
|
||||||
@@ -9571,6 +9753,7 @@ app.post('/api/admin/unified-oss-config/test',
|
|||||||
app.delete('/api/admin/unified-oss-config',
|
app.delete('/api/admin/unified-oss-config',
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
adminMiddleware,
|
adminMiddleware,
|
||||||
|
requirePasswordConfirmation,
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
try {
|
try {
|
||||||
SettingsDB.clearUnifiedOssConfig();
|
SettingsDB.clearUnifiedOssConfig();
|
||||||
@@ -9782,16 +9965,16 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
|
|||||||
suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击'
|
suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 12. Session密钥检查
|
// 12. 验证码票据签名密钥检查
|
||||||
const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32;
|
const captchaSecretSecure = isCaptchaSecretSecure();
|
||||||
checks.push({
|
checks.push({
|
||||||
name: 'Session密钥',
|
name: '验证码票据签名密钥',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
status: sessionSecure ? 'pass' : 'fail',
|
status: captchaSecretSecure ? 'pass' : 'fail',
|
||||||
message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!',
|
message: captchaSecretSecure ? '验证码票据签名密钥已正确配置' : '验证码票据签名密钥使用默认值或长度不足,存在安全风险!',
|
||||||
suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET,至少32字符'
|
suggestion: captchaSecretSecure ? null : '请在.env中设置随机生成的JWT_SECRET或CAPTCHA_SECRET,至少32字符'
|
||||||
});
|
});
|
||||||
if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical';
|
if (!captchaSecretSecure && overallStatus !== 'critical') overallStatus = 'critical';
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
const summary = {
|
const summary = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
|
|||||||
OSS_MULTIPART_MIN_PART_SIZE,
|
OSS_MULTIPART_MIN_PART_SIZE,
|
||||||
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
|
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
|
||||||
);
|
);
|
||||||
|
const OSS_RENAME_RECOVERY_KEYS = new Set();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 OSS/网络错误转换为友好的错误信息
|
* 将 OSS/网络错误转换为友好的错误信息
|
||||||
@@ -230,8 +231,10 @@ class LocalStorageClient {
|
|||||||
|
|
||||||
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
|
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
|
||||||
const netIncrease = newFileSize - oldFileSize;
|
const netIncrease = newFileSize - oldFileSize;
|
||||||
|
let reservedDelta = 0;
|
||||||
if (netIncrease > 0) {
|
if (netIncrease > 0) {
|
||||||
this.checkQuota(netIncrease);
|
this.checkQuota(netIncrease);
|
||||||
|
reservedDelta = netIncrease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保目标目录存在
|
// 确保目标目录存在
|
||||||
@@ -242,30 +245,37 @@ class LocalStorageClient {
|
|||||||
|
|
||||||
// 使用临时文件+重命名模式,避免文件被占用问题
|
// 使用临时文件+重命名模式,避免文件被占用问题
|
||||||
const tempPath = `${destPath}.uploading_${Date.now()}`;
|
const tempPath = `${destPath}.uploading_${Date.now()}`;
|
||||||
|
const backupPath = `${destPath}.backup_${Date.now()}`;
|
||||||
|
let backupCreated = false;
|
||||||
|
let destReplaced = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果目标文件存在,先删除
|
|
||||||
if (fs.existsSync(destPath)) {
|
if (fs.existsSync(destPath)) {
|
||||||
fs.unlinkSync(destPath);
|
fs.renameSync(destPath, backupPath);
|
||||||
|
backupCreated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先尝试 rename(同文件系统下瞬时完成,大文件不再需要逐字节复制)
|
// 优先尝试 rename(同文件系统下瞬时完成,大文件不再需要逐字节复制)
|
||||||
let movedDirectly = false;
|
|
||||||
try {
|
try {
|
||||||
fs.renameSync(localPath, destPath);
|
fs.renameSync(localPath, destPath);
|
||||||
movedDirectly = true;
|
destReplaced = true;
|
||||||
} catch (renameErr) {
|
} catch (renameErr) {
|
||||||
if (renameErr.code === 'EXDEV') {
|
if (renameErr.code === 'EXDEV') {
|
||||||
// 跨文件系统,回退到 copy + rename
|
// 跨文件系统,回退到 copy + rename
|
||||||
fs.copyFileSync(localPath, tempPath);
|
fs.copyFileSync(localPath, tempPath);
|
||||||
fs.renameSync(tempPath, destPath);
|
fs.renameSync(tempPath, destPath);
|
||||||
|
destReplaced = true;
|
||||||
} else {
|
} else {
|
||||||
throw renameErr;
|
throw renameErr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新已使用空间(使用净增量)
|
if (backupCreated) {
|
||||||
if (netIncrease !== 0) {
|
try { fs.unlinkSync(backupPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正向净增已在写入前原子预留,这里只处理覆盖变小时的扣减。
|
||||||
|
if (netIncrease < 0) {
|
||||||
this.updateUsedSpace(netIncrease);
|
this.updateUsedSpace(netIncrease);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -273,6 +283,17 @@ class LocalStorageClient {
|
|||||||
if (fs.existsSync(tempPath)) {
|
if (fs.existsSync(tempPath)) {
|
||||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,12 +528,13 @@ class LocalStorageClient {
|
|||||||
// 5. 拼接完整路径
|
// 5. 拼接完整路径
|
||||||
const fullPath = path.join(this.basePath, normalized);
|
const fullPath = path.join(this.basePath, normalized);
|
||||||
|
|
||||||
// 6. 解析真实路径(处理符号链接)后再次验证
|
// 6. 解析绝对路径后再次验证
|
||||||
const resolvedBasePath = path.resolve(this.basePath);
|
const resolvedBasePath = path.resolve(this.basePath);
|
||||||
const resolvedFullPath = path.resolve(fullPath);
|
const resolvedFullPath = path.resolve(fullPath);
|
||||||
|
const relativeToBase = path.relative(resolvedBasePath, resolvedFullPath);
|
||||||
|
|
||||||
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||||
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
|
if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
|
||||||
console.warn('[安全] 检测到路径遍历攻击:', {
|
console.warn('[安全] 检测到路径遍历攻击:', {
|
||||||
input: relativePath,
|
input: relativePath,
|
||||||
resolved: resolvedFullPath,
|
resolved: resolvedFullPath,
|
||||||
@@ -528,225 +550,26 @@ class LocalStorageClient {
|
|||||||
* 检查配额
|
* 检查配额
|
||||||
*/
|
*/
|
||||||
checkQuota(additionalSize) {
|
checkQuota(additionalSize) {
|
||||||
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
|
const amount = Math.max(0, Math.trunc(Number(additionalSize) || 0));
|
||||||
if (newUsed > this.user.local_storage_quota) {
|
if (amount === 0) return;
|
||||||
const used = this.formatSize(this.user.local_storage_used);
|
|
||||||
|
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 quota = this.formatSize(this.user.local_storage_quota);
|
||||||
const need = this.formatSize(additionalSize);
|
const need = this.formatSize(amount);
|
||||||
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
|
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
|
||||||
}
|
}
|
||||||
|
this.user.local_storage_used = Number(updatedUser.local_storage_used || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新已使用空间
|
* 更新已使用空间
|
||||||
*/
|
*/
|
||||||
updateUsedSpace(delta) {
|
updateUsedSpace(delta) {
|
||||||
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
|
const updatedUser = UserDB.adjustLocalStorageUsed(this.user.id, delta);
|
||||||
UserDB.update(this.user.id, { local_storage_used: newUsed });
|
this.user.local_storage_used = Number(updatedUser?.local_storage_used || 0);
|
||||||
// 更新内存中的值
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -928,6 +751,16 @@ class OssStorageClient {
|
|||||||
|
|
||||||
this.s3Client = new S3Client(s3Config);
|
this.s3Client = new S3Client(s3Config);
|
||||||
console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`);
|
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;
|
return this;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OSS存储] 连接失败:`, error.message);
|
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 名称
|
* 获取当前使用的 bucket 名称
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -979,22 +919,27 @@ class OssStorageClient {
|
|||||||
throw new Error('路径包含非法字符');
|
throw new Error('路径包含非法字符');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 先进行 URL 解码(防止双重编码绕过)
|
// 2. 仅将 URL 解码结果用于安全检查,不用于生成对象 key。
|
||||||
let decoded = relativePath;
|
// 文件名中的字面量 "%2F" 应保留为普通字符,不能变成路径分隔符。
|
||||||
|
let decodedForSecurity = relativePath;
|
||||||
try {
|
try {
|
||||||
decoded = decodeURIComponent(relativePath);
|
decodedForSecurity = decodeURIComponent(relativePath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 解码失败使用原始值
|
// 解码失败使用原始值做后续检查
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查解码后的空字节
|
// 3. 检查解码后的空字节
|
||||||
if (decoded.includes('\x00')) {
|
if (decodedForSecurity.includes('\x00')) {
|
||||||
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
|
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
|
||||||
throw new Error('路径包含非法字符');
|
throw new Error('路径包含非法字符');
|
||||||
}
|
}
|
||||||
|
if (decodedForSecurity.includes('..')) {
|
||||||
|
console.warn('[OSS安全] 检测到编码的目录遍历尝试:', relativePath);
|
||||||
|
throw new Error('路径包含非法字符');
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 规范化路径:统一使用正斜杠(OSS 使用正斜杠作为分隔符)
|
// 4. 规范化路径:统一使用正斜杠(OSS 使用正斜杠作为分隔符)
|
||||||
let normalized = decoded
|
let normalized = relativePath
|
||||||
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
|
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
|
||||||
.replace(/\/+/g, '/'); // 合并多个连续斜杠
|
.replace(/\/+/g, '/'); // 合并多个连续斜杠
|
||||||
|
|
||||||
@@ -1282,7 +1227,7 @@ class OssStorageClient {
|
|||||||
try {
|
try {
|
||||||
statResult = await this.stat(filePath);
|
statResult = await this.stat(filePath);
|
||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
if (statError.message && statResult?.message.includes('不存在')) {
|
if (statError.message && statError.message.includes('不存在')) {
|
||||||
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
|
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
|
||||||
return { size: 0 }; // 文件不存在,返回大小为 0
|
return { size: 0 }; // 文件不存在,返回大小为 0
|
||||||
}
|
}
|
||||||
@@ -1292,6 +1237,7 @@ class OssStorageClient {
|
|||||||
let totalDeletedSize = 0;
|
let totalDeletedSize = 0;
|
||||||
|
|
||||||
if (statResult.isDirectory) {
|
if (statResult.isDirectory) {
|
||||||
|
const directoryPrefix = key.endsWith('/') ? key : `${key}/`;
|
||||||
// 删除目录:列出所有对象并批量删除
|
// 删除目录:列出所有对象并批量删除
|
||||||
// 使用分页循环处理超过 1000 个对象的情况
|
// 使用分页循环处理超过 1000 个对象的情况
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
@@ -1301,7 +1247,7 @@ class OssStorageClient {
|
|||||||
do {
|
do {
|
||||||
const listCommand = new ListObjectsV2Command({
|
const listCommand = new ListObjectsV2Command({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Prefix: key,
|
Prefix: directoryPrefix,
|
||||||
MaxKeys: MAX_DELETE_BATCH,
|
MaxKeys: MAX_DELETE_BATCH,
|
||||||
ContinuationToken: continuationToken
|
ContinuationToken: continuationToken
|
||||||
});
|
});
|
||||||
@@ -1336,7 +1282,7 @@ class OssStorageClient {
|
|||||||
} while (continuationToken);
|
} while (continuationToken);
|
||||||
|
|
||||||
if (totalDeletedCount > 0) {
|
if (totalDeletedCount > 0) {
|
||||||
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
|
console.log(`[OSS存储] 删除目录: ${directoryPrefix} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { size: totalDeletedSize };
|
return { size: totalDeletedSize };
|
||||||
@@ -1742,7 +1688,7 @@ class OssStorageClient {
|
|||||||
const key = this.getObjectKey(filePath);
|
const key = this.getObjectKey(filePath);
|
||||||
const bucket = this.getBucket();
|
const bucket = this.getBucket();
|
||||||
const provider = this.getProvider();
|
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;
|
let baseUrl;
|
||||||
if (provider === 'aliyun') {
|
if (provider === 'aliyun') {
|
||||||
|
|||||||
105
backend/test_download_quota_defaults.js
Normal file
105
backend/test_download_quota_defaults.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-download-quota-'));
|
||||||
|
const tempDbPath = path.join(tempDir, 'database.db');
|
||||||
|
|
||||||
|
process.env.DATABASE_PATH = tempDbPath;
|
||||||
|
process.env.ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
|
||||||
|
process.env.JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||||
|
process.env.REFRESH_SECRET = crypto.randomBytes(32).toString('hex');
|
||||||
|
process.env.WAL_CHECKPOINT_ENABLED = 'false';
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { db: loadedDb, UserDB } = require('./database');
|
||||||
|
const { authMiddleware, generateToken } = require('./auth');
|
||||||
|
|
||||||
|
db = loadedDb;
|
||||||
|
|
||||||
|
const adminUser = UserDB.findByUsername(process.env.ADMIN_USERNAME || 'admin');
|
||||||
|
assert(adminUser, '应自动创建默认管理员账号');
|
||||||
|
assert(
|
||||||
|
Number(adminUser.download_traffic_quota) === -1,
|
||||||
|
`默认管理员下载配额应为 -1,实际: ${adminUser.download_traffic_quota}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const username = `quota_test_${Date.now()}`;
|
||||||
|
const userId = UserDB.create({
|
||||||
|
username,
|
||||||
|
email: `${username}@example.com`,
|
||||||
|
password: 'secret123',
|
||||||
|
is_verified: 1
|
||||||
|
});
|
||||||
|
const createdUser = UserDB.findById(userId);
|
||||||
|
|
||||||
|
assert(createdUser, '新用户应创建成功');
|
||||||
|
assert(
|
||||||
|
Number(createdUser.download_traffic_quota) === -1,
|
||||||
|
`新用户默认下载配额应为 -1,实际: ${createdUser.download_traffic_quota}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = generateToken(createdUser);
|
||||||
|
const req = {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
cookies: {},
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
socket: {
|
||||||
|
remoteAddress: '127.0.0.1'
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return 'quota-test-agent';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextCalled = false;
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
payload: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(body) {
|
||||||
|
this.payload = body;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
authMiddleware(req, res, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(nextCalled, `authMiddleware 应放行不限流量用户,实际状态码: ${res.statusCode}`);
|
||||||
|
assert(req.user, 'authMiddleware 应写入 req.user');
|
||||||
|
assert(
|
||||||
|
Number(req.user.download_traffic_quota) === -1,
|
||||||
|
`authMiddleware 中的下载配额应保留 -1,实际: ${req.user.download_traffic_quota}`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('PASS test_download_quota_defaults');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('FAIL test_download_quota_defaults');
|
||||||
|
console.error(error && error.stack ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (db) {
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error('关闭测试数据库失败:', closeError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -473,7 +473,7 @@ function testLocalStoragePath() {
|
|||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = '/tmp/storage/user_1';
|
const basePath = path.join(path.resolve('/tmp'), 'storage', 'user_1');
|
||||||
|
|
||||||
test('正常相对路径应该被接受', () => {
|
test('正常相对路径应该被接受', () => {
|
||||||
const result = getFullPath(basePath, 'documents/file.txt');
|
const result = getFullPath(basePath, 'documents/file.txt');
|
||||||
@@ -703,7 +703,7 @@ function testDatabaseFieldWhitelist() {
|
|||||||
const ALLOWED_FIELDS = [
|
const ALLOWED_FIELDS = [
|
||||||
'username', 'email', 'password',
|
'username', 'email', 'password',
|
||||||
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
|
'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',
|
'is_verified', 'verification_token', 'verification_expires_at',
|
||||||
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
|
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
|
||||||
'theme_preference'
|
'theme_preference'
|
||||||
@@ -730,14 +730,14 @@ function testDatabaseFieldWhitelist() {
|
|||||||
const updates = {
|
const updates = {
|
||||||
username: 'newname',
|
username: 'newname',
|
||||||
id: 999, // 尝试修改 ID
|
id: 999, // 尝试修改 ID
|
||||||
is_admin: 1, // 合法字段
|
is_admin: 1, // 权限字段不允许通过通用更新入口修改
|
||||||
sql_injection: "'; DROP TABLE users; --" // 非法字段
|
sql_injection: "'; DROP TABLE users; --" // 非法字段
|
||||||
};
|
};
|
||||||
const filtered = filterUpdates(updates);
|
const filtered = filterUpdates(updates);
|
||||||
assert.ok(!('id' in filtered));
|
assert.ok(!('id' in filtered));
|
||||||
|
assert.ok(!('is_admin' in filtered));
|
||||||
assert.ok(!('sql_injection' in filtered));
|
assert.ok(!('sql_injection' in filtered));
|
||||||
assert.strictEqual(filtered.username, 'newname');
|
assert.strictEqual(filtered.username, 'newname');
|
||||||
assert.strictEqual(filtered.is_admin, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('原型污染尝试应该被阻止', () => {
|
test('原型污染尝试应该被阻止', () => {
|
||||||
|
|||||||
420
backend/tests/full-audit-regression.js
Normal file
420
backend/tests/full-audit-regression.js
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* Full project audit regression harness.
|
||||||
|
*
|
||||||
|
* Starts the backend with an isolated database/storage root and exercises the
|
||||||
|
* highest-risk public HTTP flows through real routes, cookies and CSRF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const BACKEND_DIR = path.resolve(__dirname, '..');
|
||||||
|
const SERVER_PATH = path.join(BACKEND_DIR, 'server.js');
|
||||||
|
const AUDIT_PREFIX = `audit_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
||||||
|
|
||||||
|
function randomHex(bytes = 32) {
|
||||||
|
return crypto.randomBytes(bytes).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRegularFiles(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isFile()) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CookieJar {
|
||||||
|
constructor() {
|
||||||
|
this.cookies = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
store(setCookie) {
|
||||||
|
const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);
|
||||||
|
for (const raw of list) {
|
||||||
|
const first = String(raw).split(';')[0];
|
||||||
|
const idx = first.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
this.cookies.set(first.slice(0, idx), first.slice(idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header() {
|
||||||
|
return Array.from(this.cookies.entries())
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name) {
|
||||||
|
return this.cookies.get(name) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(baseUrl, jar, method, route, options = {}) {
|
||||||
|
const url = new URL(route, baseUrl);
|
||||||
|
const headers = { ...(options.headers || {}) };
|
||||||
|
const cookieHeader = jar?.header();
|
||||||
|
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||||
|
|
||||||
|
const upperMethod = method.toUpperCase();
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(upperMethod) && options.csrf !== false) {
|
||||||
|
const csrf = jar?.get('csrf_token');
|
||||||
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = options.body;
|
||||||
|
if (options.json !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
body = JSON.stringify(options.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: upperMethod,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
redirect: options.redirect || 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
jar?.store(response.headers.getSetCookie ? response.headers.getSetCookie() : response.headers.get('set-cookie'));
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
let data = buffer;
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
data = JSON.parse(buffer.toString('utf8') || '{}');
|
||||||
|
} else if (contentType.includes('text/') || contentType.includes('image/svg')) {
|
||||||
|
data = buffer.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
data,
|
||||||
|
raw: buffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(baseUrl, timeoutMs = 15000) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const res = await request(baseUrl, null, 'GET', '/api/health');
|
||||||
|
if (res.status === 200 && res.data?.success === true) return;
|
||||||
|
} catch {}
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
throw new Error('server did not become healthy');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = http.createServer();
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-full-audit-'));
|
||||||
|
const storageRoot = path.join(tempRoot, 'storage');
|
||||||
|
const dbPath = path.join(tempRoot, 'database.db');
|
||||||
|
const port = await getFreePort();
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const uploadsDir = path.join(BACKEND_DIR, 'uploads');
|
||||||
|
const adminPassword = `${AUDIT_PREFIX}_Pass123!`;
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(port),
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
DATABASE_PATH: dbPath,
|
||||||
|
STORAGE_ROOT: storageRoot,
|
||||||
|
JWT_SECRET: randomHex(32),
|
||||||
|
ENCRYPTION_KEY: randomHex(32),
|
||||||
|
ADMIN_USERNAME: 'admin',
|
||||||
|
ADMIN_PASSWORD: adminPassword,
|
||||||
|
PUBLIC_BASE_URL: baseUrl,
|
||||||
|
ALLOWED_ORIGINS: baseUrl,
|
||||||
|
COOKIE_SECURE: 'false',
|
||||||
|
ENABLE_CSRF: 'true',
|
||||||
|
ENFORCE_HTTPS: 'false',
|
||||||
|
TRUST_PROXY: 'false',
|
||||||
|
WAL_CHECKPOINT_ENABLED: 'false'
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, [SERVER_PATH], {
|
||||||
|
cwd: BACKEND_DIR,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const tests = [];
|
||||||
|
const test = (name, fn) => tests.push({ name, fn });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForHealth(baseUrl);
|
||||||
|
|
||||||
|
const jar = new CookieJar();
|
||||||
|
let userId = 1;
|
||||||
|
let shareId = null;
|
||||||
|
let shareCode = '';
|
||||||
|
let directLinkId = null;
|
||||||
|
|
||||||
|
test('public health/config/csrf endpoints are reachable', async () => {
|
||||||
|
const health = await request(baseUrl, jar, 'GET', '/api/health');
|
||||||
|
assert.strictEqual(health.status, 200);
|
||||||
|
assert.strictEqual(health.data.success, true);
|
||||||
|
|
||||||
|
const config = await request(baseUrl, jar, 'GET', '/api/config');
|
||||||
|
assert.strictEqual(config.status, 200);
|
||||||
|
assert.strictEqual(config.data.success, true);
|
||||||
|
|
||||||
|
const csrf = await request(baseUrl, jar, 'GET', '/api/csrf-token');
|
||||||
|
assert.strictEqual(csrf.status, 200);
|
||||||
|
assert.ok(csrf.data.csrfToken);
|
||||||
|
assert.ok(jar.get('csrf_token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
|
||||||
|
const login = await request(baseUrl, jar, 'POST', '/api/login', {
|
||||||
|
json: { username: 'admin', password: adminPassword }
|
||||||
|
});
|
||||||
|
assert.strictEqual(login.status, 200);
|
||||||
|
assert.strictEqual(login.data.success, true);
|
||||||
|
assert.ok(jar.get('token'));
|
||||||
|
assert.ok(jar.get('refreshToken'));
|
||||||
|
userId = login.data.user.id;
|
||||||
|
|
||||||
|
const profile = await request(baseUrl, jar, 'GET', '/api/user/profile');
|
||||||
|
assert.strictEqual(profile.status, 200);
|
||||||
|
assert.strictEqual(profile.data.success, true);
|
||||||
|
assert.strictEqual(profile.data.user.id, userId);
|
||||||
|
assert.strictEqual(profile.data.user.oss_access_key_secret, undefined);
|
||||||
|
|
||||||
|
const csrfBlocked = await request(baseUrl, jar, 'POST', '/api/user/theme', {
|
||||||
|
csrf: false,
|
||||||
|
json: { theme: 'light' }
|
||||||
|
});
|
||||||
|
assert.strictEqual(csrfBlocked.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can move isolated audit user to local storage only', async () => {
|
||||||
|
const res = await request(baseUrl, jar, 'POST', `/api/admin/users/${userId}/storage-permission`, {
|
||||||
|
json: {
|
||||||
|
storage_permission: 'local_only',
|
||||||
|
local_storage_quota: 10 * 1024 * 1024,
|
||||||
|
download_traffic_quota: -1,
|
||||||
|
reset_download_traffic_used: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(res.data.success, true);
|
||||||
|
assert.strictEqual(res.data.user.current_storage_type, 'local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file manager rejects unsafe folder names and handles normal local file flow', async () => {
|
||||||
|
const badMkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
|
||||||
|
json: { path: '/', folderName: '../bad' }
|
||||||
|
});
|
||||||
|
assert.strictEqual(badMkdir.status, 400);
|
||||||
|
|
||||||
|
const badList = await request(baseUrl, jar, 'GET', '/api/files?path=/../secret');
|
||||||
|
assert.strictEqual(badList.status, 400);
|
||||||
|
|
||||||
|
const mkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
|
||||||
|
json: { path: '/', folderName: AUDIT_PREFIX }
|
||||||
|
});
|
||||||
|
assert.strictEqual(mkdir.status, 200);
|
||||||
|
assert.strictEqual(mkdir.data.success, true);
|
||||||
|
|
||||||
|
const file = new Blob([`hello ${AUDIT_PREFIX}`], { type: 'text/plain' });
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('path', `/${AUDIT_PREFIX}`);
|
||||||
|
form.append('file', file, 'hello.txt');
|
||||||
|
const upload = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
|
||||||
|
assert.strictEqual(upload.status, 200);
|
||||||
|
assert.strictEqual(upload.data.success, true);
|
||||||
|
assert.strictEqual(upload.data.path, `/${AUDIT_PREFIX}/hello.txt`);
|
||||||
|
|
||||||
|
const list = await request(baseUrl, jar, 'GET', `/api/files?path=/${encodeURIComponent(AUDIT_PREFIX)}`);
|
||||||
|
assert.strictEqual(list.status, 200);
|
||||||
|
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
|
||||||
|
|
||||||
|
const search = await request(baseUrl, jar, 'GET', `/api/files/search?keyword=hello&path=/${encodeURIComponent(AUDIT_PREFIX)}`);
|
||||||
|
assert.strictEqual(search.status, 200);
|
||||||
|
assert.ok(search.data.items.some(item => item.name === 'hello.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed normal upload validation must not leave multer temp files', async () => {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
const before = countRegularFiles(uploadsDir);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('path', '/../blocked');
|
||||||
|
form.append('file', new Blob(['leak candidate'], { type: 'text/plain' }), 'leak.txt');
|
||||||
|
const res = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
|
||||||
|
assert.strictEqual(res.status, 400);
|
||||||
|
const after = countRegularFiles(uploadsDir);
|
||||||
|
assert.strictEqual(after, before, `uploads temp leak: before=${before}, after=${after}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download URL/check/download work for local file and reject traversal', async () => {
|
||||||
|
const traversal = await request(baseUrl, jar, 'GET', '/api/files/download-check?path=/../secret.txt');
|
||||||
|
assert.strictEqual(traversal.status, 400);
|
||||||
|
|
||||||
|
const check = await request(baseUrl, jar, 'GET', `/api/files/download-check?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
|
||||||
|
assert.strictEqual(check.status, 200);
|
||||||
|
assert.strictEqual(check.data.success, true);
|
||||||
|
|
||||||
|
const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`);
|
||||||
|
assert.strictEqual(url.status, 400);
|
||||||
|
assert.match(url.data.message, /OSS/);
|
||||||
|
|
||||||
|
const download = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
|
||||||
|
assert.strictEqual(download.status, 200);
|
||||||
|
assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('share and direct-link flows preserve path boundaries', async () => {
|
||||||
|
const createShare = await request(baseUrl, jar, 'POST', '/api/share/create', {
|
||||||
|
json: {
|
||||||
|
share_type: 'directory',
|
||||||
|
file_path: `/${AUDIT_PREFIX}`,
|
||||||
|
file_name: AUDIT_PREFIX,
|
||||||
|
password: `${AUDIT_PREFIX}_pw`,
|
||||||
|
expiry_days: 1,
|
||||||
|
max_downloads: 5,
|
||||||
|
device_limit: 'all'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(createShare.status, 200);
|
||||||
|
assert.strictEqual(createShare.data.success, true);
|
||||||
|
shareId = createShare.data.share_id;
|
||||||
|
shareCode = createShare.data.share_code;
|
||||||
|
|
||||||
|
const badVerify = await request(baseUrl, new CookieJar(), 'POST', `/api/share/${shareCode}/verify`, {
|
||||||
|
json: { password: 'wrong' }
|
||||||
|
});
|
||||||
|
assert.strictEqual(badVerify.status, 401);
|
||||||
|
|
||||||
|
const publicJar = new CookieJar();
|
||||||
|
const verify = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/verify`, {
|
||||||
|
json: { password: `${AUDIT_PREFIX}_pw` }
|
||||||
|
});
|
||||||
|
assert.strictEqual(verify.status, 200);
|
||||||
|
assert.strictEqual(verify.data.success, true);
|
||||||
|
|
||||||
|
const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
|
||||||
|
json: { path: '', password: `${AUDIT_PREFIX}_pw` }
|
||||||
|
});
|
||||||
|
assert.strictEqual(list.status, 200);
|
||||||
|
assert.strictEqual(list.data.success, true);
|
||||||
|
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
|
||||||
|
|
||||||
|
const traversal = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
|
||||||
|
json: { path: '/../database.db', mode: 'download', password: `${AUDIT_PREFIX}_pw` }
|
||||||
|
});
|
||||||
|
assert.ok([400, 403, 404].includes(traversal.status));
|
||||||
|
|
||||||
|
const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', {
|
||||||
|
json: {
|
||||||
|
file_path: `/${AUDIT_PREFIX}/hello.txt`,
|
||||||
|
file_name: 'hello.txt',
|
||||||
|
expiry_days: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(direct.status, 200);
|
||||||
|
assert.strictEqual(direct.data.success, true);
|
||||||
|
directLinkId = direct.data.link_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin listing/logging endpoints are authenticated and sanitized', async () => {
|
||||||
|
const noAuth = await request(baseUrl, new CookieJar(), 'GET', '/api/admin/users');
|
||||||
|
assert.strictEqual(noAuth.status, 401);
|
||||||
|
|
||||||
|
const users = await request(baseUrl, jar, 'GET', '/api/admin/users?page=1&pageSize=10');
|
||||||
|
assert.strictEqual(users.status, 200);
|
||||||
|
assert.strictEqual(users.data.success, true);
|
||||||
|
const adminRow = users.data.users.find(user => user.id === userId);
|
||||||
|
assert.ok(adminRow);
|
||||||
|
assert.strictEqual(adminRow.password, undefined);
|
||||||
|
assert.strictEqual(adminRow.oss_access_key_secret, undefined);
|
||||||
|
|
||||||
|
const logs = await request(baseUrl, jar, 'GET', '/api/admin/logs?page=1&pageSize=5');
|
||||||
|
assert.strictEqual(logs.status, 200);
|
||||||
|
assert.strictEqual(logs.data.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanup via public APIs succeeds for audit artifacts', async () => {
|
||||||
|
if (directLinkId) {
|
||||||
|
const res = await request(baseUrl, jar, 'DELETE', `/api/direct-link/${directLinkId}`);
|
||||||
|
assert.ok([200, 404].includes(res.status));
|
||||||
|
}
|
||||||
|
if (shareId) {
|
||||||
|
const res = await request(baseUrl, jar, 'DELETE', `/api/share/${shareId}`);
|
||||||
|
assert.ok([200, 404].includes(res.status));
|
||||||
|
}
|
||||||
|
const del = await request(baseUrl, jar, 'POST', '/api/files/delete', {
|
||||||
|
json: { path: '/', fileName: AUDIT_PREFIX }
|
||||||
|
});
|
||||||
|
assert.strictEqual(del.status, 200);
|
||||||
|
assert.strictEqual(del.data.success, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
for (const item of tests) {
|
||||||
|
try {
|
||||||
|
await item.fn();
|
||||||
|
console.log(`[PASS] ${item.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ name: item.name, error });
|
||||||
|
console.error(`[FAIL] ${item.name}`);
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
const summary = failures.map(item => `- ${item.name}: ${item.error.message}`).join('\n');
|
||||||
|
throw new Error(`full audit regression failed:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`PASS full-audit-regression (${tests.length} tests)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('--- backend stdout tail ---');
|
||||||
|
console.error(stdout.split(/\r?\n/).slice(-80).join('\n'));
|
||||||
|
console.error('--- backend stderr tail ---');
|
||||||
|
console.error(stderr.split(/\r?\n/).slice(-80).join('\n'));
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await delay(500);
|
||||||
|
if (!child.killed) child.kill('SIGKILL');
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(error => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const ENCRYPTION_PREFIX = 'enc:v1:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从环境变量获取加密密钥
|
* 从环境变量获取加密密钥
|
||||||
@@ -111,8 +112,8 @@ function encryptSecret(plaintext) {
|
|||||||
authTag
|
authTag
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 返回 Base64 编码的结果
|
// 返回带版本前缀的 Base64 编码结果,避免旧明文被误判为密文。
|
||||||
return combined.toString('base64');
|
return ENCRYPTION_PREFIX + combined.toString('base64');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[加密] 加密失败:', error);
|
console.error('[加密] 加密失败:', error);
|
||||||
throw new Error('数据加密失败: ' + error.message);
|
throw new Error('数据加密失败: ' + error.message);
|
||||||
@@ -134,24 +135,26 @@ function encryptSecret(plaintext) {
|
|||||||
* // 输出: 'my-secret-key'
|
* // 输出: 'my-secret-key'
|
||||||
*/
|
*/
|
||||||
function decryptSecret(ciphertext) {
|
function decryptSecret(ciphertext) {
|
||||||
try {
|
|
||||||
// 如果是 null 或 undefined,直接返回
|
// 如果是 null 或 undefined,直接返回
|
||||||
if (!ciphertext) {
|
if (!ciphertext) {
|
||||||
return ciphertext;
|
return ciphertext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为加密格式(Base64)
|
const rawValue = String(ciphertext);
|
||||||
// 如果不是 Base64,可能是旧数据(明文),直接返回
|
const hasPrefix = rawValue.startsWith(ENCRYPTION_PREFIX);
|
||||||
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
|
const encodedValue = hasPrefix ? rawValue.slice(ENCRYPTION_PREFIX.length) : rawValue;
|
||||||
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
|
|
||||||
return ciphertext;
|
if (!hasPrefix && !isEncrypted(encodedValue)) {
|
||||||
|
if (/[+/=]/.test(rawValue)) {
|
||||||
|
throw new Error('数据解密失败: 疑似密文格式无效');
|
||||||
|
}
|
||||||
|
console.warn('[加密] 检测到未加密的旧密钥,建议重新保存以完成加密');
|
||||||
|
return rawValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取加密密钥
|
try {
|
||||||
const key = getEncryptionKey();
|
const key = getEncryptionKey();
|
||||||
|
const combined = Buffer.from(encodedValue, 'base64');
|
||||||
// 解析 Base64
|
|
||||||
const combined = Buffer.from(ciphertext, 'base64');
|
|
||||||
|
|
||||||
// 提取各部分
|
// 提取各部分
|
||||||
// IV: 前 12 字节
|
// IV: 前 12 字节
|
||||||
@@ -175,13 +178,9 @@ function decryptSecret(ciphertext) {
|
|||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 如果解密失败,可能是旧数据(明文),直接返回
|
if (!hasPrefix && !/[+/=]/.test(rawValue)) {
|
||||||
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
|
console.warn('[加密] 旧格式数据解密失败,按未加密旧密钥处理:', error.message);
|
||||||
|
return rawValue;
|
||||||
// 在开发环境抛出错误,生产环境尝试返回原值
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
|
|
||||||
return ciphertext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('数据解密失败: ' + error.message);
|
throw new Error('数据解密失败: ' + error.message);
|
||||||
@@ -240,6 +239,10 @@ function isEncrypted(data) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encodedValue = data.startsWith(ENCRYPTION_PREFIX)
|
||||||
|
? data.slice(ENCRYPTION_PREFIX.length)
|
||||||
|
: data;
|
||||||
|
|
||||||
// 加密后的数据特征:
|
// 加密后的数据特征:
|
||||||
// 1. 是有效的 Base64
|
// 1. 是有效的 Base64
|
||||||
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
||||||
@@ -247,7 +250,11 @@ function isEncrypted(data) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 尝试解码 Base64
|
// 尝试解码 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)
|
// 检查长度(至少包含 IV + authTag)
|
||||||
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
||||||
|
|||||||
258
desktop-client/package-lock.json
generated
258
desktop-client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "desktop-client",
|
"name": "desktop-client",
|
||||||
"version": "0.1.29",
|
"version": "0.1.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "desktop-client",
|
"name": "desktop-client",
|
||||||
"version": "0.1.29",
|
"version": "0.1.31",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
@@ -516,9 +516,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -530,9 +530,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -544,9 +544,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -558,9 +558,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -572,9 +572,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -586,9 +586,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -600,9 +600,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -614,9 +614,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -628,9 +628,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -642,9 +642,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -656,9 +656,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -670,9 +670,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -684,9 +684,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -698,9 +698,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -712,9 +712,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -726,9 +726,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -740,9 +740,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -754,9 +754,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -768,9 +768,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -782,9 +782,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -796,9 +796,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -810,9 +810,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -824,9 +824,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -838,9 +838,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -852,9 +852,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1111,9 +1111,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1311,9 +1311,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1446,13 +1446,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -1469,9 +1469,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1500,9 +1500,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1513,9 +1513,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1532,7 +1532,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -1541,13 +1541,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
@@ -1557,31 +1557,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.61.1",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.61.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.61.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.61.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.61.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.61.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.61.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.61.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.61.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.61.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.61.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.61.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.61.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.61.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.61.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.61.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.61.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.61.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1626,9 +1626,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "desktop-client",
|
"name": "desktop-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.30",
|
"version": "0.1.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
2
desktop-client/src-tauri/Cargo.lock
generated
2
desktop-client/src-tauri/Cargo.lock
generated
@@ -693,7 +693,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "desktop-client"
|
name = "desktop-client"
|
||||||
version = "0.1.30"
|
version = "0.1.31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "desktop-client"
|
name = "desktop-client"
|
||||||
version = "0.1.30"
|
version = "0.1.31"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use reqwest::Method;
|
use reqwest::{Method, Url};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::Serialize;
|
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")
|
lower.starts_with("wanwan-cloud-desktop_v") || file_name.trim().starts_with("玩玩云_v")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_http_download_url(raw_url: &str) -> Result<(), String> {
|
||||||
|
let parsed = Url::parse(raw_url).map_err(|_| "下载地址格式无效".to_string())?;
|
||||||
|
match parsed.scheme() {
|
||||||
|
"http" | "https" => Ok(()),
|
||||||
|
_ => Err("仅允许 HTTP/HTTPS 下载地址".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_update_installer_path(installer_path: &str) -> Result<PathBuf, String> {
|
||||||
|
let path_text = installer_path.trim();
|
||||||
|
if path_text.is_empty() {
|
||||||
|
return Err("安装包路径不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installer = PathBuf::from(path_text);
|
||||||
|
if !installer.exists() {
|
||||||
|
return Err("安装包不存在,请重新下载".to_string());
|
||||||
|
}
|
||||||
|
if !installer.is_file() {
|
||||||
|
return Err("安装包路径无效".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = installer
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.ok_or_else(|| "安装包文件名无效".to_string())?;
|
||||||
|
if !is_update_installer_file_name(file_name) {
|
||||||
|
return Err("仅允许启动玩玩云官方更新安装包".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let download_dir = resolve_download_dir()
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| format!("读取下载目录失败: {}", err))?;
|
||||||
|
let canonical_installer = installer
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| format!("读取安装包路径失败: {}", err))?;
|
||||||
|
if !canonical_installer.starts_with(&download_dir) {
|
||||||
|
return Err("安装包必须位于系统下载目录内".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(canonical_installer)
|
||||||
|
}
|
||||||
|
|
||||||
fn cleanup_old_update_installers(
|
fn cleanup_old_update_installers(
|
||||||
download_dir: &Path,
|
download_dir: &Path,
|
||||||
keep_file_name: &str,
|
keep_file_name: &str,
|
||||||
@@ -1192,6 +1235,7 @@ async fn api_native_download(
|
|||||||
if trimmed_url.is_empty() {
|
if trimmed_url.is_empty() {
|
||||||
return Err("下载地址不能为空".to_string());
|
return Err("下载地址不能为空".to_string());
|
||||||
}
|
}
|
||||||
|
ensure_http_download_url(&trimmed_url)?;
|
||||||
|
|
||||||
let preferred_name = file_name
|
let preferred_name = file_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1418,18 +1462,8 @@ fn api_compute_file_sha256(file_path: String) -> Result<BridgeResponse, String>
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
|
fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String> {
|
||||||
let path_text = installer_path.trim().to_string();
|
let installer = validate_update_installer_path(&installer_path)?;
|
||||||
if path_text.is_empty() {
|
let path_text = installer.to_string_lossy().to_string();
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let spawn_result = Command::new(&installer).spawn();
|
let spawn_result = Command::new(&installer).spawn();
|
||||||
@@ -1456,18 +1490,8 @@ fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeResponse, String> {
|
fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeResponse, String> {
|
||||||
let path_text = installer_path.trim().to_string();
|
let installer = validate_update_installer_path(&installer_path)?;
|
||||||
if path_text.is_empty() {
|
let path_text = installer.to_string_lossy().to_string();
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let windows_log_file_path: String;
|
let windows_log_file_path: String;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "玩玩云",
|
"productName": "玩玩云",
|
||||||
"version": "0.1.30",
|
"version": "0.1.31",
|
||||||
"identifier": "cn.workyai.wanwancloud.desktop",
|
"identifier": "cn.workyai.wanwancloud.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ const syncState = reactive({
|
|||||||
nextRunAt: "",
|
nextRunAt: "",
|
||||||
});
|
});
|
||||||
const updateState = reactive({
|
const updateState = reactive({
|
||||||
currentVersion: "0.1.30",
|
currentVersion: "0.1.31",
|
||||||
latestVersion: "",
|
latestVersion: "",
|
||||||
available: false,
|
available: false,
|
||||||
mandatory: false,
|
mandatory: false,
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=40001
|
- PORT=40001
|
||||||
# 以下配置建议通过 .env 文件或环境变量设置
|
# 以下配置建议通过 .env 文件或环境变量设置
|
||||||
# - JWT_SECRET=your-secret-key
|
# - JWT_SECRET=<至少32字符的强随机密钥>
|
||||||
# - ADMIN_USERNAME=admin
|
# - ADMIN_USERNAME=admin
|
||||||
# - ADMIN_PASSWORD=admin123
|
# - ADMIN_PASSWORD=<至少8位且至少包含两类字符的强密码>
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1778,8 +1778,8 @@
|
|||||||
<input type="email" class="form-input" v-model="registerForm.email" required>
|
<input type="email" class="form-input" v-model="registerForm.email" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">密码 (至少6字符)</label>
|
<label class="form-label">密码 (8-128字符,至少两类字符)</label>
|
||||||
<input type="password" class="form-input" v-model="registerForm.password" required minlength="6">
|
<input type="password" class="form-input" v-model="registerForm.password" required minlength="8" maxlength="128">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">验证码</label>
|
<label class="form-label">验证码</label>
|
||||||
@@ -1986,7 +1986,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 拖拽提示层 -->
|
<!-- 拖拽提示层 -->
|
||||||
<div v-if="isDragging && storageType === 'local'" class="drag-drop-overlay">
|
<div v-if="isDragging" class="drag-drop-overlay">
|
||||||
<div class="drag-drop-content">
|
<div class="drag-drop-content">
|
||||||
<i class="fas fa-cloud-upload-alt" style="font-size: 64px; color: #667eea; margin-bottom: 20px;"></i>
|
<i class="fas fa-cloud-upload-alt" style="font-size: 64px; color: #667eea; margin-bottom: 20px;"></i>
|
||||||
<div style="font-size: 24px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px;">拖放文件到这里上传</div>
|
<div style="font-size: 24px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px;">拖放文件到这里上传</div>
|
||||||
@@ -3041,8 +3041,8 @@
|
|||||||
<input type="password" class="form-input" v-model="changePasswordForm.current_password" placeholder="输入当前密码" required>
|
<input type="password" class="form-input" v-model="changePasswordForm.current_password" placeholder="输入当前密码" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">新密码 (至少6字符)</label>
|
<label class="form-label">新密码 (8-128字符,至少两类字符)</label>
|
||||||
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
|
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="passwordChanging">
|
<button type="submit" class="btn btn-primary" :disabled="passwordChanging">
|
||||||
<i :class="passwordChanging ? 'fas fa-spinner fa-spin' : 'fas fa-key'"></i> {{ passwordChanging ? '修改中...' : '修改密码' }}
|
<i :class="passwordChanging ? 'fas fa-spinner fa-spin' : 'fas fa-key'"></i> {{ passwordChanging ? '修改中...' : '修改密码' }}
|
||||||
@@ -4252,8 +4252,8 @@
|
|||||||
重置链接已验证,请输入新密码
|
重置链接已验证,请输入新密码
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">新密码 (至少6字符)</label>
|
<label class="form-label">新密码 (8-128字符,至少两类字符)</label>
|
||||||
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
|
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="8" maxlength="128" required>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||||
<button class="btn btn-primary" @click="submitResetPassword" :disabled="passwordResetting" style="flex: 1;">
|
<button class="btn btn-primary" @click="submitResetPassword" :disabled="passwordResetting" style="flex: 1;">
|
||||||
@@ -4392,12 +4392,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 上传进度条 -->
|
<!-- 上传进度条 -->
|
||||||
<div v-if="uploadProgress > 0 && uploadProgress < 100"
|
<div v-if="uploadingFileName && uploadProgress > 0"
|
||||||
class="upload-progress-panel">
|
class="upload-progress-panel">
|
||||||
<div class="upload-progress-header">
|
<div class="upload-progress-header">
|
||||||
<i class="fas fa-cloud-upload-alt upload-progress-icon"></i>
|
<i class="fas fa-cloud-upload-alt upload-progress-icon"></i>
|
||||||
<div class="upload-progress-meta">
|
<div class="upload-progress-meta">
|
||||||
<div class="upload-progress-title">正在上传文件</div>
|
<div class="upload-progress-title">{{ uploadPhase || '正在上传文件' }}</div>
|
||||||
<div class="upload-progress-name">{{ uploadingFileName }}</div>
|
<div class="upload-progress-name">{{ uploadingFileName }}</div>
|
||||||
<div v-if="totalBytes > 0" class="upload-progress-size">{{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }}</div>
|
<div v-if="totalBytes > 0" class="upload-progress-size">{{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
264
frontend/app.js
264
frontend/app.js
@@ -163,7 +163,10 @@ createApp({
|
|||||||
uploadedBytes: 0,
|
uploadedBytes: 0,
|
||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
uploadingFileName: '',
|
uploadingFileName: '',
|
||||||
|
uploadPhase: '',
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
fileLoadRequestId: 0,
|
||||||
|
inspectionLoadRequestId: 0,
|
||||||
modalMouseDownTarget: null, // 模态框鼠标按下的目标
|
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秒,避免频繁请求)
|
// 创建防抖版本的 loadUserProfile(延迟2秒,避免频繁请求)
|
||||||
debouncedLoadUserProfile() {
|
debouncedLoadUserProfile() {
|
||||||
if (!this._debouncedLoadUserProfile) {
|
if (!this._debouncedLoadUserProfile) {
|
||||||
@@ -1001,8 +1034,7 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
const files = e.dataTransfer.files;
|
const files = e.dataTransfer.files;
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const file = files[0];
|
await this.uploadFiles(files);
|
||||||
await this.uploadFile(file);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1031,6 +1063,27 @@ handleDragLeave(e) {
|
|||||||
return generated;
|
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() {
|
buildLoginClientMeta() {
|
||||||
const platform = navigator.platform || '未知平台';
|
const platform = navigator.platform || '未知平台';
|
||||||
const name = `${navigator.userAgent?.includes('Mobile') ? '移动端网页' : '网页端'} · ${platform || '未知平台'}`;
|
const name = `${navigator.userAgent?.includes('Mobile') ? '移动端网页' : '网页端'} · ${platform || '未知平台'}`;
|
||||||
@@ -1121,7 +1174,7 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
// 保存用户信息到localStorage(非敏感信息,用于页面刷新后恢复)
|
// 保存用户信息到localStorage(非敏感信息,用于页面刷新后恢复)
|
||||||
// 注意:token 通过 HttpOnly Cookie 传递,不再存储在 localStorage
|
// 注意:token 通过 HttpOnly Cookie 传递,不再存储在 localStorage
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
this.persistUser();
|
||||||
|
|
||||||
// 启动token自动刷新(在过期前5分钟刷新)
|
// 启动token自动刷新(在过期前5分钟刷新)
|
||||||
const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000);
|
const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000);
|
||||||
@@ -1152,29 +1205,26 @@ handleDragLeave(e) {
|
|||||||
this.loadUserTheme();
|
this.loadUserTheme();
|
||||||
// 管理员直接跳转到管理后台
|
// 管理员直接跳转到管理后台
|
||||||
if (this.user.is_admin) {
|
if (this.user.is_admin) {
|
||||||
this.currentView = 'admin';
|
this.switchView('admin', true);
|
||||||
}
|
}
|
||||||
// 普通用户:检查存储权限
|
// 普通用户:检查存储权限
|
||||||
else {
|
else {
|
||||||
// 如果用户可以使用本地存储,直接进入文件页面
|
// 如果用户可以使用本地存储,直接进入文件页面
|
||||||
if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') {
|
if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') {
|
||||||
this.currentView = 'files';
|
this.switchView('files', true);
|
||||||
this.loadFiles('/');
|
|
||||||
}
|
}
|
||||||
// 如果仅OSS模式,需要检查是否配置了OSS(包括系统级统一配置)
|
// 如果仅OSS模式,需要检查是否配置了OSS(包括系统级统一配置)
|
||||||
else if (this.storagePermission === 'oss_only') {
|
else if (this.storagePermission === 'oss_only') {
|
||||||
if (this.user?.oss_config_source !== 'none') {
|
if (this.user?.oss_config_source !== 'none') {
|
||||||
this.currentView = 'files';
|
this.switchView('files', true);
|
||||||
this.loadFiles('/');
|
|
||||||
} else {
|
} else {
|
||||||
this.currentView = 'settings';
|
this.switchView('settings', true);
|
||||||
this.showToast('info', '欢迎', '请先配置您的OSS服务');
|
this.showToast('info', '欢迎', '请先配置您的OSS服务');
|
||||||
this.openOssConfigModal();
|
this.openOssConfigModal();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 默认行为:跳转到文件页面
|
// 默认行为:跳转到文件页面
|
||||||
this.currentView = 'files';
|
this.switchView('files', true);
|
||||||
this.loadFiles('/');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1298,6 +1348,13 @@ handleDragLeave(e) {
|
|||||||
async handleRegister() {
|
async handleRegister() {
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.successMessage = '';
|
this.successMessage = '';
|
||||||
|
|
||||||
|
const passwordCheck = this.validateAccountPassword(this.registerForm.password);
|
||||||
|
if (!passwordCheck.valid) {
|
||||||
|
this.errorMessage = passwordCheck.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.registerLoading = true;
|
this.registerLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1350,7 +1407,7 @@ handleDragLeave(e) {
|
|||||||
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
this.showToast('error', '配置错误', 'Access Key ID 不能为空');
|
||||||
return;
|
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 不能为空');
|
this.showToast('error', '配置错误', 'Access Key Secret 不能为空');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1392,8 +1449,7 @@ handleDragLeave(e) {
|
|||||||
this.showOssConfigModal = false;
|
this.showOssConfigModal = false;
|
||||||
|
|
||||||
// 刷新到文件页面
|
// 刷新到文件页面
|
||||||
this.currentView = 'files';
|
this.switchView('files', true);
|
||||||
this.loadFiles('/');
|
|
||||||
|
|
||||||
// 显示成功提示
|
// 显示成功提示
|
||||||
this.showToast('success', '配置成功', 'OSS存储配置已保存!');
|
this.showToast('success', '配置成功', 'OSS存储配置已保存!');
|
||||||
@@ -1470,7 +1526,7 @@ handleDragLeave(e) {
|
|||||||
// 更新用户信息(后端已通过 Cookie 更新 token)
|
// 更新用户信息(后端已通过 Cookie 更新 token)
|
||||||
if (response.data.user) {
|
if (response.data.user) {
|
||||||
this.user = 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.changePasswordForm.new_password.length < 6) {
|
const passwordCheck = this.validateAccountPassword(this.changePasswordForm.new_password);
|
||||||
this.showToast('warning', '提示', '新密码至少6个字符');
|
if (!passwordCheck.valid) {
|
||||||
|
this.showToast('warning', '提示', passwordCheck.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1554,7 +1611,7 @@ handleDragLeave(e) {
|
|||||||
this.showToast('success', '成功', '用户名修改成功!');
|
this.showToast('success', '成功', '用户名修改成功!');
|
||||||
// 更新本地用户信息
|
// 更新本地用户信息
|
||||||
this.user.username = this.usernameForm.newUsername;
|
this.user.username = this.usernameForm.newUsername;
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
this.persistUser();
|
||||||
this.usernameForm.newUsername = '';
|
this.usernameForm.newUsername = '';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1576,7 +1633,7 @@ handleDragLeave(e) {
|
|||||||
// 更新本地用户信息
|
// 更新本地用户信息
|
||||||
if (response.data.user) {
|
if (response.data.user) {
|
||||||
this.user = response.data.user;
|
this.user = response.data.user;
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
this.persistUser();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1637,7 +1694,7 @@ handleDragLeave(e) {
|
|||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
// 更新localStorage中的用户信息(非敏感信息)
|
// 更新localStorage中的用户信息(非敏感信息)
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
this.persistUser();
|
||||||
|
|
||||||
// 从最新的用户信息初始化存储相关字段
|
// 从最新的用户信息初始化存储相关字段
|
||||||
this.storagePermission = this.user.storage_permission || 'oss_only';
|
this.storagePermission = this.user.storage_permission || 'oss_only';
|
||||||
@@ -1774,16 +1831,22 @@ handleDragLeave(e) {
|
|||||||
// ===== 文件管理 =====
|
// ===== 文件管理 =====
|
||||||
|
|
||||||
async loadFiles(path) {
|
async loadFiles(path) {
|
||||||
|
const requestId = ++this.fileLoadRequestId;
|
||||||
|
const targetPath = path || '/';
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
// 确保路径不为undefined
|
// 确保路径不为undefined
|
||||||
this.currentPath = path || '/';
|
this.currentPath = targetPath;
|
||||||
this.globalSearchVisible = false;
|
this.globalSearchVisible = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${this.apiBase}/api/files`, {
|
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) {
|
if (response.data.success) {
|
||||||
this.files = response.data.items;
|
this.files = response.data.items;
|
||||||
this.thumbnailLoadErrors = {};
|
this.thumbnailLoadErrors = {};
|
||||||
@@ -1805,6 +1868,9 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== this.fileLoadRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('加载文件失败:', error);
|
console.error('加载文件失败:', error);
|
||||||
this.showToast('error', '加载失败', error.response?.data?.message || error.message);
|
this.showToast('error', '加载失败', error.response?.data?.message || error.message);
|
||||||
|
|
||||||
@@ -1812,8 +1878,10 @@ handleDragLeave(e) {
|
|||||||
this.logout();
|
this.logout();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === this.fileLoadRequestId) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
triggerGlobalSearch() {
|
triggerGlobalSearch() {
|
||||||
@@ -2246,11 +2314,17 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
// 长按取消(移动端)
|
// 长按取消(移动端)
|
||||||
handleLongPressEnd() {
|
handleLongPressEnd() {
|
||||||
|
const wasTriggered = this.longPressTriggered;
|
||||||
if (this.longPressTimer) {
|
if (this.longPressTimer) {
|
||||||
clearTimeout(this.longPressTimer);
|
clearTimeout(this.longPressTimer);
|
||||||
this.longPressTimer = null;
|
this.longPressTimer = null;
|
||||||
}
|
}
|
||||||
this.longPressFile = 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;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2482,6 +2549,8 @@ handleDragLeave(e) {
|
|||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = this.currentMediaUrl;
|
link.href = this.currentMediaUrl;
|
||||||
link.setAttribute('download', this.currentMediaName);
|
link.setAttribute('download', this.currentMediaName);
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@@ -2521,7 +2590,7 @@ handleDragLeave(e) {
|
|||||||
openShareFileModal(file) {
|
openShareFileModal(file) {
|
||||||
this.shareFileForm.fileName = file.name;
|
this.shareFileForm.fileName = file.name;
|
||||||
this.shareFileForm.filePath = this.currentPath === '/'
|
this.shareFileForm.filePath = this.currentPath === '/'
|
||||||
? file.name
|
? `/${file.name}`
|
||||||
: `${this.currentPath}/${file.name}`;
|
: `${this.currentPath}/${file.name}`;
|
||||||
this.shareFileForm.isDirectory = file.isDirectory; // 设置是否为文件夹
|
this.shareFileForm.isDirectory = file.isDirectory; // 设置是否为文件夹
|
||||||
this.shareFileForm.enablePassword = false;
|
this.shareFileForm.enablePassword = false;
|
||||||
@@ -2746,23 +2815,26 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
// ===== 文件上传 =====
|
// ===== 文件上传 =====
|
||||||
|
|
||||||
handleFileSelect(event) {
|
async handleFileSelect(event) {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
// 支持多文件上传
|
await this.uploadFiles(files);
|
||||||
Array.from(files).forEach(file => {
|
|
||||||
this.uploadFile(file);
|
|
||||||
});
|
|
||||||
// 清空input,允许重复上传相同文件
|
// 清空input,允许重复上传相同文件
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleFileDrop(event) {
|
async handleFileDrop(event) {
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
const file = event.dataTransfer.files[0];
|
await this.uploadFiles(event.dataTransfer.files);
|
||||||
if (file) {
|
},
|
||||||
this.uploadFile(file);
|
|
||||||
|
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.uploadProgress = 0;
|
||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = file.size;
|
this.totalBytes = file.size;
|
||||||
|
this.uploadPhase = '准备上传';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileHash = await this.computeQuickFileHash(file);
|
const fileHash = await this.computeQuickFileHash(file);
|
||||||
@@ -2793,6 +2866,7 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
await this.loadFiles(this.currentPath);
|
await this.loadFiles(this.currentPath);
|
||||||
await this.refreshStorageUsage();
|
await this.refreshStorageUsage();
|
||||||
return;
|
return;
|
||||||
@@ -2816,6 +2890,7 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
|
|
||||||
this.showToast('error', '上传失败', error.message || '上传失败,请重试');
|
this.showToast('error', '上传失败', error.message || '上传失败,请重试');
|
||||||
}
|
}
|
||||||
@@ -2934,6 +3009,7 @@ handleDragLeave(e) {
|
|||||||
'Content-Type': file.type || 'application/octet-stream'
|
'Content-Type': file.type || 'application/octet-stream'
|
||||||
},
|
},
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
|
this.uploadPhase = '上传中';
|
||||||
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||||
this.uploadedBytes = progressEvent.loaded;
|
this.uploadedBytes = progressEvent.loaded;
|
||||||
this.totalBytes = progressEvent.total;
|
this.totalBytes = progressEvent.total;
|
||||||
@@ -2942,6 +3018,7 @@ handleDragLeave(e) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. 通知后端上传完成
|
// 3. 通知后端上传完成
|
||||||
|
this.uploadPhase = '服务端确认中';
|
||||||
await axios.post(`${this.apiBase}/api/files/upload-complete`, {
|
await axios.post(`${this.apiBase}/api/files/upload-complete`, {
|
||||||
objectKey: signData.objectKey,
|
objectKey: signData.objectKey,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -2957,6 +3034,7 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
|
|
||||||
// 6. 刷新文件列表和空间统计
|
// 6. 刷新文件列表和空间统计
|
||||||
await this.loadFiles(this.currentPath);
|
await this.loadFiles(this.currentPath);
|
||||||
@@ -3006,6 +3084,7 @@ handleDragLeave(e) {
|
|||||||
if (uploadedBytes > 0 && file.size > 0) {
|
if (uploadedBytes > 0 && file.size > 0) {
|
||||||
this.uploadedBytes = uploadedBytes;
|
this.uploadedBytes = uploadedBytes;
|
||||||
this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100));
|
this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100));
|
||||||
|
this.uploadPhase = '上传中';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
|
||||||
@@ -3037,8 +3116,10 @@ handleDragLeave(e) {
|
|||||||
this.uploadProgress = file.size > 0
|
this.uploadProgress = file.size > 0
|
||||||
? Math.min(100, Math.round((uploadedBytes / file.size) * 100))
|
? Math.min(100, Math.round((uploadedBytes / file.size) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
|
this.uploadPhase = this.uploadProgress >= 100 ? '服务端合并中' : '上传中';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.uploadPhase = '服务端合并中';
|
||||||
const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, {
|
const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, {
|
||||||
session_id: sessionId
|
session_id: sessionId
|
||||||
});
|
});
|
||||||
@@ -3052,6 +3133,7 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
await this.loadFiles(this.currentPath);
|
await this.loadFiles(this.currentPath);
|
||||||
await this.refreshStorageUsage();
|
await this.refreshStorageUsage();
|
||||||
return true;
|
return true;
|
||||||
@@ -3078,6 +3160,7 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3092,6 +3175,7 @@ handleDragLeave(e) {
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
timeout: 30 * 60 * 1000,
|
timeout: 30 * 60 * 1000,
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
|
this.uploadPhase = '上传中';
|
||||||
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||||
this.uploadedBytes = progressEvent.loaded;
|
this.uploadedBytes = progressEvent.loaded;
|
||||||
this.totalBytes = progressEvent.total;
|
this.totalBytes = progressEvent.total;
|
||||||
@@ -3104,8 +3188,16 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
|
this.uploadPhase = '';
|
||||||
await this.loadFiles(this.currentPath);
|
await this.loadFiles(this.currentPath);
|
||||||
await this.refreshStorageUsage();
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -3702,8 +3794,14 @@ handleDragLeave(e) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async submitResetPassword() {
|
async submitResetPassword() {
|
||||||
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password || this.resetPasswordForm.new_password.length < 6) {
|
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password) {
|
||||||
this.showToast('error', '错误', '请输入有效的重置链接和新密码(至少6位)');
|
this.showToast('error', '错误', '请输入有效的重置链接和新密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordCheck = this.validateAccountPassword(this.resetPasswordForm.new_password);
|
||||||
|
if (!passwordCheck.valid) {
|
||||||
|
this.showToast('error', '错误', passwordCheck.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.passwordResetting = true;
|
this.passwordResetting = true;
|
||||||
@@ -3735,26 +3833,37 @@ handleDragLeave(e) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadUserFiles(path) {
|
async loadUserFiles(path) {
|
||||||
|
const requestId = ++this.inspectionLoadRequestId;
|
||||||
|
const targetPath = path || '/';
|
||||||
this.inspectionLoading = true;
|
this.inspectionLoading = true;
|
||||||
this.inspectionPath = path;
|
this.inspectionPath = targetPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(
|
const response = await axios.get(
|
||||||
`${this.apiBase}/api/admin/users/${this.inspectionUser.id}/files`,
|
`${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) {
|
if (response.data.success) {
|
||||||
this.inspectionFiles = response.data.items;
|
this.inspectionFiles = response.data.items;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== this.inspectionLoadRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('加载用户文件失败:', error);
|
console.error('加载用户文件失败:', error);
|
||||||
this.showToast('error', '错误', error.response?.data?.message || '加载文件失败');
|
this.showToast('error', '错误', error.response?.data?.message || '加载文件失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === this.inspectionLoadRequestId) {
|
||||||
this.inspectionLoading = false;
|
this.inspectionLoading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleInspectionFileClick(file) {
|
handleInspectionFileClick(file) {
|
||||||
@@ -4023,16 +4132,13 @@ handleDragLeave(e) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openOssConfigModal() {
|
openOssConfigModal() {
|
||||||
// 只有管理员才能配置OSS
|
if (!this.user) {
|
||||||
if (!this.user?.is_admin) {
|
this.showToast('error', '请先登录', '登录后才能配置 OSS');
|
||||||
this.showToast('error', '权限不足', '只有管理员才能配置OSS服务');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.showOssGuideModal = false;
|
this.showOssGuideModal = false;
|
||||||
this.showOssConfigModal = true;
|
this.showOssConfigModal = true;
|
||||||
if (this.user && !this.user.is_admin) {
|
|
||||||
this.loadOssConfig();
|
this.loadOssConfig();
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
closeOssConfigModal() {
|
closeOssConfigModal() {
|
||||||
@@ -4075,6 +4181,7 @@ handleDragLeave(e) {
|
|||||||
// 切换到管理后台时,重新加载用户列表、健康检测和系统日志
|
// 切换到管理后台时,重新加载用户列表、健康检测和系统日志
|
||||||
if (this.user && this.user.is_admin) {
|
if (this.user && this.user.is_admin) {
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
|
this.loadSystemSettings();
|
||||||
this.loadServerStorageStats();
|
this.loadServerStorageStats();
|
||||||
if (this.adminTab === 'monitor') {
|
if (this.adminTab === 'monitor') {
|
||||||
this.initMonitorTab();
|
this.initMonitorTab();
|
||||||
@@ -4087,6 +4194,7 @@ handleDragLeave(e) {
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
this.loadOnlineDevices();
|
this.loadOnlineDevices();
|
||||||
if (this.user && !this.user.is_admin) {
|
if (this.user && !this.user.is_admin) {
|
||||||
|
this.loadOssConfig();
|
||||||
this.loadDownloadTrafficReport();
|
this.loadDownloadTrafficReport();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -4360,11 +4468,25 @@ handleDragLeave(e) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getHighlightedText(value, keyword) {
|
getHighlightedText(value, keyword) {
|
||||||
const text = this.escapeHtml(value || '-');
|
const rawText = String(value || '-');
|
||||||
const search = String(keyword || '').trim();
|
const search = String(keyword || '').trim();
|
||||||
if (!search) return text;
|
if (!search) return this.escapeHtml(rawText);
|
||||||
const reg = new RegExp(this.escapeRegExp(search), 'ig');
|
|
||||||
return text.replace(reg, (match) => `<mark class="admin-search-hit">${match}</mark>`);
|
const lowerText = rawText.toLowerCase();
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
let cursor = 0;
|
||||||
|
let output = '';
|
||||||
|
let index = lowerText.indexOf(lowerSearch, cursor);
|
||||||
|
|
||||||
|
while (index !== -1) {
|
||||||
|
output += this.escapeHtml(rawText.slice(cursor, index));
|
||||||
|
output += `<mark class="admin-search-hit">${this.escapeHtml(rawText.slice(index, index + search.length))}</mark>`;
|
||||||
|
cursor = index + search.length;
|
||||||
|
index = lowerText.indexOf(lowerSearch, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
output += this.escapeHtml(rawText.slice(cursor));
|
||||||
|
return output;
|
||||||
},
|
},
|
||||||
|
|
||||||
formatBytes(bytes) {
|
formatBytes(bytes) {
|
||||||
@@ -4566,9 +4688,14 @@ handleDragLeave(e) {
|
|||||||
async updateSystemSettings() {
|
async updateSystemSettings() {
|
||||||
try {
|
try {
|
||||||
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
|
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
|
||||||
|
const currentPassword = window.prompt('请输入当前管理员密码以确认修改系统设置');
|
||||||
|
if (currentPassword === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
max_upload_size: maxUploadSize,
|
max_upload_size: maxUploadSize,
|
||||||
|
current_password: currentPassword,
|
||||||
download_security: {
|
download_security: {
|
||||||
enabled: !!this.systemSettings.downloadSecurity.enabled,
|
enabled: !!this.systemSettings.downloadSecurity.enabled,
|
||||||
same_ip_same_file: {
|
same_ip_same_file: {
|
||||||
@@ -4610,7 +4737,7 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新系统设置失败:', error);
|
console.error('更新系统设置失败:', error);
|
||||||
this.showToast('error', '错误', '更新系统设置失败');
|
this.showToast('error', '错误', error.response?.data?.message || '更新系统设置失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4966,7 +5093,7 @@ handleDragLeave(e) {
|
|||||||
const csrfToken = document.cookie
|
const csrfToken = document.cookie
|
||||||
.split('; ')
|
.split('; ')
|
||||||
.find(row => row.startsWith('csrf_token='))
|
.find(row => row.startsWith('csrf_token='))
|
||||||
?.split('=')[1];
|
?.substring('csrf_token='.length);
|
||||||
|
|
||||||
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
||||||
config.headers['X-CSRF-Token'] = csrfToken;
|
config.headers['X-CSRF-Token'] = csrfToken;
|
||||||
@@ -5017,12 +5144,18 @@ handleDragLeave(e) {
|
|||||||
// 设置axios响应拦截器,处理401错误(token过期/失效)
|
// 设置axios响应拦截器,处理401错误(token过期/失效)
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
response => response,
|
response => response,
|
||||||
error => {
|
async error => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
// 排除登录接口本身的401(密码错误等)
|
// 排除登录接口本身的401(密码错误等)
|
||||||
const isLoginApi = error.config?.url?.includes('/api/login');
|
const isLoginApi = error.config?.url?.includes('/api/login');
|
||||||
if (!isLoginApi && this.isLoggedIn) {
|
const isRefreshApi = error.config?.url?.includes('/api/refresh');
|
||||||
console.warn('[认证] 收到401响应,Token已失效');
|
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.handleTokenExpired();
|
||||||
this.showToast('warning', '登录已过期', '请重新登录');
|
this.showToast('warning', '登录已过期', '请重新登录');
|
||||||
}
|
}
|
||||||
@@ -5049,21 +5182,6 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
currentView(newView) {
|
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)) {
|
if (this.isLoggedIn && this.isViewAllowed(newView)) {
|
||||||
localStorage.setItem('lastView', newView);
|
localStorage.setItem('lastView', newView);
|
||||||
|
|||||||
BIN
frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe
Normal file
BIN
frontend/downloads/wanwan-cloud-desktop_v0.1.31_x64-setup.exe
Normal file
Binary file not shown.
@@ -292,8 +292,8 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">新密码</label>
|
<label class="form-label">新密码</label>
|
||||||
<input type="password" id="password" class="form-input"
|
<input type="password" id="password" class="form-input"
|
||||||
placeholder="请输入新密码" required minlength="6">
|
placeholder="请输入新密码" required minlength="8" maxlength="128">
|
||||||
<div class="password-hint">密码长度至少6位</div>
|
<div class="password-hint">密码长度8-128位,且包含字母、数字、特殊字符中的至少两种</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -347,6 +347,28 @@
|
|||||||
return url.searchParams.get(name);
|
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) {
|
function showSection(id) {
|
||||||
['loading', 'error', 'form', 'success'].forEach(s => {
|
['loading', 'error', 'form', 'success'].forEach(s => {
|
||||||
@@ -363,6 +385,35 @@
|
|||||||
alert.classList.remove('hidden');
|
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
|
// 验证token
|
||||||
async function validateToken() {
|
async function validateToken() {
|
||||||
resetToken = getParam('resetToken') || getParam('token');
|
resetToken = getParam('resetToken') || getParam('token');
|
||||||
@@ -373,6 +424,11 @@
|
|||||||
return;
|
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存在,显示表单
|
// Token存在,显示表单
|
||||||
showSection('form');
|
showSection('form');
|
||||||
}
|
}
|
||||||
@@ -386,8 +442,9 @@
|
|||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if (password.length < 6) {
|
const passwordCheck = validateAccountPassword(password);
|
||||||
showFormAlert('error', '密码长度至少6位');
|
if (!passwordCheck.valid) {
|
||||||
|
showFormAlert('error', passwordCheck.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,9 +458,14 @@
|
|||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const csrfToken = await ensureCsrfToken();
|
||||||
const res = await fetch('/api/password/reset', {
|
const res = await fetch('/api/password/reset', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: resetToken,
|
token: resetToken,
|
||||||
new_password: password
|
new_password: password
|
||||||
|
|||||||
@@ -1040,6 +1040,13 @@
|
|||||||
<span v-else> | 有效期: <strong class="share-expire-time valid">永久有效</strong></span>
|
<span v-else> | 有效期: <strong class="share-expire-time valid">永久有效</strong></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="shareInfo.share_type !== 'file'" class="share-meta-bar" style="justify-content: space-between;">
|
||||||
|
<span>当前位置:/{{ currentPath || '' }}</span>
|
||||||
|
<button v-if="currentPath" class="btn btn-secondary" style="width: auto; padding: 8px 14px;" @click="goParentDirectory">
|
||||||
|
<i class="fas fa-arrow-left"></i> 返回上级
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 视图切换按钮 (多文件时才显示) -->
|
<!-- 视图切换按钮 (多文件时才显示) -->
|
||||||
<div v-if="files.length > 1" class="view-controls">
|
<div v-if="files.length > 1" class="view-controls">
|
||||||
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
|
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
|
||||||
@@ -1061,6 +1068,9 @@
|
|||||||
<i class="single-file-icon fas" :class="getFileIcon(viewingFile || files[0])" :style="getIconColor(viewingFile || files[0])"></i>
|
<i class="single-file-icon fas" :class="getFileIcon(viewingFile || files[0])" :style="getIconColor(viewingFile || files[0])"></i>
|
||||||
<div class="single-file-name">{{ (viewingFile || files[0]).name }}</div>
|
<div class="single-file-name">{{ (viewingFile || files[0]).name }}</div>
|
||||||
<div class="single-file-size">{{ (viewingFile || files[0]).sizeFormatted }}</div>
|
<div class="single-file-size">{{ (viewingFile || files[0]).sizeFormatted }}</div>
|
||||||
|
<button v-if="(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="enterDirectory(viewingFile || files[0])">
|
||||||
|
<i class="fas fa-folder-open"></i> 进入文件夹
|
||||||
|
</button>
|
||||||
<button v-if="!(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="downloadFile(viewingFile || files[0])">
|
<button v-if="!(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="downloadFile(viewingFile || files[0])">
|
||||||
<i class="fas fa-download"></i> 下载文件
|
<i class="fas fa-download"></i> 下载文件
|
||||||
</button>
|
</button>
|
||||||
@@ -1083,7 +1093,7 @@
|
|||||||
|
|
||||||
<!-- 列表视图 -->
|
<!-- 列表视图 -->
|
||||||
<ul v-else-if="!viewingFile" class="file-list">
|
<ul v-else-if="!viewingFile" class="file-list">
|
||||||
<li v-for="file in files" :key="file.name" class="file-item">
|
<li v-for="file in files" :key="file.name" class="file-item" @click="handleFileClick(file)" style="cursor: pointer;">
|
||||||
<div class="file-info">
|
<div class="file-info">
|
||||||
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
|
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
|
||||||
<div class="file-name-container">
|
<div class="file-name-container">
|
||||||
@@ -1126,6 +1136,7 @@
|
|||||||
shareNotFound: false,
|
shareNotFound: false,
|
||||||
shareInfo: null,
|
shareInfo: null,
|
||||||
files: [],
|
files: [],
|
||||||
|
currentPath: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
downloadAlertMessage: '',
|
downloadAlertMessage: '',
|
||||||
@@ -1196,6 +1207,8 @@
|
|||||||
async verifyShare() {
|
async verifyShare() {
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.downloadAlertMessage = '';
|
this.downloadAlertMessage = '';
|
||||||
|
this.currentPath = '';
|
||||||
|
this.viewingFile = null;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1210,40 +1223,45 @@
|
|||||||
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
|
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
|
||||||
if (response.data.file) {
|
if (response.data.file) {
|
||||||
this.files = [response.data.file];
|
this.files = [response.data.file];
|
||||||
this.loading = false;
|
|
||||||
} else {
|
} else {
|
||||||
// 目录分享,需要加载文件列表
|
// 目录分享,需要加载文件列表
|
||||||
await this.loadFiles();
|
await this.loadFiles();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.errorMessage = response.data.message || '验证失败';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 404错误 - 分享不存在
|
// 404错误 - 分享不存在
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
this.shareNotFound = true;
|
this.shareNotFound = true;
|
||||||
this.loading = false;
|
|
||||||
}
|
}
|
||||||
// 需要密码
|
// 需要密码
|
||||||
else if (error.response?.data?.needPassword) {
|
else if (error.response?.data?.needPassword) {
|
||||||
this.needPassword = true;
|
this.needPassword = true;
|
||||||
this.loading = false;
|
|
||||||
}
|
}
|
||||||
// 其他错误
|
// 其他错误
|
||||||
else {
|
else {
|
||||||
this.errorMessage = error.response?.data?.message || '验证失败';
|
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 {
|
try {
|
||||||
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
|
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
|
||||||
password: this.password,
|
password: this.password,
|
||||||
path: ''
|
path: this.currentPath
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
this.files = response.data.items;
|
this.files = response.data.items;
|
||||||
|
} else {
|
||||||
|
this.errorMessage = response.data.message || '加载文件失败';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载文件失败:', error);
|
console.error('加载文件失败:', error);
|
||||||
@@ -1255,10 +1273,32 @@
|
|||||||
|
|
||||||
// 处理文件点击 - 显示文件详情页面
|
// 处理文件点击 - 显示文件详情页面
|
||||||
handleFileClick(file) {
|
handleFileClick(file) {
|
||||||
// 所有文件类型都显示详情页面(分享页面不提供媒体预览)
|
if (file?.isDirectory) {
|
||||||
|
this.enterDirectory(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.viewFileDetail(file);
|
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) {
|
viewFileDetail(file) {
|
||||||
this.viewingFile = file;
|
this.viewingFile = file;
|
||||||
@@ -1279,8 +1319,9 @@
|
|||||||
filePath = this.shareInfo.share_path;
|
filePath = this.shareInfo.share_path;
|
||||||
} else {
|
} else {
|
||||||
// 目录分享,组合路径
|
// 目录分享,组合路径
|
||||||
const basePath = this.shareInfo.share_path;
|
const basePath = String(this.shareInfo.share_path || '/').replace(/\/+$/g, '') || '/';
|
||||||
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
|
const relativePath = this.joinSharePath(this.currentPath, file.name);
|
||||||
|
filePath = basePath === '/' ? `/${relativePath}` : `${basePath}/${relativePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1295,11 +1336,10 @@
|
|||||||
// OSS 直连下载:新窗口打开
|
// OSS 直连下载:新窗口打开
|
||||||
console.log("[分享下载] OSS 直连下载");
|
console.log("[分享下载] OSS 直连下载");
|
||||||
|
|
||||||
// 仅直连下载需要单独记录下载次数(本地代理下载在后端接口内已计数)
|
const downloadWindow = window.open(data.downloadUrl, '_blank', 'noopener,noreferrer');
|
||||||
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
|
if (downloadWindow) {
|
||||||
.catch(err => console.error('记录下载次数失败:', err));
|
downloadWindow.opener = null;
|
||||||
|
}
|
||||||
window.open(data.downloadUrl, '_blank');
|
|
||||||
} else {
|
} else {
|
||||||
// 本地存储:通过后端下载
|
// 本地存储:通过后端下载
|
||||||
console.log("[分享下载] 后端代理下载");
|
console.log("[分享下载] 后端代理下载");
|
||||||
@@ -1474,7 +1514,7 @@
|
|||||||
const csrfToken = document.cookie
|
const csrfToken = document.cookie
|
||||||
.split('; ')
|
.split('; ')
|
||||||
.find(row => row.startsWith('csrf_token='))
|
.find(row => row.startsWith('csrf_token='))
|
||||||
?.split('=')[1];
|
?.substring('csrf_token='.length);
|
||||||
|
|
||||||
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) {
|
||||||
config.headers['X-CSRF-Token'] = csrfToken;
|
config.headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
|||||||
@@ -265,6 +265,11 @@
|
|||||||
return;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/verify-email?token=${encodeURIComponent(token)}`);
|
const res = await fetch(`/api/verify-email?token=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
|||||||
30
install.sh
30
install.sh
@@ -2033,10 +2033,18 @@ configure_admin_account() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
read -s -p "管理员密码(至少6位): " ADMIN_PASSWORD < /dev/tty
|
read -s -p "管理员密码(至少8位,且至少包含字母/数字/特殊字符中的两类): " ADMIN_PASSWORD < /dev/tty
|
||||||
echo ""
|
echo ""
|
||||||
if [[ ${#ADMIN_PASSWORD} -lt 6 ]]; then
|
if [[ ${#ADMIN_PASSWORD} -lt 8 ]]; then
|
||||||
print_error "密码至少6个字符"
|
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
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -2111,9 +2119,6 @@ create_env_file() {
|
|||||||
# 生成随机JWT密钥
|
# 生成随机JWT密钥
|
||||||
JWT_SECRET=$(openssl rand -base64 32)
|
JWT_SECRET=$(openssl rand -base64 32)
|
||||||
|
|
||||||
# 生成随机Session密钥
|
|
||||||
SESSION_SECRET=$(openssl rand -hex 32)
|
|
||||||
|
|
||||||
# 生成随机加密密钥(用于加密OSS等敏感信息)
|
# 生成随机加密密钥(用于加密OSS等敏感信息)
|
||||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||||
|
|
||||||
@@ -2162,9 +2167,6 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
|||||||
# JWT密钥
|
# JWT密钥
|
||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
|
||||||
# Session密钥(用于会话管理)
|
|
||||||
SESSION_SECRET=${SESSION_SECRET}
|
|
||||||
|
|
||||||
# 加密密钥(用于加密OSS Access Key Secret等敏感信息)
|
# 加密密钥(用于加密OSS Access Key Secret等敏感信息)
|
||||||
# 重要:此密钥必须配置,否则服务无法启动
|
# 重要:此密钥必须配置,否则服务无法启动
|
||||||
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
@@ -3730,16 +3732,6 @@ update_patch_env() {
|
|||||||
print_info ".env 已包含 TRUST_PROXY,保持不变"
|
print_info ".env 已包含 TRUST_PROXY,保持不变"
|
||||||
fi
|
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等敏感信息,必需)
|
# 检查 ENCRYPTION_KEY(加密密钥,用于加密OSS等敏感信息,必需)
|
||||||
if ! grep -q "^ENCRYPTION_KEY=" "${PROJECT_DIR}/backend/.env"; then
|
if ! grep -q "^ENCRYPTION_KEY=" "${PROJECT_DIR}/backend/.env"; then
|
||||||
# 自动生成随机加密密钥
|
# 自动生成随机加密密钥
|
||||||
|
|||||||
Reference in New Issue
Block a user