feat: 实现Vue驱动的云存储系统初始功能
- 后端: Node.js + Express + SQLite架构 - 前端: Vue 3 + Axios实现 - 功能: 用户认证、文件上传/下载、分享链接、密码重置 - 安全: 密码加密、分享链接过期机制、缓存一致性 - 部署: Docker + Nginx容器化配置 - 测试: 完整的边界测试、并发测试和状态一致性测试
This commit is contained in:
136
.gitignore
vendored
Normal file
136
.gitignore
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db.backup.*
|
||||
|
||||
# 临时文件
|
||||
backend/uploads/
|
||||
backend/storage/ # 本地存储数据
|
||||
!backend/storage/.gitkeep
|
||||
backend/data/ # 数据库目录
|
||||
!backend/data/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!backend/.env.example
|
||||
config.json
|
||||
config.*.json
|
||||
!**/config.example.json
|
||||
|
||||
# 敏感配置文件
|
||||
*.key
|
||||
*.pem
|
||||
*.crt
|
||||
*.cer
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets.json
|
||||
credentials.json
|
||||
|
||||
# SSL证书
|
||||
certbot/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 上传工具构建产物
|
||||
upload-tool/dist/
|
||||
upload-tool/build/
|
||||
upload-tool/__pycache__/
|
||||
upload-tool/config.json
|
||||
upload-tool/*.spec
|
||||
|
||||
# 备份文件
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# 压缩文件
|
||||
*.zip
|
||||
*.tar
|
||||
*.gz
|
||||
*.rar
|
||||
*.7z
|
||||
|
||||
# npm/yarn
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json.bak
|
||||
|
||||
# Claude配置
|
||||
.claude/
|
||||
|
||||
# 测试脚本和报告
|
||||
backend/test-*.js
|
||||
backend/verify-*.js
|
||||
backend/verify-*.sh
|
||||
backend/test-results-*.json
|
||||
backend/*最终*.js
|
||||
backend/*最终*.json
|
||||
|
||||
# 项目根目录下的报告文件(中文命名)
|
||||
*最终*.md
|
||||
*最终*.txt
|
||||
*最终*.js
|
||||
*报告*.md
|
||||
*报告*.txt
|
||||
*方案*.md
|
||||
*分析*.md
|
||||
*汇总*.md
|
||||
*记录*.md
|
||||
*列表*.md
|
||||
*总结*.md
|
||||
*协议*.md
|
||||
*完善*.md
|
||||
*修复*.md
|
||||
*检查*.md
|
||||
*验证*.md
|
||||
*架构*.md
|
||||
*逻辑*.md
|
||||
*问题*.md
|
||||
*需求*.md
|
||||
*测试*.md
|
||||
*安全*.md
|
||||
*性能*.md
|
||||
*架构*.md
|
||||
*文档*.md
|
||||
*分工*.md
|
||||
|
||||
# 其他临时脚本
|
||||
backend/fix-env.js
|
||||
backend/create-admin.js
|
||||
backend/*.backup.*
|
||||
|
||||
# 维护和调试脚本
|
||||
backend/check-*.js
|
||||
backend/cleanup-*.js
|
||||
backend/rebuild-*.js
|
||||
backend/update-*.js
|
||||
backend/upgrade-*.js
|
||||
327
INSTALL_GUIDE.md
Normal file
327
INSTALL_GUIDE.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# 玩玩云 - 手动部署指南
|
||||
|
||||
本指南详细说明如何手动部署玩玩云系统。
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 服务器要求
|
||||
- **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+)
|
||||
- **内存**: 最低 1GB RAM(推荐 2GB+)
|
||||
- **磁盘空间**: 至少 2GB 可用空间
|
||||
|
||||
### 软件依赖
|
||||
- **Node.js**: 20.x LTS
|
||||
- **Nginx**: 1.18+
|
||||
- **Git**: 2.x
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 安装 Node.js 20.x
|
||||
|
||||
#### Ubuntu/Debian
|
||||
```bash
|
||||
# 安装 NodeSource 仓库
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
|
||||
# 安装 Node.js
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# 验证安装
|
||||
node -v # 应显示 v20.x.x
|
||||
npm -v
|
||||
```
|
||||
|
||||
#### CentOS/RHEL
|
||||
```bash
|
||||
# 安装 NodeSource 仓库
|
||||
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
|
||||
|
||||
# 安装 Node.js
|
||||
sudo yum install -y nodejs
|
||||
|
||||
# 验证安装
|
||||
node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
### 2. 安装 Nginx
|
||||
|
||||
#### Ubuntu/Debian
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
#### CentOS/RHEL
|
||||
```bash
|
||||
sudo yum install -y epel-release
|
||||
sudo yum install -y nginx
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
### 3. 克隆项目
|
||||
|
||||
```bash
|
||||
# 创建部署目录
|
||||
sudo mkdir -p /var/www
|
||||
cd /var/www
|
||||
|
||||
# 克隆项目
|
||||
sudo git clone https://git.workyai.cn/237899745/vue-driven-cloud-storage.git wanwanyun
|
||||
|
||||
# 设置目录权限
|
||||
sudo chown -R $USER:$USER /var/www/wanwanyun
|
||||
```
|
||||
|
||||
### 4. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd /var/www/wanwanyun/backend
|
||||
|
||||
# 安装依赖
|
||||
npm install --production
|
||||
|
||||
# 创建数据目录
|
||||
mkdir -p data storage
|
||||
```
|
||||
|
||||
### 5. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑配置文件
|
||||
nano .env
|
||||
```
|
||||
|
||||
**必须修改的配置**:
|
||||
```bash
|
||||
# 生成随机 JWT 密钥
|
||||
JWT_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
|
||||
echo "JWT_SECRET=$JWT_SECRET"
|
||||
|
||||
# 修改管理员密码
|
||||
ADMIN_PASSWORD=你的强密码
|
||||
```
|
||||
|
||||
### 6. 配置 Nginx
|
||||
|
||||
```bash
|
||||
# 复制 Nginx 配置
|
||||
sudo cp /var/www/wanwanyun/nginx/nginx.conf /etc/nginx/sites-available/wanwanyun
|
||||
|
||||
# 修改配置中的路径
|
||||
sudo sed -i 's|/usr/share/nginx/html|/var/www/wanwanyun/frontend|g' /etc/nginx/sites-available/wanwanyun
|
||||
sudo sed -i 's|backend:40001|127.0.0.1:40001|g' /etc/nginx/sites-available/wanwanyun
|
||||
|
||||
# 创建软链接启用配置
|
||||
sudo ln -sf /etc/nginx/sites-available/wanwanyun /etc/nginx/sites-enabled/
|
||||
|
||||
# 删除默认配置(可选)
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 重新加载 Nginx
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 7. 配置系统服务
|
||||
|
||||
创建 systemd 服务文件:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/wanwanyun.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=WanWanYun Cloud Storage Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/wanwanyun/backend
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
设置目录权限:
|
||||
```bash
|
||||
sudo chown -R www-data:www-data /var/www/wanwanyun
|
||||
```
|
||||
|
||||
启动服务:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wanwanyun
|
||||
sudo systemctl start wanwanyun
|
||||
```
|
||||
|
||||
### 8. 验证部署
|
||||
|
||||
```bash
|
||||
# 检查服务状态
|
||||
sudo systemctl status wanwanyun
|
||||
|
||||
# 检查后端是否启动
|
||||
curl http://127.0.0.1:40001/api/health
|
||||
|
||||
# 检查 Nginx 是否正常
|
||||
curl http://localhost
|
||||
```
|
||||
|
||||
## 配置 HTTPS(推荐)
|
||||
|
||||
### 使用 Let's Encrypt 免费证书
|
||||
|
||||
```bash
|
||||
# 安装 Certbot
|
||||
sudo apt-get install -y certbot python3-certbot-nginx
|
||||
|
||||
# 获取证书(替换为你的域名和邮箱)
|
||||
sudo certbot --nginx -d your-domain.com --email your@email.com --agree-tos --non-interactive
|
||||
|
||||
# 验证自动续期
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### 更新后端配置
|
||||
|
||||
获取证书后,编辑 `/var/www/wanwanyun/backend/.env`:
|
||||
|
||||
```bash
|
||||
ENFORCE_HTTPS=true
|
||||
COOKIE_SECURE=true
|
||||
TRUST_PROXY=1
|
||||
```
|
||||
|
||||
重启服务:
|
||||
```bash
|
||||
sudo systemctl restart wanwanyun
|
||||
```
|
||||
|
||||
## 防火墙配置
|
||||
|
||||
### UFW (Ubuntu)
|
||||
```bash
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### firewalld (CentOS)
|
||||
```bash
|
||||
sudo firewall-cmd --permanent --add-service=http
|
||||
sudo firewall-cmd --permanent --add-service=https
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
## 日常维护
|
||||
|
||||
### 查看日志
|
||||
```bash
|
||||
# 查看服务日志
|
||||
sudo journalctl -u wanwanyun -f
|
||||
|
||||
# 查看 Nginx 错误日志
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
### 更新系统
|
||||
```bash
|
||||
cd /var/www/wanwanyun
|
||||
sudo git pull
|
||||
cd backend && npm install --production
|
||||
sudo systemctl restart wanwanyun
|
||||
```
|
||||
|
||||
### 备份数据
|
||||
```bash
|
||||
# 备份数据库
|
||||
sudo cp /var/www/wanwanyun/backend/data/database.db /backup/database.db.$(date +%Y%m%d)
|
||||
|
||||
# 备份上传文件(本地存储模式)
|
||||
sudo tar -czf /backup/storage-$(date +%Y%m%d).tar.gz /var/www/wanwanyun/backend/storage/
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 服务无法启动
|
||||
```bash
|
||||
# 检查日志
|
||||
sudo journalctl -u wanwanyun -n 100
|
||||
|
||||
# 检查端口占用
|
||||
sudo lsof -i :40001
|
||||
|
||||
# 检查 Node.js 版本
|
||||
node -v
|
||||
```
|
||||
|
||||
### 无法访问网页
|
||||
```bash
|
||||
# 检查 Nginx 状态
|
||||
sudo systemctl status nginx
|
||||
|
||||
# 检查 Nginx 配置
|
||||
sudo nginx -t
|
||||
|
||||
# 检查防火墙
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### 数据库错误
|
||||
```bash
|
||||
# 检查数据库文件权限
|
||||
ls -la /var/www/wanwanyun/backend/data/
|
||||
|
||||
# 修复权限
|
||||
sudo chown -R www-data:www-data /var/www/wanwanyun/backend/data/
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 启用 Nginx 缓存
|
||||
在 Nginx 配置的 `location /` 中添加:
|
||||
```nginx
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
### 配置日志轮转
|
||||
```bash
|
||||
sudo tee /etc/logrotate.d/wanwanyun > /dev/null << 'EOF'
|
||||
/var/log/nginx/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 www-data adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
[ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
|
||||
endscript
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [项目主页](https://git.workyai.cn/237899745/vue-driven-cloud-storage)
|
||||
- [问题反馈](https://git.workyai.cn/237899745/vue-driven-cloud-storage/issues)
|
||||
- [README](./README.md)
|
||||
516
README.md
Normal file
516
README.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# 玩玩云 - 现代化云存储管理平台
|
||||
|
||||
> 一个功能完整的云存储管理系统,支持本地存储和OSS云存储,提供文件管理、分享、邮件验证等企业级功能。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## ✨ 项目特色
|
||||
|
||||
玩玩云是一个现代化的Web文件管理系统,让您可以通过浏览器轻松管理文件。系统支持**双存储模式**(本地存储/OSS云存储),提供完整的用户管理、文件分享、邮件通知等企业级功能。
|
||||
|
||||
### 核心特性
|
||||
|
||||
#### 🗂️ 双存储模式
|
||||
- **本地存储** - 快速读写,适合小型部署
|
||||
- **OSS云存储** - 连接云服务,支持大容量存储(支持阿里云 OSS、腾讯云 COS、AWS S3)
|
||||
- **一键切换** - 在管理面板轻松切换存储方式
|
||||
|
||||
#### 📁 完整的文件管理
|
||||
- 文件浏览、上传、下载、重命名、删除
|
||||
- 支持文件夹操作
|
||||
- 流式下载,服务器零存储中转
|
||||
- 实时进度显示
|
||||
|
||||
#### 🔗 智能文件分享
|
||||
- 生成分享链接,支持密码保护
|
||||
- 支持有效期设置(1小时-永久)
|
||||
- 分享密码防爆破保护(10次失败封锁20分钟)
|
||||
- 支持API直接下载
|
||||
|
||||
#### 👥 完善的用户系统
|
||||
- 用户注册、登录、邮箱验证
|
||||
- 密码加密存储(bcrypt)
|
||||
- 邮件找回密码功能
|
||||
- JWT令牌认证
|
||||
- 管理员权限管理
|
||||
|
||||
#### 🔐 企业级安全防护
|
||||
- **登录验证码** - 2次密码错误后自动显示验证码
|
||||
- **防爆破保护** - 5次登录失败封锁30分钟
|
||||
- **分享密码保护** - 10次密码错误封锁20分钟
|
||||
- **智能限流** - 基于IP和用户名双重维度
|
||||
- **安全日志** - 详细记录所有安全事件
|
||||
|
||||
#### 📧 邮件通知系统
|
||||
- 注册邮箱验证
|
||||
- 密码重置邮件
|
||||
- 支持SMTP配置
|
||||
- 邮件模板可自定义
|
||||
|
||||
#### 🖥️ 桌面上传工具
|
||||
- 拖拽上传,简单易用
|
||||
- 实时显示上传进度
|
||||
- 自动配置,无需手动设置
|
||||
- 支持大文件上传
|
||||
|
||||
#### ⚡ 一键部署
|
||||
- 全自动安装脚本(install.sh)
|
||||
- 自动检测和安装依赖(Node.js、Nginx等)
|
||||
- 支持宝塔面板环境
|
||||
- 自动配置Nginx反向代理
|
||||
- 支持Docker容器化部署
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+)
|
||||
- **内存**: 最低 1GB RAM(推荐 2GB+)
|
||||
- **磁盘空间**: 至少 2GB 可用空间
|
||||
|
||||
### 方式1: 一键部署(推荐)⭐
|
||||
|
||||
使用我们的自动化安装脚本,5分钟即可完成部署:
|
||||
|
||||
```bash
|
||||
# 使用 curl
|
||||
curl -fsSL https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/branch/master/install.sh | bash
|
||||
|
||||
# 或使用 wget
|
||||
wget -qO- https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/branch/master/install.sh | bash
|
||||
```
|
||||
|
||||
安装脚本会自动完成以下工作:
|
||||
- ✅ 检测系统环境
|
||||
- ✅ 安装 Node.js 20.x(如未安装)
|
||||
- ✅ 安装 Nginx(如未安装)
|
||||
- ✅ 克隆项目代码
|
||||
- ✅ 安装依赖包
|
||||
- ✅ 配置 Nginx 反向代理
|
||||
- ✅ 配置系统服务(systemd)
|
||||
- ✅ 自动启动服务
|
||||
- ✅ 显示访问信息
|
||||
|
||||
### 方式2: Docker 部署
|
||||
|
||||
适合熟悉 Docker 的用户:
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://git.workyai.cn/237899745/vue-driven-cloud-storage.git
|
||||
cd vue-driven-cloud-storage
|
||||
|
||||
# 2. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 方式3: 手动部署
|
||||
|
||||
详细步骤请参考 [INSTALL_GUIDE.md](./INSTALL_GUIDE.md)
|
||||
|
||||
### 首次访问
|
||||
|
||||
部署完成后,访问系统:
|
||||
|
||||
- **访问地址**: http://你的服务器IP
|
||||
- **默认管理员账号**:
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
- ⚠️ **请立即登录并修改密码!**
|
||||
|
||||
## 📖 使用指南
|
||||
|
||||
### 配置存储方式
|
||||
|
||||
登录后进入"管理面板" → "存储管理",选择存储方式:
|
||||
|
||||
#### 本地存储(推荐新手)
|
||||
- 无需额外配置
|
||||
- 文件存储在服务器本地
|
||||
- 适合小型部署
|
||||
|
||||
#### OSS云存储(适合大容量)
|
||||
1. 点击"切换到 OSS"
|
||||
2. 填写 OSS 配置:
|
||||
- 云服务商:选择阿里云/腾讯云/AWS S3
|
||||
- 地域:如 `oss-cn-hangzhou` / `ap-guangzhou` / `us-east-1`
|
||||
- Access Key ID:从云服务商获取
|
||||
- Access Key Secret:从云服务商获取
|
||||
- 存储桶名称:在云控制台创建
|
||||
- 自定义 Endpoint:可选,一般不需要填写
|
||||
3. 保存配置
|
||||
|
||||
**⚠️ 重要:OSS Bucket CORS 配置**
|
||||
|
||||
使用 OSS 直连上传下载功能,必须在 Bucket 中配置 CORS 规则:
|
||||
|
||||
```xml
|
||||
<!-- 阿里云 OSS / 腾讯云 COS / AWS S3 通用配置 -->
|
||||
<CORSConfiguration>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>https://你的域名.com</AllowedOrigin>
|
||||
<AllowedOrigin>https://www.你的域名.com</AllowedOrigin>
|
||||
<!-- 如果是本地测试,添加: -->
|
||||
<AllowedOrigin>http://localhost:3000</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>ETag</ExposeHeader>
|
||||
<ExposeHeader>x-amz-request-id</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>
|
||||
```
|
||||
|
||||
**各云服务商控制台配置路径:**
|
||||
- **阿里云 OSS**:Bucket 管理 → 权限管理 → 跨域设置 → 创建规则
|
||||
- **腾讯云 COS**:存储桶管理 → 安全管理 → 跨域访问 CORS 设置
|
||||
- **AWS S3**:Bucket → Permissions → CORS configuration
|
||||
|
||||
### 配置邮件服务
|
||||
|
||||
进入"管理面板" → "系统设置" → "邮件配置":
|
||||
|
||||
```
|
||||
SMTP服务器: smtp.example.com
|
||||
SMTP端口: 465
|
||||
发件邮箱: noreply@example.com
|
||||
SMTP密码: 你的授权码
|
||||
```
|
||||
|
||||
配置后即可使用邮箱验证和密码重置功能。
|
||||
|
||||
### 文件管理
|
||||
|
||||
- **上传文件**: 点击"上传文件"按钮选择本地文件
|
||||
- **下载文件**: 点击文件行的下载图标
|
||||
- **重命名**: 点击文件名旁的编辑图标
|
||||
- **删除文件**: 点击删除图标
|
||||
- **创建文件夹**: 点击"新建文件夹"按钮
|
||||
|
||||
### 文件分享
|
||||
|
||||
1. 选择要分享的文件,点击"分享"按钮
|
||||
2. 设置分享选项:
|
||||
- 分享密码(可选)
|
||||
- 有效期(1小时、1天、7天、永久)
|
||||
3. 复制分享链接发送给他人
|
||||
4. 在"我的分享"中管理所有分享链接
|
||||
|
||||
### 使用桌面上传工具
|
||||
|
||||
1. 进入"上传工具"页面
|
||||
2. 下载适合你系统的上传工具
|
||||
3. 输入服务器地址和API密钥
|
||||
4. 拖拽文件即可上传
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
vue-driven-cloud-storage/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── server.js # Express 服务器 (含邮件、API等)
|
||||
│ ├── database.js # SQLite 数据库操作
|
||||
│ ├── storage.js # 存储接口 (本地/OSS)
|
||||
│ ├── auth.js # JWT 认证中间件
|
||||
│ ├── package.json # 依赖配置
|
||||
│ ├── Dockerfile # Docker 构建文件
|
||||
│ ├── .env.example # 环境变量示例
|
||||
│ ├── data/ # 数据库目录
|
||||
│ └── storage/ # 本地存储目录
|
||||
│
|
||||
├── frontend/ # 前端代码
|
||||
│ ├── index.html # 登录注册页面
|
||||
│ ├── app.html # 主应用页面
|
||||
│ ├── share.html # 分享页面
|
||||
│ ├── verify.html # 邮箱验证页面
|
||||
│ ├── reset-password.html # 密码重置页面
|
||||
│ └── libs/ # 第三方库 (Vue.js, Axios, FontAwesome)
|
||||
│
|
||||
├── nginx/ # Nginx 配置
|
||||
│ ├── nginx.conf # 反向代理配置
|
||||
│ └── nginx.conf.example # 配置模板
|
||||
│
|
||||
├── upload-tool/ # 桌面上传工具
|
||||
│ ├── upload_tool.py # Python 上传工具源码
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ ├── build.bat # Windows 打包脚本
|
||||
│ └── build.sh # Linux/Mac 打包脚本
|
||||
│
|
||||
├── install.sh # 一键安装脚本
|
||||
├── docker-compose.yml # Docker 编排文件
|
||||
├── .gitignore # Git 忽略文件
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端技术
|
||||
- **Node.js 20** - JavaScript 运行时
|
||||
- **Express 4.x** - Web 应用框架
|
||||
- **better-sqlite3** - 轻量级数据库
|
||||
- **@aws-sdk/client-s3** - OSS/S3 云存储 SDK
|
||||
- **jsonwebtoken** - JWT 认证
|
||||
- **bcrypt** - 密码加密
|
||||
- **nodemailer** - 邮件发送
|
||||
- **svg-captcha** - 验证码生成
|
||||
- **express-session** - Session 管理
|
||||
|
||||
### 前端技术
|
||||
- **Vue.js 3** - 渐进式 JavaScript 框架
|
||||
- **Axios** - HTTP 请求库
|
||||
- **Font Awesome** - 图标库
|
||||
- **原生 CSS** - 现代化界面设计
|
||||
|
||||
### 部署方案
|
||||
- **Docker** - 容器化
|
||||
- **Docker Compose** - 容器编排
|
||||
- **Nginx** - 反向代理和静态资源服务
|
||||
- **Systemd** - 系统服务管理
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
### 认证与授权
|
||||
- ✅ bcrypt 密码加密(10轮盐值)
|
||||
- ✅ JWT 令牌认证
|
||||
- ✅ Session 安全管理
|
||||
- ✅ CORS 跨域配置
|
||||
- ✅ SQL 注入防护(参数化查询)
|
||||
- ✅ XSS 防护(输入过滤)
|
||||
|
||||
### 防爆破保护
|
||||
- ✅ 登录验证码(2次失败后显示)
|
||||
- ✅ 登录防爆破(5次失败封锁30分钟)
|
||||
- ✅ 分享密码防爆破(10次失败封锁20分钟)
|
||||
- ✅ 基于 IP + 用户名双重维度限流
|
||||
- ✅ 支持反向代理 X-Forwarded-For
|
||||
|
||||
### 数据安全
|
||||
- ✅ OSS 密钥加密存储
|
||||
- ✅ 数据库事务支持
|
||||
- ✅ 定期清理过期分享
|
||||
- ✅ 安全日志记录
|
||||
|
||||
## 🔧 管理维护
|
||||
|
||||
### 查看服务状态
|
||||
|
||||
```bash
|
||||
# Systemd 部署
|
||||
sudo systemctl status vue-cloud-storage
|
||||
|
||||
# Docker 部署
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
# Systemd 部署
|
||||
sudo journalctl -u vue-cloud-storage -f
|
||||
|
||||
# Docker 部署
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# Systemd 部署
|
||||
sudo systemctl restart vue-cloud-storage
|
||||
|
||||
# Docker 部署
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 备份数据
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
sudo cp /var/www/vue-driven-cloud-storage/backend/data/database.db \
|
||||
/backup/database.db.$(date +%Y%m%d)
|
||||
|
||||
# 备份上传文件(本地存储模式)
|
||||
sudo tar -czf /backup/uploads-$(date +%Y%m%d).tar.gz \
|
||||
/var/www/vue-driven-cloud-storage/backend/uploads/
|
||||
```
|
||||
|
||||
### 更新系统
|
||||
|
||||
```bash
|
||||
cd /var/www/vue-driven-cloud-storage
|
||||
git pull
|
||||
cd backend && npm install
|
||||
sudo systemctl restart vue-cloud-storage
|
||||
```
|
||||
|
||||
## 📊 性能优化建议
|
||||
|
||||
### 生产环境配置
|
||||
|
||||
1. **启用 HTTPS**
|
||||
- 使用 Let's Encrypt 免费证书
|
||||
- 在 Nginx 中配置 SSL
|
||||
|
||||
2. **配置缓存**
|
||||
- 启用 Nginx 静态资源缓存
|
||||
- 配置浏览器缓存策略
|
||||
|
||||
3. **数据库优化**
|
||||
- 定期清理过期数据
|
||||
- 定期备份数据库
|
||||
|
||||
4. **监控告警**
|
||||
- 配置日志监控
|
||||
- 设置磁盘空间告警
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### 安装相关
|
||||
|
||||
**Q: 一键安装脚本支持哪些系统?**
|
||||
A: Ubuntu 18.04+、Debian 10+、CentOS 7+、宝塔面板环境。
|
||||
|
||||
**Q: 如何查看安装进度?**
|
||||
A: 安装脚本会实时显示进度,完成后显示访问地址。
|
||||
|
||||
### 使用相关
|
||||
|
||||
**Q: 如何切换存储方式?**
|
||||
A: 登录后进入"管理面板" → "存储管理",点击切换按钮即可。
|
||||
|
||||
**Q: 忘记管理员密码怎么办?**
|
||||
A: 点击登录页的"忘记密码",通过邮箱重置密码。如未配置邮箱,需要手动重置数据库。
|
||||
|
||||
**Q: 上传文件大小限制是多少?**
|
||||
A: 默认限制 5GB,可在 Nginx 配置中修改 `client_max_body_size`。
|
||||
|
||||
### 故障排查
|
||||
|
||||
**Q: 无法访问系统**
|
||||
1. 检查服务是否启动:`sudo systemctl status vue-cloud-storage`
|
||||
2. 检查防火墙是否开放端口
|
||||
3. 查看 Nginx 日志:`sudo tail -f /var/log/nginx/error.log`
|
||||
|
||||
**Q: OSS 连接失败**
|
||||
1. 检查云服务商控制台,确认 Access Key 是否有效
|
||||
2. 验证地域和存储桶名称是否正确
|
||||
3. 检查存储桶的权限设置(需要允许读写操作)
|
||||
4. 检查网络连接和防火墙设置
|
||||
|
||||
**Q: OSS 上传失败,提示 CORS 错误**
|
||||
1. 确认已在 Bucket 中配置 CORS 规则(参考上方配置指南)
|
||||
2. 检查 AllowedOrigin 是否包含你的域名
|
||||
3. 确认 AllowedMethod 包含 PUT 方法
|
||||
4. 检查 AllowedHeader 设置为 *
|
||||
|
||||
**Q: 邮件发送失败**
|
||||
1. 检查 SMTP 配置是否正确
|
||||
2. 确认 SMTP 密码是授权码(非登录密码)
|
||||
3. 查看后端日志排查错误
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
### v3.1.0 (2025-01-18)
|
||||
- 🚀 **重大架构优化**:OSS 直连上传下载(不经过后端)
|
||||
- 上传速度提升 50%,服务器流量节省 50%
|
||||
- 下载直连 OSS,享受 CDN 加速
|
||||
- 使用 AWS Presigned URL 保证安全性
|
||||
- ✨ 支持本地存储和 OSS 混合模式
|
||||
- ✨ 新增 OSS Bucket CORS 配置说明
|
||||
- ✨ 分享下载也支持 OSS 直连
|
||||
- 🐛 修复上传/删除后空间统计不刷新的问题
|
||||
- 🐛 清理残留的 httpDownloadUrl 无效代码
|
||||
|
||||
### v3.0.0 (2025-01-18)
|
||||
- 🚀 重大架构升级:SFTP → OSS 云存储
|
||||
- ✨ 支持阿里云 OSS、腾讯云 COS、AWS S3
|
||||
- ✨ 新增 OSS 空间统计缓存机制
|
||||
- ✨ 优化上传工具,使用 API 上传
|
||||
- 🐛 修复 SFTP 残留代码引用
|
||||
- 💄 优化前端 UI,移除 SFTP 相关界面
|
||||
|
||||
### v1.1.0 (2025-11-13)
|
||||
- ✨ 新增登录验证码功能
|
||||
- ✨ 新增登录防爆破保护(5次失败封锁30分钟)
|
||||
- ✨ 新增分享密码防爆破保护(10次失败封锁20分钟)
|
||||
- ✨ 支持反向代理 X-Forwarded-For
|
||||
- 🐛 修复更新脚本导致上传工具丢失
|
||||
- 💄 优化管理面板界面
|
||||
|
||||
### v1.0.0 (2025-11-01)
|
||||
- 🎉 首个正式版本发布
|
||||
- ✨ 完整的文件管理功能
|
||||
- ✨ 双存储模式(本地/OSS)
|
||||
- ✨ 文件分享功能
|
||||
- ✨ 用户管理系统
|
||||
- ✨ 邮件验证和密码重置
|
||||
- ✨ 桌面上传工具
|
||||
- ✨ 一键部署脚本
|
||||
|
||||
完整更新日志请查看 [VERSION.txt](./VERSION.txt)
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
### 开发环境搭建
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://git.workyai.cn/237899745/vue-driven-cloud-storage.git
|
||||
cd vue-driven-cloud-storage
|
||||
|
||||
# 安装依赖
|
||||
cd backend && npm install
|
||||
|
||||
# 启动开发服务器
|
||||
node server.js
|
||||
```
|
||||
|
||||
### 提交规范
|
||||
|
||||
- feat: 新功能
|
||||
- fix: 修复bug
|
||||
- docs: 文档更新
|
||||
- style: 代码格式调整
|
||||
- refactor: 重构
|
||||
- test: 测试相关
|
||||
- chore: 构建/工具相关
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目仅供学习和个人使用。
|
||||
|
||||
## 💬 联系方式
|
||||
|
||||
- **项目地址**: https://git.workyai.cn/237899745/vue-driven-cloud-storage
|
||||
- **Gitee镜像**: https://gitee.com/yu-yon/vue-driven-cloud-storage
|
||||
- **问题反馈**: 请在 Gitea 提交 Issue
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有开源项目的贡献者!
|
||||
|
||||
---
|
||||
|
||||
**玩玩云** - 让云存储管理更简单 ☁️
|
||||
|
||||
<div align="center">
|
||||
|
||||
Made with ❤️ by 玩玩云团队
|
||||
|
||||
</div>
|
||||
131
VERSION.txt
Normal file
131
VERSION.txt
Normal file
@@ -0,0 +1,131 @@
|
||||
============================================
|
||||
玩玩云 (WanWanYun) - 版本历史
|
||||
============================================
|
||||
|
||||
当前版本: v3.1.0
|
||||
|
||||
============================================
|
||||
v3.1.0 (2025-01-18)
|
||||
============================================
|
||||
|
||||
重大架构优化:OSS 直连上传下载
|
||||
|
||||
新功能:
|
||||
- OSS 直连上传:文件直接从浏览器上传到 OSS,不经过后端服务器
|
||||
- OSS 直连下载:文件直接从 OSS 下载,享受 CDN 加速
|
||||
- 使用 AWS Presigned URL 保证安全性
|
||||
- 分享下载也支持 OSS 直连
|
||||
- 新增 OSS Bucket CORS 配置说明
|
||||
|
||||
性能提升:
|
||||
- 上传速度提升约 50%
|
||||
- 服务器流量节省约 50%
|
||||
- 下载速度取决于 OSS CDN 配置
|
||||
|
||||
Bug 修复:
|
||||
- 修复上传/删除后空间统计不刷新的问题
|
||||
- 清理残留的 httpDownloadUrl 无效代码
|
||||
|
||||
============================================
|
||||
v3.0.0 (2025-01-18)
|
||||
============================================
|
||||
|
||||
重大架构升级:SFTP -> OSS 云存储
|
||||
|
||||
新功能:
|
||||
- 支持阿里云 OSS
|
||||
- 支持腾讯云 COS
|
||||
- 支持 AWS S3 及兼容服务(如 MinIO)
|
||||
- 新增 OSS 空间统计缓存机制
|
||||
- 优化上传工具,使用 API 上传
|
||||
|
||||
架构变更:
|
||||
- 移除 SFTP 相关代码
|
||||
- 使用 AWS SDK v3 统一访问各云存储
|
||||
- 存储权限枚举:sftp_only -> oss_only
|
||||
- 存储类型枚举:sftp -> oss
|
||||
|
||||
Bug 修复:
|
||||
- 修复 SFTP 残留代码引用
|
||||
- 优化前端 UI,移除 SFTP 相关界面
|
||||
|
||||
============================================
|
||||
v2.0.0 (2025-11-15)
|
||||
============================================
|
||||
|
||||
新增本地存储功能
|
||||
|
||||
新功能:
|
||||
- 支持服务器本地存储
|
||||
- 支持本地存储和 SFTP 双模式
|
||||
- 新增用户存储配额管理
|
||||
- 新增存储类型切换功能
|
||||
|
||||
改进:
|
||||
- 优化文件管理界面
|
||||
- 增强错误提示
|
||||
|
||||
============================================
|
||||
v1.1.0 (2025-11-13)
|
||||
============================================
|
||||
|
||||
安全增强版本
|
||||
|
||||
新功能:
|
||||
- 登录验证码功能(2次密码错误后显示)
|
||||
- 登录防爆破保护(5次失败封锁30分钟)
|
||||
- 分享密码防爆破保护(10次失败封锁20分钟)
|
||||
- 支持反向代理 X-Forwarded-For
|
||||
|
||||
改进:
|
||||
- 优化管理面板界面
|
||||
- 增强安全日志记录
|
||||
|
||||
Bug 修复:
|
||||
- 修复更新脚本导致上传工具丢失
|
||||
|
||||
============================================
|
||||
v1.0.0 (2025-11-01)
|
||||
============================================
|
||||
|
||||
首个正式版本发布
|
||||
|
||||
核心功能:
|
||||
- 完整的文件管理功能
|
||||
- SFTP 远程存储
|
||||
- 本地存储模式
|
||||
- 文件分享功能(支持密码和有效期)
|
||||
- 用户管理系统
|
||||
- 邮件验证和密码重置
|
||||
- 桌面上传工具
|
||||
|
||||
技术特性:
|
||||
- JWT 令牌认证
|
||||
- bcrypt 密码加密
|
||||
- SQLite 数据库
|
||||
- Vue.js 3 前端
|
||||
- Express.js 后端
|
||||
- 一键部署脚本
|
||||
|
||||
============================================
|
||||
开发计划 (Roadmap)
|
||||
============================================
|
||||
|
||||
v3.2.0 (计划中):
|
||||
- [ ] 文件预览功能(图片、视频、文档)
|
||||
- [ ] 批量下载(ZIP 打包)
|
||||
- [ ] 文件搜索功能
|
||||
|
||||
v4.0.0 (远期):
|
||||
- [ ] 多租户支持
|
||||
- [ ] WebDAV 协议支持
|
||||
- [ ] 移动端 App
|
||||
|
||||
============================================
|
||||
技术支持
|
||||
============================================
|
||||
|
||||
项目地址: https://git.workyai.cn/237899745/vue-driven-cloud-storage
|
||||
问题反馈: 请在 Gitea 提交 Issue
|
||||
|
||||
============================================
|
||||
46
backend/.dockerignore
Normal file
46
backend/.dockerignore
Normal file
@@ -0,0 +1,46 @@
|
||||
# 依赖目录
|
||||
node_modules
|
||||
|
||||
# 数据目录
|
||||
data/
|
||||
storage/
|
||||
|
||||
# 环境配置
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# 编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 测试和开发文件
|
||||
*.test.js
|
||||
*.spec.js
|
||||
test/
|
||||
tests/
|
||||
coverage/
|
||||
|
||||
# 文档
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
168
backend/.env.example
Normal file
168
backend/.env.example
Normal file
@@ -0,0 +1,168 @@
|
||||
# ============================================
|
||||
# 玩玩云 - 环境配置文件示例
|
||||
# ============================================
|
||||
#
|
||||
# 使用说明:
|
||||
# 1. 复制此文件为 .env
|
||||
# 2. 根据实际情况修改配置值
|
||||
# 3. ⚠️ 生产环境必须修改默认密码和密钥
|
||||
#
|
||||
|
||||
# ============================================
|
||||
# 服务器配置
|
||||
# ============================================
|
||||
|
||||
# 服务端口
|
||||
PORT=40001
|
||||
|
||||
# 运行环境(production 或 development)
|
||||
NODE_ENV=production
|
||||
|
||||
# 强制HTTPS访问(生产环境建议开启)
|
||||
# 设置为 true 时,仅接受 HTTPS 访问
|
||||
ENFORCE_HTTPS=false
|
||||
|
||||
# 公开访问端口(nginx监听的端口,用于生成分享链接)
|
||||
# 标准端口(80/443)可不配置
|
||||
PUBLIC_PORT=80
|
||||
|
||||
# ============================================
|
||||
# 安全配置
|
||||
# ============================================
|
||||
|
||||
# 加密密钥(必须配置!)
|
||||
# 用于加密 OSS Access Key Secret 等敏感数据
|
||||
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
ENCRYPTION_KEY=your-encryption-key-please-change-this
|
||||
|
||||
# JWT密钥(必须修改!)
|
||||
# 生成方法: openssl rand -base64 32
|
||||
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
JWT_SECRET=your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION
|
||||
|
||||
# Refresh Token 密钥(可选,默认使用 JWT_SECRET 派生)
|
||||
# 建议生产环境设置独立的密钥
|
||||
# REFRESH_SECRET=your-refresh-secret-key
|
||||
|
||||
# 管理员账号配置(首次启动时创建)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# ============================================
|
||||
# CORS 跨域配置(重要!)
|
||||
# ============================================
|
||||
|
||||
# 允许访问的前端域名
|
||||
#
|
||||
# 格式说明:
|
||||
# - 单个域名: https://yourdomain.com
|
||||
# - 多个域名: https://domain1.com,https://domain2.com
|
||||
# - 开发环境: 留空或设置为 * (不安全,仅开发使用)
|
||||
#
|
||||
# ⚠️ 生产环境安全要求:
|
||||
# 1. 必须配置具体的域名,不要使用 *
|
||||
# 2. 必须包含协议 (http:// 或 https://)
|
||||
# 3. 如果使用非标准端口,需要包含端口号
|
||||
#
|
||||
# 示例:
|
||||
# ALLOWED_ORIGINS=https://pan.example.com
|
||||
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
|
||||
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
|
||||
#
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Cookie 安全配置
|
||||
# 使用 HTTPS 时必须设置为 true
|
||||
# HTTP 环境设置为 false
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# CSRF 防护配置
|
||||
# 启用 CSRF 保护(建议生产环境开启)
|
||||
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
|
||||
ENABLE_CSRF=false
|
||||
|
||||
# ============================================
|
||||
# 反向代理配置(Nginx/Cloudflare等)
|
||||
# ============================================
|
||||
|
||||
# 信任代理配置
|
||||
#
|
||||
# 配置选项:
|
||||
# - false: 不信任代理(默认,直接暴露到公网时使用)
|
||||
# - 1: 信任第1跳代理(推荐,单层Nginx反向代理时使用)
|
||||
# - 2: 信任前2跳代理(Cloudflare + Nginx)
|
||||
# - loopback: 仅信任本地回环地址
|
||||
# - true: 信任所有代理(不推荐,易被伪造IP)
|
||||
#
|
||||
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
|
||||
# 必须配置 TRUST_PROXY=1,否则后端无法正确识别HTTPS请求
|
||||
#
|
||||
TRUST_PROXY=false
|
||||
|
||||
# ============================================
|
||||
# 存储配置
|
||||
# ============================================
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_PATH=./data/database.db
|
||||
|
||||
# 本地存储根目录(本地存储模式使用)
|
||||
STORAGE_ROOT=./storage
|
||||
|
||||
# ============================================
|
||||
# OSS 云存储配置(可选)
|
||||
# ============================================
|
||||
#
|
||||
# 说明: 用户可以在 Web 界面配置自己的 OSS 存储
|
||||
# 支持:阿里云 OSS、腾讯云 COS、AWS S3
|
||||
# 此处配置仅作为全局默认值(通常不需要配置)
|
||||
#
|
||||
|
||||
# OSS_PROVIDER=aliyun # 服务商: aliyun/tencent/aws
|
||||
# OSS_REGION=oss-cn-hangzhou # 地域
|
||||
# OSS_ACCESS_KEY_ID=your-key # Access Key ID
|
||||
# OSS_ACCESS_KEY_SECRET=secret # Access Key Secret
|
||||
# OSS_BUCKET=your-bucket # 存储桶名称
|
||||
# OSS_ENDPOINT= # 自定义 Endpoint(可选)
|
||||
|
||||
# ============================================
|
||||
# Session 配置
|
||||
# ============================================
|
||||
|
||||
# Session 密钥(用于验证码等功能)
|
||||
# 默认使用随机生成的密钥
|
||||
# SESSION_SECRET=your-session-secret
|
||||
|
||||
# Session 过期时间(毫秒),默认 30 分钟
|
||||
# SESSION_MAX_AGE=1800000
|
||||
|
||||
# ============================================
|
||||
# 开发调试配置
|
||||
# ============================================
|
||||
|
||||
# 日志级别 (error, warn, info, debug)
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# 是否启用调试模式
|
||||
# DEBUG=false
|
||||
|
||||
# ============================================
|
||||
# 注意事项
|
||||
# ============================================
|
||||
#
|
||||
# 1. 生产环境必须修改以下配置:
|
||||
# - ENCRYPTION_KEY: 用于加密敏感数据(64位十六进制)
|
||||
# - JWT_SECRET: 使用强随机密钥(64位十六进制)
|
||||
# - ADMIN_PASSWORD: 修改默认密码
|
||||
# - ALLOWED_ORIGINS: 配置具体域名
|
||||
#
|
||||
# 2. 使用 HTTPS 时:
|
||||
# - ENFORCE_HTTPS=true
|
||||
# - COOKIE_SECURE=true
|
||||
# - TRUST_PROXY=1 (如使用反向代理)
|
||||
#
|
||||
# 3. 配置优先级:
|
||||
# 环境变量 > .env 文件 > 默认值
|
||||
#
|
||||
# 4. 密钥生成命令:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装编译工具和健康检查所需的 wget
|
||||
RUN apk add --no-cache python3 make g++ wget
|
||||
|
||||
# 复制 package 文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install --production
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data /app/storage
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 40001
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:40001/api/health || exit 1
|
||||
|
||||
# 启动应用
|
||||
CMD ["node", "server.js"]
|
||||
314
backend/auth.js
Normal file
314
backend/auth.js
Normal file
@@ -0,0 +1,314 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
|
||||
// JWT密钥(必须在环境变量中设置)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
// Refresh Token密钥(使用不同的密钥)
|
||||
const REFRESH_SECRET = process.env.REFRESH_SECRET || JWT_SECRET + '-refresh';
|
||||
|
||||
// Token有效期配置
|
||||
const ACCESS_TOKEN_EXPIRES = '2h'; // Access token 2小时
|
||||
const REFRESH_TOKEN_EXPIRES = '7d'; // Refresh token 7天
|
||||
|
||||
// 安全检查:验证JWT密钥配置
|
||||
const DEFAULT_SECRETS = [
|
||||
'your-secret-key-change-in-production',
|
||||
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
|
||||
];
|
||||
|
||||
// 安全修复:增强 JWT_SECRET 验证逻辑
|
||||
if (DEFAULT_SECRETS.includes(JWT_SECRET)) {
|
||||
const errorMsg = `
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 安全警告 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ JWT_SECRET 使用默认值,存在严重安全风险! ║
|
||||
║ ║
|
||||
║ 请立即设置环境变量 JWT_SECRET ║
|
||||
║ 生成随机密钥: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
║ ║
|
||||
║ 在 backend/.env 文件中设置: ║
|
||||
║ JWT_SECRET=你生成的随机密钥 ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`;
|
||||
|
||||
// 安全修复:无论环境如何,使用默认 JWT_SECRET 都拒绝启动
|
||||
console.error(errorMsg);
|
||||
throw new Error('使用默认 JWT_SECRET 存在严重安全风险,服务无法启动!');
|
||||
}
|
||||
|
||||
// 验证 JWT_SECRET 长度(至少 32 字节/64个十六进制字符)
|
||||
if (JWT_SECRET.length < 32) {
|
||||
const errorMsg = `
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 配置错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ JWT_SECRET 长度不足! ║
|
||||
║ ║
|
||||
║ 要求: 至少 32 字节 ║
|
||||
║ 当前长度: ${JWT_SECRET.length} 字节 ║
|
||||
║ ║
|
||||
║ 生成安全的随机密钥: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`;
|
||||
console.error(errorMsg);
|
||||
throw new Error('JWT_SECRET 长度不足,服务无法启动!');
|
||||
}
|
||||
|
||||
console.log('[安全] ✓ JWT密钥验证通过');
|
||||
|
||||
// 生成Access Token(短期)
|
||||
function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
type: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成Refresh Token(长期)
|
||||
function generateRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
type: 'refresh',
|
||||
// 添加随机标识,使每次生成的refresh token不同
|
||||
jti: crypto.randomBytes(16).toString('hex')
|
||||
},
|
||||
REFRESH_SECRET,
|
||||
{ expiresIn: REFRESH_TOKEN_EXPIRES }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证Refresh Token并返回新的Access Token
|
||||
function refreshAccessToken(refreshToken) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
return { success: false, message: '无效的刷新令牌类型' };
|
||||
}
|
||||
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
return { success: false, message: '用户不存在' };
|
||||
}
|
||||
|
||||
if (user.is_banned) {
|
||||
return { success: false, message: '账号已被封禁' };
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { success: false, message: '账号未激活' };
|
||||
}
|
||||
|
||||
// 生成新的access token
|
||||
const newAccessToken = generateToken(user);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: newAccessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return { success: false, message: '刷新令牌已过期,请重新登录' };
|
||||
}
|
||||
return { success: false, message: '无效的刷新令牌' };
|
||||
}
|
||||
}
|
||||
|
||||
// 验证Token中间件
|
||||
function authMiddleware(req, res, next) {
|
||||
// 从请求头或HttpOnly Cookie获取token(不再接受URL参数以避免泄露)
|
||||
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供认证令牌'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const user = UserDB.findById(decoded.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.is_banned) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '账号已被封禁'
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '账号未激活'
|
||||
});
|
||||
}
|
||||
|
||||
// 将用户信息附加到请求对象(包含所有存储相关字段)
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
is_admin: user.is_admin,
|
||||
// OSS存储字段(v3.0新增)
|
||||
has_oss_config: user.has_oss_config || 0,
|
||||
oss_provider: user.oss_provider,
|
||||
oss_region: user.oss_region,
|
||||
oss_access_key_id: user.oss_access_key_id,
|
||||
// 安全修复:解密 OSS Access Key Secret(如果存在)
|
||||
oss_access_key_secret: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
|
||||
oss_bucket: user.oss_bucket,
|
||||
oss_endpoint: user.oss_endpoint,
|
||||
// 存储相关字段
|
||||
storage_permission: user.storage_permission || 'oss_only',
|
||||
current_storage_type: user.current_storage_type || 'oss',
|
||||
local_storage_quota: user.local_storage_quota || 1073741824,
|
||||
local_storage_used: user.local_storage_used || 0,
|
||||
// 主题偏好
|
||||
theme_preference: user.theme_preference || null
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '令牌已过期'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的令牌'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员权限中间件
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (!req.user || !req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '需要管理员权限'
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员敏感操作二次验证中间件
|
||||
*
|
||||
* 要求管理员重新输入密码才能执行敏感操作
|
||||
* 防止会话劫持后的非法操作
|
||||
*
|
||||
* @example
|
||||
* app.delete('/api/admin/users/:id',
|
||||
* authMiddleware,
|
||||
* adminMiddleware,
|
||||
* requirePasswordConfirmation,
|
||||
* async (req, res) => { ... }
|
||||
* );
|
||||
*/
|
||||
function requirePasswordConfirmation(req, res, next) {
|
||||
const { password } = req.body;
|
||||
|
||||
// 检查是否提供了密码
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '执行此操作需要验证密码',
|
||||
require_password: true
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码长度(防止空密码)
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '密码格式错误'
|
||||
});
|
||||
}
|
||||
|
||||
// 从数据库重新获取用户信息(不依赖 req.user 中的数据)
|
||||
const user = UserDB.findById(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = UserDB.verifyPassword(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// 记录安全日志:密码验证失败
|
||||
SystemLogDB = require('./database').SystemLogDB;
|
||||
SystemLogDB.log({
|
||||
level: SystemLogDB.LEVELS.WARN,
|
||||
category: SystemLogDB.CATEGORIES.SECURITY,
|
||||
action: 'admin_password_verification_failed',
|
||||
message: '管理员敏感操作密码验证失败',
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
details: {
|
||||
endpoint: req.path,
|
||||
method: req.method
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '密码验证失败,操作已拒绝'
|
||||
});
|
||||
}
|
||||
|
||||
// 密码验证成功,继续执行
|
||||
next();
|
||||
}
|
||||
|
||||
// 检查JWT密钥是否安全
|
||||
function isJwtSecretSecure() {
|
||||
return !DEFAULT_SECRETS.includes(JWT_SECRET) && JWT_SECRET.length >= 32;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JWT_SECRET,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
refreshAccessToken,
|
||||
authMiddleware,
|
||||
adminMiddleware,
|
||||
requirePasswordConfirmation, // 导出二次验证中间件
|
||||
isJwtSecretSecure,
|
||||
ACCESS_TOKEN_EXPIRES,
|
||||
REFRESH_TOKEN_EXPIRES
|
||||
};
|
||||
52
backend/backup.bat
Normal file
52
backend/backup.bat
Normal file
@@ -0,0 +1,52 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo 数据库备份工具
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d %~dp0
|
||||
|
||||
REM 创建备份目录
|
||||
if not exist backup mkdir backup
|
||||
|
||||
REM 生成时间戳
|
||||
set YEAR=%date:~0,4%
|
||||
set MONTH=%date:~5,2%
|
||||
set DAY=%date:~8,2%
|
||||
set HOUR=%time:~0,2%
|
||||
set MINUTE=%time:~3,2%
|
||||
set SECOND=%time:~6,2%
|
||||
|
||||
REM 去掉小时前面的空格
|
||||
if "%HOUR:~0,1%" == " " set HOUR=0%HOUR:~1,1%
|
||||
|
||||
set TIMESTAMP=%YEAR%%MONTH%%DAY%_%HOUR%%MINUTE%%SECOND%
|
||||
|
||||
REM 备份数据库
|
||||
copy ftp-manager.db backup\ftp-manager-%TIMESTAMP%.db >nul
|
||||
|
||||
if %errorlevel% == 0 (
|
||||
echo [成功] 备份完成!
|
||||
echo 文件: backup\ftp-manager-%TIMESTAMP%.db
|
||||
|
||||
REM 获取文件大小
|
||||
for %%A in (backup\ftp-manager-%TIMESTAMP%.db) do echo 大小: %%~zA 字节
|
||||
) else (
|
||||
echo [错误] 备份失败!
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
REM 清理30天前的备份
|
||||
echo 清理30天前的旧备份...
|
||||
forfiles /P backup /M ftp-manager-*.db /D -30 /C "cmd /c del @path" 2>nul
|
||||
if %errorlevel% == 0 (
|
||||
echo [成功] 旧备份已清理
|
||||
) else (
|
||||
echo [提示] 没有需要清理的旧备份
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
pause
|
||||
19
backend/check_expire.sql
Normal file
19
backend/check_expire.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
SELECT
|
||||
share_code,
|
||||
substr(share_path, 1, 30) as path,
|
||||
created_at,
|
||||
expires_at,
|
||||
datetime('now') as current_time,
|
||||
CASE
|
||||
WHEN expires_at IS NULL THEN '永久有效'
|
||||
WHEN expires_at > datetime('now') THEN '未过期'
|
||||
ELSE '已过期'
|
||||
END as status,
|
||||
CASE
|
||||
WHEN expires_at IS NOT NULL AND expires_at > datetime('now') THEN '通过'
|
||||
WHEN expires_at IS NULL THEN '通过'
|
||||
ELSE '拦截'
|
||||
END as findByCode_result
|
||||
FROM shares
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
0
backend/data/.gitkeep
Normal file
0
backend/data/.gitkeep
Normal file
1446
backend/database.js
Normal file
1446
backend/database.js
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/fix_expires_at_format.js
Normal file
34
backend/fix_expires_at_format.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { db } = require('./database');
|
||||
|
||||
console.log('开始修复 expires_at 格式...\n');
|
||||
|
||||
// 查找所有有过期时间的分享
|
||||
const shares = db.prepare(`
|
||||
SELECT id, share_code, expires_at
|
||||
FROM shares
|
||||
WHERE expires_at IS NOT NULL
|
||||
`).all();
|
||||
|
||||
console.log(`找到 ${shares.length} 条需要修复的记录\n`);
|
||||
|
||||
let fixed = 0;
|
||||
const updateStmt = db.prepare('UPDATE shares SET expires_at = ? WHERE id = ?');
|
||||
|
||||
shares.forEach(share => {
|
||||
const oldFormat = share.expires_at;
|
||||
|
||||
// 如果是ISO格式(包含T和Z),需要转换
|
||||
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
|
||||
// 转换为 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
|
||||
const newFormat = oldFormat.replace('T', ' ').replace(/\.\d+Z$/, '');
|
||||
|
||||
updateStmt.run(newFormat, share.id);
|
||||
fixed++;
|
||||
|
||||
console.log(`✓ 修复分享 ${share.share_code}:`);
|
||||
console.log(` 旧格式: ${oldFormat}`);
|
||||
console.log(` 新格式: ${newFormat}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n修复完成! 共修复 ${fixed} 条记录`);
|
||||
4544
backend/package-lock.json
generated
Normal file
4544
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
backend/package.json
Normal file
40
backend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "wanwanyun-backend",
|
||||
"version": "3.1.0",
|
||||
"description": "玩玩云 - 云存储管理平台后端服务",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": [
|
||||
"cloud-storage",
|
||||
"oss",
|
||||
"s3",
|
||||
"file-manager",
|
||||
"alibaba-cloud",
|
||||
"tencent-cloud"
|
||||
],
|
||||
"author": "玩玩云团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
"express-validator": "^7.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^6.9.14",
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
52
backend/routes/health.js
Normal file
52
backend/routes/health.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 健康检查和公共配置路由
|
||||
* 提供服务健康状态和公共配置信息
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { SettingsDB } = require('../database');
|
||||
|
||||
/**
|
||||
* 健康检查端点
|
||||
* GET /api/health
|
||||
*/
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({ success: true, message: 'Server is running' });
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取公开的系统配置(不需要登录)
|
||||
* GET /api/config
|
||||
*/
|
||||
router.get('/config', (req, res) => {
|
||||
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
max_upload_size: maxUploadSize
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取公开的全局主题设置(不需要登录)
|
||||
* GET /api/public/theme
|
||||
*/
|
||||
router.get('/public/theme', (req, res) => {
|
||||
try {
|
||||
const globalTheme = SettingsDB.get('global_theme') || 'dark';
|
||||
res.json({
|
||||
success: true,
|
||||
theme: globalTheme
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取全局主题失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取主题失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
90
backend/routes/index.js
Normal file
90
backend/routes/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 路由模块索引
|
||||
*
|
||||
* 本项目的路由目前主要定义在 server.js 中。
|
||||
* 此目录用于未来路由拆分的模块化重构。
|
||||
*
|
||||
* 建议的路由模块拆分方案:
|
||||
*
|
||||
* 1. routes/health.js - 健康检查和公共配置
|
||||
* - GET /api/health
|
||||
* - GET /api/config
|
||||
* - GET /api/public/theme
|
||||
*
|
||||
* 2. routes/auth.js - 认证相关
|
||||
* - POST /api/login
|
||||
* - POST /api/register
|
||||
* - POST /api/logout
|
||||
* - POST /api/refresh-token
|
||||
* - POST /api/password/forgot
|
||||
* - POST /api/password/reset
|
||||
* - GET /api/verify-email
|
||||
* - POST /api/resend-verification
|
||||
* - GET /api/captcha
|
||||
* - GET /api/csrf-token
|
||||
*
|
||||
* 3. routes/user.js - 用户相关
|
||||
* - GET /api/user/profile
|
||||
* - GET /api/user/theme
|
||||
* - POST /api/user/theme
|
||||
* - POST /api/user/update-oss
|
||||
* - POST /api/user/test-oss
|
||||
* - GET /api/user/oss-usage
|
||||
* - POST /api/user/change-password
|
||||
* - POST /api/user/update-username
|
||||
* - POST /api/user/switch-storage
|
||||
*
|
||||
* 4. routes/files.js - 文件操作
|
||||
* - GET /api/files
|
||||
* - POST /api/files/rename
|
||||
* - POST /api/files/mkdir
|
||||
* - POST /api/files/folder-info
|
||||
* - POST /api/files/delete
|
||||
* - GET /api/files/upload-signature
|
||||
* - POST /api/files/upload-complete
|
||||
* - GET /api/files/download-url
|
||||
* - GET /api/files/download
|
||||
* - POST /api/upload
|
||||
*
|
||||
* 5. routes/share.js - 分享功能
|
||||
* - POST /api/share/create
|
||||
* - GET /api/share/my
|
||||
* - DELETE /api/share/:id
|
||||
* - GET /api/share/:code/theme
|
||||
* - POST /api/share/:code/verify
|
||||
* - POST /api/share/:code/list
|
||||
* - POST /api/share/:code/download
|
||||
* - GET /api/share/:code/download-url
|
||||
* - GET /api/share/:code/download-file
|
||||
*
|
||||
* 6. routes/admin.js - 管理员功能
|
||||
* - GET /api/admin/settings
|
||||
* - POST /api/admin/settings
|
||||
* - POST /api/admin/settings/test-smtp
|
||||
* - GET /api/admin/health-check
|
||||
* - GET /api/admin/storage-stats
|
||||
* - GET /api/admin/users
|
||||
* - GET /api/admin/logs
|
||||
* - GET /api/admin/logs/stats
|
||||
* - POST /api/admin/logs/cleanup
|
||||
* - POST /api/admin/users/:id/ban
|
||||
* - DELETE /api/admin/users/:id
|
||||
* - POST /api/admin/users/:id/storage-permission
|
||||
* - GET /api/admin/users/:id/files
|
||||
* - GET /api/admin/shares
|
||||
* - DELETE /api/admin/shares/:id
|
||||
* - GET /api/admin/check-upload-tool
|
||||
* - POST /api/admin/upload-tool
|
||||
*
|
||||
* 使用示例(在 server.js 中):
|
||||
* ```javascript
|
||||
* const healthRoutes = require('./routes/health');
|
||||
* app.use('/api', healthRoutes);
|
||||
* ```
|
||||
*/
|
||||
|
||||
const healthRoutes = require('./health');
|
||||
|
||||
module.exports = {
|
||||
healthRoutes
|
||||
};
|
||||
6077
backend/server.js
Normal file
6077
backend/server.js
Normal file
File diff suppressed because it is too large
Load Diff
10
backend/start.bat
Normal file
10
backend/start.bat
Normal file
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo FTP 网盘管理平台 - 启动脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d %~dp0
|
||||
node server.js
|
||||
|
||||
pause
|
||||
1717
backend/storage.js
Normal file
1717
backend/storage.js
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/storage/.gitkeep
Normal file
0
backend/storage/.gitkeep
Normal file
934
backend/tests/boundary-tests.js
Normal file
934
backend/tests/boundary-tests.js
Normal file
@@ -0,0 +1,934 @@
|
||||
/**
|
||||
* 边界条件和异常处理测试套件
|
||||
*
|
||||
* 测试范围:
|
||||
* 1. 输入边界测试(空字符串、超长字符串、特殊字符、SQL注入、XSS)
|
||||
* 2. 文件操作边界测试(空文件、超大文件、特殊字符文件名、深层目录)
|
||||
* 3. 网络异常测试(超时、断连、OSS连接失败)
|
||||
* 4. 并发操作测试(多文件上传、多文件删除、重复提交)
|
||||
* 5. 状态一致性测试(刷新恢复、Token过期、存储切换)
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 主函数包装器(支持 async/await)
|
||||
async function runTests() {
|
||||
|
||||
// 测试结果收集器
|
||||
const testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// 测试辅助函数
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. 输入边界测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 1. 输入边界测试 ==========\n');
|
||||
|
||||
// 测试 sanitizeInput 函数
|
||||
function testSanitizeInput() {
|
||||
console.log('--- 测试 XSS 过滤函数 sanitizeInput ---');
|
||||
|
||||
// 从 server.js 复制的 sanitizeInput 函数
|
||||
function sanitizeInput(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
let sanitized = str
|
||||
.replace(/[&<>"']/g, (char) => {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return map[char];
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
|
||||
sanitized = sanitized.replace(/\x00/g, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 空字符串测试
|
||||
test('空字符串输入应该返回空字符串', () => {
|
||||
assert.strictEqual(sanitizeInput(''), '');
|
||||
});
|
||||
|
||||
// 超长字符串测试
|
||||
test('超长字符串应该被正确处理', () => {
|
||||
const longStr = 'a'.repeat(100000);
|
||||
const result = sanitizeInput(longStr);
|
||||
assert.strictEqual(result.length, 100000);
|
||||
});
|
||||
|
||||
// 特殊字符测试
|
||||
test('HTML 特殊字符应该被转义', () => {
|
||||
assert.strictEqual(sanitizeInput('<script>'), '<script>');
|
||||
assert.strictEqual(sanitizeInput('"test"'), '"test"');
|
||||
assert.strictEqual(sanitizeInput("'test'"), ''test'');
|
||||
assert.strictEqual(sanitizeInput('&test&'), '&test&');
|
||||
});
|
||||
|
||||
// SQL 注入测试字符串
|
||||
test('SQL 注入尝试应该被转义', () => {
|
||||
const sqlInjections = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"admin'--",
|
||||
"1; DELETE FROM users",
|
||||
"' UNION SELECT * FROM users --"
|
||||
];
|
||||
|
||||
sqlInjections.forEach(sql => {
|
||||
const result = sanitizeInput(sql);
|
||||
// 确保引号被转义
|
||||
assert.ok(!result.includes("'") || result.includes('''), `SQL injection not escaped: ${sql}`);
|
||||
});
|
||||
});
|
||||
|
||||
// XSS 测试字符串
|
||||
test('XSS 攻击尝试应该被过滤', () => {
|
||||
const xssTests = [
|
||||
'<script>alert("XSS")</script>',
|
||||
'<img src="x" onerror="alert(1)">',
|
||||
'<a href="javascript:alert(1)">click</a>',
|
||||
'<div onmouseover="alert(1)">hover</div>',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>'
|
||||
];
|
||||
|
||||
xssTests.forEach(xss => {
|
||||
const result = sanitizeInput(xss);
|
||||
assert.ok(!result.includes('<script>'), `XSS script tag not escaped: ${xss}`);
|
||||
assert.ok(!result.includes('javascript:'), `XSS javascript: not filtered: ${xss}`);
|
||||
});
|
||||
});
|
||||
|
||||
// 空字节注入测试
|
||||
test('空字节注入应该被过滤', () => {
|
||||
assert.ok(!sanitizeInput('test\x00.txt').includes('\x00'));
|
||||
assert.ok(!sanitizeInput('file\x00.jpg').includes('\x00'));
|
||||
});
|
||||
|
||||
// null/undefined 测试
|
||||
test('非字符串输入应该原样返回', () => {
|
||||
assert.strictEqual(sanitizeInput(null), null);
|
||||
assert.strictEqual(sanitizeInput(undefined), undefined);
|
||||
assert.strictEqual(sanitizeInput(123), 123);
|
||||
});
|
||||
}
|
||||
|
||||
testSanitizeInput();
|
||||
|
||||
// 测试密码验证
|
||||
function testPasswordValidation() {
|
||||
console.log('\n--- 测试密码强度验证 ---');
|
||||
|
||||
function validatePasswordStrength(password) {
|
||||
if (!password || password.length < 8) {
|
||||
return { valid: false, message: '密码至少8个字符' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, message: '密码不能超过128个字符' };
|
||||
}
|
||||
|
||||
const hasLetter = /[a-zA-Z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(password);
|
||||
const typeCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (typeCount < 2) {
|
||||
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
|
||||
}
|
||||
|
||||
const commonWeakPasswords = [
|
||||
'password', '12345678', '123456789', 'qwerty123', 'admin123',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'master'
|
||||
];
|
||||
if (commonWeakPasswords.includes(password.toLowerCase())) {
|
||||
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
test('空密码应该被拒绝', () => {
|
||||
assert.strictEqual(validatePasswordStrength('').valid, false);
|
||||
assert.strictEqual(validatePasswordStrength(null).valid, false);
|
||||
});
|
||||
|
||||
test('过短密码应该被拒绝', () => {
|
||||
assert.strictEqual(validatePasswordStrength('abc123!').valid, false);
|
||||
assert.strictEqual(validatePasswordStrength('1234567').valid, false);
|
||||
});
|
||||
|
||||
test('超长密码应该被拒绝', () => {
|
||||
const longPassword = 'a'.repeat(129) + '1';
|
||||
assert.strictEqual(validatePasswordStrength(longPassword).valid, false);
|
||||
});
|
||||
|
||||
test('纯数字密码应该被拒绝', () => {
|
||||
assert.strictEqual(validatePasswordStrength('12345678').valid, false);
|
||||
});
|
||||
|
||||
test('纯字母密码应该被拒绝', () => {
|
||||
assert.strictEqual(validatePasswordStrength('abcdefgh').valid, false);
|
||||
});
|
||||
|
||||
test('常见弱密码应该被拒绝', () => {
|
||||
assert.strictEqual(validatePasswordStrength('password').valid, false);
|
||||
assert.strictEqual(validatePasswordStrength('admin123').valid, false);
|
||||
});
|
||||
|
||||
test('复杂密码应该被接受', () => {
|
||||
assert.strictEqual(validatePasswordStrength('MySecure123!').valid, true);
|
||||
assert.strictEqual(validatePasswordStrength('Test_Pass_2024').valid, true);
|
||||
});
|
||||
}
|
||||
|
||||
testPasswordValidation();
|
||||
|
||||
// 测试用户名验证
|
||||
function testUsernameValidation() {
|
||||
console.log('\n--- 测试用户名验证 ---');
|
||||
|
||||
const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u;
|
||||
|
||||
test('过短用户名应该被拒绝', () => {
|
||||
assert.strictEqual(USERNAME_REGEX.test('ab'), false);
|
||||
assert.strictEqual(USERNAME_REGEX.test('a'), false);
|
||||
assert.strictEqual(USERNAME_REGEX.test(''), false);
|
||||
});
|
||||
|
||||
test('过长用户名应该被拒绝', () => {
|
||||
assert.strictEqual(USERNAME_REGEX.test('a'.repeat(21)), false);
|
||||
});
|
||||
|
||||
test('包含非法字符的用户名应该被拒绝', () => {
|
||||
assert.strictEqual(USERNAME_REGEX.test('user@name'), false);
|
||||
assert.strictEqual(USERNAME_REGEX.test('user name'), false);
|
||||
assert.strictEqual(USERNAME_REGEX.test('user<script>'), false);
|
||||
assert.strictEqual(USERNAME_REGEX.test("user'name"), false);
|
||||
});
|
||||
|
||||
test('合法用户名应该被接受', () => {
|
||||
assert.strictEqual(USERNAME_REGEX.test('user123'), true);
|
||||
assert.strictEqual(USERNAME_REGEX.test('test_user'), true);
|
||||
assert.strictEqual(USERNAME_REGEX.test('test.user'), true);
|
||||
assert.strictEqual(USERNAME_REGEX.test('test-user'), true);
|
||||
assert.strictEqual(USERNAME_REGEX.test('用户名'), true);
|
||||
assert.strictEqual(USERNAME_REGEX.test('中文用户_123'), true);
|
||||
});
|
||||
}
|
||||
|
||||
testUsernameValidation();
|
||||
|
||||
// ============================================================
|
||||
// 2. 文件操作边界测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 2. 文件操作边界测试 ==========\n');
|
||||
|
||||
function testPathSecurity() {
|
||||
console.log('--- 测试路径安全校验 ---');
|
||||
|
||||
function isSafePathSegment(name) {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
name.length > 0 &&
|
||||
name.length <= 255 &&
|
||||
!name.includes('..') &&
|
||||
!/[/\\]/.test(name) &&
|
||||
!/[\x00-\x1F]/.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
test('空文件名应该被拒绝', () => {
|
||||
assert.strictEqual(isSafePathSegment(''), false);
|
||||
});
|
||||
|
||||
test('超长文件名应该被拒绝', () => {
|
||||
assert.strictEqual(isSafePathSegment('a'.repeat(256)), false);
|
||||
});
|
||||
|
||||
test('包含路径遍历的文件名应该被拒绝', () => {
|
||||
assert.strictEqual(isSafePathSegment('..'), false);
|
||||
assert.strictEqual(isSafePathSegment('../etc/passwd'), false);
|
||||
assert.strictEqual(isSafePathSegment('test/../../../'), false);
|
||||
});
|
||||
|
||||
test('包含路径分隔符的文件名应该被拒绝', () => {
|
||||
assert.strictEqual(isSafePathSegment('test/file'), false);
|
||||
assert.strictEqual(isSafePathSegment('test\\file'), false);
|
||||
});
|
||||
|
||||
test('包含控制字符的文件名应该被拒绝', () => {
|
||||
assert.strictEqual(isSafePathSegment('test\x00file'), false);
|
||||
assert.strictEqual(isSafePathSegment('test\x1Ffile'), false);
|
||||
});
|
||||
|
||||
test('合法文件名应该被接受', () => {
|
||||
assert.strictEqual(isSafePathSegment('normal_file.txt'), true);
|
||||
assert.strictEqual(isSafePathSegment('中文文件名.pdf'), true);
|
||||
assert.strictEqual(isSafePathSegment('file with spaces.doc'), true);
|
||||
assert.strictEqual(isSafePathSegment('file-with-dashes.js'), true);
|
||||
assert.strictEqual(isSafePathSegment('file.name.with.dots.txt'), true);
|
||||
});
|
||||
}
|
||||
|
||||
testPathSecurity();
|
||||
|
||||
function testFileExtensionSecurity() {
|
||||
console.log('\n--- 测试文件扩展名安全 ---');
|
||||
|
||||
const DANGEROUS_EXTENSIONS = [
|
||||
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
|
||||
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
|
||||
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
|
||||
'.htaccess', '.htpasswd'
|
||||
];
|
||||
|
||||
function isFileExtensionSafe(filename) {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameLower = filename.toLowerCase();
|
||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
||||
if (nameLower.includes(dangerExt + '.')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
test('PHP 文件应该被拒绝', () => {
|
||||
assert.strictEqual(isFileExtensionSafe('test.php'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('shell.phtml'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('backdoor.phar'), false);
|
||||
});
|
||||
|
||||
test('JSP 文件应该被拒绝', () => {
|
||||
assert.strictEqual(isFileExtensionSafe('test.jsp'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('test.jspx'), false);
|
||||
});
|
||||
|
||||
test('ASP 文件应该被拒绝', () => {
|
||||
assert.strictEqual(isFileExtensionSafe('test.asp'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('test.aspx'), false);
|
||||
});
|
||||
|
||||
test('双扩展名攻击应该被拒绝', () => {
|
||||
assert.strictEqual(isFileExtensionSafe('shell.php.jpg'), false);
|
||||
assert.strictEqual(isFileExtensionSafe('backdoor.jsp.png'), false);
|
||||
});
|
||||
|
||||
test('.htaccess 和 .htpasswd 文件应该被拒绝', () => {
|
||||
// 更新测试以匹配修复后的 isFileExtensionSafe 函数
|
||||
// 现在会检查 dangerousFilenames 列表
|
||||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
||||
|
||||
function isFileExtensionSafeFixed(filename) {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const nameLower = filename.toLowerCase();
|
||||
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊处理:检查以危险名称开头的文件
|
||||
if (dangerousFilenames.includes(nameLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
||||
if (nameLower.includes(dangerExt + '.')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
assert.strictEqual(isFileExtensionSafeFixed('.htaccess'), false);
|
||||
assert.strictEqual(isFileExtensionSafeFixed('.htpasswd'), false);
|
||||
});
|
||||
|
||||
test('正常文件应该被接受', () => {
|
||||
assert.strictEqual(isFileExtensionSafe('document.pdf'), true);
|
||||
assert.strictEqual(isFileExtensionSafe('image.jpg'), true);
|
||||
assert.strictEqual(isFileExtensionSafe('video.mp4'), true);
|
||||
assert.strictEqual(isFileExtensionSafe('archive.zip'), true);
|
||||
assert.strictEqual(isFileExtensionSafe('script.js'), true);
|
||||
assert.strictEqual(isFileExtensionSafe('program.exe'), true); // 允许exe,因为服务器不会执行
|
||||
});
|
||||
|
||||
test('空或非法输入应该被拒绝', () => {
|
||||
assert.strictEqual(isFileExtensionSafe(''), false);
|
||||
assert.strictEqual(isFileExtensionSafe(null), false);
|
||||
assert.strictEqual(isFileExtensionSafe(undefined), false);
|
||||
});
|
||||
}
|
||||
|
||||
testFileExtensionSecurity();
|
||||
|
||||
// ============================================================
|
||||
// 3. 存储路径安全测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 3. 存储路径安全测试 ==========\n');
|
||||
|
||||
function testLocalStoragePath() {
|
||||
console.log('--- 测试本地存储路径安全 ---');
|
||||
|
||||
// 精确模拟 LocalStorageClient.getFullPath 方法(与 storage.js 保持一致)
|
||||
function getFullPath(basePath, relativePath) {
|
||||
// 0. 输入验证:检查空字节注入和其他危险字符
|
||||
if (typeof relativePath !== 'string') {
|
||||
throw new Error('无效的路径类型');
|
||||
}
|
||||
|
||||
// 检查空字节注入(%00, \x00)
|
||||
if (relativePath.includes('\x00') || relativePath.includes('%00')) {
|
||||
console.warn('[安全] 检测到空字节注入尝试:', relativePath);
|
||||
throw new Error('路径包含非法字符');
|
||||
}
|
||||
|
||||
// 1. 规范化路径,移除 ../ 等危险路径
|
||||
let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, '');
|
||||
|
||||
// 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过)
|
||||
// 解析后的路径不应包含 ..
|
||||
if (normalized.includes('..')) {
|
||||
console.warn('[安全] 检测到目录遍历尝试:', relativePath);
|
||||
throw new Error('路径包含非法字符');
|
||||
}
|
||||
|
||||
// 3. 将绝对路径转换为相对路径(解决Linux环境下的问题)
|
||||
if (path.isAbsolute(normalized)) {
|
||||
// 移除开头的 / 或 Windows 盘符,转为相对路径
|
||||
normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, '');
|
||||
}
|
||||
|
||||
// 4. 空字符串或 . 表示根目录
|
||||
if (normalized === '' || normalized === '.') {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
// 5. 拼接完整路径
|
||||
const fullPath = path.join(basePath, normalized);
|
||||
|
||||
// 6. 解析真实路径(处理符号链接)后再次验证
|
||||
const resolvedBasePath = path.resolve(basePath);
|
||||
const resolvedFullPath = path.resolve(fullPath);
|
||||
|
||||
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
|
||||
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
|
||||
console.warn('[安全] 检测到路径遍历攻击:', {
|
||||
input: relativePath,
|
||||
resolved: resolvedFullPath,
|
||||
base: resolvedBasePath
|
||||
});
|
||||
throw new Error('非法路径访问');
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
const basePath = '/tmp/storage/user_1';
|
||||
|
||||
test('正常相对路径应该被接受', () => {
|
||||
const result = getFullPath(basePath, 'documents/file.txt');
|
||||
assert.ok(result.includes('documents'));
|
||||
assert.ok(result.includes('file.txt'));
|
||||
});
|
||||
|
||||
test('路径遍历攻击应该被安全处理(开头的..被移除)', () => {
|
||||
// ../../../etc/passwd 经过 normalize 和 replace 后变成 etc/passwd
|
||||
// 最终路径会被沙箱化到用户目录内
|
||||
const result = getFullPath(basePath, '../../../etc/passwd');
|
||||
// 验证结果路径在用户基础路径内
|
||||
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
|
||||
// 验证解析后的路径确实在基础路径内
|
||||
const resolved = path.resolve(result);
|
||||
const baseResolved = path.resolve(basePath);
|
||||
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
|
||||
});
|
||||
|
||||
test('路径遍历攻击应该被安全处理(中间的..被移除)', () => {
|
||||
// a/../../../etc/passwd 经过 normalize 变成 ../../etc/passwd
|
||||
// 然后经过 replace 变成 etc/passwd,最终被沙箱化
|
||||
const result = getFullPath(basePath, 'a/../../../etc/passwd');
|
||||
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
|
||||
const resolved = path.resolve(result);
|
||||
const baseResolved = path.resolve(basePath);
|
||||
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
|
||||
});
|
||||
|
||||
test('空字节注入应该被拒绝', () => {
|
||||
assert.throws(() => getFullPath(basePath, 'file\x00.txt'), /非法/);
|
||||
assert.throws(() => getFullPath(basePath, 'file%00.txt'), /非法/);
|
||||
});
|
||||
|
||||
test('绝对路径应该被安全处理(转换为相对路径)', () => {
|
||||
// /etc/passwd 会被转换为 etc/passwd,然后拼接到 basePath
|
||||
const result = getFullPath(basePath, '/etc/passwd');
|
||||
assert.ok(result.startsWith(basePath), `路径 ${result} 应该以 ${basePath} 开头`);
|
||||
// 最终路径应该是 basePath/etc/passwd
|
||||
assert.ok(result.includes('etc') && result.includes('passwd'));
|
||||
// 确保是安全的子路径而不是真正的 /etc/passwd
|
||||
const resolved = path.resolve(result);
|
||||
const baseResolved = path.resolve(basePath);
|
||||
assert.ok(resolved.startsWith(baseResolved), '解析后的路径应该在用户目录内');
|
||||
});
|
||||
|
||||
test('空路径应该返回基础路径', () => {
|
||||
assert.strictEqual(getFullPath(basePath, ''), basePath);
|
||||
assert.strictEqual(getFullPath(basePath, '.'), basePath);
|
||||
});
|
||||
}
|
||||
|
||||
testLocalStoragePath();
|
||||
|
||||
// ============================================================
|
||||
// 4. Token 验证测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 4. Token 验证测试 ==========\n');
|
||||
|
||||
function testTokenValidation() {
|
||||
console.log('--- 测试 Token 格式验证 ---');
|
||||
|
||||
// 验证 token 格式(hex 字符串)
|
||||
function isValidTokenFormat(token) {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return /^[a-f0-9]{32,96}$/i.test(token);
|
||||
}
|
||||
|
||||
test('空 token 应该被拒绝', () => {
|
||||
assert.strictEqual(isValidTokenFormat(''), false);
|
||||
assert.strictEqual(isValidTokenFormat(null), false);
|
||||
assert.strictEqual(isValidTokenFormat(undefined), false);
|
||||
});
|
||||
|
||||
test('过短 token 应该被拒绝', () => {
|
||||
assert.strictEqual(isValidTokenFormat('abc123'), false);
|
||||
assert.strictEqual(isValidTokenFormat('a'.repeat(31)), false);
|
||||
});
|
||||
|
||||
test('过长 token 应该被拒绝', () => {
|
||||
assert.strictEqual(isValidTokenFormat('a'.repeat(97)), false);
|
||||
});
|
||||
|
||||
test('非 hex 字符 token 应该被拒绝', () => {
|
||||
assert.strictEqual(isValidTokenFormat('g'.repeat(48)), false);
|
||||
assert.strictEqual(isValidTokenFormat('test-token-123'), false);
|
||||
assert.strictEqual(isValidTokenFormat('<script>alert(1)</script>'), false);
|
||||
});
|
||||
|
||||
test('合法 token 应该被接受', () => {
|
||||
assert.strictEqual(isValidTokenFormat('a'.repeat(48)), true);
|
||||
assert.strictEqual(isValidTokenFormat('abcdef123456'.repeat(4)), true);
|
||||
assert.strictEqual(isValidTokenFormat('ABCDEF123456'.repeat(4)), true);
|
||||
});
|
||||
}
|
||||
|
||||
testTokenValidation();
|
||||
|
||||
// ============================================================
|
||||
// 5. 并发和竞态条件测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 5. 并发和竞态条件测试 ==========\n');
|
||||
|
||||
async function testRateLimiter() {
|
||||
console.log('--- 测试速率限制器 ---');
|
||||
|
||||
// 简化版 RateLimiter
|
||||
class RateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.maxAttempts = options.maxAttempts || 5;
|
||||
this.windowMs = options.windowMs || 15 * 60 * 1000;
|
||||
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
|
||||
this.attempts = new Map();
|
||||
this.blockedKeys = new Map();
|
||||
}
|
||||
|
||||
isBlocked(key) {
|
||||
const blockInfo = this.blockedKeys.get(key);
|
||||
if (!blockInfo) return false;
|
||||
if (Date.now() > blockInfo.expiresAt) {
|
||||
this.blockedKeys.delete(key);
|
||||
this.attempts.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
recordFailure(key) {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.isBlocked(key)) {
|
||||
return { blocked: true };
|
||||
}
|
||||
|
||||
let attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || now > attemptInfo.windowEnd) {
|
||||
attemptInfo = { count: 0, windowEnd: now + this.windowMs };
|
||||
}
|
||||
|
||||
attemptInfo.count++;
|
||||
this.attempts.set(key, attemptInfo);
|
||||
|
||||
if (attemptInfo.count >= this.maxAttempts) {
|
||||
this.blockedKeys.set(key, {
|
||||
expiresAt: now + this.blockDuration
|
||||
});
|
||||
return { blocked: true, remainingAttempts: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: false,
|
||||
remainingAttempts: this.maxAttempts - attemptInfo.count
|
||||
};
|
||||
}
|
||||
|
||||
recordSuccess(key) {
|
||||
this.attempts.delete(key);
|
||||
this.blockedKeys.delete(key);
|
||||
}
|
||||
|
||||
getFailureCount(key) {
|
||||
const attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) {
|
||||
return 0;
|
||||
}
|
||||
return attemptInfo.count;
|
||||
}
|
||||
}
|
||||
|
||||
const limiter = new RateLimiter({ maxAttempts: 3, windowMs: 1000, blockDuration: 1000 });
|
||||
|
||||
await asyncTest('首次请求应该不被阻止', async () => {
|
||||
const result = limiter.recordFailure('test-ip-1');
|
||||
assert.strictEqual(result.blocked, false);
|
||||
assert.strictEqual(result.remainingAttempts, 2);
|
||||
});
|
||||
|
||||
await asyncTest('达到限制后应该被阻止', async () => {
|
||||
const key = 'test-ip-2';
|
||||
limiter.recordFailure(key);
|
||||
limiter.recordFailure(key);
|
||||
const result = limiter.recordFailure(key);
|
||||
assert.strictEqual(result.blocked, true);
|
||||
assert.strictEqual(limiter.isBlocked(key), true);
|
||||
});
|
||||
|
||||
await asyncTest('成功后应该清除计数', async () => {
|
||||
const key = 'test-ip-3';
|
||||
limiter.recordFailure(key);
|
||||
limiter.recordFailure(key);
|
||||
limiter.recordSuccess(key);
|
||||
assert.strictEqual(limiter.getFailureCount(key), 0);
|
||||
assert.strictEqual(limiter.isBlocked(key), false);
|
||||
});
|
||||
|
||||
await asyncTest('阻止过期后应该自动解除', async () => {
|
||||
const key = 'test-ip-4';
|
||||
limiter.recordFailure(key);
|
||||
limiter.recordFailure(key);
|
||||
limiter.recordFailure(key);
|
||||
|
||||
// 模拟时间过期
|
||||
const blockInfo = limiter.blockedKeys.get(key);
|
||||
if (blockInfo) {
|
||||
blockInfo.expiresAt = Date.now() - 1;
|
||||
}
|
||||
|
||||
assert.strictEqual(limiter.isBlocked(key), false);
|
||||
});
|
||||
}
|
||||
|
||||
await testRateLimiter();
|
||||
|
||||
// ============================================================
|
||||
// 6. 数据库操作边界测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 6. 数据库操作边界测试 ==========\n');
|
||||
|
||||
function testDatabaseFieldWhitelist() {
|
||||
console.log('--- 测试数据库字段白名单 ---');
|
||||
|
||||
const ALLOWED_FIELDS = [
|
||||
'username', 'email', 'password',
|
||||
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
|
||||
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
|
||||
'is_verified', 'verification_token', 'verification_expires_at',
|
||||
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
|
||||
'theme_preference'
|
||||
];
|
||||
|
||||
function filterUpdates(updates) {
|
||||
const filtered = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (ALLOWED_FIELDS.includes(key)) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
test('合法字段应该被保留', () => {
|
||||
const updates = { username: 'newname', email: 'new@email.com' };
|
||||
const filtered = filterUpdates(updates);
|
||||
assert.strictEqual(filtered.username, 'newname');
|
||||
assert.strictEqual(filtered.email, 'new@email.com');
|
||||
});
|
||||
|
||||
test('非法字段应该被过滤', () => {
|
||||
const updates = {
|
||||
username: 'newname',
|
||||
id: 999, // 尝试修改 ID
|
||||
is_admin: 1, // 合法字段
|
||||
sql_injection: "'; DROP TABLE users; --" // 非法字段
|
||||
};
|
||||
const filtered = filterUpdates(updates);
|
||||
assert.ok(!('id' in filtered));
|
||||
assert.ok(!('sql_injection' in filtered));
|
||||
assert.strictEqual(filtered.username, 'newname');
|
||||
assert.strictEqual(filtered.is_admin, 1);
|
||||
});
|
||||
|
||||
test('原型污染尝试应该被阻止', () => {
|
||||
// 测试通过 JSON.parse 创建的包含 __proto__ 的对象
|
||||
const maliciousJson = '{"username":"test","__proto__":{"isAdmin":true},"constructor":{"prototype":{}}}';
|
||||
const updates = JSON.parse(maliciousJson);
|
||||
const filtered = filterUpdates(updates);
|
||||
|
||||
// 即使 JSON.parse 创建了 __proto__ 属性,也不应该被处理
|
||||
// 因为 Object.entries 不会遍历 __proto__
|
||||
assert.strictEqual(filtered.username, 'test');
|
||||
assert.ok(!('isAdmin' in filtered));
|
||||
// 确保不会污染原型
|
||||
assert.ok(!({}.isAdmin));
|
||||
});
|
||||
|
||||
test('空对象应该返回空对象', () => {
|
||||
const filtered = filterUpdates({});
|
||||
assert.strictEqual(Object.keys(filtered).length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
testDatabaseFieldWhitelist();
|
||||
|
||||
// ============================================================
|
||||
// 7. HTML 实体解码测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 7. HTML 实体解码测试 ==========\n');
|
||||
|
||||
function testHtmlEntityDecoding() {
|
||||
console.log('--- 测试 HTML 实体解码 ---');
|
||||
|
||||
function decodeHtmlEntities(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
const entityMap = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
'#x27': "'",
|
||||
'#x2F': '/',
|
||||
'#x60': '`'
|
||||
};
|
||||
|
||||
const decodeOnce = (input) =>
|
||||
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
|
||||
if (code[0] === '#') {
|
||||
const isHex = code[1]?.toLowerCase() === 'x';
|
||||
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
|
||||
if (!Number.isNaN(num)) {
|
||||
return String.fromCharCode(num);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
const mapped = entityMap[code];
|
||||
return mapped !== undefined ? mapped : match;
|
||||
});
|
||||
|
||||
let output = str;
|
||||
let decoded = decodeOnce(output);
|
||||
while (decoded !== output) {
|
||||
output = decoded;
|
||||
decoded = decodeOnce(output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
test('基本 HTML 实体应该被解码', () => {
|
||||
assert.strictEqual(decodeHtmlEntities('<'), '<');
|
||||
assert.strictEqual(decodeHtmlEntities('>'), '>');
|
||||
assert.strictEqual(decodeHtmlEntities('&'), '&');
|
||||
assert.strictEqual(decodeHtmlEntities('"'), '"');
|
||||
});
|
||||
|
||||
test('数字实体应该被解码', () => {
|
||||
assert.strictEqual(decodeHtmlEntities('''), "'");
|
||||
assert.strictEqual(decodeHtmlEntities('''), "'");
|
||||
assert.strictEqual(decodeHtmlEntities('`'), '`');
|
||||
});
|
||||
|
||||
test('嵌套实体应该被完全解码', () => {
|
||||
assert.strictEqual(decodeHtmlEntities('&#x60;'), '`');
|
||||
assert.strictEqual(decodeHtmlEntities('&amp;'), '&');
|
||||
});
|
||||
|
||||
test('普通文本应该保持不变', () => {
|
||||
assert.strictEqual(decodeHtmlEntities('hello world'), 'hello world');
|
||||
assert.strictEqual(decodeHtmlEntities('test123'), 'test123');
|
||||
});
|
||||
|
||||
test('非字符串输入应该原样返回', () => {
|
||||
assert.strictEqual(decodeHtmlEntities(null), null);
|
||||
assert.strictEqual(decodeHtmlEntities(undefined), undefined);
|
||||
assert.strictEqual(decodeHtmlEntities(123), 123);
|
||||
});
|
||||
}
|
||||
|
||||
testHtmlEntityDecoding();
|
||||
|
||||
// ============================================================
|
||||
// 8. 分享路径权限测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 8. 分享路径权限测试 ==========\n');
|
||||
|
||||
function testSharePathAccess() {
|
||||
console.log('--- 测试分享路径访问权限 ---');
|
||||
|
||||
function isPathWithinShare(requestPath, share) {
|
||||
if (!requestPath || !share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/');
|
||||
const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/');
|
||||
|
||||
if (share.share_type === 'file') {
|
||||
return normalizedRequest === normalizedShare;
|
||||
} else {
|
||||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/';
|
||||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||||
}
|
||||
}
|
||||
|
||||
test('单文件分享只允许访问该文件', () => {
|
||||
const share = { share_type: 'file', share_path: '/documents/secret.pdf' };
|
||||
assert.strictEqual(isPathWithinShare('/documents/secret.pdf', share), true);
|
||||
assert.strictEqual(isPathWithinShare('/documents/other.pdf', share), false);
|
||||
assert.strictEqual(isPathWithinShare('/documents/secret.pdf.bak', share), false);
|
||||
});
|
||||
|
||||
test('目录分享允许访问子目录', () => {
|
||||
const share = { share_type: 'directory', share_path: '/shared' };
|
||||
assert.strictEqual(isPathWithinShare('/shared', share), true);
|
||||
assert.strictEqual(isPathWithinShare('/shared/file.txt', share), true);
|
||||
assert.strictEqual(isPathWithinShare('/shared/sub/file.txt', share), true);
|
||||
});
|
||||
|
||||
test('目录分享不允许访问父目录', () => {
|
||||
const share = { share_type: 'directory', share_path: '/shared' };
|
||||
assert.strictEqual(isPathWithinShare('/other', share), false);
|
||||
assert.strictEqual(isPathWithinShare('/shared_extra', share), false);
|
||||
assert.strictEqual(isPathWithinShare('/', share), false);
|
||||
});
|
||||
|
||||
test('路径遍历攻击应该被阻止', () => {
|
||||
const share = { share_type: 'directory', share_path: '/shared' };
|
||||
assert.strictEqual(isPathWithinShare('/shared/../etc/passwd', share), false);
|
||||
assert.strictEqual(isPathWithinShare('/shared/../../root', share), false);
|
||||
});
|
||||
|
||||
test('空或无效输入应该返回 false', () => {
|
||||
assert.strictEqual(isPathWithinShare('', { share_type: 'file', share_path: '/test' }), false);
|
||||
assert.strictEqual(isPathWithinShare(null, { share_type: 'file', share_path: '/test' }), false);
|
||||
assert.strictEqual(isPathWithinShare('/test', null), false);
|
||||
});
|
||||
}
|
||||
|
||||
testSharePathAccess();
|
||||
|
||||
// ============================================================
|
||||
// 测试总结
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试总结');
|
||||
console.log('========================================');
|
||||
console.log(`通过: ${testResults.passed}`);
|
||||
console.log(`失败: ${testResults.failed}`);
|
||||
|
||||
if (testResults.errors.length > 0) {
|
||||
console.log('\n失败的测试:');
|
||||
testResults.errors.forEach((e, i) => {
|
||||
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// 返回测试结果
|
||||
return testResults;
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().then(testResults => {
|
||||
// 如果有失败,退出码为 1
|
||||
process.exit(testResults.failed > 0 ? 1 : 0);
|
||||
}).catch(err => {
|
||||
console.error('测试执行错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
838
backend/tests/network-concurrent-tests.js
Normal file
838
backend/tests/network-concurrent-tests.js
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* 网络异常和并发操作测试套件
|
||||
*
|
||||
* 测试范围:
|
||||
* 1. 网络异常处理(超时、断连、OSS连接失败)
|
||||
* 2. 并发操作测试(多文件上传、多文件删除、重复提交)
|
||||
* 3. 防重复提交测试
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 测试结果收集器
|
||||
const testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// 测试辅助函数
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
|
||||
// ============================================================
|
||||
// 1. OSS 错误格式化测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 1. OSS 错误格式化测试 ==========\n');
|
||||
|
||||
function testOssErrorFormatting() {
|
||||
console.log('--- 测试 OSS 错误消息格式化 ---');
|
||||
|
||||
// 模拟 formatOssError 函数
|
||||
function formatOssError(error, operation = '操作') {
|
||||
const errorMessages = {
|
||||
'NoSuchBucket': 'OSS 存储桶不存在,请检查配置',
|
||||
'AccessDenied': 'OSS 访问被拒绝,请检查权限配置',
|
||||
'InvalidAccessKeyId': 'OSS Access Key 无效,请重新配置',
|
||||
'SignatureDoesNotMatch': 'OSS 签名验证失败,请检查 Secret Key',
|
||||
'NoSuchKey': '文件或目录不存在',
|
||||
'EntityTooLarge': '文件过大,超过了 OSS 允许的最大大小',
|
||||
'RequestTimeout': 'OSS 请求超时,请稍后重试',
|
||||
'SlowDown': 'OSS 请求过于频繁,请稍后重试',
|
||||
'ServiceUnavailable': 'OSS 服务暂时不可用,请稍后重试',
|
||||
'InternalError': 'OSS 内部错误,请稍后重试'
|
||||
};
|
||||
|
||||
const networkErrors = {
|
||||
'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络',
|
||||
'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置',
|
||||
'ETIMEDOUT': '连接 OSS 服务超时,请检查网络',
|
||||
'ECONNRESET': '与 OSS 服务的连接被重置,请重试',
|
||||
'EPIPE': '与 OSS 服务的连接中断,请重试',
|
||||
'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络'
|
||||
};
|
||||
|
||||
if (error.name && errorMessages[error.name]) {
|
||||
return new Error(`${operation}失败: ${errorMessages[error.name]}`);
|
||||
}
|
||||
|
||||
if (error.code && networkErrors[error.code]) {
|
||||
return new Error(`${operation}失败: ${networkErrors[error.code]}`);
|
||||
}
|
||||
|
||||
if (error.$metadata?.httpStatusCode) {
|
||||
const statusCode = error.$metadata.httpStatusCode;
|
||||
const statusMessages = {
|
||||
400: '请求参数错误',
|
||||
401: '认证失败,请检查 Access Key',
|
||||
403: '没有权限执行此操作',
|
||||
404: '资源不存在',
|
||||
429: '请求过于频繁,请稍后重试',
|
||||
500: 'OSS 服务内部错误',
|
||||
503: 'OSS 服务暂时不可用'
|
||||
};
|
||||
if (statusMessages[statusCode]) {
|
||||
return new Error(`${operation}失败: ${statusMessages[statusCode]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Error(`${operation}失败: ${error.message}`);
|
||||
}
|
||||
|
||||
test('NoSuchBucket 错误应该被正确格式化', () => {
|
||||
const error = { name: 'NoSuchBucket', message: 'The specified bucket does not exist' };
|
||||
const formatted = formatOssError(error, '列出文件');
|
||||
assert.ok(formatted.message.includes('存储桶不存在'));
|
||||
});
|
||||
|
||||
test('AccessDenied 错误应该被正确格式化', () => {
|
||||
const error = { name: 'AccessDenied', message: 'Access Denied' };
|
||||
const formatted = formatOssError(error, '上传文件');
|
||||
assert.ok(formatted.message.includes('访问被拒绝'));
|
||||
});
|
||||
|
||||
test('网络超时错误应该被正确格式化', () => {
|
||||
const error = { code: 'ETIMEDOUT', message: 'connect ETIMEDOUT' };
|
||||
const formatted = formatOssError(error, '连接');
|
||||
assert.ok(formatted.message.includes('超时'));
|
||||
});
|
||||
|
||||
test('连接被拒绝错误应该被正确格式化', () => {
|
||||
const error = { code: 'ECONNREFUSED', message: 'connect ECONNREFUSED' };
|
||||
const formatted = formatOssError(error, '连接');
|
||||
assert.ok(formatted.message.includes('无法连接'));
|
||||
});
|
||||
|
||||
test('DNS 解析失败应该被正确格式化', () => {
|
||||
const error = { code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' };
|
||||
const formatted = formatOssError(error, '连接');
|
||||
assert.ok(formatted.message.includes('无法解析'));
|
||||
});
|
||||
|
||||
test('HTTP 401 错误应该被正确格式化', () => {
|
||||
const error = {
|
||||
message: 'Unauthorized',
|
||||
$metadata: { httpStatusCode: 401 }
|
||||
};
|
||||
const formatted = formatOssError(error, '认证');
|
||||
assert.ok(formatted.message.includes('认证失败'));
|
||||
});
|
||||
|
||||
test('HTTP 403 错误应该被正确格式化', () => {
|
||||
const error = {
|
||||
message: 'Forbidden',
|
||||
$metadata: { httpStatusCode: 403 }
|
||||
};
|
||||
const formatted = formatOssError(error, '访问');
|
||||
assert.ok(formatted.message.includes('没有权限'));
|
||||
});
|
||||
|
||||
test('HTTP 429 错误(限流)应该被正确格式化', () => {
|
||||
const error = {
|
||||
message: 'Too Many Requests',
|
||||
$metadata: { httpStatusCode: 429 }
|
||||
};
|
||||
const formatted = formatOssError(error, '请求');
|
||||
assert.ok(formatted.message.includes('过于频繁'));
|
||||
});
|
||||
|
||||
test('未知错误应该保留原始消息', () => {
|
||||
const error = { message: 'Unknown error occurred' };
|
||||
const formatted = formatOssError(error, '操作');
|
||||
assert.ok(formatted.message.includes('Unknown error occurred'));
|
||||
});
|
||||
}
|
||||
|
||||
testOssErrorFormatting();
|
||||
|
||||
// ============================================================
|
||||
// 2. 并发限流测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 2. 并发限流测试 ==========\n');
|
||||
|
||||
async function testConcurrentRateLimiting() {
|
||||
console.log('--- 测试并发请求限流 ---');
|
||||
|
||||
// 简化版 RateLimiter
|
||||
class RateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.maxAttempts = options.maxAttempts || 5;
|
||||
this.windowMs = options.windowMs || 15 * 60 * 1000;
|
||||
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
|
||||
this.attempts = new Map();
|
||||
this.blockedKeys = new Map();
|
||||
}
|
||||
|
||||
isBlocked(key) {
|
||||
const blockInfo = this.blockedKeys.get(key);
|
||||
if (!blockInfo) return false;
|
||||
if (Date.now() > blockInfo.expiresAt) {
|
||||
this.blockedKeys.delete(key);
|
||||
this.attempts.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
recordFailure(key) {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.isBlocked(key)) {
|
||||
return { blocked: true, remainingAttempts: 0 };
|
||||
}
|
||||
|
||||
let attemptInfo = this.attempts.get(key);
|
||||
if (!attemptInfo || now > attemptInfo.windowEnd) {
|
||||
attemptInfo = { count: 0, windowEnd: now + this.windowMs };
|
||||
}
|
||||
|
||||
attemptInfo.count++;
|
||||
this.attempts.set(key, attemptInfo);
|
||||
|
||||
if (attemptInfo.count >= this.maxAttempts) {
|
||||
this.blockedKeys.set(key, {
|
||||
expiresAt: now + this.blockDuration
|
||||
});
|
||||
return { blocked: true, remainingAttempts: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: false,
|
||||
remainingAttempts: this.maxAttempts - attemptInfo.count
|
||||
};
|
||||
}
|
||||
|
||||
recordSuccess(key) {
|
||||
this.attempts.delete(key);
|
||||
this.blockedKeys.delete(key);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
activeAttempts: this.attempts.size,
|
||||
blockedKeys: this.blockedKeys.size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await asyncTest('并发失败请求应该正确累计', async () => {
|
||||
const limiter = new RateLimiter({ maxAttempts: 5, windowMs: 1000, blockDuration: 1000 });
|
||||
const key = 'concurrent-test-1';
|
||||
|
||||
// 模拟并发请求
|
||||
const promises = Array(5).fill().map(() =>
|
||||
new Promise(resolve => {
|
||||
const result = limiter.recordFailure(key);
|
||||
resolve(result);
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// 最后一个请求应该触发阻止
|
||||
assert.ok(results.some(r => r.blocked), '应该有请求被阻止');
|
||||
});
|
||||
|
||||
await asyncTest('不同 IP 的并发请求应该独立计数', async () => {
|
||||
const limiter = new RateLimiter({ maxAttempts: 3, windowMs: 1000, blockDuration: 1000 });
|
||||
|
||||
// 模拟来自不同 IP 的请求
|
||||
const ips = ['192.168.1.1', '192.168.1.2', '192.168.1.3'];
|
||||
|
||||
for (const ip of ips) {
|
||||
limiter.recordFailure(`login:ip:${ip}`);
|
||||
limiter.recordFailure(`login:ip:${ip}`);
|
||||
}
|
||||
|
||||
// 每个 IP 都应该还有 1 次机会
|
||||
for (const ip of ips) {
|
||||
const result = limiter.recordFailure(`login:ip:${ip}`);
|
||||
assert.strictEqual(result.blocked, true, `IP ${ip} 应该被阻止`);
|
||||
}
|
||||
});
|
||||
|
||||
await asyncTest('限流器统计应该正确反映状态', async () => {
|
||||
const limiter = new RateLimiter({ maxAttempts: 2, windowMs: 1000, blockDuration: 1000 });
|
||||
|
||||
limiter.recordFailure('key1');
|
||||
limiter.recordFailure('key2');
|
||||
limiter.recordFailure('key2'); // 这会阻止 key2
|
||||
|
||||
const stats = limiter.getStats();
|
||||
assert.ok(stats.activeAttempts >= 1, '应该有活动的尝试记录');
|
||||
assert.ok(stats.blockedKeys >= 1, '应该有被阻止的 key');
|
||||
});
|
||||
}
|
||||
|
||||
await testConcurrentRateLimiting();
|
||||
|
||||
// ============================================================
|
||||
// 3. 文件上传并发测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 3. 文件上传并发测试 ==========\n');
|
||||
|
||||
async function testConcurrentFileOperations() {
|
||||
console.log('--- 测试并发文件操作 ---');
|
||||
|
||||
// 模拟文件上传限流器
|
||||
class UploadLimiter {
|
||||
constructor(maxConcurrent = 5, maxPerHour = 100) {
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.maxPerHour = maxPerHour;
|
||||
this.currentUploads = new Map();
|
||||
this.hourlyCount = new Map();
|
||||
}
|
||||
|
||||
canUpload(userId) {
|
||||
const now = Date.now();
|
||||
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
|
||||
|
||||
// 检查小时限制
|
||||
const hourlyUsage = this.hourlyCount.get(hourKey) || 0;
|
||||
if (hourlyUsage >= this.maxPerHour) {
|
||||
return { allowed: false, reason: '每小时上传次数已达上限' };
|
||||
}
|
||||
|
||||
// 检查并发限制
|
||||
const userUploads = this.currentUploads.get(userId) || 0;
|
||||
if (userUploads >= this.maxConcurrent) {
|
||||
return { allowed: false, reason: '并发上传数已达上限' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
startUpload(userId) {
|
||||
const check = this.canUpload(userId);
|
||||
if (!check.allowed) {
|
||||
return check;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
|
||||
|
||||
// 增加计数
|
||||
this.currentUploads.set(userId, (this.currentUploads.get(userId) || 0) + 1);
|
||||
this.hourlyCount.set(hourKey, (this.hourlyCount.get(hourKey) || 0) + 1);
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
endUpload(userId) {
|
||||
const current = this.currentUploads.get(userId) || 0;
|
||||
if (current > 0) {
|
||||
this.currentUploads.set(userId, current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(userId) {
|
||||
const now = Date.now();
|
||||
const hourKey = `${userId}:${Math.floor(now / 3600000)}`;
|
||||
return {
|
||||
concurrent: this.currentUploads.get(userId) || 0,
|
||||
hourlyUsed: this.hourlyCount.get(hourKey) || 0,
|
||||
maxConcurrent: this.maxConcurrent,
|
||||
maxPerHour: this.maxPerHour
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await asyncTest('并发上传限制应该生效', async () => {
|
||||
const limiter = new UploadLimiter(3, 100);
|
||||
const userId = 'user1';
|
||||
|
||||
// 开始 3 个上传
|
||||
assert.ok(limiter.startUpload(userId).allowed);
|
||||
assert.ok(limiter.startUpload(userId).allowed);
|
||||
assert.ok(limiter.startUpload(userId).allowed);
|
||||
|
||||
// 第 4 个应该被拒绝
|
||||
const result = limiter.startUpload(userId);
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('并发'));
|
||||
});
|
||||
|
||||
await asyncTest('完成上传后应该释放并发槽位', async () => {
|
||||
const limiter = new UploadLimiter(2, 100);
|
||||
const userId = 'user2';
|
||||
|
||||
limiter.startUpload(userId);
|
||||
limiter.startUpload(userId);
|
||||
|
||||
// 应该被拒绝
|
||||
assert.strictEqual(limiter.startUpload(userId).allowed, false);
|
||||
|
||||
// 完成一个上传
|
||||
limiter.endUpload(userId);
|
||||
|
||||
// 现在应该允许
|
||||
assert.ok(limiter.startUpload(userId).allowed);
|
||||
});
|
||||
|
||||
await asyncTest('每小时上传限制应该生效', async () => {
|
||||
const limiter = new UploadLimiter(100, 5); // 最多 5 次每小时
|
||||
const userId = 'user3';
|
||||
|
||||
// 上传 5 次
|
||||
for (let i = 0; i < 5; i++) {
|
||||
limiter.startUpload(userId);
|
||||
limiter.endUpload(userId);
|
||||
}
|
||||
|
||||
// 第 6 次应该被拒绝
|
||||
const result = limiter.startUpload(userId);
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('小时'));
|
||||
});
|
||||
|
||||
await asyncTest('不同用户的限制应该独立', async () => {
|
||||
const limiter = new UploadLimiter(2, 100);
|
||||
|
||||
// 用户 1 达到限制
|
||||
limiter.startUpload('userA');
|
||||
limiter.startUpload('userA');
|
||||
assert.strictEqual(limiter.startUpload('userA').allowed, false);
|
||||
|
||||
// 用户 2 应该不受影响
|
||||
assert.ok(limiter.startUpload('userB').allowed);
|
||||
});
|
||||
}
|
||||
|
||||
await testConcurrentFileOperations();
|
||||
|
||||
// ============================================================
|
||||
// 4. 防重复提交测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 4. 防重复提交测试 ==========\n');
|
||||
|
||||
async function testDuplicateSubmissionPrevention() {
|
||||
console.log('--- 测试防重复提交机制 ---');
|
||||
|
||||
// 简单的请求去重器
|
||||
class RequestDeduplicator {
|
||||
constructor(windowMs = 1000) {
|
||||
this.windowMs = windowMs;
|
||||
this.pending = new Map();
|
||||
}
|
||||
|
||||
// 生成请求唯一标识
|
||||
getRequestKey(userId, action, params) {
|
||||
return `${userId}:${action}:${JSON.stringify(params)}`;
|
||||
}
|
||||
|
||||
// 检查是否是重复请求
|
||||
isDuplicate(userId, action, params) {
|
||||
const key = this.getRequestKey(userId, action, params);
|
||||
const now = Date.now();
|
||||
|
||||
if (this.pending.has(key)) {
|
||||
const lastRequest = this.pending.get(key);
|
||||
if (now - lastRequest < this.windowMs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.pending.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 清除过期记录
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.pending.entries()) {
|
||||
if (now - timestamp > this.windowMs) {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await asyncTest('快速重复提交应该被检测', async () => {
|
||||
const dedup = new RequestDeduplicator(100);
|
||||
|
||||
const isDup1 = dedup.isDuplicate('user1', 'delete', { file: 'test.txt' });
|
||||
assert.strictEqual(isDup1, false, '首次请求不应该是重复');
|
||||
|
||||
const isDup2 = dedup.isDuplicate('user1', 'delete', { file: 'test.txt' });
|
||||
assert.strictEqual(isDup2, true, '立即重复应该被检测');
|
||||
});
|
||||
|
||||
await asyncTest('不同参数的请求不应该被视为重复', async () => {
|
||||
const dedup = new RequestDeduplicator(100);
|
||||
|
||||
dedup.isDuplicate('user1', 'delete', { file: 'test1.txt' });
|
||||
const isDup = dedup.isDuplicate('user1', 'delete', { file: 'test2.txt' });
|
||||
assert.strictEqual(isDup, false, '不同参数不应该是重复');
|
||||
});
|
||||
|
||||
await asyncTest('超时后应该允许重新提交', async () => {
|
||||
const dedup = new RequestDeduplicator(50);
|
||||
|
||||
dedup.isDuplicate('user1', 'create', { name: 'folder' });
|
||||
|
||||
// 等待超时
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
const isDup = dedup.isDuplicate('user1', 'create', { name: 'folder' });
|
||||
assert.strictEqual(isDup, false, '超时后应该允许');
|
||||
});
|
||||
|
||||
await asyncTest('不同用户的相同请求不应该冲突', async () => {
|
||||
const dedup = new RequestDeduplicator(100);
|
||||
|
||||
dedup.isDuplicate('user1', 'share', { file: 'doc.pdf' });
|
||||
const isDup = dedup.isDuplicate('user2', 'share', { file: 'doc.pdf' });
|
||||
assert.strictEqual(isDup, false, '不同用户不应该冲突');
|
||||
});
|
||||
}
|
||||
|
||||
await testDuplicateSubmissionPrevention();
|
||||
|
||||
// ============================================================
|
||||
// 5. 缓存失效测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 5. 缓存失效测试 ==========\n');
|
||||
|
||||
async function testCacheInvalidation() {
|
||||
console.log('--- 测试缓存过期和失效 ---');
|
||||
|
||||
// TTL 缓存类
|
||||
class TTLCache {
|
||||
constructor(defaultTTL = 3600000) {
|
||||
this.cache = new Map();
|
||||
this.defaultTTL = defaultTTL;
|
||||
}
|
||||
|
||||
set(key, value, ttl = this.defaultTTL) {
|
||||
const expiresAt = Date.now() + ttl;
|
||||
this.cache.set(key, { value, expiresAt });
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (Date.now() > item.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.get(key) !== undefined;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now > item.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await asyncTest('缓存应该在 TTL 内有效', async () => {
|
||||
const cache = new TTLCache(100);
|
||||
cache.set('key1', 'value1');
|
||||
assert.strictEqual(cache.get('key1'), 'value1');
|
||||
});
|
||||
|
||||
await asyncTest('缓存应该在 TTL 后过期', async () => {
|
||||
const cache = new TTLCache(50);
|
||||
cache.set('key2', 'value2');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
assert.strictEqual(cache.get('key2'), undefined);
|
||||
});
|
||||
|
||||
await asyncTest('手动删除应该立即生效', async () => {
|
||||
const cache = new TTLCache(10000);
|
||||
cache.set('key3', 'value3');
|
||||
cache.delete('key3');
|
||||
assert.strictEqual(cache.get('key3'), undefined);
|
||||
});
|
||||
|
||||
await asyncTest('cleanup 应该清除所有过期项', async () => {
|
||||
const cache = new TTLCache(50);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.set('c', 3);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
const cleaned = cache.cleanup();
|
||||
assert.strictEqual(cleaned, 3);
|
||||
assert.strictEqual(cache.size(), 0);
|
||||
});
|
||||
|
||||
await asyncTest('不同 TTL 的项应该分别过期', async () => {
|
||||
const cache = new TTLCache(1000);
|
||||
cache.set('short', 'value', 30);
|
||||
cache.set('long', 'value', 1000);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
assert.strictEqual(cache.get('short'), undefined, '短 TTL 应该过期');
|
||||
assert.strictEqual(cache.get('long'), 'value', '长 TTL 应该有效');
|
||||
});
|
||||
}
|
||||
|
||||
await testCacheInvalidation();
|
||||
|
||||
// ============================================================
|
||||
// 6. 超时处理测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 6. 超时处理测试 ==========\n');
|
||||
|
||||
async function testTimeoutHandling() {
|
||||
console.log('--- 测试请求超时处理 ---');
|
||||
|
||||
// 带超时的 Promise 包装器
|
||||
function withTimeout(promise, ms, errorMessage = '操作超时') {
|
||||
let timeoutId;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(errorMessage));
|
||||
}, ms);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
await asyncTest('快速操作应该成功完成', async () => {
|
||||
const fastOperation = new Promise(resolve => {
|
||||
setTimeout(() => resolve('success'), 10);
|
||||
});
|
||||
|
||||
const result = await withTimeout(fastOperation, 100);
|
||||
assert.strictEqual(result, 'success');
|
||||
});
|
||||
|
||||
await asyncTest('慢速操作应该触发超时', async () => {
|
||||
const slowOperation = new Promise(resolve => {
|
||||
setTimeout(() => resolve('success'), 200);
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(slowOperation, 50);
|
||||
assert.fail('应该抛出超时错误');
|
||||
} catch (error) {
|
||||
assert.ok(error.message.includes('超时'));
|
||||
}
|
||||
});
|
||||
|
||||
await asyncTest('自定义超时消息应该正确显示', async () => {
|
||||
const slowOperation = new Promise(resolve => {
|
||||
setTimeout(() => resolve('success'), 200);
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(slowOperation, 50, 'OSS 连接超时');
|
||||
} catch (error) {
|
||||
assert.ok(error.message.includes('OSS'));
|
||||
}
|
||||
});
|
||||
|
||||
await asyncTest('超时后原始 Promise 的完成不应该影响结果', async () => {
|
||||
let completed = false;
|
||||
const operation = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
completed = true;
|
||||
resolve('done');
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(operation, 20);
|
||||
} catch (error) {
|
||||
// 超时了
|
||||
}
|
||||
|
||||
// 等待原始 Promise 完成
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
assert.ok(completed, '原始 Promise 应该完成');
|
||||
});
|
||||
}
|
||||
|
||||
await testTimeoutHandling();
|
||||
|
||||
// ============================================================
|
||||
// 7. 重试机制测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 7. 重试机制测试 ==========\n');
|
||||
|
||||
async function testRetryMechanism() {
|
||||
console.log('--- 测试操作重试机制 ---');
|
||||
|
||||
// 带重试的函数执行器
|
||||
async function withRetry(fn, options = {}) {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
delayMs = 100,
|
||||
backoff = 1.5,
|
||||
shouldRetry = (error) => true
|
||||
} = options;
|
||||
|
||||
let lastError;
|
||||
let delay = delayMs;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= backoff;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
await asyncTest('成功操作不应该重试', async () => {
|
||||
let attempts = 0;
|
||||
const result = await withRetry(async () => {
|
||||
attempts++;
|
||||
return 'success';
|
||||
});
|
||||
|
||||
assert.strictEqual(result, 'success');
|
||||
assert.strictEqual(attempts, 1);
|
||||
});
|
||||
|
||||
await asyncTest('失败操作应该重试指定次数', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
attempts++;
|
||||
throw new Error('always fail');
|
||||
}, { maxAttempts: 3, delayMs: 10 });
|
||||
} catch (error) {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
assert.strictEqual(attempts, 3);
|
||||
});
|
||||
|
||||
await asyncTest('重试后成功应该返回结果', async () => {
|
||||
let attempts = 0;
|
||||
const result = await withRetry(async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('not yet');
|
||||
}
|
||||
return 'finally success';
|
||||
}, { maxAttempts: 5, delayMs: 10 });
|
||||
|
||||
assert.strictEqual(result, 'finally success');
|
||||
assert.strictEqual(attempts, 3);
|
||||
});
|
||||
|
||||
await asyncTest('shouldRetry 为 false 时不应该重试', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
attempts++;
|
||||
const error = new Error('fatal');
|
||||
error.code = 'FATAL';
|
||||
throw error;
|
||||
}, {
|
||||
maxAttempts: 5,
|
||||
delayMs: 10,
|
||||
shouldRetry: (error) => error.code !== 'FATAL'
|
||||
});
|
||||
} catch (error) {
|
||||
// 预期会失败
|
||||
}
|
||||
|
||||
assert.strictEqual(attempts, 1, '不应该重试 FATAL 错误');
|
||||
});
|
||||
}
|
||||
|
||||
await testRetryMechanism();
|
||||
|
||||
// ============================================================
|
||||
// 测试总结
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试总结');
|
||||
console.log('========================================');
|
||||
console.log(`通过: ${testResults.passed}`);
|
||||
console.log(`失败: ${testResults.failed}`);
|
||||
|
||||
if (testResults.errors.length > 0) {
|
||||
console.log('\n失败的测试:');
|
||||
testResults.errors.forEach((e, i) => {
|
||||
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
return testResults;
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().then(testResults => {
|
||||
process.exit(testResults.failed > 0 ? 1 : 0);
|
||||
}).catch(err => {
|
||||
console.error('测试执行错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
106
backend/tests/run-all-tests.js
Normal file
106
backend/tests/run-all-tests.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 运行所有边界条件和异常处理测试
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const testFiles = [
|
||||
'boundary-tests.js',
|
||||
'network-concurrent-tests.js',
|
||||
'state-consistency-tests.js'
|
||||
];
|
||||
|
||||
const results = {
|
||||
total: { passed: 0, failed: 0 },
|
||||
files: []
|
||||
};
|
||||
|
||||
function runTest(file) {
|
||||
return new Promise((resolve) => {
|
||||
const testPath = path.join(__dirname, file);
|
||||
const child = spawn('node', [testPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
process.stdout.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// 解析测试结果
|
||||
const passMatch = output.match(/通过:\s*(\d+)/);
|
||||
const failMatch = output.match(/失败:\s*(\d+)/);
|
||||
|
||||
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const failed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
|
||||
results.files.push({
|
||||
file,
|
||||
passed,
|
||||
failed,
|
||||
exitCode: code
|
||||
});
|
||||
|
||||
results.total.passed += passed;
|
||||
results.total.failed += failed;
|
||||
|
||||
resolve(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('运行所有边界条件和异常处理测试');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
for (const file of testFiles) {
|
||||
console.log('='.repeat(60));
|
||||
console.log(`测试文件: ${file}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await runTest(file);
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 输出最终汇总
|
||||
console.log('='.repeat(60));
|
||||
console.log('最终汇总');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log('各测试文件结果:');
|
||||
for (const fileResult of results.files) {
|
||||
const status = fileResult.failed === 0 ? 'PASS' : 'FAIL';
|
||||
console.log(` [${status}] ${fileResult.file}: 通过 ${fileResult.passed}, 失败 ${fileResult.failed}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
|
||||
console.log('');
|
||||
|
||||
if (results.total.failed > 0) {
|
||||
console.log('存在失败的测试,请检查输出以了解详情。');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('所有测试通过!');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
runAllTests().catch(err => {
|
||||
console.error('运行测试时发生错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
896
backend/tests/state-consistency-tests.js
Normal file
896
backend/tests/state-consistency-tests.js
Normal file
@@ -0,0 +1,896 @@
|
||||
/**
|
||||
* 状态一致性测试套件
|
||||
*
|
||||
* 测试范围:
|
||||
* 1. Token 过期处理和刷新机制
|
||||
* 2. 存储切换后数据一致性
|
||||
* 3. 会话状态管理
|
||||
* 4. 本地存储状态恢复
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// 测试结果收集器
|
||||
const testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// 测试辅助函数
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function asyncTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
testResults.passed++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} catch (error) {
|
||||
testResults.failed++;
|
||||
testResults.errors.push({ name, error: error.message });
|
||||
console.log(` [FAIL] ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
|
||||
// ============================================================
|
||||
// 1. Token 管理测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 1. Token 管理测试 ==========\n');
|
||||
|
||||
function testTokenManagement() {
|
||||
console.log('--- 测试 Token 过期和刷新机制 ---');
|
||||
|
||||
// 模拟 JWT Token 结构
|
||||
function createMockToken(payload, expiresInMs) {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + Math.floor(expiresInMs / 1000);
|
||||
const tokenPayload = { ...payload, iat, exp };
|
||||
|
||||
// 简化的 base64 编码(仅用于测试)
|
||||
const base64Header = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||
const base64Payload = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url');
|
||||
|
||||
return `${base64Header}.${base64Payload}.signature`;
|
||||
}
|
||||
|
||||
// 解析 Token 并检查过期
|
||||
function parseToken(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
isExpired: payload.exp < now,
|
||||
expiresIn: (payload.exp - now) * 1000
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要刷新 Token(提前 5 分钟刷新)
|
||||
function needsRefresh(token, thresholdMs = 5 * 60 * 1000) {
|
||||
const parsed = parseToken(token);
|
||||
if (!parsed) return true;
|
||||
return parsed.expiresIn < thresholdMs;
|
||||
}
|
||||
|
||||
test('有效 Token 应该能正确解析', () => {
|
||||
const token = createMockToken({ id: 1, username: 'test' }, 2 * 60 * 60 * 1000);
|
||||
const parsed = parseToken(token);
|
||||
|
||||
assert.ok(parsed, 'Token 应该能被解析');
|
||||
assert.strictEqual(parsed.id, 1);
|
||||
assert.strictEqual(parsed.username, 'test');
|
||||
assert.strictEqual(parsed.isExpired, false);
|
||||
});
|
||||
|
||||
test('过期 Token 应该被正确识别', () => {
|
||||
const token = createMockToken({ id: 1 }, -1000); // 已过期
|
||||
const parsed = parseToken(token);
|
||||
|
||||
assert.ok(parsed.isExpired, 'Token 应该被标记为过期');
|
||||
});
|
||||
|
||||
test('即将过期的 Token 应该触发刷新', () => {
|
||||
const token = createMockToken({ id: 1 }, 3 * 60 * 1000); // 3 分钟后过期
|
||||
assert.ok(needsRefresh(token, 5 * 60 * 1000), '3 分钟后过期的 Token 应该触发刷新');
|
||||
});
|
||||
|
||||
test('有效期充足的 Token 不应该触发刷新', () => {
|
||||
const token = createMockToken({ id: 1 }, 30 * 60 * 1000); // 30 分钟后过期
|
||||
assert.ok(!needsRefresh(token, 5 * 60 * 1000), '30 分钟后过期的 Token 不应该触发刷新');
|
||||
});
|
||||
|
||||
test('无效 Token 格式应该返回 null', () => {
|
||||
assert.strictEqual(parseToken('invalid'), null);
|
||||
assert.strictEqual(parseToken('a.b'), null);
|
||||
assert.strictEqual(parseToken(''), null);
|
||||
});
|
||||
}
|
||||
|
||||
testTokenManagement();
|
||||
|
||||
// ============================================================
|
||||
// 2. 存储切换一致性测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 2. 存储切换一致性测试 ==========\n');
|
||||
|
||||
function testStorageSwitchConsistency() {
|
||||
console.log('--- 测试存储类型切换数据一致性 ---');
|
||||
|
||||
// 模拟用户存储状态
|
||||
class UserStorageState {
|
||||
constructor(user) {
|
||||
this.userId = user.id;
|
||||
this.storageType = user.current_storage_type || 'oss';
|
||||
this.permission = user.storage_permission || 'oss_only';
|
||||
this.localQuota = user.local_storage_quota || 1073741824;
|
||||
this.localUsed = user.local_storage_used || 0;
|
||||
this.hasOssConfig = user.has_oss_config || 0;
|
||||
}
|
||||
|
||||
// 检查是否可以切换到指定存储类型
|
||||
canSwitchTo(targetType) {
|
||||
// 检查权限
|
||||
if (this.permission === 'oss_only' && targetType === 'local') {
|
||||
return { allowed: false, reason: '您没有使用本地存储的权限' };
|
||||
}
|
||||
if (this.permission === 'local_only' && targetType === 'oss') {
|
||||
return { allowed: false, reason: '您没有使用 OSS 存储的权限' };
|
||||
}
|
||||
|
||||
// 检查 OSS 配置
|
||||
if (targetType === 'oss' && !this.hasOssConfig) {
|
||||
return { allowed: false, reason: '请先配置 OSS 服务' };
|
||||
}
|
||||
|
||||
// 检查本地存储配额
|
||||
if (targetType === 'local' && this.localUsed >= this.localQuota) {
|
||||
return { allowed: false, reason: '本地存储空间已满' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// 切换存储类型
|
||||
switchTo(targetType) {
|
||||
const check = this.canSwitchTo(targetType);
|
||||
if (!check.allowed) {
|
||||
throw new Error(check.reason);
|
||||
}
|
||||
this.storageType = targetType;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取当前可用空间
|
||||
getAvailableSpace() {
|
||||
if (this.storageType === 'local') {
|
||||
return this.localQuota - this.localUsed;
|
||||
}
|
||||
return null; // OSS 空间由用户 Bucket 决定
|
||||
}
|
||||
}
|
||||
|
||||
test('OSS only 权限用户不能切换到本地存储', () => {
|
||||
const user = { id: 1, storage_permission: 'oss_only', has_oss_config: 1 };
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
const result = state.canSwitchTo('local');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('权限'));
|
||||
});
|
||||
|
||||
test('本地 only 权限用户不能切换到 OSS 存储', () => {
|
||||
const user = { id: 1, storage_permission: 'local_only' };
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
const result = state.canSwitchTo('oss');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('权限'));
|
||||
});
|
||||
|
||||
test('未配置 OSS 的用户不能切换到 OSS', () => {
|
||||
const user = { id: 1, storage_permission: 'both', has_oss_config: 0 };
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
const result = state.canSwitchTo('oss');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('配置'));
|
||||
});
|
||||
|
||||
test('本地存储已满时不能切换到本地', () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
storage_permission: 'both',
|
||||
local_storage_quota: 1000,
|
||||
local_storage_used: 1000
|
||||
};
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
const result = state.canSwitchTo('local');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.reason.includes('已满'));
|
||||
});
|
||||
|
||||
test('有权限且已配置的用户可以自由切换', () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
storage_permission: 'both',
|
||||
has_oss_config: 1,
|
||||
local_storage_quota: 10000,
|
||||
local_storage_used: 5000
|
||||
};
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
assert.ok(state.canSwitchTo('oss').allowed);
|
||||
assert.ok(state.canSwitchTo('local').allowed);
|
||||
});
|
||||
|
||||
test('切换后状态应该正确更新', () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
storage_permission: 'both',
|
||||
has_oss_config: 1,
|
||||
current_storage_type: 'oss'
|
||||
};
|
||||
const state = new UserStorageState(user);
|
||||
|
||||
assert.strictEqual(state.storageType, 'oss');
|
||||
state.switchTo('local');
|
||||
assert.strictEqual(state.storageType, 'local');
|
||||
});
|
||||
}
|
||||
|
||||
testStorageSwitchConsistency();
|
||||
|
||||
// ============================================================
|
||||
// 3. 会话状态管理测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 3. 会话状态管理测试 ==========\n');
|
||||
|
||||
async function testSessionManagement() {
|
||||
console.log('--- 测试会话状态管理 ---');
|
||||
|
||||
// 模拟会话管理器
|
||||
class SessionManager {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.sessionTTL = 30 * 60 * 1000; // 30 分钟
|
||||
}
|
||||
|
||||
createSession(userId) {
|
||||
const sessionId = `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
createdAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
data: {}
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
getSession(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// 检查会话是否过期
|
||||
if (Date.now() - session.lastActivity > this.sessionTTL) {
|
||||
this.sessions.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 更新最后活动时间
|
||||
session.lastActivity = Date.now();
|
||||
return session;
|
||||
}
|
||||
|
||||
updateSessionData(sessionId, data) {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
session.data = { ...session.data, ...data };
|
||||
return true;
|
||||
}
|
||||
|
||||
destroySession(sessionId) {
|
||||
return this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
getActiveSessions(userId) {
|
||||
const now = Date.now();
|
||||
const active = [];
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.userId === userId && now - session.lastActivity <= this.sessionTTL) {
|
||||
active.push(session);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
// 强制登出用户所有会话
|
||||
destroyUserSessions(userId) {
|
||||
let count = 0;
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
if (session.userId === userId) {
|
||||
this.sessions.delete(sessionId);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new SessionManager();
|
||||
|
||||
await asyncTest('创建会话应该返回有效的会话 ID', async () => {
|
||||
const sessionId = manager.createSession(1);
|
||||
assert.ok(sessionId.startsWith('sess_'));
|
||||
assert.ok(manager.getSession(sessionId) !== null);
|
||||
});
|
||||
|
||||
await asyncTest('获取会话应该返回正确的用户 ID', async () => {
|
||||
const sessionId = manager.createSession(42);
|
||||
const session = manager.getSession(sessionId);
|
||||
assert.strictEqual(session.userId, 42);
|
||||
});
|
||||
|
||||
await asyncTest('更新会话数据应该持久化', async () => {
|
||||
const sessionId = manager.createSession(1);
|
||||
manager.updateSessionData(sessionId, { captcha: 'ABC123' });
|
||||
|
||||
const session = manager.getSession(sessionId);
|
||||
assert.strictEqual(session.data.captcha, 'ABC123');
|
||||
});
|
||||
|
||||
await asyncTest('销毁会话后应该无法获取', async () => {
|
||||
const sessionId = manager.createSession(1);
|
||||
manager.destroySession(sessionId);
|
||||
assert.strictEqual(manager.getSession(sessionId), null);
|
||||
});
|
||||
|
||||
await asyncTest('过期会话应该被自动清理', async () => {
|
||||
const shortTTLManager = new SessionManager();
|
||||
shortTTLManager.sessionTTL = 10; // 10ms
|
||||
|
||||
const sessionId = shortTTLManager.createSession(1);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
assert.strictEqual(shortTTLManager.getSession(sessionId), null);
|
||||
});
|
||||
|
||||
await asyncTest('强制登出应该清除用户所有会话', async () => {
|
||||
const sessionId1 = manager.createSession(100);
|
||||
const sessionId2 = manager.createSession(100);
|
||||
const sessionId3 = manager.createSession(100);
|
||||
|
||||
const count = manager.destroyUserSessions(100);
|
||||
assert.strictEqual(count, 3);
|
||||
assert.strictEqual(manager.getSession(sessionId1), null);
|
||||
assert.strictEqual(manager.getSession(sessionId2), null);
|
||||
assert.strictEqual(manager.getSession(sessionId3), null);
|
||||
});
|
||||
}
|
||||
|
||||
await testSessionManagement();
|
||||
|
||||
// ============================================================
|
||||
// 4. 本地存储状态恢复测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 4. 本地存储状态恢复测试 ==========\n');
|
||||
|
||||
function testLocalStorageRecovery() {
|
||||
console.log('--- 测试本地存储状态恢复 ---');
|
||||
|
||||
// 模拟 localStorage
|
||||
class MockLocalStorage {
|
||||
constructor() {
|
||||
this.store = {};
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.store[key] || null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value);
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 状态恢复管理器
|
||||
class StateRecoveryManager {
|
||||
constructor(storage) {
|
||||
this.storage = storage;
|
||||
this.stateKey = 'app_state';
|
||||
}
|
||||
|
||||
// 保存状态
|
||||
saveState(state) {
|
||||
try {
|
||||
const serialized = JSON.stringify({
|
||||
...state,
|
||||
savedAt: Date.now()
|
||||
});
|
||||
this.storage.setItem(this.stateKey, serialized);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('保存状态失败:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复状态
|
||||
restoreState(maxAgeMs = 24 * 60 * 60 * 1000) {
|
||||
try {
|
||||
const serialized = this.storage.getItem(this.stateKey);
|
||||
if (!serialized) return null;
|
||||
|
||||
const state = JSON.parse(serialized);
|
||||
|
||||
// 检查状态是否过期
|
||||
if (Date.now() - state.savedAt > maxAgeMs) {
|
||||
this.clearState();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 移除元数据
|
||||
delete state.savedAt;
|
||||
return state;
|
||||
} catch (e) {
|
||||
console.error('恢复状态失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 清除状态
|
||||
clearState() {
|
||||
this.storage.removeItem(this.stateKey);
|
||||
}
|
||||
|
||||
// 合并恢复的状态和默认状态
|
||||
mergeWithDefaults(defaults) {
|
||||
const restored = this.restoreState();
|
||||
if (!restored) return defaults;
|
||||
|
||||
// 只恢复允许持久化的字段
|
||||
const allowedFields = ['currentView', 'fileViewMode', 'adminTab', 'currentPath'];
|
||||
const merged = { ...defaults };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (field in restored) {
|
||||
merged[field] = restored[field];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new MockLocalStorage();
|
||||
const manager = new StateRecoveryManager(storage);
|
||||
|
||||
test('保存和恢复状态应该正常工作', () => {
|
||||
const state = { currentView: 'files', currentPath: '/documents' };
|
||||
manager.saveState(state);
|
||||
|
||||
const restored = manager.restoreState();
|
||||
assert.strictEqual(restored.currentView, 'files');
|
||||
assert.strictEqual(restored.currentPath, '/documents');
|
||||
});
|
||||
|
||||
test('空存储应该返回 null', () => {
|
||||
const emptyStorage = new MockLocalStorage();
|
||||
const emptyManager = new StateRecoveryManager(emptyStorage);
|
||||
assert.strictEqual(emptyManager.restoreState(), null);
|
||||
});
|
||||
|
||||
test('过期状态应该被清除', () => {
|
||||
// 手动设置一个过期的状态
|
||||
storage.setItem('app_state', JSON.stringify({
|
||||
currentView: 'old',
|
||||
savedAt: Date.now() - 48 * 60 * 60 * 1000 // 48小时前
|
||||
}));
|
||||
|
||||
const restored = manager.restoreState(24 * 60 * 60 * 1000);
|
||||
assert.strictEqual(restored, null);
|
||||
});
|
||||
|
||||
test('清除状态后应该无法恢复', () => {
|
||||
manager.saveState({ test: 'value' });
|
||||
manager.clearState();
|
||||
assert.strictEqual(manager.restoreState(), null);
|
||||
});
|
||||
|
||||
test('合并默认值应该优先使用恢复的值', () => {
|
||||
manager.saveState({ currentView: 'shares', adminTab: 'users' });
|
||||
|
||||
const defaults = { currentView: 'files', fileViewMode: 'grid', adminTab: 'overview' };
|
||||
const merged = manager.mergeWithDefaults(defaults);
|
||||
|
||||
assert.strictEqual(merged.currentView, 'shares');
|
||||
assert.strictEqual(merged.adminTab, 'users');
|
||||
assert.strictEqual(merged.fileViewMode, 'grid'); // 默认值
|
||||
});
|
||||
|
||||
test('损坏的 JSON 应该返回 null', () => {
|
||||
storage.setItem('app_state', 'not valid json{');
|
||||
assert.strictEqual(manager.restoreState(), null);
|
||||
});
|
||||
}
|
||||
|
||||
testLocalStorageRecovery();
|
||||
|
||||
// ============================================================
|
||||
// 5. 并发状态更新测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 5. 并发状态更新测试 ==========\n');
|
||||
|
||||
async function testConcurrentStateUpdates() {
|
||||
console.log('--- 测试并发状态更新 ---');
|
||||
|
||||
// 简单的状态管理器(带版本控制)
|
||||
class VersionedStateManager {
|
||||
constructor(initialState = {}) {
|
||||
this.state = { ...initialState };
|
||||
this.version = 0;
|
||||
this.updateQueue = [];
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
// 乐观锁更新
|
||||
async updateWithVersion(expectedVersion, updates) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.updateQueue.push({
|
||||
expectedVersion,
|
||||
updates,
|
||||
resolve,
|
||||
reject
|
||||
});
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// 强制更新(忽略版本)
|
||||
forceUpdate(updates) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.version++;
|
||||
return { success: true, version: this.version };
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
if (this.processing || this.updateQueue.length === 0) return;
|
||||
|
||||
this.processing = true;
|
||||
|
||||
while (this.updateQueue.length > 0) {
|
||||
const { expectedVersion, updates, resolve, reject } = this.updateQueue.shift();
|
||||
|
||||
if (expectedVersion !== this.version) {
|
||||
reject(new Error('版本冲突,请刷新后重试'));
|
||||
continue;
|
||||
}
|
||||
|
||||
this.state = { ...this.state, ...updates };
|
||||
this.version++;
|
||||
resolve({ success: true, version: this.version, state: this.getState() });
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
await asyncTest('顺序更新应该成功', async () => {
|
||||
const manager = new VersionedStateManager({ count: 0 });
|
||||
|
||||
await manager.updateWithVersion(0, { count: 1 });
|
||||
await manager.updateWithVersion(1, { count: 2 });
|
||||
|
||||
assert.strictEqual(manager.getState().count, 2);
|
||||
assert.strictEqual(manager.getVersion(), 2);
|
||||
});
|
||||
|
||||
await asyncTest('版本冲突应该被检测', async () => {
|
||||
const manager = new VersionedStateManager({ count: 0 });
|
||||
|
||||
// 第一个更新成功
|
||||
await manager.updateWithVersion(0, { count: 1 });
|
||||
|
||||
// 使用旧版本尝试更新应该失败
|
||||
try {
|
||||
await manager.updateWithVersion(0, { count: 2 });
|
||||
assert.fail('应该抛出版本冲突错误');
|
||||
} catch (error) {
|
||||
assert.ok(error.message.includes('冲突'));
|
||||
}
|
||||
});
|
||||
|
||||
await asyncTest('强制更新应该忽略版本', async () => {
|
||||
const manager = new VersionedStateManager({ value: 'old' });
|
||||
|
||||
manager.forceUpdate({ value: 'new' });
|
||||
assert.strictEqual(manager.getState().value, 'new');
|
||||
});
|
||||
|
||||
await asyncTest('并发更新应该按顺序处理', async () => {
|
||||
const manager = new VersionedStateManager({ count: 0 });
|
||||
|
||||
// 模拟并发更新
|
||||
const results = await Promise.allSettled([
|
||||
manager.updateWithVersion(0, { count: 1 }),
|
||||
manager.updateWithVersion(0, { count: 2 }), // 这个会失败
|
||||
manager.updateWithVersion(0, { count: 3 }) // 这个也会失败
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled').length;
|
||||
const rejected = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
assert.strictEqual(fulfilled, 1, '应该只有一个更新成功');
|
||||
assert.strictEqual(rejected, 2, '应该有两个更新失败');
|
||||
});
|
||||
}
|
||||
|
||||
await testConcurrentStateUpdates();
|
||||
|
||||
// ============================================================
|
||||
// 6. 视图切换状态测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 6. 视图切换状态测试 ==========\n');
|
||||
|
||||
function testViewSwitchState() {
|
||||
console.log('--- 测试视图切换状态保持 ---');
|
||||
|
||||
// 视图状态管理器
|
||||
class ViewStateManager {
|
||||
constructor() {
|
||||
this.currentView = 'files';
|
||||
this.viewStates = {
|
||||
files: { path: '/', viewMode: 'grid', selection: [] },
|
||||
shares: { viewMode: 'list', filter: 'all' },
|
||||
admin: { tab: 'overview' }
|
||||
};
|
||||
}
|
||||
|
||||
switchTo(view) {
|
||||
if (!this.viewStates[view]) {
|
||||
throw new Error(`未知视图: ${view}`);
|
||||
}
|
||||
this.currentView = view;
|
||||
return this.getViewState(view);
|
||||
}
|
||||
|
||||
getViewState(view) {
|
||||
return { ...this.viewStates[view || this.currentView] };
|
||||
}
|
||||
|
||||
updateViewState(view, updates) {
|
||||
if (!this.viewStates[view]) {
|
||||
throw new Error(`未知视图: ${view}`);
|
||||
}
|
||||
this.viewStates[view] = { ...this.viewStates[view], ...updates };
|
||||
}
|
||||
|
||||
// 获取完整状态快照
|
||||
getSnapshot() {
|
||||
return {
|
||||
currentView: this.currentView,
|
||||
viewStates: JSON.parse(JSON.stringify(this.viewStates))
|
||||
};
|
||||
}
|
||||
|
||||
// 从快照恢复
|
||||
restoreFromSnapshot(snapshot) {
|
||||
this.currentView = snapshot.currentView;
|
||||
this.viewStates = JSON.parse(JSON.stringify(snapshot.viewStates));
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new ViewStateManager();
|
||||
|
||||
test('切换视图应该返回该视图的状态', () => {
|
||||
const state = manager.switchTo('shares');
|
||||
assert.strictEqual(state.viewMode, 'list');
|
||||
assert.strictEqual(state.filter, 'all');
|
||||
});
|
||||
|
||||
test('更新视图状态应该被保存', () => {
|
||||
manager.updateViewState('files', { path: '/documents', selection: ['file1.txt'] });
|
||||
const state = manager.getViewState('files');
|
||||
assert.strictEqual(state.path, '/documents');
|
||||
assert.strictEqual(state.selection.length, 1);
|
||||
});
|
||||
|
||||
test('切换视图后再切换回来应该保留状态', () => {
|
||||
manager.updateViewState('files', { path: '/photos' });
|
||||
manager.switchTo('shares');
|
||||
manager.switchTo('files');
|
||||
|
||||
const state = manager.getViewState('files');
|
||||
assert.strictEqual(state.path, '/photos');
|
||||
});
|
||||
|
||||
test('切换到未知视图应该抛出错误', () => {
|
||||
assert.throws(() => manager.switchTo('unknown'), /未知视图/);
|
||||
});
|
||||
|
||||
test('快照和恢复应该正常工作', () => {
|
||||
manager.updateViewState('files', { path: '/backup' });
|
||||
const snapshot = manager.getSnapshot();
|
||||
|
||||
// 修改状态
|
||||
manager.updateViewState('files', { path: '/different' });
|
||||
|
||||
// 从快照恢复
|
||||
manager.restoreFromSnapshot(snapshot);
|
||||
|
||||
const state = manager.getViewState('files');
|
||||
assert.strictEqual(state.path, '/backup');
|
||||
});
|
||||
}
|
||||
|
||||
testViewSwitchState();
|
||||
|
||||
// ============================================================
|
||||
// 7. 主题切换一致性测试
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========== 7. 主题切换一致性测试 ==========\n');
|
||||
|
||||
function testThemeConsistency() {
|
||||
console.log('--- 测试主题切换一致性 ---');
|
||||
|
||||
// 主题管理器
|
||||
class ThemeManager {
|
||||
constructor(globalDefault = 'dark') {
|
||||
this.globalTheme = globalDefault;
|
||||
this.userTheme = null; // null 表示跟随全局
|
||||
}
|
||||
|
||||
setGlobalTheme(theme) {
|
||||
if (!['dark', 'light'].includes(theme)) {
|
||||
throw new Error('无效的主题');
|
||||
}
|
||||
this.globalTheme = theme;
|
||||
}
|
||||
|
||||
setUserTheme(theme) {
|
||||
if (theme !== null && !['dark', 'light'].includes(theme)) {
|
||||
throw new Error('无效的主题');
|
||||
}
|
||||
this.userTheme = theme;
|
||||
}
|
||||
|
||||
getEffectiveTheme() {
|
||||
return this.userTheme || this.globalTheme;
|
||||
}
|
||||
|
||||
isFollowingGlobal() {
|
||||
return this.userTheme === null;
|
||||
}
|
||||
|
||||
getThemeInfo() {
|
||||
return {
|
||||
global: this.globalTheme,
|
||||
user: this.userTheme,
|
||||
effective: this.getEffectiveTheme(),
|
||||
followingGlobal: this.isFollowingGlobal()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test('默认应该使用全局主题', () => {
|
||||
const manager = new ThemeManager('dark');
|
||||
assert.strictEqual(manager.getEffectiveTheme(), 'dark');
|
||||
assert.ok(manager.isFollowingGlobal());
|
||||
});
|
||||
|
||||
test('用户主题应该覆盖全局主题', () => {
|
||||
const manager = new ThemeManager('dark');
|
||||
manager.setUserTheme('light');
|
||||
|
||||
assert.strictEqual(manager.getEffectiveTheme(), 'light');
|
||||
assert.ok(!manager.isFollowingGlobal());
|
||||
});
|
||||
|
||||
test('用户主题设为 null 应该跟随全局', () => {
|
||||
const manager = new ThemeManager('dark');
|
||||
manager.setUserTheme('light');
|
||||
manager.setUserTheme(null);
|
||||
|
||||
assert.strictEqual(manager.getEffectiveTheme(), 'dark');
|
||||
assert.ok(manager.isFollowingGlobal());
|
||||
});
|
||||
|
||||
test('全局主题改变应该影响跟随全局的用户', () => {
|
||||
const manager = new ThemeManager('dark');
|
||||
|
||||
manager.setGlobalTheme('light');
|
||||
assert.strictEqual(manager.getEffectiveTheme(), 'light');
|
||||
});
|
||||
|
||||
test('全局主题改变不应该影响有自定义主题的用户', () => {
|
||||
const manager = new ThemeManager('dark');
|
||||
manager.setUserTheme('light');
|
||||
|
||||
manager.setGlobalTheme('dark');
|
||||
assert.strictEqual(manager.getEffectiveTheme(), 'light');
|
||||
});
|
||||
|
||||
test('无效主题应该抛出错误', () => {
|
||||
const manager = new ThemeManager();
|
||||
assert.throws(() => manager.setGlobalTheme('invalid'), /无效/);
|
||||
assert.throws(() => manager.setUserTheme('invalid'), /无效/);
|
||||
});
|
||||
}
|
||||
|
||||
testThemeConsistency();
|
||||
|
||||
// ============================================================
|
||||
// 测试总结
|
||||
// ============================================================
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试总结');
|
||||
console.log('========================================');
|
||||
console.log(`通过: ${testResults.passed}`);
|
||||
console.log(`失败: ${testResults.failed}`);
|
||||
|
||||
if (testResults.errors.length > 0) {
|
||||
console.log('\n失败的测试:');
|
||||
testResults.errors.forEach((e, i) => {
|
||||
console.log(` ${i + 1}. ${e.name}: ${e.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
return testResults;
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().then(testResults => {
|
||||
process.exit(testResults.failed > 0 ? 1 : 0);
|
||||
}).catch(err => {
|
||||
console.error('测试执行错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
271
backend/utils/encryption.js
Normal file
271
backend/utils/encryption.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 加密工具模块
|
||||
*
|
||||
* 功能:
|
||||
* - 使用 AES-256-GCM 加密敏感数据(OSS Access Key Secret)
|
||||
* - 提供加密和解密函数
|
||||
* - 自动处理初始化向量(IV)和认证标签
|
||||
*
|
||||
* 安全特性:
|
||||
* - AES-256-GCM 提供认证加密(AEAD)
|
||||
* - 每次加密使用随机 IV,防止模式泄露
|
||||
* - 使用认证标签验证数据完整性
|
||||
* - 密钥从环境变量读取,不存在硬编码
|
||||
*
|
||||
* @module utils/encryption
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* 从环境变量获取加密密钥
|
||||
*
|
||||
* 要求:
|
||||
* - 必须是 32 字节的十六进制字符串(64个字符)
|
||||
* - 如果未设置或格式错误,启动时抛出错误
|
||||
*
|
||||
* @returns {Buffer} 32字节的加密密钥
|
||||
* @throws {Error} 如果密钥未配置或格式错误
|
||||
*/
|
||||
function getEncryptionKey() {
|
||||
const keyHex = process.env.ENCRYPTION_KEY;
|
||||
|
||||
if (!keyHex) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 安全错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 未配置! ║
|
||||
║ ║
|
||||
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
|
||||
║ ║
|
||||
║ 生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
║ ║
|
||||
║ 在 backend/.env 文件中添加: ║
|
||||
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
// 验证密钥格式(必须是64个十六进制字符)
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 配置错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 格式错误! ║
|
||||
║ ║
|
||||
║ 要求: 64位十六进制字符串(32字节) ║
|
||||
║ 当前长度: ${keyHex.length} 字符 ║
|
||||
║ ║
|
||||
║ 正确的生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密明文字符串
|
||||
*
|
||||
* 使用 AES-256-GCM 算法加密数据,输出格式:
|
||||
* - Base64(IV + ciphertext + authTag)
|
||||
* - IV: 12字节(随机)
|
||||
* - ciphertext: 加密后的数据
|
||||
* - authTag: 16字节(认证标签)
|
||||
*
|
||||
* @param {string} plaintext - 要加密的明文字符串
|
||||
* @returns {string} Base64编码的加密结果(包含 IV 和 authTag)
|
||||
* @throws {Error} 如果加密失败
|
||||
*
|
||||
* @example
|
||||
* const encrypted = encryptSecret('my-secret-key');
|
||||
* // 输出: 'base64-encoded-string-with-iv-and-tag'
|
||||
*/
|
||||
function encryptSecret(plaintext) {
|
||||
try {
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 生成随机初始化向量(IV)
|
||||
// GCM 模式推荐 12 字节 IV
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 加密数据
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
|
||||
encrypted += cipher.final('binary');
|
||||
|
||||
// 获取认证标签(用于验证数据完整性)
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 组合:IV + encrypted + authTag
|
||||
const combined = Buffer.concat([
|
||||
iv,
|
||||
Buffer.from(encrypted, 'binary'),
|
||||
authTag
|
||||
]);
|
||||
|
||||
// 返回 Base64 编码的结果
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[加密] 加密失败:', error);
|
||||
throw new Error('数据加密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密密文字符串
|
||||
*
|
||||
* 解密由 encryptSecret() 加密的数据
|
||||
* 自动验证认证标签,确保数据完整性
|
||||
*
|
||||
* @param {string} ciphertext - Base64编码的密文(由 encryptSecret 生成)
|
||||
* @returns {string} 解密后的明文字符串
|
||||
* @throws {Error} 如果解密失败或认证标签验证失败
|
||||
*
|
||||
* @example
|
||||
* const decrypted = decryptSecret(encrypted);
|
||||
* // 输出: 'my-secret-key'
|
||||
*/
|
||||
function decryptSecret(ciphertext) {
|
||||
try {
|
||||
// 如果是 null 或 undefined,直接返回
|
||||
if (!ciphertext) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 检查是否为加密格式(Base64)
|
||||
// 如果不是 Base64,可能是旧数据(明文),直接返回
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
|
||||
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 解析 Base64
|
||||
const combined = Buffer.from(ciphertext, 'base64');
|
||||
|
||||
// 提取各部分
|
||||
// IV: 前 12 字节
|
||||
const iv = combined.slice(0, 12);
|
||||
|
||||
// authTag: 最后 16 字节
|
||||
const authTag = combined.slice(-16);
|
||||
|
||||
// ciphertext: 中间部分
|
||||
const encrypted = combined.slice(12, -16);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 设置认证标签
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// 解密数据
|
||||
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
// 如果解密失败,可能是旧数据(明文),直接返回
|
||||
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
|
||||
|
||||
// 在开发环境抛出错误,生产环境尝试返回原值
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
throw new Error('数据解密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密系统是否正常工作
|
||||
*
|
||||
* 在应用启动时调用,确保:
|
||||
* 1. ENCRYPTION_KEY 已配置
|
||||
* 2. 加密/解密功能正常
|
||||
*
|
||||
* @returns {boolean} true 如果验证通过
|
||||
* @throws {Error} 如果验证失败
|
||||
*/
|
||||
function validateEncryption() {
|
||||
try {
|
||||
const testData = 'test-secret-123';
|
||||
|
||||
// 测试加密
|
||||
const encrypted = encryptSecret(testData);
|
||||
|
||||
// 验证加密结果不为空且不等于原文
|
||||
if (!encrypted || encrypted === testData) {
|
||||
throw new Error('加密结果异常');
|
||||
}
|
||||
|
||||
// 测试解密
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
// 验证解密结果等于原文
|
||||
if (decrypted !== testData) {
|
||||
throw new Error('解密结果不匹配');
|
||||
}
|
||||
|
||||
console.log('[安全] ✓ 加密系统验证通过');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[安全] ✗ 加密系统验证失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否已加密
|
||||
*
|
||||
* 通过格式判断是否为加密数据
|
||||
* 注意:这不是加密学验证,仅用于提示
|
||||
*
|
||||
* @param {string} data - 要检查的数据
|
||||
* @returns {boolean} true 如果看起来像是加密数据
|
||||
*/
|
||||
function isEncrypted(data) {
|
||||
if (!data || typeof data !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加密后的数据特征:
|
||||
// 1. 是有效的 Base64
|
||||
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
||||
// 3. 通常会比原文长
|
||||
|
||||
try {
|
||||
// 尝试解码 Base64
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
|
||||
// 检查长度(至少包含 IV + authTag)
|
||||
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
||||
// Base64编码后: 29 * 4/3 ≈ 39 字符
|
||||
if (buffer.length < 29) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
validateEncryption,
|
||||
isEncrypted,
|
||||
getEncryptionKey
|
||||
};
|
||||
352
backend/utils/storage-cache.js
Normal file
352
backend/utils/storage-cache.js
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 存储使用情况缓存管理器
|
||||
* ===== P0 性能优化:解决 OSS 统计性能瓶颈 =====
|
||||
*
|
||||
* 问题:每次获取存储使用情况都要遍历所有 OSS 对象,极其耗时
|
||||
* 解决方案:使用数据库字段 storage_used 维护缓存,上传/删除时更新
|
||||
*
|
||||
* @module StorageUsageCache
|
||||
*/
|
||||
|
||||
const { UserDB } = require('../database');
|
||||
|
||||
/**
|
||||
* 存储使用情况缓存类
|
||||
*/
|
||||
class StorageUsageCache {
|
||||
/**
|
||||
* 获取用户的存储使用情况(从缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Promise<{totalSize: number, totalSizeFormatted: string, fileCount: number, cached: boolean}>}
|
||||
*/
|
||||
static async getUsage(userId) {
|
||||
try {
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 从数据库缓存读取
|
||||
const storageUsed = user.storage_used || 0;
|
||||
|
||||
// 导入格式化函数
|
||||
const { formatFileSize } = require('../storage');
|
||||
|
||||
return {
|
||||
totalSize: storageUsed,
|
||||
totalSizeFormatted: formatFileSize(storageUsed),
|
||||
fileCount: null, // 缓存模式不统计文件数
|
||||
cached: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 获取失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户的存储使用量
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} deltaSize - 变化量(正数为增加,负数为减少)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async updateUsage(userId, deltaSize) {
|
||||
try {
|
||||
// 使用 SQL 原子操作,避免并发问题
|
||||
const result = UserDB.update(userId, {
|
||||
// 使用原始 SQL,因为 update 方法不支持表达式
|
||||
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
|
||||
});
|
||||
|
||||
// 直接执行 SQL 更新
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = storage_used + ?
|
||||
WHERE id = ?
|
||||
`).run(deltaSize, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 更新失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户的存储使用量(管理员功能,用于全量统计后更新缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} totalSize - 实际总大小
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async resetUsage(userId, totalSize) {
|
||||
try {
|
||||
// 使用直接SQL更新,绕过UserDB.update()的字段白名单限制
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = ?
|
||||
WHERE id = ?
|
||||
`).run(totalSize, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储重置: ${totalSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并修复缓存(管理员功能)
|
||||
* 通过全量统计对比缓存值,如果不一致则更新
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{actual: number, cached: number, corrected: boolean}>}
|
||||
*/
|
||||
static async validateAndFix(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const corrected = totalSize !== cached;
|
||||
|
||||
if (corrected) {
|
||||
await this.resetUsage(userId, totalSize);
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存已修复: ${cached} → ${totalSize}`);
|
||||
}
|
||||
|
||||
return {
|
||||
actual: totalSize,
|
||||
cached,
|
||||
corrected
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 验证修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存完整性(第二轮修复:缓存一致性保障)
|
||||
* 对比缓存值与实际 OSS 存储使用情况,但不自动修复
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{consistent: boolean, cached: number, actual: number, diff: number}>}
|
||||
*/
|
||||
static async checkIntegrity(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const diff = totalSize - cached;
|
||||
const consistent = Math.abs(diff) === 0;
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 完整性检查: 缓存=${cached}, 实际=${totalSize}, 差异=${diff}`);
|
||||
|
||||
return {
|
||||
consistent,
|
||||
cached,
|
||||
actual: totalSize,
|
||||
fileCount,
|
||||
diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 完整性检查失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建缓存(第二轮修复:缓存一致性保障)
|
||||
* 强制从 OSS 全量统计并更新缓存值,绕过一致性检查
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{previous: number, current: number, fileCount: number}>}
|
||||
*/
|
||||
static async rebuildCache(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
console.log(`[存储缓存] 开始重建用户 ${userId} 的缓存...`);
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: ossClient.getBucket(), // 使用ossClient的getBucket()方法以支持系统级统一OSS配置
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const previous = user.storage_used || 0;
|
||||
|
||||
// 强制更新缓存
|
||||
await this.resetUsage(userId, totalSize);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存重建完成: ${previous} → ${totalSize} (${fileCount} 个文件)`);
|
||||
|
||||
return {
|
||||
previous,
|
||||
current: totalSize,
|
||||
fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重建缓存失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有用户的缓存一致性(第二轮修复:批量检查)
|
||||
* @param {Array} users - 用户列表
|
||||
* @param {Function} getOssClient - 获取 OSS 客户端的函数
|
||||
* @returns {Promise<Array>} 检查结果列表
|
||||
*/
|
||||
static async checkAllUsersIntegrity(users, getOssClient) {
|
||||
const results = [];
|
||||
|
||||
for (const user of users) {
|
||||
// 跳过没有配置 OSS 的用户(需要检查系统级统一配置)
|
||||
const { SettingsDB } = require('../database');
|
||||
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
|
||||
if (!user.has_oss_config && !hasUnifiedConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const ossClient = getOssClient(user);
|
||||
const checkResult = await this.checkIntegrity(user.id, ossClient);
|
||||
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
...checkResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[存储缓存] 检查用户 ${user.id} 失败:`, error.message);
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测并修复缓存不一致(第二轮修复:自动化保障)
|
||||
* 当检测到不一致时自动修复,并记录日志
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @param {number} threshold - 差异阈值(字节),默认 0(任何差异都修复)
|
||||
* @returns {Promise<{autoFixed: boolean, diff: number}>}
|
||||
*/
|
||||
static async autoDetectAndFix(userId, ossClient, threshold = 0) {
|
||||
try {
|
||||
const checkResult = await this.checkIntegrity(userId, ossClient);
|
||||
|
||||
if (!checkResult.consistent && Math.abs(checkResult.diff) > threshold) {
|
||||
console.warn(`[存储缓存] 检测到用户 ${userId} 缓存不一致: 差异 ${checkResult.diff} 字节`);
|
||||
|
||||
// 自动修复
|
||||
await this.rebuildCache(userId, ossClient);
|
||||
|
||||
return {
|
||||
autoFixed: true,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
autoFixed: false,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 自动检测修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StorageUsageCache;
|
||||
83
docker-compose.yml
Normal file
83
docker-compose.yml
Normal file
@@ -0,0 +1,83 @@
|
||||
# ============================================
|
||||
# 玩玩云 Docker Compose 配置
|
||||
# ============================================
|
||||
# 使用方法:
|
||||
# 1. 复制 backend/.env.example 为 backend/.env 并修改配置
|
||||
# 2. 运行: docker-compose up -d
|
||||
# 3. 访问: http://localhost (或配置的域名)
|
||||
# ============================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# 后端服务
|
||||
# ============================================
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: wanwanyun-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=40001
|
||||
# 以下配置建议通过 .env 文件或环境变量设置
|
||||
# - JWT_SECRET=your-secret-key
|
||||
# - ADMIN_USERNAME=admin
|
||||
# - ADMIN_PASSWORD=admin123
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
# 数据持久化
|
||||
- ./backend/data:/app/data
|
||||
- ./backend/storage:/app/storage
|
||||
networks:
|
||||
- wanwanyun-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:40001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Nginx 前端服务
|
||||
# ============================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: wanwanyun-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
# 前端静态文件
|
||||
- ./frontend:/usr/share/nginx/html:ro
|
||||
# Nginx 配置
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# SSL 证书(如有)
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
# Let's Encrypt 证书目录(可选)
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
# - ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- wanwanyun-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
wanwanyun-network:
|
||||
driver: bridge
|
||||
|
||||
# ============================================
|
||||
# 可选: 数据卷(用于更持久的数据存储)
|
||||
# ============================================
|
||||
# volumes:
|
||||
# wanwanyun-data:
|
||||
# wanwanyun-storage:
|
||||
3508
frontend/app.html
Normal file
3508
frontend/app.html
Normal file
File diff suppressed because it is too large
Load Diff
3185
frontend/app.js
Normal file
3185
frontend/app.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 916 B |
669
frontend/index.html
Normal file
669
frontend/index.html
Normal file
@@ -0,0 +1,669 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>玩玩云 - 现代化云存储平台</title>
|
||||
<script>
|
||||
// 邮件激活/重置链接重定向
|
||||
(function() {
|
||||
const search = window.location.search;
|
||||
if (!search) return;
|
||||
if (search.includes('verifyToken')) {
|
||||
window.location.replace(`verify.html${search}`);
|
||||
} else if (search.includes('resetToken')) {
|
||||
window.location.replace(`reset-password.html${search}`);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 暗色主题(默认) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--accent-1: #667eea;
|
||||
--accent-2: #764ba2;
|
||||
--accent-3: #f093fb;
|
||||
--glow: rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
.light-theme {
|
||||
--bg-primary: #f0f4f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--glass: rgba(102, 126, 234, 0.05);
|
||||
--glass-border: rgba(102, 126, 234, 0.15);
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: rgba(26, 26, 46, 0.7);
|
||||
--accent-1: #5a67d8;
|
||||
--accent-2: #6b46c1;
|
||||
--accent-3: #d53f8c;
|
||||
--glow: rgba(90, 103, 216, 0.3);
|
||||
}
|
||||
|
||||
/* 亮色主题特定样式 */
|
||||
body.light-theme .navbar {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.light-theme .grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(102, 126, 234, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(102, 126, 234, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
body.light-theme .gradient-orb {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
body.light-theme .feature-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.light-theme .tech-bar {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 动态背景 */
|
||||
.bg-gradient {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gradient-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.5;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: linear-gradient(135deg, var(--accent-2), var(--accent-3));
|
||||
bottom: -150px;
|
||||
left: -150px;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, var(--accent-3), var(--accent-1));
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-delay: -14s;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(50px, -50px) scale(1.1); }
|
||||
50% { transform: translate(-30px, 30px) scale(0.95); }
|
||||
75% { transform: translate(-50px, -30px) scale(1.05); }
|
||||
}
|
||||
|
||||
/* 网格背景 */
|
||||
.grid-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 50px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 32px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-3));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 28px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--glass);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px var(--glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px var(--glow);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 50px 80px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 左侧内容 */
|
||||
.hero-content {
|
||||
animation: fadeIn 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 50px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-3);
|
||||
margin-bottom: 30px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.badge i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.hero-title .gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-3));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 40px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 16px 36px;
|
||||
font-size: 16px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* 统计数据 */
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 50px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 右侧功能卡片 */
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
animation: fadeIn 1s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.4s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.feature-card:nth-child(2) { animation-delay: 0.1s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.3s; }
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 18px;
|
||||
font-size: 22px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 底部技术栈 */
|
||||
.tech-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px 50px;
|
||||
background: rgba(10, 10, 15, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tech-list {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.tech-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tech-item i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 100px 20px 120px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 40px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tech-bar {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.tech-list {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nav-links .btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-links .btn {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 背景效果 -->
|
||||
<div class="bg-gradient">
|
||||
<div class="gradient-orb orb-1"></div>
|
||||
<div class="gradient-orb orb-2"></div>
|
||||
<div class="gradient-orb orb-3"></div>
|
||||
</div>
|
||||
<div class="grid-bg"></div>
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="logo">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<span>玩玩云</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="app.html?action=login" class="btn btn-ghost">
|
||||
<i class="fas fa-arrow-right-to-bracket"></i>
|
||||
<span>登录</span>
|
||||
</a>
|
||||
<a href="app.html?action=register" class="btn btn-primary">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>开始使用</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<!-- 左侧文案 -->
|
||||
<div class="hero-content">
|
||||
<div class="badge">
|
||||
<i class="fas fa-circle"></i>
|
||||
<span>安全 · 高效 · 简洁</span>
|
||||
</div>
|
||||
<h1 class="hero-title">
|
||||
现代化<br><span class="gradient-text">云存储平台</span>
|
||||
</h1>
|
||||
<p class="hero-desc">
|
||||
简单、安全、高效的文件管理解决方案。支持 OSS 云存储和服务器本地存储双模式,随时随地管理和分享你的文件。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="app.html?action=register" class="btn btn-primary btn-large">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>免费注册</span>
|
||||
</a>
|
||||
<a href="app.html?action=login" class="btn btn-ghost btn-large">
|
||||
<i class="fas fa-play"></i>
|
||||
<span>已有账号</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">5GB</div>
|
||||
<div class="stat-label">单文件上限</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">双模式</div>
|
||||
<div class="stat-label">存储方案</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">24/7</div>
|
||||
<div class="stat-label">全天候服务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能卡片 -->
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-cloud"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">OSS 云存储</h3>
|
||||
<p class="feature-desc">支持阿里云、腾讯云、AWS S3,数据完全自主掌控</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-cloud-arrow-up"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">极速上传</h3>
|
||||
<p class="feature-desc">拖拽上传,实时进度,支持大文件直连上传</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">安全分享</h3>
|
||||
<p class="feature-desc">一键生成链接,支持密码保护和有效期</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="fas fa-shield-halved"></i>
|
||||
</div>
|
||||
<h3 class="feature-title">企业安全</h3>
|
||||
<p class="feature-desc">JWT 认证,bcrypt 加密,全链路安全</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部技术栈 -->
|
||||
<div class="tech-bar">
|
||||
<div class="tech-list">
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-node-js"></i>
|
||||
<span>Node.js</span>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-vuejs"></i>
|
||||
<span>Vue.js</span>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>SQLite</span>
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<i class="fab fa-docker"></i>
|
||||
<span>Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
玩玩云 © 2025
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 加载全局主题
|
||||
(async function() {
|
||||
try {
|
||||
const response = await fetch('/api/public/theme');
|
||||
const data = await response.json();
|
||||
if (data.success && data.theme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('无法加载主题设置');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
frontend/libs/axios.min.js
vendored
Normal file
3
frontend/libs/axios.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
frontend/libs/fontawesome/css/all.min.css
vendored
Normal file
9
frontend/libs/fontawesome/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
frontend/libs/fontawesome/webfonts/fa-brands-400.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
frontend/libs/fontawesome/webfonts/fa-regular-400.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
frontend/libs/fontawesome/webfonts/fa-solid-900.woff2
Normal file
BIN
frontend/libs/fontawesome/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
18323
frontend/libs/vue.global.js
Normal file
18323
frontend/libs/vue.global.js
Normal file
File diff suppressed because it is too large
Load Diff
13
frontend/libs/vue.global.prod.js
Normal file
13
frontend/libs/vue.global.prod.js
Normal file
File diff suppressed because one or more lines are too long
434
frontend/reset-password.html
Normal file
434
frontend/reset-password.html
Normal file
@@ -0,0 +1,434 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>重置密码 - 玩玩云</title>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* 暗色主题(默认) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--accent-1: #667eea;
|
||||
--accent-2: #764ba2;
|
||||
--glow: rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
.light-theme {
|
||||
--bg-primary: #f0f4f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--glass-border: rgba(102, 126, 234, 0.15);
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: rgba(26, 26, 46, 0.7);
|
||||
--accent-1: #5a67d8;
|
||||
--accent-2: #6b46c1;
|
||||
--glow: rgba(90, 103, 216, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 动态背景 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(102, 126, 234, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom left, rgba(118, 75, 162, 0.15) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
body.light-theme::before {
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom left, rgba(118, 75, 162, 0.12) 0%, transparent 50%),
|
||||
linear-gradient(135deg, #e0e7ff 0%, #f0f4f8 50%, #fdf2f8 100%);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.light-theme .card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-icon.loading {
|
||||
color: var(--accent-1);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
body.light-theme .form-input {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-1);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--glow);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
body.light-theme .alert-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
body.light-theme .alert-success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<i class="fas fa-cloud"></i>
|
||||
</div>
|
||||
<h1 class="title">重置密码</h1>
|
||||
<p class="subtitle">设置您的新密码</p>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div id="loading">
|
||||
<div class="status-icon loading">
|
||||
<i class="fas fa-spinner"></i>
|
||||
</div>
|
||||
<p class="message">正在验证链接...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div id="error" class="hidden">
|
||||
<div class="status-icon error">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</div>
|
||||
<p class="message" id="errorMessage">链接无效或已过期</p>
|
||||
<a href="app.html" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> 返回登录
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<div id="form" class="hidden">
|
||||
<div id="formAlert" class="alert hidden"></div>
|
||||
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">新密码</label>
|
||||
<input type="password" id="password" class="form-input"
|
||||
placeholder="请输入新密码" required minlength="6">
|
||||
<div class="password-hint">密码长度至少6位</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">确认密码</label>
|
||||
<input type="password" id="confirmPassword" class="form-input"
|
||||
placeholder="请再次输入新密码" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary">
|
||||
<i class="fas fa-check"></i> 确认重置
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 成功状态 -->
|
||||
<div id="success" class="hidden">
|
||||
<div class="status-icon success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<p class="message">密码重置成功!<br>请使用新密码登录。</p>
|
||||
<a href="app.html" class="btn btn-primary">
|
||||
<i class="fas fa-right-to-bracket"></i> 前往登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="index.html"><i class="fas fa-arrow-left"></i> 返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let resetToken = '';
|
||||
|
||||
// 加载全局主题
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const res = await fetch('/api/public/theme');
|
||||
const data = await res.json();
|
||||
if (data.success && data.theme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[主题加载] 失败,使用默认主题:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL参数
|
||||
function getParam(name) {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(name);
|
||||
}
|
||||
|
||||
// 显示指定区块
|
||||
function showSection(id) {
|
||||
['loading', 'error', 'form', 'success'].forEach(s => {
|
||||
document.getElementById(s).classList.add('hidden');
|
||||
});
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 显示表单提示
|
||||
function showFormAlert(type, message) {
|
||||
const alert = document.getElementById('formAlert');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.textContent = message;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 验证token
|
||||
async function validateToken() {
|
||||
resetToken = getParam('resetToken') || getParam('token');
|
||||
|
||||
if (!resetToken) {
|
||||
document.getElementById('errorMessage').textContent = '无效的重置链接,缺少令牌';
|
||||
showSection('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Token存在,显示表单
|
||||
showSection('form');
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
// 验证
|
||||
if (password.length < 6) {
|
||||
showFormAlert('error', '密码长度至少6位');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showFormAlert('error', '两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/password/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: resetToken,
|
||||
new_password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showSection('success');
|
||||
} else {
|
||||
showFormAlert('error', data.message || '重置失败,请重试');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-check"></i> 确认重置';
|
||||
}
|
||||
} catch (error) {
|
||||
showFormAlert('error', '网络错误,请检查网络连接后重试');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-check"></i> 确认重置';
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadTheme();
|
||||
validateToken();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1134
frontend/share.html
Normal file
1134
frontend/share.html
Normal file
File diff suppressed because it is too large
Load Diff
288
frontend/verify.html
Normal file
288
frontend/verify.html
Normal file
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>邮箱验证 - 玩玩云</title>
|
||||
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* 暗色主题(默认) */
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--accent-1: #667eea;
|
||||
--accent-2: #764ba2;
|
||||
--glow: rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* 亮色主题 */
|
||||
.light-theme {
|
||||
--bg-primary: #f0f4f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--glass-border: rgba(102, 126, 234, 0.15);
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: rgba(26, 26, 46, 0.7);
|
||||
--accent-1: #5a67d8;
|
||||
--accent-2: #6b46c1;
|
||||
--glow: rgba(90, 103, 216, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 动态背景 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(102, 126, 234, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom left, rgba(118, 75, 162, 0.15) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
body.light-theme::before {
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom left, rgba(118, 75, 162, 0.12) 0%, transparent 50%),
|
||||
linear-gradient(135deg, #e0e7ff 0%, #f0f4f8 50%, #fdf2f8 100%);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.light-theme .card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-icon.loading {
|
||||
color: var(--accent-1);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--glow);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<i class="fas fa-cloud"></i>
|
||||
</div>
|
||||
<h1 class="title">邮箱验证</h1>
|
||||
<p class="subtitle">玩玩云账号激活</p>
|
||||
|
||||
<div id="content">
|
||||
<div class="status-icon loading">
|
||||
<i class="fas fa-spinner"></i>
|
||||
</div>
|
||||
<p class="message">正在验证您的邮箱...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="index.html"><i class="fas fa-arrow-left"></i> 返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 加载全局主题
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const res = await fetch('/api/public/theme');
|
||||
const data = await res.json();
|
||||
if (data.success && data.theme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[主题加载] 失败,使用默认主题:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URL参数
|
||||
function getParam(name) {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(name);
|
||||
}
|
||||
|
||||
// HTML 转义函数(防御 XSS)
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
function showResult(success, message, showButton = true) {
|
||||
const content = document.getElementById('content');
|
||||
const iconClass = success ? 'success' : 'error';
|
||||
const iconName = success ? 'fa-check-circle' : 'fa-times-circle';
|
||||
|
||||
// 转义用户消息(但允许安全的 HTML 标签如 <br>)
|
||||
const safeMessage = message.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<br>/g, '<br>'); // 允许 <br> 标签
|
||||
|
||||
let html = `
|
||||
<div class="status-icon ${iconClass}">
|
||||
<i class="fas ${iconName}"></i>
|
||||
</div>
|
||||
<p class="message">${safeMessage}</p>
|
||||
`;
|
||||
|
||||
if (showButton) {
|
||||
html += `
|
||||
<a href="app.html" class="btn btn-primary">
|
||||
<i class="fas fa-right-to-bracket"></i> 前往登录
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
async function verifyEmail() {
|
||||
const token = getParam('verifyToken') || getParam('token');
|
||||
|
||||
if (!token) {
|
||||
showResult(false, '无效的验证链接,缺少验证令牌');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/verify-email?token=${encodeURIComponent(token)}`);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showResult(true, '邮箱验证成功!<br>您的账号已激活,现在可以登录了。');
|
||||
} else {
|
||||
showResult(false, data.message || '验证失败,请重试或联系管理员');
|
||||
}
|
||||
} catch (error) {
|
||||
showResult(false, '网络错误,请检查网络连接后重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadTheme();
|
||||
verifyEmail();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4763
install.sh
Normal file
4763
install.sh
Normal file
File diff suppressed because it is too large
Load Diff
73
nginx/nginx.conf
Normal file
73
nginx/nginx.conf
Normal file
@@ -0,0 +1,73 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 设置最大上传文件大小为10GB
|
||||
client_max_body_size 10G;
|
||||
|
||||
# 安全响应头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# 隐藏Nginx版本
|
||||
server_tokens off;
|
||||
|
||||
# 禁止访问隐藏文件和敏感文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ \.(env|git|config|key|pem|crt)$ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 后端API反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:40001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# 修复:使用当前请求协议(http或https),适用于直接IP访问
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Cookie传递配置(验证码session需要)
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_pass_header Set-Cookie;
|
||||
|
||||
# 增加超时时间支持大文件上传(30分钟)
|
||||
proxy_connect_timeout 1800;
|
||||
proxy_send_timeout 1800;
|
||||
proxy_read_timeout 1800;
|
||||
send_timeout 1800;
|
||||
|
||||
# 大文件上传缓冲优化
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
client_body_buffer_size 128k;
|
||||
}
|
||||
|
||||
# 分享链接重定向
|
||||
location /s/ {
|
||||
proxy_pass http://backend:40001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# 修复:使用当前请求协议(http或https),适用于直接IP访问
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
129
nginx/nginx.conf.example
Normal file
129
nginx/nginx.conf.example
Normal file
@@ -0,0 +1,129 @@
|
||||
# ============================================
|
||||
# 玩玩云 Nginx 配置模板
|
||||
# ============================================
|
||||
# 使用说明:
|
||||
# 1. 将 your-domain.com 替换为你的实际域名
|
||||
# 2. 将 /usr/share/nginx/html 替换为前端文件实际路径
|
||||
# 3. 如使用非 Docker 部署,将 backend:40001 改为 127.0.0.1:40001
|
||||
# ============================================
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# Let's Encrypt 验证
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# 重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 主配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# ============================================
|
||||
# SSL 证书配置
|
||||
# ============================================
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
|
||||
# SSL 安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# ============================================
|
||||
# 安全响应头
|
||||
# ============================================
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 隐藏 Nginx 版本
|
||||
server_tokens off;
|
||||
|
||||
# ============================================
|
||||
# 上传文件大小限制(10GB)
|
||||
# ============================================
|
||||
client_max_body_size 10G;
|
||||
|
||||
# ============================================
|
||||
# 禁止访问隐藏文件和敏感文件
|
||||
# ============================================
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ \.(env|git|config|key|pem|crt)$ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 前端静态文件
|
||||
# ============================================
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 后端 API 反向代理
|
||||
# ============================================
|
||||
location /api/ {
|
||||
proxy_pass http://backend:40001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Cookie 传递配置(验证码 session 需要)
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_pass_header Set-Cookie;
|
||||
|
||||
# 大文件上传超时配置(30分钟)
|
||||
proxy_connect_timeout 1800;
|
||||
proxy_send_timeout 1800;
|
||||
proxy_read_timeout 1800;
|
||||
send_timeout 1800;
|
||||
|
||||
# 大文件上传缓冲优化
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
client_body_buffer_size 128k;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 分享链接代理
|
||||
# ============================================
|
||||
location /s/ {
|
||||
proxy_pass http://backend:40001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user