feat: 实现Vue驱动的云存储系统初始功能

- 后端: Node.js + Express + SQLite架构
- 前端: Vue 3 + Axios实现
- 功能: 用户认证、文件上传/下载、分享链接、密码重置
- 安全: 密码加密、分享链接过期机制、缓存一致性
- 部署: Docker + Nginx容器化配置
- 测试: 完整的边界测试、并发测试和状态一致性测试
This commit is contained in:
Dev Team
2026-01-20 23:23:51 +08:00
commit b7b00fff48
45 changed files with 51758 additions and 0 deletions

136
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,516 @@
# 玩玩云 - 现代化云存储管理平台
> 一个功能完整的云存储管理系统支持本地存储和OSS云存储提供文件管理、分享、邮件验证等企业级功能。
<div align="center">
![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)
![License](https://img.shields.io/badge/license-Personal%20Use-green.svg)
![Node](https://img.shields.io/badge/node-20.x-brightgreen.svg)
![Vue](https://img.shields.io/badge/vue-3.x-42b883.svg)
</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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

1446
backend/database.js Normal file

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

40
backend/package.json Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

10
backend/start.bat Normal file
View 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

File diff suppressed because it is too large Load Diff

0
backend/storage/.gitkeep Normal file
View File

View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
};
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>'), '&lt;script&gt;');
assert.strictEqual(sanitizeInput('"test"'), '&quot;test&quot;');
assert.strictEqual(sanitizeInput("'test'"), '&#x27;test&#x27;');
assert.strictEqual(sanitizeInput('&test&'), '&amp;test&amp;');
});
// 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('&#x27;'), `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('&lt;'), '<');
assert.strictEqual(decodeHtmlEntities('&gt;'), '>');
assert.strictEqual(decodeHtmlEntities('&amp;'), '&');
assert.strictEqual(decodeHtmlEntities('&quot;'), '"');
});
test('数字实体应该被解码', () => {
assert.strictEqual(decodeHtmlEntities('&#x27;'), "'");
assert.strictEqual(decodeHtmlEntities('&#39;'), "'");
assert.strictEqual(decodeHtmlEntities('&#x60;'), '`');
});
test('嵌套实体应该被完全解码', () => {
assert.strictEqual(decodeHtmlEntities('&amp;#x60;'), '`');
assert.strictEqual(decodeHtmlEntities('&amp;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);
});

View 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);
});

View 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);
});

View 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
View 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
};

View 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
View 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

File diff suppressed because it is too large Load Diff

3185
frontend/app.js Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

669
frontend/index.html Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

18323
frontend/libs/vue.global.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because it is too large Load Diff

288
frontend/verify.html Normal file
View 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, '&lt;').replace(/>/g, '&gt;')
.replace(/&lt;br&gt;/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

File diff suppressed because it is too large Load Diff

73
nginx/nginx.conf Normal file
View 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
View 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;
}
}