commit b7b00fff482241b3df185569c67dd1b78b13edf6 Author: Dev Team Date: Tue Jan 20 23:23:51 2026 +0800 feat: 实现Vue驱动的云存储系统初始功能 - 后端: Node.js + Express + SQLite架构 - 前端: Vue 3 + Axios实现 - 功能: 用户认证、文件上传/下载、分享链接、密码重置 - 安全: 密码加密、分享链接过期机制、缓存一致性 - 部署: Docker + Nginx容器化配置 - 测试: 完整的边界测试、并发测试和状态一致性测试 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e316f53 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/INSTALL_GUIDE.md b/INSTALL_GUIDE.md new file mode 100644 index 0000000..5064022 --- /dev/null +++ b/INSTALL_GUIDE.md @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a50e814 --- /dev/null +++ b/README.md @@ -0,0 +1,516 @@ +# 玩玩云 - 现代化云存储管理平台 + +> 一个功能完整的云存储管理系统,支持本地存储和OSS云存储,提供文件管理、分享、邮件验证等企业级功能。 + +
+ +![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) + +
+ +## ✨ 项目特色 + +玩玩云是一个现代化的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 + + + + https://你的域名.com + https://www.你的域名.com + + http://localhost:3000 + + GET + PUT + POST + DELETE + + * + ETag + x-amz-request-id + + +``` + +**各云服务商控制台配置路径:** +- **阿里云 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 + +## 🙏 致谢 + +感谢所有开源项目的贡献者! + +--- + +**玩玩云** - 让云存储管理更简单 ☁️ + +
+ +Made with ❤️ by 玩玩云团队 + +
diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..5f025e3 --- /dev/null +++ b/VERSION.txt @@ -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 + +============================================ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..135f8a3 --- /dev/null +++ b/backend/.dockerignore @@ -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/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0fdb8c2 --- /dev/null +++ b/backend/.env.example @@ -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'))" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e5862e2 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/auth.js b/backend/auth.js new file mode 100644 index 0000000..ce1edf8 --- /dev/null +++ b/backend/auth.js @@ -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 +}; diff --git a/backend/backup.bat b/backend/backup.bat new file mode 100644 index 0000000..5c4eb22 --- /dev/null +++ b/backend/backup.bat @@ -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 diff --git a/backend/check_expire.sql b/backend/check_expire.sql new file mode 100644 index 0000000..b4359f8 --- /dev/null +++ b/backend/check_expire.sql @@ -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; diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.js b/backend/database.js new file mode 100644 index 0000000..1348b54 --- /dev/null +++ b/backend/database.js @@ -0,0 +1,1446 @@ +// 加载环境变量(确保在 server.js 之前也能读取) +require('dotenv').config(); + +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +// 引入加密工具(用于敏感数据加密存储) +const { encryptSecret, decryptSecret, validateEncryption } = require('./utils/encryption'); + +// 验证加密系统在启动时正常工作 +try { + validateEncryption(); +} catch (error) { + console.error('[安全] 加密系统验证失败,服务无法启动'); + console.error('[安全] 请检查 ENCRYPTION_KEY 配置'); + process.exit(1); +} + +// 数据库路径配置 +// 优先使用环境变量 DATABASE_PATH,默认为 ./data/database.db +const DEFAULT_DB_PATH = path.join(__dirname, 'data', 'database.db'); +const dbPath = process.env.DATABASE_PATH + ? path.resolve(__dirname, process.env.DATABASE_PATH) + : DEFAULT_DB_PATH; + +// 确保数据库目录存在 +const dbDir = path.dirname(dbPath); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + console.log(`[数据库] 创建目录: ${dbDir}`); +} + +console.log(`[数据库] 路径: ${dbPath}`); + +// 创建或连接数据库 +const db = new Database(dbPath); + +// ===== 性能优化配置(P0 优先级修复) ===== + +// 1. 启用 WAL 模式(Write-Ahead Logging) +// 优势:支持并发读写,大幅提升数据库性能 +db.pragma('journal_mode = WAL'); + +// 2. 配置同步模式为 NORMAL +// 性能提升:在安全性和性能之间取得平衡,比 FULL 模式快很多 +db.pragma('synchronous = NORMAL'); + +// 3. 增加缓存大小到 64MB +// 性能提升:减少磁盘 I/O,缓存更多数据页和索引页 +// 负值表示 KB,-64000 = 64MB +db.pragma('cache_size = -64000'); + +// 4. 临时表存储在内存中 +// 性能提升:避免临时表写入磁盘,加速排序和分组操作 +db.pragma('temp_store = MEMORY'); + +// 5. 启用外键约束 +db.pragma('foreign_keys = ON'); + +console.log('[数据库性能优化] ✓ WAL 模式已启用'); +console.log('[数据库性能优化] ✓ 同步模式: NORMAL'); +console.log('[数据库性能优化] ✓ 缓存大小: 64MB'); +console.log('[数据库性能优化] ✓ 临时表存储: 内存'); + +// ===== 第二轮修复:WAL 文件定期清理机制 ===== + +/** + * 执行数据库检查点(Checkpoint) + * 将 WAL 文件中的内容写入主数据库文件,并清理 WAL + * @param {Database} database - 数据库实例 + * @returns {boolean} 是否成功执行 + */ +function performCheckpoint(database = db) { + try { + // 执行 checkpoint(将 WAL 内容合并到主数据库) + database.pragma('wal_checkpoint(PASSIVE)'); + + // 获取 WAL 文件大小信息 + const walInfo = database.pragma('wal_checkpoint(TRUNCATE)', { simple: true }); + + console.log('[WAL清理] ✓ 检查点完成'); + return true; + } catch (error) { + console.error('[WAL清理] ✗ 检查点失败:', error.message); + return false; + } +} + +/** + * 获取 WAL 文件大小 + * @param {Database} database - 数据库实例 + * @returns {number} WAL 文件大小(字节) + */ +function getWalFileSize(database = db) { + try { + const dbPath = database.name; + const walPath = `${dbPath}-wal`; + + if (fs.existsSync(walPath)) { + const stats = fs.statSync(walPath); + return stats.size; + } + + return 0; + } catch (error) { + console.error('[WAL清理] 获取 WAL 文件大小失败:', error.message); + return 0; + } +} + +/** + * 启动时检查 WAL 文件大小,如果超过阈值则执行清理 + * @param {number} threshold - 阈值(字节),默认 100MB + */ +function checkWalOnStartup(threshold = 100 * 1024 * 1024) { + try { + const walSize = getWalFileSize(); + + if (walSize > threshold) { + console.warn(`[WAL清理] ⚠ 启动时检测到 WAL 文件过大: ${(walSize / 1024 / 1024).toFixed(2)}MB`); + console.log('[WAL清理] 正在执行自动清理...'); + + const success = performCheckpoint(); + + if (success) { + const newSize = getWalFileSize(); + console.log(`[WAL清理] ✓ 清理完成: ${walSize} → ${newSize} 字节`); + } + } else { + console.log(`[WAL清理] ✓ WAL 文件大小正常: ${(walSize / 1024 / 1024).toFixed(2)}MB`); + } + } catch (error) { + console.error('[WAL清理] 启动检查失败:', error.message); + } +} + +/** + * 设置定期 WAL 检查点 + * 每隔指定时间自动执行一次检查点,防止 WAL 文件无限增长 + * @param {number} intervalHours - 间隔时间(小时),默认 24 小时 + * @returns {NodeJS.Timeout} 定时器 ID,可用于取消 + */ +function schedulePeriodicCheckpoint(intervalHours = 24) { + const intervalMs = intervalHours * 60 * 60 * 1000; + + const timerId = setInterval(() => { + const walSize = getWalFileSize(); + + console.log(`[WAL清理] 定期检查点执行中... (当前 WAL: ${(walSize / 1024 / 1024).toFixed(2)}MB)`); + + performCheckpoint(); + }, intervalMs); + + console.log(`[WAL清理] ✓ 定期检查点已启用: 每 ${intervalHours} 小时执行一次`); + + return timerId; +} + +// 立即执行启动时检查 +checkWalOnStartup(100 * 1024 * 1024); // 100MB 阈值 + +// 启动定期检查点(24 小时) +let walCheckpointTimer = null; +if (process.env.WAL_CHECKPOINT_ENABLED !== 'false') { + const interval = parseInt(process.env.WAL_CHECKPOINT_INTERVAL_HOURS || '24', 10); + walCheckpointTimer = schedulePeriodicCheckpoint(interval); +} else { + console.log('[WAL清理] 定期检查点已禁用(WAL_CHECKPOINT_ENABLED=false)'); +} + +// 导出 WAL 管理函数 +const WalManager = { + performCheckpoint, + getWalFileSize, + checkWalOnStartup, + schedulePeriodicCheckpoint +}; + +// 初始化数据库表 +function initDatabase() { + // 用户表 + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + + -- OSS配置(可选) + oss_provider TEXT, + oss_region TEXT, + oss_access_key_id TEXT, + oss_access_key_secret TEXT, + oss_bucket TEXT, + oss_endpoint TEXT, + + -- 上传工具API密钥 + upload_api_key TEXT, + + -- 用户状态 + is_admin INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + is_banned INTEGER DEFAULT 0, + has_oss_config INTEGER DEFAULT 0, + + -- 时间戳 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 分享链接表 + db.exec(` + CREATE TABLE IF NOT EXISTS shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + share_code TEXT UNIQUE NOT NULL, + share_path TEXT NOT NULL, + share_type TEXT DEFAULT 'file', + share_password TEXT, + + -- 分享统计 + view_count INTEGER DEFAULT 0, + download_count INTEGER DEFAULT 0, + + -- 时间戳 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + + // 系统设置表 + db.exec(` + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 创建索引 + db.exec(` + -- 基础索引 + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_users_upload_api_key ON users(upload_api_key); + CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code); + CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id); + CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); + + -- ===== 性能优化:复合索引(P0 优先级修复) ===== + + -- 1. 分享链接复合索引:share_code + expires_at + -- 优势:加速分享码查询(最常见的操作),同时过滤过期链接 + -- 使用场景:ShareDB.findByCode, 分享访问验证 + CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_code, expires_at); + + -- 注意:system_logs 表的复合索引在表创建后创建(第372行之后) + -- 2. 活动日志复合索引:user_id + created_at + -- 优势:快速查询用户最近的活动记录,支持时间范围过滤 + -- 使用场景:用户活动历史、审计日志查询 + -- CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at); + + -- 3. 文件复合索引:user_id + parent_path + -- 注意:当前系统使用 OSS,不直接存储文件元数据到数据库 + -- 如果未来需要文件系统功能,此索引将优化目录浏览性能 + -- CREATE INDEX IF NOT EXISTS idx_files_user_parent ON files(user_id, parent_path); + `); + + console.log('[数据库性能优化] ✓ 基础索引已创建'); + console.log(' - idx_shares_code_expires: 分享码+过期时间'); + + // 数据库迁移:添加upload_api_key字段(如果不存在) + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasUploadApiKey = columns.some(col => col.name === 'upload_api_key'); + + if (!hasUploadApiKey) { + db.exec(`ALTER TABLE users ADD COLUMN upload_api_key TEXT`); + console.log('数据库迁移:添加upload_api_key字段完成'); + } + } catch (error) { + console.error('数据库迁移失败:', error); + } + + // 数据库迁移:添加share_type字段(如果不存在) + try { + const shareColumns = db.prepare("PRAGMA table_info(shares)").all(); + const hasShareType = shareColumns.some(col => col.name === 'share_type'); + + if (!hasShareType) { + db.exec(`ALTER TABLE shares ADD COLUMN share_type TEXT DEFAULT 'file'`); + console.log('数据库迁移:添加share_type字段完成'); + } + } catch (error) { + console.error('数据库迁移(share_type)失败:', error); + } + + // 数据库迁移:邮箱验证字段 + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasVerified = columns.some(col => col.name === 'is_verified'); + const hasVerifyToken = columns.some(col => col.name === 'verification_token'); + const hasVerifyExpires = columns.some(col => col.name === 'verification_expires_at'); + + if (!hasVerified) { + db.exec(`ALTER TABLE users ADD COLUMN is_verified INTEGER DEFAULT 0`); + } + if (!hasVerifyToken) { + db.exec(`ALTER TABLE users ADD COLUMN verification_token TEXT`); + } + if (!hasVerifyExpires) { + db.exec(`ALTER TABLE users ADD COLUMN verification_expires_at DATETIME`); + } + + // 注意:不再自动将未验证用户设为已验证 + // 仅修复 is_verified 为 NULL 的旧数据(添加字段前创建的用户) + // 这些用户没有 verification_token,说明是在邮箱验证功能上线前注册的 + db.exec(`UPDATE users SET is_verified = 1 WHERE is_verified IS NULL AND verification_token IS NULL`); + } catch (error) { + console.error('数据库迁移(邮箱验证)失败:', error); + } + + // 数据库迁移:密码重置Token表 + try { + db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + used INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(user_id);`); + } catch (error) { + console.error('数据库迁移(密码重置Token)失败:', error); + } + + // 系统日志表 + db.exec(` + CREATE TABLE IF NOT EXISTS system_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL DEFAULT 'info', + category TEXT NOT NULL, + action TEXT NOT NULL, + message TEXT NOT NULL, + user_id INTEGER, + username TEXT, + ip_address TEXT, + user_agent TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL + ) + `); + + // 日志表索引 + db.exec(` + CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_logs_category ON system_logs(category); + CREATE INDEX IF NOT EXISTS idx_logs_level ON system_logs(level); + CREATE INDEX IF NOT EXISTS idx_logs_user_id ON system_logs(user_id); + + -- ===== 性能优化:复合索引(P0 优先级修复) ===== + -- 活动日志复合索引:user_id + created_at + -- 优势:快速查询用户最近的活动记录,支持时间范围过滤 + -- 使用场景:用户活动历史、审计日志查询 + CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at); + `); + + console.log('[数据库性能优化] ✓ 日志表复合索引已创建'); + console.log(' - idx_logs_user_created: 用户+创建时间'); + + // 数据库迁移:添加 storage_used 字段(P0 性能优化) + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasStorageUsed = columns.some(col => col.name === 'storage_used'); + + if (!hasStorageUsed) { + db.exec(`ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0`); + console.log('[数据库迁移] ✓ storage_used 字段已添加'); + } + } catch (error) { + console.error('[数据库迁移] storage_used 字段添加失败:', error); + } + + console.log('数据库初始化完成'); +} + +// 创建默认管理员账号 +function createDefaultAdmin() { + const adminExists = db.prepare('SELECT id FROM users WHERE is_admin = 1').get(); + + if (!adminExists) { + // 从环境变量读取管理员账号密码,如果没有则使用默认值 + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; + + const hashedPassword = bcrypt.hashSync(adminPassword, 10); + + db.prepare(` + INSERT INTO users ( + username, email, password, + is_admin, is_active, has_oss_config, is_verified + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + adminUsername, + `${adminUsername}@example.com`, + hashedPassword, + 1, + 1, + 0, // 管理员不需要OSS配置 + 1 // 管理员默认已验证 + ); + + console.log('默认管理员账号已创建'); + console.log('用户名:', adminUsername); + console.log('密码: ********'); + console.log('⚠️ 请登录后立即修改密码!'); + } +} + +// 用户相关操作 +const UserDB = { + // 创建用户 + create(userData) { + const hashedPassword = bcrypt.hashSync(userData.password, 10); + + const hasOssConfig = userData.oss_provider && userData.oss_access_key_id && userData.oss_access_key_secret && userData.oss_bucket ? 1 : 0; + + // 对验证令牌进行哈希存储(与 VerificationDB.setVerification 保持一致) + const hashedVerificationToken = userData.verification_token + ? crypto.createHash('sha256').update(userData.verification_token).digest('hex') + : null; + + const stmt = db.prepare(` + INSERT INTO users ( + username, email, password, + oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint, + has_oss_config, + is_verified, verification_token, verification_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + userData.username, + userData.email, + hashedPassword, + userData.oss_provider || null, + userData.oss_region || null, + userData.oss_access_key_id || null, + userData.oss_access_key_secret || null, + userData.oss_bucket || null, + userData.oss_endpoint || null, + hasOssConfig, + userData.is_verified !== undefined ? userData.is_verified : 0, + hashedVerificationToken, + userData.verification_expires_at || null + ); + + return result.lastInsertRowid; + }, + + // 根据用户名查找 + findByUsername(username) { + return db.prepare('SELECT * FROM users WHERE username = ?').get(username); + }, + + // 根据邮箱查找 + findByEmail(email) { + return db.prepare('SELECT * FROM users WHERE email = ?').get(email); + }, + + // 根据ID查找 + findById(id) { + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); + }, + + // 验证密码 + verifyPassword(plainPassword, hashedPassword) { + return bcrypt.compareSync(plainPassword, hashedPassword); + }, + + /** + * 字段类型验证函数 + * 确保所有字段值类型符合数据库要求 + * @param {string} fieldName - 字段名 + * @param {*} value - 字段值 + * @returns {boolean} 是否有效 + * @private + */ + _validateFieldValue(fieldName, value) { + // 字段类型白名单(根据数据库表结构定义) + const FIELD_TYPES = { + // 文本类型字段 + 'username': 'string', + 'email': 'string', + 'password': 'string', + 'oss_provider': 'string', + 'oss_region': 'string', + 'oss_access_key_id': 'string', + 'oss_access_key_secret': 'string', + 'oss_bucket': 'string', + 'oss_endpoint': 'string', + 'upload_api_key': 'string', + 'verification_token': 'string', + 'verification_expires_at': 'string', + 'storage_permission': 'string', + 'current_storage_type': 'string', + 'theme_preference': 'string', + + // 数值类型字段 + 'is_admin': 'number', + 'is_active': 'number', + 'is_banned': 'is_banned', + 'has_oss_config': 'number', + 'is_verified': 'number', + 'local_storage_quota': 'number', + 'local_storage_used': 'number' + }; + + const expectedType = FIELD_TYPES[fieldName]; + + // 如果字段不在类型定义中,允许通过(向后兼容) + if (!expectedType) { + return true; + } + + // 检查类型匹配 + if (expectedType === 'string') { + return typeof value === 'string'; + } else if (expectedType === 'number') { + // 允许数值或可转换为数值的字符串 + return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value))); + } + + return true; + }, + + /** + * 验证字段映射完整性 + * 确保 FIELD_MAP 中定义的所有字段都在数据库表中存在 + * @returns {Object} 验证结果 { valid: boolean, missing: string[], extra: string[] } + * @private + */ + _validateFieldMapping() { + // 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法) + const FIELD_MAP = { + // 基础字段 + 'username': 'username', + 'email': 'email', + 'password': 'password', + + // OSS 配置字段 + 'oss_provider': 'oss_provider', + 'oss_region': 'oss_region', + 'oss_access_key_id': 'oss_access_key_id', + 'oss_access_key_secret': 'oss_access_key_secret', + 'oss_bucket': 'oss_bucket', + 'oss_endpoint': 'oss_endpoint', + + // API 密钥和权限字段 + 'upload_api_key': 'upload_api_key', + 'is_admin': 'is_admin', + 'is_active': 'is_active', + 'is_banned': 'is_banned', + 'has_oss_config': 'has_oss_config', + + // 验证字段 + 'is_verified': 'is_verified', + 'verification_token': 'verification_token', + 'verification_expires_at': 'verification_expires_at', + + // 存储配置字段 + 'storage_permission': 'storage_permission', + 'current_storage_type': 'current_storage_type', + 'local_storage_quota': 'local_storage_quota', + 'local_storage_used': 'local_storage_used', + + // 偏好设置 + 'theme_preference': 'theme_preference' + }; + + try { + // 获取数据库表的实际列信息 + const columns = db.prepare("PRAGMA table_info(users)").all(); + const dbFields = new Set(columns.map(col => col.name)); + + // 检查 FIELD_MAP 中的字段是否都在数据库中存在 + const mappedFields = new Set(Object.values(FIELD_MAP)); + const missingFields = []; + const extraFields = []; + + for (const field of mappedFields) { + if (!dbFields.has(field)) { + missingFields.push(field); + } + } + + // 检查数据库中是否有 FIELD_MAP 未定义的字段(可选) + for (const dbField of dbFields) { + if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) { + extraFields.push(dbField); + } + } + + const isValid = missingFields.length === 0; + + if (!isValid) { + console.error(`[数据库错误] 字段映射验证失败,缺失字段: ${missingFields.join(', ')}`); + } + + if (extraFields.length > 0) { + console.warn(`[数据库警告] 数据库存在 FIELD_MAP 未定义的字段: ${extraFields.join(', ')}`); + } + + return { valid: isValid, missing: missingFields, extra: extraFields }; + } catch (error) { + console.error(`[数据库错误] 字段映射验证失败: ${error.message}`); + return { valid: false, missing: [], extra: [], error: error.message }; + } + }, + + // 更新用户 + // 安全修复:使用字段映射白名单,防止 SQL 注入和原型污染攻击 + update(id, updates) { + // 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法) + const FIELD_MAP = { + // 基础字段 + 'username': 'username', + 'email': 'email', + 'password': 'password', + + // OSS 配置字段 + 'oss_provider': 'oss_provider', + 'oss_region': 'oss_region', + 'oss_access_key_id': 'oss_access_key_id', + 'oss_access_key_secret': 'oss_access_key_secret', + 'oss_bucket': 'oss_bucket', + 'oss_endpoint': 'oss_endpoint', + + // API 密钥和权限字段 + 'upload_api_key': 'upload_api_key', + 'is_admin': 'is_admin', + 'is_active': 'is_active', + 'is_banned': 'is_banned', + 'has_oss_config': 'has_oss_config', + + // 验证字段 + 'is_verified': 'is_verified', + 'verification_token': 'verification_token', + 'verification_expires_at': 'verification_expires_at', + + // 存储配置字段 + 'storage_permission': 'storage_permission', + 'current_storage_type': 'current_storage_type', + 'local_storage_quota': 'local_storage_quota', + 'local_storage_used': 'local_storage_used', + + // 偏好设置 + 'theme_preference': 'theme_preference' + }; + + const fields = []; + const values = []; + const rejectedFields = []; // 记录被拒绝的字段(类型不符) + + for (const [key, value] of Object.entries(updates)) { + // 安全检查 1:确保是对象自身的属性(防止原型污染) + // 使用 Object.prototype.hasOwnProperty.call() 避免原型链污染 + if (!Object.prototype.hasOwnProperty.call(updates, key)) { + console.warn(`[安全警告] 跳过非自身属性: ${key} (类型: ${typeof key})`); + continue; + } + + // 安全检查 2:字段名必须是字符串类型 + if (typeof key !== 'string' || key.trim() === '') { + console.warn(`[安全警告] 跳过无效字段名: ${key} (类型: ${typeof key})`); + rejectedFields.push({ field: key, reason: '字段名不是有效字符串' }); + continue; + } + + // 安全检查 3:验证字段映射(防止别名攻击) + const mappedField = FIELD_MAP[key]; + if (!mappedField) { + console.warn(`[安全警告] 尝试更新非法字段: ${key}`); + rejectedFields.push({ field: key, reason: '字段不在白名单中' }); + continue; + } + + // 安全检查 4:确保字段名不包含特殊字符或 SQL 关键字 + // 只允许字母、数字和下划线 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(mappedField)) { + console.warn(`[安全警告] 字段名包含非法字符: ${mappedField}`); + rejectedFields.push({ field: key, reason: '字段名包含非法字符' }); + continue; + } + + // 安全检查 5:验证字段值类型(第二轮修复) + if (!this._validateFieldValue(key, value)) { + const expectedType = { + 'username': 'string', 'email': 'string', 'password': 'string', + 'oss_provider': 'string', 'oss_region': 'string', + 'oss_access_key_id': 'string', 'oss_access_key_secret': 'string', + 'oss_bucket': 'string', 'oss_endpoint': 'string', + 'upload_api_key': 'string', 'verification_token': 'string', + 'verification_expires_at': 'string', 'storage_permission': 'string', + 'current_storage_type': 'string', 'theme_preference': 'string', + 'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number', + 'has_oss_config': 'number', 'is_verified': 'number', + 'local_storage_quota': 'number', 'local_storage_used': 'number' + }[key]; + + console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`); + rejectedFields.push({ field: key, reason: `值类型不符 (期望: ${expectedType}, 实际: ${typeof value})` }); + continue; + } + + // 特殊处理密码字段(需要哈希) + if (key === 'password') { + fields.push(`${mappedField} = ?`); + values.push(bcrypt.hashSync(value, 10)); + } else { + fields.push(`${mappedField} = ?`); + values.push(value); + } + } + + // 记录被拒绝的字段(用于调试) + if (rejectedFields.length > 0) { + console.log(`[类型检查] 用户 ${id} 更新请求拒绝了 ${rejectedFields.length} 个字段:`, rejectedFields); + } + + // 如果没有有效字段,返回空结果 + if (fields.length === 0) { + console.warn(`[安全警告] 没有有效字段可更新,用户ID: ${id}`); + return { changes: 0, rejectedFields }; + } + + // 添加 updated_at 时间戳 + fields.push('updated_at = CURRENT_TIMESTAMP'); + values.push(id); + + // 使用参数化查询执行更新(防止 SQL 注入) + const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`); + const result = stmt.run(...values); + + // 附加被拒绝字段信息到返回结果 + result.rejectedFields = rejectedFields; + return result; + }, + + // 获取所有用户 + getAll(filters = {}) { + let query = 'SELECT * FROM users WHERE 1=1'; + const params = []; + + if (filters.is_admin !== undefined) { + query += ' AND is_admin = ?'; + params.push(filters.is_admin); + } + + if (filters.is_banned !== undefined) { + query += ' AND is_banned = ?'; + params.push(filters.is_banned); + } + + query += ' ORDER BY created_at DESC'; + + return db.prepare(query).all(...params); + }, + + // 删除用户 + delete(id) { + return db.prepare('DELETE FROM users WHERE id = ?').run(id); + }, + + // 封禁/解封用户 + setBanStatus(id, isBanned) { + return db.prepare('UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(isBanned ? 1 : 0, id); + } +}; + +// 分享链接相关操作 +const ShareDB = { + // 生成随机分享码 + generateShareCode(length = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const bytes = crypto.randomBytes(length); + let code = ''; + for (let i = 0; i < length; i++) { + code += chars[bytes[i] % chars.length]; + } + return code; + }, + + // 创建分享链接 + // 创建分享链接 + create(userId, options = {}) { + const { + share_type = 'file', + file_path = '', + file_name = '', + password = null, + expiry_days = null + } = options; + + let shareCode; + let attempts = 0; + + // 尝试生成唯一的分享码 + do { + shareCode = this.generateShareCode(); + attempts++; + if (attempts > 10) { + shareCode = this.generateShareCode(10); // 增加长度 + } + } while (this.findByCode(shareCode) && attempts < 20); + + // 计算过期时间 + let expiresAt = null; + if (expiry_days) { + const expireDate = new Date(); + expireDate.setDate(expireDate.getDate() + parseInt(expiry_days)); + // 使用本地时区时间,而不是UTC时间 + // 这样前端解析时会正确显示为本地时间 + const year = expireDate.getFullYear(); + const month = String(expireDate.getMonth() + 1).padStart(2, '0'); + const day = String(expireDate.getDate()).padStart(2, '0'); + const hours = String(expireDate.getHours()).padStart(2, '0'); + const minutes = String(expireDate.getMinutes()).padStart(2, '0'); + const seconds = String(expireDate.getSeconds()).padStart(2, '0'); + expiresAt = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + const stmt = db.prepare(` + INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const hashedPassword = password ? bcrypt.hashSync(password, 10) : null; + + // 修复:正确处理不同类型的分享路径 + let sharePath; + if (share_type === 'file') { + // 单文件分享:使用完整文件路径 + sharePath = file_path; + } else if (share_type === 'directory') { + // 文件夹分享:使用文件夹路径 + sharePath = file_path; + } else { + // all类型:分享根目录 + sharePath = '/'; + } + + const result = stmt.run( + userId, + shareCode, + sharePath, + share_type, + hashedPassword, + expiresAt + ); + + return { + id: result.lastInsertRowid, + share_code: shareCode, + share_type: share_type, + expires_at: expiresAt, + }; + }, + + // 根据分享码查找 + // 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问) + // ===== 性能优化(P0 优先级修复):只查询必要字段,避免 N+1 查询 ===== + // 移除了敏感字段:oss_access_key_id, oss_access_key_secret(不需要传递给分享访问者) + findByCode(shareCode) { + const result = db.prepare(` + SELECT + s.id, s.user_id, s.share_code, s.share_path, s.share_type, + s.view_count, s.download_count, s.created_at, s.expires_at, + u.username, + -- OSS 配置(访问分享文件所需) + u.oss_provider, u.oss_region, u.oss_bucket, u.oss_endpoint, + -- 用户偏好(主题) + u.theme_preference, + -- 安全检查 + u.is_banned + FROM shares s + JOIN users u ON s.user_id = u.id + WHERE s.share_code = ? + AND (s.expires_at IS NULL OR s.expires_at > datetime('now', 'localtime')) + AND u.is_banned = 0 + `).get(shareCode); + + return result; + }, + + // 根据ID查找 + findById(id) { + return db.prepare('SELECT * FROM shares WHERE id = ?').get(id); + }, + + // 验证分享密码 + verifyPassword(plainPassword, hashedPassword) { + return bcrypt.compareSync(plainPassword, hashedPassword); + }, + + // 获取用户的所有分享 + getUserShares(userId) { + return db.prepare(` + SELECT * FROM shares + WHERE user_id = ? + ORDER BY created_at DESC + `).all(userId); + }, + + // 增加查看次数 + incrementViewCount(shareCode) { + return db.prepare(` + UPDATE shares + SET view_count = view_count + 1 + WHERE share_code = ? + `).run(shareCode); + }, + + // 增加下载次数 + incrementDownloadCount(shareCode) { + return db.prepare(` + UPDATE shares + SET download_count = download_count + 1 + WHERE share_code = ? + `).run(shareCode); + }, + + // 删除分享 + delete(id, userId = null) { + if (userId) { + return db.prepare('DELETE FROM shares WHERE id = ? AND user_id = ?').run(id, userId); + } + return db.prepare('DELETE FROM shares WHERE id = ?').run(id); + }, + + // 获取所有分享(管理员) + getAll() { + return db.prepare(` + SELECT s.*, u.username + FROM shares s + JOIN users u ON s.user_id = u.id + ORDER BY s.created_at DESC + `).all(); + } +}; + +// 系统设置管理 +const SettingsDB = { + // 获取设置 + get(key) { + const row = db.prepare('SELECT value FROM system_settings WHERE key = ?').get(key); + return row ? row.value : null; + }, + + // 设置值 + set(key, value) { + db.prepare(` + INSERT INTO system_settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + `).run(key, value); + }, + + // 获取所有设置 + getAll() { + return db.prepare('SELECT key, value FROM system_settings').all(); + }, + + // ===== 统一 OSS 配置管理(管理员配置,所有用户共享) ===== + + /** + * 获取统一的 OSS 配置 + * @returns {Object|null} OSS 配置对象,如果未配置则返回 null + */ + getUnifiedOssConfig() { + const config = { + provider: this.get('oss_provider'), + region: this.get('oss_region'), + access_key_id: this.get('oss_access_key_id'), + access_key_secret: this.get('oss_access_key_secret'), + bucket: this.get('oss_bucket'), + endpoint: this.get('oss_endpoint') + }; + + // 检查是否所有必需字段都已配置 + if (!config.provider || !config.access_key_id || !config.access_key_secret || !config.bucket) { + return null; + } + + // 安全修复:解密 OSS Access Key Secret + try { + if (config.access_key_secret) { + config.access_key_secret = decryptSecret(config.access_key_secret); + } + } catch (error) { + console.error('[安全] 解密统一 OSS 配置失败:', error.message); + return null; + } + + return config; + }, + + /** + * 设置统一的 OSS 配置 + * @param {Object} ossConfig - OSS 配置对象 + * @param {string} ossConfig.provider - 服务商(aliyun/tencent/aws) + * @param {string} ossConfig.region - 区域 + * @param {string} ossConfig.access_key_id - Access Key ID + * @param {string} ossConfig.access_key_secret - Access Key Secret + * @param {string} ossConfig.bucket - 存储桶名称 + * @param {string} [ossConfig.endpoint] - 自定义 Endpoint(可选) + */ + setUnifiedOssConfig(ossConfig) { + this.set('oss_provider', ossConfig.provider); + this.set('oss_region', ossConfig.region); + this.set('oss_access_key_id', ossConfig.access_key_id); + + // 安全修复:加密存储 OSS Access Key Secret + try { + const encryptedSecret = encryptSecret(ossConfig.access_key_secret); + this.set('oss_access_key_secret', encryptedSecret); + } catch (error) { + console.error('[安全] 加密统一 OSS 配置失败:', error.message); + throw new Error('保存 OSS 配置失败:加密错误'); + } + + this.set('oss_bucket', ossConfig.bucket); + this.set('oss_endpoint', ossConfig.endpoint || ''); + console.log('[系统设置] 统一 OSS 配置已更新(已加密)'); + }, + + /** + * 删除统一的 OSS 配置 + */ + clearUnifiedOssConfig() { + db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run(); + console.log('[系统设置] 统一 OSS 配置已清除'); + }, + + /** + * 检查是否已配置统一的 OSS + * @returns {boolean} + */ + hasUnifiedOssConfig() { + return this.getUnifiedOssConfig() !== null; + } +}; + +// 邮箱验证管理(增强安全:哈希存储) +const VerificationDB = { + setVerification(userId, token, expiresAtMs) { + // 对令牌进行哈希后存储 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + db.prepare(` + UPDATE users + SET verification_token = ?, verification_expires_at = ?, is_verified = 0, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(hashedToken, expiresAtMs, userId); + }, + consumeVerificationToken(token) { + // 对用户提供的令牌进行哈希 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + const row = db.prepare(` + SELECT * FROM users + WHERE verification_token = ? + AND ( + verification_expires_at IS NULL + OR verification_expires_at = '' + OR verification_expires_at > strftime('%s','now')*1000 -- 数值时间戳(ms) + OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 + ) + AND is_verified = 0 + `).get(hashedToken); + if (!row) return null; + + db.prepare(` + UPDATE users + SET is_verified = 1, verification_token = NULL, verification_expires_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(row.id); + return row; + } +}; + +// 密码重置 Token 管理(增强安全:哈希存储) +const PasswordResetTokenDB = { + // 创建令牌时存储哈希值 + create(userId, token, expiresAtMs) { + // 对令牌进行哈希后存储(防止数据库泄露时令牌被直接使用) + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + db.prepare(` + INSERT INTO password_reset_tokens (user_id, token, expires_at, used) + VALUES (?, ?, ?, 0) + `).run(userId, hashedToken, expiresAtMs); + }, + // 验证令牌时先哈希再比较 + use(token) { + // 对用户提供的令牌进行哈希 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + const row = db.prepare(` + SELECT * FROM password_reset_tokens + WHERE token = ? AND used = 0 AND ( + expires_at > strftime('%s','now')*1000 -- 数值时间戳 + OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 + ) + `).get(hashedToken); + if (!row) return null; + // 立即标记为已使用(防止重复使用) + db.prepare(`UPDATE password_reset_tokens SET used = 1 WHERE id = ?`).run(row.id); + return row; + } +}; + +// 初始化默认设置 +function initDefaultSettings() { + // 默认上传限制为10GB + if (!SettingsDB.get('max_upload_size')) { + SettingsDB.set('max_upload_size', '10737418240'); // 10GB in bytes + } + // 默认全局主题为暗色 + if (!SettingsDB.get('global_theme')) { + SettingsDB.set('global_theme', 'dark'); + } +} + +// 数据库迁移 - 主题偏好字段 +function migrateThemePreference() { + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasThemePreference = columns.some(col => col.name === 'theme_preference'); + + if (!hasThemePreference) { + console.log('[数据库迁移] 添加主题偏好字段...'); + db.exec(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT NULL`); + console.log('[数据库迁移] ✓ 主题偏好字段已添加'); + } + } catch (error) { + console.error('[数据库迁移] 主题偏好迁移失败:', error); + } +} + +// 数据库版本迁移 - v2.0 本地存储功能 +function migrateToV2() { + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasStoragePermission = columns.some(col => col.name === 'storage_permission'); + + if (!hasStoragePermission) { + console.log('[数据库迁移] 检测到旧版本数据库,开始升级到 v2.0...'); + + // 添加本地存储相关字段 + db.exec(` + ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only'; + ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp'; + ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT 1073741824; + ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0; + `); + + console.log('[数据库迁移] ✓ 用户表已升级'); + + // 为分享表添加存储类型字段 + const shareColumns = db.prepare("PRAGMA table_info(shares)").all(); + const hasShareStorageType = shareColumns.some(col => col.name === 'storage_type'); + + if (!hasShareStorageType) { + db.exec(`ALTER TABLE shares ADD COLUMN storage_type TEXT DEFAULT 'sftp';`); + console.log('[数据库迁移] ✓ 分享表已升级'); + } + + console.log('[数据库迁移] ✅ 数据库升级到 v2.0 完成!本地存储功能已启用'); + } + } catch (error) { + console.error('[数据库迁移] 迁移失败:', error); + throw error; + } +} + +// 数据库版本迁移 - v3.0 SFTP → OSS +function migrateToOss() { + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasOssProvider = columns.some(col => col.name === 'oss_provider'); + + if (!hasOssProvider) { + console.log('[数据库迁移] 检测到 SFTP 版本,开始升级到 v3.0 OSS...'); + + // 添加 OSS 相关字段 + db.exec(` + ALTER TABLE users ADD COLUMN oss_provider TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN oss_region TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN oss_access_key_id TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN oss_access_key_secret TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN oss_bucket TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN oss_endpoint TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN has_oss_config INTEGER DEFAULT 0; + `); + console.log('[数据库迁移] ✓ OSS 字段已添加'); + } + + // 修复:无论 OSS 字段是否刚添加,都要确保更新现有的 sftp 数据 + // 检查是否有用户仍使用 sftp 类型 + const sftpUsers = db.prepare("SELECT COUNT(*) as count FROM users WHERE storage_permission = 'sftp_only' OR current_storage_type = 'sftp'").get(); + if (sftpUsers.count > 0) { + console.log(`[数据库迁移] 检测到 ${sftpUsers.count} 个用户仍使用 sftp 类型,正在更新...`); + + // 更新存储权限枚举值:sftp_only → oss_only + db.exec(`UPDATE users SET storage_permission = 'oss_only' WHERE storage_permission = 'sftp_only'`); + console.log('[数据库迁移] ✓ 存储权限枚举值已更新'); + + // 更新存储类型:sftp → oss + db.exec(`UPDATE users SET current_storage_type = 'oss' WHERE current_storage_type = 'sftp'`); + console.log('[数据库迁移] ✓ 存储类型已更新'); + + // 更新分享表的存储类型 + const shareColumns = db.prepare("PRAGMA table_info(shares)").all(); + const hasStorageType = shareColumns.some(col => col.name === 'storage_type'); + if (hasStorageType) { + db.exec(`UPDATE shares SET storage_type = 'oss' WHERE storage_type = 'sftp'`); + console.log('[数据库迁移] ✓ 分享表存储类型已更新'); + } + + console.log('[数据库迁移] ✅ SFTP → OSS 数据更新完成!'); + } + } catch (error) { + console.error('[数据库迁移] OSS 迁移失败:', error); + // 不抛出错误,允许服务继续启动 + } +} + +// 系统日志操作 +const SystemLogDB = { + // 日志级别常量 + LEVELS: { + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error' + }, + + // 日志分类常量 + CATEGORIES: { + AUTH: 'auth', // 认证相关(登录、登出、注册) + USER: 'user', // 用户管理(创建、修改、删除、封禁) + FILE: 'file', // 文件操作(上传、下载、删除、重命名) + SHARE: 'share', // 分享操作(创建、删除、访问) + SYSTEM: 'system', // 系统操作(设置修改、服务启动) + SECURITY: 'security' // 安全事件(登录失败、暴力破解、异常访问) + }, + + // 写入日志 + log({ level = 'info', category, action, message, userId = null, username = null, ipAddress = null, userAgent = null, details = null }) { + try { + const stmt = db.prepare(` + INSERT INTO system_logs (level, category, action, message, user_id, username, ip_address, user_agent, details, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime')) + `); + + const detailsStr = details ? (typeof details === 'string' ? details : JSON.stringify(details)) : null; + + stmt.run(level, category, action, message, userId, username, ipAddress, userAgent, detailsStr); + } catch (error) { + console.error('写入日志失败:', error); + } + }, + + // 查询日志(支持分页和筛选) + query({ page = 1, pageSize = 50, level = null, category = null, userId = null, startDate = null, endDate = null, keyword = null }) { + let sql = 'SELECT * FROM system_logs WHERE 1=1'; + let countSql = 'SELECT COUNT(*) as total FROM system_logs WHERE 1=1'; + const params = []; + + if (level) { + sql += ' AND level = ?'; + countSql += ' AND level = ?'; + params.push(level); + } + + if (category) { + sql += ' AND category = ?'; + countSql += ' AND category = ?'; + params.push(category); + } + + if (userId) { + sql += ' AND user_id = ?'; + countSql += ' AND user_id = ?'; + params.push(userId); + } + + if (startDate) { + sql += ' AND created_at >= ?'; + countSql += ' AND created_at >= ?'; + params.push(startDate); + } + + if (endDate) { + sql += ' AND created_at <= ?'; + countSql += ' AND created_at <= ?'; + params.push(endDate); + } + + if (keyword) { + sql += ' AND (message LIKE ? OR username LIKE ? OR action LIKE ?)'; + countSql += ' AND (message LIKE ? OR username LIKE ? OR action LIKE ?)'; + const kw = `%${keyword}%`; + params.push(kw, kw, kw); + } + + // 获取总数 + const totalResult = db.prepare(countSql).get(...params); + const total = totalResult ? totalResult.total : 0; + + // 分页查询 + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + const offset = (page - 1) * pageSize; + + const logs = db.prepare(sql).all(...params, pageSize, offset); + + return { + logs, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize) + }; + }, + + // 获取最近的日志 + getRecent(limit = 100) { + return db.prepare('SELECT * FROM system_logs ORDER BY created_at DESC LIMIT ?').all(limit); + }, + + // 按分类统计 + getStatsByCategory() { + return db.prepare(` + SELECT category, COUNT(*) as count + FROM system_logs + GROUP BY category + ORDER BY count DESC + `).all(); + }, + + // 按日期统计(最近7天) + getStatsByDate(days = 7) { + return db.prepare(` + SELECT DATE(created_at) as date, COUNT(*) as count + FROM system_logs + WHERE created_at >= datetime('now', 'localtime', '-' || ? || ' days') + GROUP BY DATE(created_at) + ORDER BY date DESC + `).all(days); + }, + + // 清理旧日志(保留指定天数) + cleanup(keepDays = 90) { + const result = db.prepare(` + DELETE FROM system_logs + WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days') + `).run(keepDays); + + return result.changes; + } +}; + +// 事务工具函数 +const TransactionDB = { + /** + * 在事务中执行操作 + * @param {Function} fn - 要执行的函数,接收 db 作为参数 + * @returns {*} 函数返回值 + * @throws {Error} 如果事务失败则抛出错误 + */ + run(fn) { + const transaction = db.transaction((callback) => { + return callback(db); + }); + return transaction(fn); + }, + + /** + * 删除用户及其所有相关数据(使用事务) + * @param {number} userId - 用户ID + * @returns {object} 删除结果 + */ + deleteUserWithData(userId) { + return this.run(() => { + // 1. 删除用户的所有分享 + const sharesDeleted = db.prepare('DELETE FROM shares WHERE user_id = ?').run(userId); + + // 2. 删除密码重置令牌 + const tokensDeleted = db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId); + + // 3. 更新日志中的用户引用(设为 NULL,保留日志记录) + db.prepare('UPDATE system_logs SET user_id = NULL WHERE user_id = ?').run(userId); + + // 4. 删除用户记录 + const userDeleted = db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + return { + sharesDeleted: sharesDeleted.changes, + tokensDeleted: tokensDeleted.changes, + userDeleted: userDeleted.changes + }; + }); + } +}; + +// 初始化数据库 +initDatabase(); +createDefaultAdmin(); +initDefaultSettings(); +migrateToV2(); // 执行数据库迁移 +migrateThemePreference(); // 主题偏好迁移 +migrateToOss(); // SFTP → OSS 迁移 + +module.exports = { + db, + UserDB, + ShareDB, + SettingsDB, + VerificationDB, + PasswordResetTokenDB, + SystemLogDB, + TransactionDB, + WalManager +}; diff --git a/backend/fix_expires_at_format.js b/backend/fix_expires_at_format.js new file mode 100644 index 0000000..c039db7 --- /dev/null +++ b/backend/fix_expires_at_format.js @@ -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} 条记录`); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..5d63484 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,4544 @@ +{ + "name": "wanwanyun-backend", + "version": "3.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wanwanyun-backend", + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "^3.600.0", + "@aws-sdk/s3-request-presigner": "^3.600.0", + "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", + "svg-captcha": "^1.4.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.971.0.tgz", + "integrity": "sha512-BBUne390fKa4C4QvZlUZ5gKcu+Uyid4IyQ20N4jl0vS7SK2xpfXlJcgKqPW5ts6kx6hWTQBk6sH5Lf12RvuJxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-node": "3.971.0", + "@aws-sdk/middleware-bucket-endpoint": "3.969.0", + "@aws-sdk/middleware-expect-continue": "3.969.0", + "@aws-sdk/middleware-flexible-checksums": "3.971.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-location-constraint": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-sdk-s3": "3.970.0", + "@aws-sdk/middleware-ssec": "3.971.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/signature-v4-multi-region": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.971.0.tgz", + "integrity": "sha512-Xx+w6DQqJxDdymYyIxyKJnRzPvVJ4e/Aw0czO7aC9L/iraaV7AG8QtRe93OGW6aoHSh72CIiinnpJJfLsQqP4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.970.0.tgz", + "integrity": "sha512-klpzObldOq8HXzDjDlY6K8rMhYZU6mXRz6P9F9N+tWnjoYFfeBMra8wYApydElTUYQKP1O7RLHwH1OKFfKcqIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws-sdk/xml-builder": "3.969.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.969.0.tgz", + "integrity": "sha512-IGNkP54HD3uuLnrPCYsv3ZD478UYq+9WwKrIVJ9Pdi3hxPg8562CH3ZHf8hEgfePN31P9Kj+Zu9kq2Qcjjt61A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.970.0.tgz", + "integrity": "sha512-rtVzXzEtAfZBfh+lq3DAvRar4c3jyptweOAJR2DweyXx71QSMY+O879hjpMwES7jl07a3O1zlnFIDo4KP/96kQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.970.0.tgz", + "integrity": "sha512-CjDbWL7JxjLc9ZxQilMusWSw05yRvUJKRpz59IxDpWUnSMHC9JMMUUkOy5Izk8UAtzi6gupRWArp4NG4labt9Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.971.0.tgz", + "integrity": "sha512-c0TGJG4xyfTZz3SInXfGU8i5iOFRrLmy4Bo7lMyH+IpngohYMYGYl61omXqf2zdwMbDv+YJ9AviQTcCaEUKi8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-env": "3.970.0", + "@aws-sdk/credential-provider-http": "3.970.0", + "@aws-sdk/credential-provider-login": "3.971.0", + "@aws-sdk/credential-provider-process": "3.970.0", + "@aws-sdk/credential-provider-sso": "3.971.0", + "@aws-sdk/credential-provider-web-identity": "3.971.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.971.0.tgz", + "integrity": "sha512-yhbzmDOsk0RXD3rTPhZra4AWVnVAC4nFWbTp+sUty1hrOPurUmhuz8bjpLqYTHGnlMbJp+UqkQONhS2+2LzW2g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.971.0.tgz", + "integrity": "sha512-epUJBAKivtJqalnEBRsYIULKYV063o/5mXNJshZfyvkAgNIzc27CmmKRXTN4zaNOZg8g/UprFp25BGsi19x3nQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.970.0", + "@aws-sdk/credential-provider-http": "3.970.0", + "@aws-sdk/credential-provider-ini": "3.971.0", + "@aws-sdk/credential-provider-process": "3.970.0", + "@aws-sdk/credential-provider-sso": "3.971.0", + "@aws-sdk/credential-provider-web-identity": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.970.0.tgz", + "integrity": "sha512-0XeT8OaT9iMA62DFV9+m6mZfJhrD0WNKf4IvsIpj2Z7XbaYfz3CoDDvNoALf3rPY9NzyMHgDxOspmqdvXP00mw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.971.0.tgz", + "integrity": "sha512-dY0hMQ7dLVPQNJ8GyqXADxa9w5wNfmukgQniLxGVn+dMRx3YLViMp5ZpTSQpFhCWNF0oKQrYAI5cHhUJU1hETw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.971.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/token-providers": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.971.0.tgz", + "integrity": "sha512-F1AwfNLr7H52T640LNON/h34YDiMuIqW/ZreGzhRR6vnFGaSPtNSKAKB2ssAMkLM8EVg8MjEAYD3NCUiEo+t/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.969.0.tgz", + "integrity": "sha512-MlbrlixtkTVhYhoasblKOkr7n2yydvUZjjxTnBhIuHmkyBS1619oGnTfq/uLeGYb4NYXdeQ5OYcqsRGvmWSuTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.969.0.tgz", + "integrity": "sha512-qXygzSi8osok7tH9oeuS3HoKw6jRfbvg5Me/X5RlHOvSSqQz8c5O9f3MjUApaCUSwbAU92KrbZWasw2PKiaVHg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.971.0.tgz", + "integrity": "sha512-+hGUDUxeIw8s2kkjfeXym0XZxdh0cqkHkDpEanWYdS1gnWkIR+gf9u/DKbKqGHXILPaqHXhWpLTQTVlaB4sI7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/crc64-nvme": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.969.0.tgz", + "integrity": "sha512-AWa4rVsAfBR4xqm7pybQ8sUNJYnjyP/bJjfAw34qPuh3M9XrfGbAHG0aiAfQGrBnmS28jlO6Kz69o+c6PRw1dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.969.0.tgz", + "integrity": "sha512-zH7pDfMLG/C4GWMOpvJEoYcSpj7XsNP9+irlgqwi667sUQ6doHQJ3yyDut3yiTk0maq1VgmriPFELyI9lrvH/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.969.0.tgz", + "integrity": "sha512-xwrxfip7Y2iTtCMJ+iifN1E1XMOuhxIHY9DreMCvgdl4r7+48x2S1bCYPWH3eNY85/7CapBWdJ8cerpEl12sQQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.969.0.tgz", + "integrity": "sha512-2r3PuNquU3CcS1Am4vn/KHFwLi8QFjMdA/R+CRDXT4AFO/0qxevF/YStW3gAKntQIgWgQV8ZdEtKAoJvLI4UWg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.970.0.tgz", + "integrity": "sha512-v/Y5F1lbFFY7vMeG5yYxuhnn0CAshz6KMxkz1pDyPxejNE9HtA0w8R6OTBh/bVdIm44QpjhbI7qeLdOE/PLzXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.971.0.tgz", + "integrity": "sha512-QGVhvRveYG64ZhnS/b971PxXM6N2NU79Fxck4EfQ7am8v1Br0ctoeDDAn9nXNblLGw87we9Z65F7hMxxiFHd3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.970.0.tgz", + "integrity": "sha512-dnSJGGUGSFGEX2NzvjwSefH+hmZQ347AwbLhAsi0cdnISSge+pcGfOFrJt2XfBIypwFe27chQhlfuf/gWdzpZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@smithy/core": "^3.20.6", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.971.0.tgz", + "integrity": "sha512-TWaILL8GyYlhGrxxnmbkazM4QsXatwQgoWUvo251FXmUOsiXDFDVX3hoGIfB3CaJhV2pJPfebHUNJtY6TjZ11g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.969.0.tgz", + "integrity": "sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.971.0.tgz", + "integrity": "sha512-j4wCCoQ//xm03JQn7/Jq6BJ0HV3VzlI/HrIQSQupWWjZTrdxyqa9PXBhcYNNtvZtF1adA/cRpYTMS+2SUsZGRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-format-url": "3.969.0", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.970.0.tgz", + "integrity": "sha512-z3syXfuK/x/IsKf/AeYmgc2NT7fcJ+3fHaGO+fkghkV9WEba3fPyOwtTBX4KpFMNb2t50zDGZwbzW1/5ighcUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.971.0.tgz", + "integrity": "sha512-4hKGWZbmuDdONMJV0HJ+9jwTDb0zLfKxcCLx2GEnBY31Gt9GeyIQ+DZ97Bb++0voawj6pnZToFikXTyrEq2x+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.969.0.tgz", + "integrity": "sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.968.0.tgz", + "integrity": "sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.970.0.tgz", + "integrity": "sha512-TZNZqFcMUtjvhZoZRtpEGQAdULYiy6rcGiXAbLU7e9LSpIYlRqpLa207oMNfgbzlL2PnHko+eVg8rajDiSOYCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.969.0.tgz", + "integrity": "sha512-C7ZiE8orcrEF9In+XDlIKrZhMjp0HCPUH6u74pgadE3T2LRre5TmOQcTt785/wVS2G0we9cxkjlzMrfDsfPvFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.2.tgz", + "integrity": "sha512-qKgO7wAYsXzhwCHhdbaKFyxd83Fgs8/1Ka+jjSPrv2Ll7mB55Wbwlo0kkfMLh993/yEc8aoDIAc1Fz9h4Spi4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.969.0.tgz", + "integrity": "sha512-bpJGjuKmFr0rA6UKUCmN8D19HQFMLXMx5hKBXqBlPFdalMhxJSjcxzX9DbQh0Fn6bJtxCguFmRGOBdQqNOt49g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.971.0.tgz", + "integrity": "sha512-Eygjo9mFzQYjbGY3MYO6CsIhnTwAMd3WmuFalCykqEmj2r5zf0leWrhPaqvA5P68V5JdGfPYgj7vhNOd6CtRBQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.969.0.tgz", + "integrity": "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.20.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.7.tgz", + "integrity": "sha512-aO7jmh3CtrmPsIJxUwYIzI5WVlMK8BMCPQ4D4nTzqTqBhbzvxHNzBMGcEg13yg/z9R2Qsz49NUFl0F0lVbTVFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.8.tgz", + "integrity": "sha512-TV44qwB/T0OMMzjIuI+JeS0ort3bvlPJ8XIH0MSlGADraXpZqmyND27ueuAL3E14optleADWqtd7dUgc2w+qhQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.7", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.24", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.24.tgz", + "integrity": "sha512-yiUY1UvnbUFfP5izoKLtfxDSTRv724YRRwyiC/5HYY6vdsVDcDOXKSXmkJl/Hovcxt5r+8tZEUAdrOaCJwrl9Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.10.9", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", + "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.9.tgz", + "integrity": "sha512-Je0EvGXVJ0Vrrr2lsubq43JGRIluJ/hX17aN/W/A0WfE+JpoMdI8kwk2t9F0zTX9232sJDGcoH4zZre6m6f/sg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.7", + "@smithy/middleware-endpoint": "^4.4.8", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.23", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.23.tgz", + "integrity": "sha512-mMg+r/qDfjfF/0psMbV4zd7F/i+rpyp7Hjh0Wry7eY15UnzTEId+xmQTGDU8IdZtDfbGQxuWNfgBZKBj+WuYbA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.9", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.26.tgz", + "integrity": "sha512-EQqe/WkbCinah0h1lMWh9ICl0Ob4lyl20/10WTB35SC9vDQfD8zWsOT+x2FIOXKAoZQ8z/y0EFMoodbcqWJY/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.9", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz", + "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.86.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.86.0.tgz", + "integrity": "sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opentype.js": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz", + "integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svg-captcha": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz", + "integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==", + "license": "MIT", + "dependencies": { + "opentype.js": "^0.7.3" + }, + "engines": { + "node": ">=4.x" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..917b632 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/routes/health.js b/backend/routes/health.js new file mode 100644 index 0000000..76284e5 --- /dev/null +++ b/backend/routes/health.js @@ -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; diff --git a/backend/routes/index.js b/backend/routes/index.js new file mode 100644 index 0000000..d70d588 --- /dev/null +++ b/backend/routes/index.js @@ -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 +}; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..0600653 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,6077 @@ +// 加载环境变量(必须在最开始) +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const svgCaptcha = require('svg-captcha'); +const multer = require('multer'); +const nodemailer = require('nodemailer'); +const path = require('path'); +const fs = require('fs'); +const { body, validationResult } = require('express-validator'); +const archiver = require('archiver'); +const crypto = require('crypto'); +const { exec, execSync, execFile } = require('child_process'); +const util = require('util'); +const execAsync = util.promisify(exec); +const execFileAsync = util.promisify(execFile); + +// ===== OSS 使用情况缓存 ===== +// 缓存 OSS 空间统计结果,避免频繁遍历对象 +const OSS_USAGE_CACHE = new Map(); +const OSS_USAGE_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 + +/** + * 获取缓存的 OSS 使用情况 + * @param {number} userId - 用户ID + * @returns {object|null} 缓存的数据或 null + */ +function getOssUsageCache(userId) { + const cacheKey = `oss_usage_${userId}`; + const cached = OSS_USAGE_CACHE.get(cacheKey); + if (cached && Date.now() - cached.timestamp < OSS_USAGE_CACHE_TTL) { + console.log(`[OSS缓存] 命中缓存: 用户 ${userId}`); + return cached.data; + } + return null; +} + +/** + * 设置 OSS 使用情况缓存 + * @param {number} userId - 用户ID + * @param {object} data - 使用情况数据 + */ +function setOssUsageCache(userId, data) { + const cacheKey = `oss_usage_${userId}`; + OSS_USAGE_CACHE.set(cacheKey, { + data, + timestamp: Date.now() + }); + console.log(`[OSS缓存] 已缓存: 用户 ${userId}, 大小: ${data.totalSize}`); +} + +/** + * 清除用户的 OSS 使用情况缓存 + * @param {number} userId - 用户ID + */ +function clearOssUsageCache(userId) { + const cacheKey = `oss_usage_${userId}`; + OSS_USAGE_CACHE.delete(cacheKey); + console.log(`[OSS缓存] 已清除: 用户 ${userId}`); +} + +const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB, WalManager } = require('./database'); +const StorageUsageCache = require('./utils/storage-cache'); +const { generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, requirePasswordConfirmation, isJwtSecretSecure } = require('./auth'); +const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage'); +const { encryptSecret, decryptSecret } = require('./utils/encryption'); + +const app = express(); +const PORT = process.env.PORT || 40001; +const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英文、数字、下划线、点和短横线 +const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; + +// ===== 安全配置:公开域名白名单(防止 Host Header 注入) ===== +// 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接 +// 例如: PUBLIC_BASE_URL=https://cloud.example.com +const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || null; +const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS + ? process.env.ALLOWED_HOSTS.split(',').map(h => h.trim().toLowerCase()) + : []; + +// 获取安全的基础URL(用于生成邮件链接、分享链接等) +function getSecureBaseUrl(req) { + // 优先使用配置的公开域名 + if (PUBLIC_BASE_URL) { + return PUBLIC_BASE_URL.replace(/\/+$/, ''); // 移除尾部斜杠 + } + + // 如果没有配置,验证 Host 头是否在白名单中 + const host = (req.get('host') || '').toLowerCase(); + if (ALLOWED_HOSTS.length > 0 && !ALLOWED_HOSTS.includes(host)) { + console.warn(`[安全警告] 检测到非白名单 Host 头: ${host}`); + // 返回第一个白名单域名作为后备 + const protocol = getProtocol(req); + return `${protocol}://${ALLOWED_HOSTS[0]}`; + } + + // 开发环境回退(仅在没有配置时使用) + if (process.env.NODE_ENV !== 'production') { + return `${getProtocol(req)}://${req.get('host')}`; + } + + // 生产环境没有配置时,记录警告并使用请求的 Host(不推荐) + console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!'); + return `${getProtocol(req)}://${req.get('host')}`; +} + +// ===== 安全配置:信任代理 ===== +// 默认不信任任何代理(直接暴露场景) +// 配置选项: +// - false: 不信任代理(默认,直接暴露) +// - true: 信任所有代理(不推荐,易被伪造) +// - 1/2/3: 信任前N跳代理(推荐,如 Nginx 后部署用 1) +// - 'loopback': 仅信任本地回环地址 +// - '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16': 信任指定IP/CIDR段 +const TRUST_PROXY_RAW = process.env.TRUST_PROXY; +let trustProxyValue = false; // 默认不信任 + +if (TRUST_PROXY_RAW !== undefined && TRUST_PROXY_RAW !== '') { + if (TRUST_PROXY_RAW === 'true') { + trustProxyValue = true; + console.warn('[安全警告] TRUST_PROXY=true 将信任所有代理,存在 IP/协议伪造风险!建议设置为具体跳数(1)或IP段'); + } else if (TRUST_PROXY_RAW === 'false') { + trustProxyValue = false; + } else if (/^\d+$/.test(TRUST_PROXY_RAW)) { + // 数字:信任前N跳 + trustProxyValue = parseInt(TRUST_PROXY_RAW, 10); + } else { + // 字符串:loopback 或 IP/CIDR 列表 + trustProxyValue = TRUST_PROXY_RAW; + } +} + +app.set('trust proxy', trustProxyValue); +console.log(`[安全] trust proxy 配置: ${JSON.stringify(trustProxyValue)}`); + +// 配置CORS - 严格白名单模式 +const allowedOrigins = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()) + : []; // 默认为空数组,不允许任何域名 + +const corsOptions = { + credentials: true, + origin: (origin, callback) => { + // 生产环境必须配置白名单 + if (allowedOrigins.length === 0 && process.env.NODE_ENV === 'production') { + console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!'); + callback(new Error('CORS未配置')); + return; + } + + // 开发环境如果没有配置,允许 localhost + if (allowedOrigins.length === 0) { + const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000']; + if (!origin || devOrigins.some(o => origin.startsWith(o))) { + callback(null, true); + return; + } + } + + // 严格白名单模式:只允许白名单中的域名 + // 但需要允许没有Origin头的同源请求(浏览器访问时不会发送Origin) + if (!origin) { + // 没有Origin头的请求通常是: + // 1. 浏览器的同源请求(不触发CORS) + // 2. 直接的服务器请求 + // 这些都应该允许 + callback(null, true); + } else if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { + // 白名单中的域名,或通配符允许所有域名 + callback(null, true); + } else { + // 拒绝不在白名单中的跨域请求 + console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`); + callback(new Error('CORS策略不允许来自该来源的访问')); + } + } +}; + +// 中间件 +app.use(cors(corsOptions)); + +// 静态文件服务 - 提供前端页面 +const frontendPath = path.join(__dirname, '../frontend'); +console.log('[静态文件] 前端目录:', frontendPath); +app.use(express.static(frontendPath)); + +app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS +app.use(cookieParser()); + +// ===== CSRF 防护 ===== +// 基于 Double Submit Cookie 模式的 CSRF 保护 +// 对于修改数据的请求(POST/PUT/DELETE),验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配 + +// 生成 CSRF Token +function generateCsrfToken() { + return crypto.randomBytes(32).toString('hex'); +} + +// CSRF Token Cookie 名称 +const CSRF_COOKIE_NAME = 'csrf_token'; + +// 设置 CSRF Cookie 的中间件 +app.use((req, res, next) => { + // 如果没有 CSRF cookie,则生成一个 + if (!req.cookies[CSRF_COOKIE_NAME]) { + const csrfToken = generateCsrfToken(); + const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + res.cookie(CSRF_COOKIE_NAME, csrfToken, { + httpOnly: false, // 前端需要读取此值 + secure: isSecureEnv, + sameSite: isSecureEnv ? 'strict' : 'lax', + maxAge: 24 * 60 * 60 * 1000 // 24小时 + }); + } + next(); +}); + +// CSRF 验证中间件(仅用于需要保护的路由) +function csrfProtection(req, res, next) { + // GET、HEAD、OPTIONS 请求不需要 CSRF 保护 + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + return next(); + } + + // 白名单:某些公开 API 不需要 CSRF 保护(如分享页面的密码验证) + const csrfExemptPaths = [ + '/api/share/', // 分享相关的公开接口 + '/api/captcha', // 验证码 + '/api/health' // 健康检查 + ]; + + if (csrfExemptPaths.some(path => req.path.startsWith(path))) { + return next(); + } + + const cookieToken = req.cookies[CSRF_COOKIE_NAME]; + const headerToken = req.headers['x-csrf-token']; + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`); + return res.status(403).json({ + success: false, + message: 'CSRF 验证失败,请刷新页面后重试' + }); + } + + next(); +} + +// 注意:CSRF 保护将在 authMiddleware 后的路由中按需启用 +// 可以通过环境变量 ENABLE_CSRF=true 开启(默认关闭以保持向后兼容) +const ENABLE_CSRF = process.env.ENABLE_CSRF === 'true'; +if (ENABLE_CSRF) { + console.log('[安全] CSRF 保护已启用'); +} + +// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境) +// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置, +// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头 +app.use((req, res, next) => { + if (!ENFORCE_HTTPS) return next(); + + // req.secure 由 Express 根据 trust proxy 配置计算: + // - 如果 trust proxy = false,仅检查直接连接是否为 TLS + // - 如果 trust proxy 已配置,会检查可信代理的 X-Forwarded-Proto + if (!req.secure) { + return res.status(400).json({ + success: false, + message: '仅支持HTTPS访问,请使用HTTPS' + }); + } + return next(); +}); + +// Session配置(用于验证码) +const isSecureCookie = process.env.COOKIE_SECURE === 'true'; +const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 + +// 安全检查:Session密钥配置 +const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production'; +const DEFAULT_SESSION_SECRETS = [ + 'your-session-secret-change-in-production', + 'session-secret-change-me' +]; + +if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) { + const sessionWarnMsg = ` +[安全警告] SESSION_SECRET 使用默认值,存在安全风险! +请在 .env 文件中设置随机生成的 SESSION_SECRET +生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +`; + if (process.env.NODE_ENV === 'production') { + console.error(sessionWarnMsg); + throw new Error('生产环境必须设置 SESSION_SECRET!'); + } else { + console.warn(sessionWarnMsg); + } +} + +app.use(session({ + secret: SESSION_SECRET, + resave: false, + saveUninitialized: true, // 改为true,确保验证码请求时创建session + name: 'captcha.sid', // 自定义session cookie名称 + cookie: { + secure: isSecureCookie, + httpOnly: true, + sameSite: sameSiteMode, + maxAge: 10 * 60 * 1000 // 10分钟 + } +})); + +// 安全响应头中间件 +app.use((req, res, next) => { + // 防止点击劫持 + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + // 防止MIME类型嗅探 + res.setHeader('X-Content-Type-Options', 'nosniff'); + // XSS保护 + res.setHeader('X-XSS-Protection', '1; mode=block'); + // HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置) + // req.secure 基于 trust proxy 配置,不会被不可信代理伪造 + if (req.secure) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + // 内容安全策略 + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"); + // 隐藏X-Powered-By + res.removeHeader('X-Powered-By'); + next(); +}); + +/** + * XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码 + * 注意:不转义 / 因为它是文件路径的合法字符 + * @param {string} str - 需要过滤的输入字符串 + * @returns {string} 过滤后的安全字符串 + */ +function sanitizeInput(str) { + if (typeof str !== 'string') return str; + + // 1. 基础HTML实体转义(不包括 / 因为是路径分隔符,不包括 ` 因为是合法文件名字符) + let sanitized = str + .replace(/[&<>"']/g, (char) => { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return map[char]; + }); + + // 2. 过滤危险协议(javascript:, data:, vbscript:等) + sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); + + // 3. 移除空字节 + sanitized = sanitized.replace(/\x00/g, ''); + + return sanitized; +} + +/** + * 将 HTML 实体解码为原始字符 + * 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符 + * 支持嵌套实体的递归解码(如 &#x60; -> ` -> `) + * @param {string} str - 包含HTML实体的字符串 + * @returns {string} 解码后的原始字符串 + */ +function decodeHtmlEntities(str) { + if (typeof str !== 'string') return str; + + // 支持常见实体和数字实体(含多次嵌套,如 &#x60;) + 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); + // 处理嵌套实体(如 &#x60;),直到稳定 + while (decoded !== output) { + output = decoded; + decoded = decodeOnce(output); + } + return output; +} + +// HTML转义(用于模板输出) +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +// 规范化并校验HTTP直链前缀,只允许http/https +function sanitizeHttpBaseUrl(raw) { + if (!raw) return null; + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + return null; + } + url.search = ''; + url.hash = ''; + // 去掉多余的结尾斜杠,保持路径稳定 + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString(); + } catch { + return null; + } +} + +// 构建安全的下载URL,编码路径片段并拒绝非HTTP(S)前缀 +function buildHttpDownloadUrl(rawBaseUrl, filePath) { + const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl); + if (!baseUrl || !filePath) return null; + + try { + const url = new URL(baseUrl); + const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + const safeSegments = normalizedPath + .split('/') + .filter(Boolean) + .map(segment => encodeURIComponent(segment)); + const safePath = safeSegments.length ? '/' + safeSegments.join('/') : ''; + + const basePath = url.pathname.replace(/\/+$/, ''); + const joinedPath = `${basePath}${safePath || '/'}`; + url.pathname = joinedPath || '/'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (err) { + console.warn('[安全] 生成下载URL失败:', err.message); + return null; + } +} + +// 校验文件名/路径片段安全(禁止分隔符、控制字符、..) +function isSafePathSegment(name) { + return ( + typeof name === 'string' && + name.length > 0 && + name.length <= 255 && // 限制文件名长度 + !name.includes('..') && + !/[/\\]/.test(name) && + !/[\x00-\x1F]/.test(name) + ); +} + +// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件) +// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行) +const DANGEROUS_EXTENSIONS = [ + '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP + '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages + '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET + '.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为) +]; + +// 检查文件扩展名是否安全 +function isFileExtensionSafe(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; + } + + // 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd) + // 因为 path.extname('.htaccess') 返回空字符串 + const dangerousFilenames = ['.htaccess', '.htpasswd']; + if (dangerousFilenames.includes(nameLower)) { + return false; + } + + // 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行) + for (const dangerExt of DANGEROUS_EXTENSIONS) { + if (nameLower.includes(dangerExt + '.')) { + return false; + } + } + + return true; +} + +// 应用XSS过滤到所有POST/PUT请求的body +app.use((req, res, next) => { + if ((req.method === 'POST' || req.method === 'PUT') && req.body) { + // 递归过滤所有字符串字段 + function sanitizeObject(obj) { + if (typeof obj === 'string') { + return sanitizeInput(obj); + } else if (Array.isArray(obj)) { + return obj.map(item => sanitizeObject(item)); + } else if (obj && typeof obj === 'object') { + const sanitized = {}; + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeObject(value); + } + return sanitized; + } + return obj; + } + req.body = sanitizeObject(req.body); + } + next(); +}); + +// 请求日志 +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); + next(); +}); + +// 获取正确的协议(基于可信代理链) +// 安全说明:req.protocol 由 Express 根据 trust proxy 配置计算, +// 只有可信代理的 X-Forwarded-Proto 才会被采信 +function getProtocol(req) { + // req.protocol 会根据 trust proxy 配置: + // - trust proxy = false: 仅检查直接连接(TLS -> 'https', 否则 'http') + // - trust proxy 已配置: 会检查可信代理的 X-Forwarded-Proto + return req.protocol || (req.secure ? 'https' : 'http'); +} + +// ===== 系统日志工具函数 ===== + +// 从请求中提取日志信息 +function getLogInfoFromReq(req) { + return { + ipAddress: req.ip || req.socket?.remoteAddress || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + userId: req.user?.id || null, + username: req.user?.username || null + }; +} + +// 记录认证日志 +function logAuth(req, action, message, details = null, level = 'info') { + const info = getLogInfoFromReq(req); + SystemLogDB.log({ + level, + category: 'auth', + action, + message, + ...info, + details + }); +} + +// 记录用户管理日志 +function logUser(req, action, message, details = null, level = 'info') { + const info = getLogInfoFromReq(req); + SystemLogDB.log({ + level, + category: 'user', + action, + message, + ...info, + details + }); +} + +// 记录文件操作日志 +function logFile(req, action, message, details = null, level = 'info') { + const info = getLogInfoFromReq(req); + SystemLogDB.log({ + level, + category: 'file', + action, + message, + ...info, + details + }); +} + +// 记录分享操作日志 +function logShare(req, action, message, details = null, level = 'info') { + const info = getLogInfoFromReq(req); + SystemLogDB.log({ + level, + category: 'share', + action, + message, + ...info, + details + }); +} + +// 记录系统操作日志 +function logSystem(req, action, message, details = null, level = 'info') { + const info = req ? getLogInfoFromReq(req) : {}; + SystemLogDB.log({ + level, + category: 'system', + action, + message, + ...info, + details + }); +} + +// 记录安全事件日志 +function logSecurity(req, action, message, details = null, level = 'warn') { + const info = getLogInfoFromReq(req); + SystemLogDB.log({ + level, + category: 'security', + action, + message, + ...info, + details + }); +} + +// 文件上传配置(临时存储) +const upload = multer({ + dest: path.join(__dirname, 'uploads'), + limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 5GB限制 +}); + +// ===== TTL缓存类 ===== + +// 带过期时间的缓存类 +class TTLCache { + constructor(defaultTTL = 3600000) { // 默认1小时 + this.cache = new Map(); + this.defaultTTL = defaultTTL; + + // 每10分钟清理一次过期缓存 + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 10 * 60 * 1000); + } + + 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) { + const item = this.cache.get(key); + if (!item) { + return false; + } + + // 检查是否过期 + if (Date.now() > item.expiresAt) { + this.cache.delete(key); + return false; + } + + return true; + } + + delete(key) { + return this.cache.delete(key); + } + + // 清理过期缓存 + 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++; + } + } + + if (cleaned > 0) { + console.log(`[缓存清理] 已清理 ${cleaned} 个过期的分享缓存`); + } + } + + // 获取缓存大小 + size() { + return this.cache.size; + } + + // 停止清理定时器 + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + } +} + +// 分享文件信息缓存(内存缓存,1小时TTL) +const shareFileCache = new TTLCache(60 * 60 * 1000); + +// ===== 防爆破限流器 ===== + +// 防爆破限流器类 +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(); + + // 每5分钟清理一次过期记录 + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 5 * 60 * 1000); + } + + // 获取客户端IP(基于可信代理链) + // 安全说明:req.ip 由 Express 根据 trust proxy 配置计算, + // 只有可信代理的 X-Forwarded-For 才会被采信 + getClientKey(req) { + // req.ip 会根据 trust proxy 配置: + // - trust proxy = false: 使用直接连接的 IP(socket 地址) + // - trust proxy = 1: 取 X-Forwarded-For 的最后 1 个 IP + // - trust proxy = true: 取 X-Forwarded-For 的第 1 个 IP(不推荐) + return req.ip || req.socket?.remoteAddress || 'unknown'; + } + + // 检查是否被封锁 + 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)) { + const blockInfo = this.blockedKeys.get(key); + return { + blocked: true, + remainingAttempts: 0, + resetTime: blockInfo.expiresAt, + waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000), + needCaptcha: true + }; + } + + // 获取或创建尝试记录 + let attemptInfo = this.attempts.get(key); + if (!attemptInfo || now > attemptInfo.windowEnd) { + attemptInfo = { + count: 0, + windowEnd: now + this.windowMs, + firstAttempt: now + }; + } + + attemptInfo.count++; + this.attempts.set(key, attemptInfo); + + // 检查是否达到封锁阈值 + if (attemptInfo.count >= this.maxAttempts) { + const blockExpiresAt = now + this.blockDuration; + this.blockedKeys.set(key, { + expiresAt: blockExpiresAt, + blockedAt: now + }); + console.warn(`[防爆破] 封锁Key: ${key}, 失败次数: ${attemptInfo.count}, 封锁时长: ${Math.ceil(this.blockDuration / 60000)}分钟`); + return { + blocked: true, + remainingAttempts: 0, + resetTime: blockExpiresAt, + waitMinutes: Math.ceil(this.blockDuration / 60000), + needCaptcha: true + }; + } + + return { + blocked: false, + remainingAttempts: this.maxAttempts - attemptInfo.count, + resetTime: attemptInfo.windowEnd, + waitMinutes: 0, + needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码 + }; + } + + // 获取失败次数 + getFailureCount(key) { + const attemptInfo = this.attempts.get(key); + if (!attemptInfo || Date.now() > attemptInfo.windowEnd) { + return 0; + } + return attemptInfo.count; + } + + // 记录成功(清除失败记录) + recordSuccess(key) { + this.attempts.delete(key); + this.blockedKeys.delete(key); + } + + // 清理过期记录 + cleanup() { + const now = Date.now(); + let cleanedAttempts = 0; + let cleanedBlocks = 0; + + // 清理过期的尝试记录 + for (const [key, info] of this.attempts.entries()) { + if (now > info.windowEnd) { + this.attempts.delete(key); + cleanedAttempts++; + } + } + + // 清理过期的封锁记录 + for (const [key, info] of this.blockedKeys.entries()) { + if (now > info.expiresAt) { + this.blockedKeys.delete(key); + cleanedBlocks++; + } + } + + if (cleanedAttempts > 0 || cleanedBlocks > 0) { + console.log(`[防爆破清理] 已清理 ${cleanedAttempts} 个过期尝试记录, ${cleanedBlocks} 个过期封锁记录`); + } + } + + // 获取统计信息 + getStats() { + return { + activeAttempts: this.attempts.size, + blockedKeys: this.blockedKeys.size + }; + } + + // 停止清理定时器 + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + } +} + +// 创建登录限流器(5次失败/15分钟,封锁30分钟) +const loginLimiter = new RateLimiter({ + maxAttempts: 5, + windowMs: 15 * 60 * 1000, + blockDuration: 30 * 60 * 1000 +}); + +// 创建分享密码限流器(10次失败/10分钟,封锁20分钟) +const shareLimiter = new RateLimiter({ + maxAttempts: 10, + windowMs: 10 * 60 * 1000, + blockDuration: 20 * 60 * 1000 +}); + +// 邮件发送限流(防刷) +// 半小时最多3次,超过封30分钟;全天最多10次,超过封24小时 +const mailLimiter30Min = new RateLimiter({ + maxAttempts: 3, + windowMs: 30 * 60 * 1000, + blockDuration: 30 * 60 * 1000 +}); +const mailLimiterDay = new RateLimiter({ + maxAttempts: 10, + windowMs: 24 * 60 * 60 * 1000, + blockDuration: 24 * 60 * 60 * 1000 +}); + +// 创建验证码获取限流器(30次请求/10分钟,封锁30分钟) +const captchaLimiter = new RateLimiter({ + maxAttempts: 30, + windowMs: 10 * 60 * 1000, + blockDuration: 30 * 60 * 1000 +}); + +// 创建API密钥验证限流器(防止暴力枚举API密钥,5次失败/小时,封锁24小时) +const apiKeyLimiter = new RateLimiter({ + maxAttempts: 5, + windowMs: 60 * 60 * 1000, // 1小时窗口 + blockDuration: 24 * 60 * 60 * 1000 // 封锁24小时 +}); + +// 创建文件上传限流器(每用户每小时最多100次上传) +const uploadLimiter = new RateLimiter({ + maxAttempts: 100, + windowMs: 60 * 60 * 1000, + blockDuration: 60 * 60 * 1000 +}); + +// 创建文件列表查询限流器(每用户每分钟最多60次) +const fileListLimiter = new RateLimiter({ + maxAttempts: 60, + windowMs: 60 * 1000, + blockDuration: 5 * 60 * 1000 +}); + +// 验证码最小请求间隔控制 +const CAPTCHA_MIN_INTERVAL = 1000; // 1秒 +const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理 + +// 验证码防刷中间件 +function captchaRateLimitMiddleware(req, res, next) { + const clientKey = `captcha:${captchaLimiter.getClientKey(req)}`; + const now = Date.now(); + + // 最小时间间隔限制 + const lastRequest = captchaLastRequest.get(clientKey); + if (lastRequest && (now - lastRequest) < CAPTCHA_MIN_INTERVAL) { + return res.status(429).json({ + success: false, + message: '验证码请求过于频繁,请稍后再试' + }); + } + captchaLastRequest.set(clientKey, now, 15 * 60 * 1000); + + // 窗口内总次数限流 + const result = captchaLimiter.recordFailure(clientKey); + if (result.blocked) { + return res.status(429).json({ + success: false, + message: `验证码请求过多,请在 ${result.waitMinutes} 分钟后再试`, + blocked: true, + resetTime: result.resetTime + }); + } + + next(); +} + +// 登录防爆破中间件 +function loginRateLimitMiddleware(req, res, next) { + const clientIP = loginLimiter.getClientKey(req); + const { username } = req.body; + const ipKey = `login:ip:${clientIP}`; + + // 检查IP是否被封锁 + if (loginLimiter.isBlocked(ipKey)) { + const result = loginLimiter.recordFailure(ipKey); + console.warn(`[防爆破] 拦截登录尝试 - IP: ${clientIP}, 原因: IP被封锁`); + return res.status(429).json({ + success: false, + message: `登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`, + blocked: true, + resetTime: result.resetTime + }); + } + + // 检查用户名是否被封锁 + if (username) { + const usernameKey = `login:username:${username}`; + if (loginLimiter.isBlocked(usernameKey)) { + const result = loginLimiter.recordFailure(usernameKey); + console.warn(`[防爆破] 拦截登录尝试 - 用户名: ${username}, 原因: 用户名被封锁`); + return res.status(429).json({ + success: false, + message: `该账号登录尝试过多,请在 ${result.waitMinutes} 分钟后重试`, + blocked: true, + resetTime: result.resetTime + }); + } + } + + // 将限流key附加到请求对象,供后续使用 + req.rateLimitKeys = { + ipKey, + usernameKey: username ? `login:username:${username}` : null + }; + next(); +} + +// 分享密码防爆破中间件 +function shareRateLimitMiddleware(req, res, next) { + const clientIP = shareLimiter.getClientKey(req); + const { code } = req.params; + const key = `share:${code}:${clientIP}`; + + // 检查是否被封锁 + if (shareLimiter.isBlocked(key)) { + const result = shareLimiter.recordFailure(key); + console.warn(`[防爆破] 拦截分享密码尝试 - 分享码: ${code}, IP: ${clientIP}`); + return res.status(429).json({ + success: false, + message: `密码尝试过多,请在 ${result.waitMinutes} 分钟后重试`, + blocked: true, + resetTime: result.resetTime + }); + } + + req.shareRateLimitKey = key; + next(); +} + + + +// ===== 工具函数 ===== + +/** + * 安全的错误响应处理 + * 在生产环境中隐藏敏感的错误详情,仅在开发环境显示详细信息 + * @param {Error} error - 原始错误对象 + * @param {string} userMessage - 给用户显示的友好消息 + * @param {string} logContext - 日志上下文标识 + * @returns {string} 返回给客户端的错误消息 + */ +function getSafeErrorMessage(error, userMessage, logContext = '') { + // 记录完整错误日志 + if (logContext) { + console.error(`[${logContext}]`, error); + } else { + console.error(error); + } + + // 生产环境返回通用消息,开发环境返回详细信息 + if (process.env.NODE_ENV === 'production') { + return userMessage; + } + // 开发环境下,返回详细错误信息便于调试 + return `${userMessage}: ${error.message}`; +} + +// 安全删除文件(不抛出异常) +function safeDeleteFile(filePath) { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[清理] 已删除临时文件: ${filePath}`); + return true; + } + } catch (error) { + console.error(`[清理] 删除临时文件失败: ${filePath}`, error.message); + return false; + } +} + + +// 验证请求路径是否在分享范围内(防止越权访问) +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); + } +} +// 清理旧的临时文件(启动时执行一次) +function cleanupOldTempFiles() { + const uploadsDir = path.join(__dirname, 'uploads'); + if (!fs.existsSync(uploadsDir)) { + return; + } + + try { + const files = fs.readdirSync(uploadsDir); + const now = Date.now(); + const maxAge = 24 * 60 * 60 * 1000; // 24小时 + + let cleaned = 0; + files.forEach(file => { + const filePath = path.join(uploadsDir, file); + try { + const stats = fs.statSync(filePath); + if (now - stats.mtimeMs > maxAge) { + fs.unlinkSync(filePath); + cleaned++; + } + } catch (err) { + console.error(`[清理] 检查文件失败: ${filePath}`, err.message); + } + }); + + if (cleaned > 0) { + console.log(`[清理] 已清理 ${cleaned} 个超过24小时的临时文件`); + } + } catch (error) { + console.error('[清理] 清理临时文件目录失败:', error.message); + } +} + +// formatFileSize 已在文件顶部导入 + +// 生成随机Token(crypto 已在文件顶部导入) +function generateRandomToken(length = 48) { + return crypto.randomBytes(length).toString('hex'); +} + +// 获取SMTP配置 +function getSmtpConfig() { + const host = SettingsDB.get('smtp_host'); + const port = SettingsDB.get('smtp_port'); + const secure = SettingsDB.get('smtp_secure'); + const user = SettingsDB.get('smtp_user'); + const pass = SettingsDB.get('smtp_password'); + const from = SettingsDB.get('smtp_from') || user; + + if (!host || !port || !user || !pass) { + return null; + } + + return { + host, + port: parseInt(port, 10) || 465, + secure: secure === 'true' || secure === true || port === '465', + auth: { user, pass }, + from + }; +} + +// 创建邮件传输器 +function createTransport() { + const config = getSmtpConfig(); + if (!config) return null; + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.auth + }); +} + +// 发送邮件 +async function sendMail(to, subject, html) { + const config = getSmtpConfig(); + const transporter = createTransport(); + if (!config || !transporter) { + throw new Error('SMTP未配置'); + } + + const from = (config.from && config.from.trim()) ? config.from.trim() : config.auth.user; + + await transporter.sendMail({ + from, + to, + subject, + html + }); +} + +// 检查邮件发送限流 +function checkMailRateLimit(req, type = 'mail') { + // 使用 req.ip,基于 trust proxy 配置获取可信的客户端 IP + const clientKey = `${type}:${req.ip || req.socket?.remoteAddress || 'unknown'}`; + + const res30 = mailLimiter30Min.recordFailure(clientKey); + if (res30.blocked) { + const err = new Error(`请求过于频繁,30分钟内最多3次,请在 ${res30.waitMinutes} 分钟后再试`); + err.status = 429; + throw err; + } + + const resDay = mailLimiterDay.recordFailure(clientKey); + if (resDay.blocked) { + const err = new Error(`今天的次数已用完(最多10次),请稍后再试`); + err.status = 429; + throw err; + } +} + +// ===== 验证码验证辅助函数 ===== + +/** + * 验证验证码 + * @param {Object} req - 请求对象 + * @param {string} captcha - 用户输入的验证码 + * @returns {{valid: boolean, message?: string}} 验证结果 + */ +function verifyCaptcha(req, captcha) { + if (!captcha) { + return { valid: false, message: '请输入验证码' }; + } + + const sessionCaptcha = req.session.captcha; + const captchaTime = req.session.captchaTime; + + // 调试日志 + console.log('[验证码验证] SessionID:', req.sessionID, '输入:', captcha?.toLowerCase(), 'Session中:', sessionCaptcha); + + if (!sessionCaptcha || !captchaTime) { + console.log('[验证码验证] 失败: session中无验证码'); + return { valid: false, message: '验证码已过期,请刷新验证码' }; + } + + // 验证码有效期5分钟 + if (Date.now() - captchaTime > 5 * 60 * 1000) { + console.log('[验证码验证] 失败: 验证码已超时'); + return { valid: false, message: '验证码已过期,请刷新验证码' }; + } + + if (captcha.toLowerCase() !== sessionCaptcha) { + console.log('[验证码验证] 失败: 验证码不匹配'); + return { valid: false, message: '验证码错误' }; + } + + console.log('[验证码验证] 成功'); + // 验证通过后清除session中的验证码 + delete req.session.captcha; + delete req.session.captchaTime; + + return { valid: true }; +} + +// ===== 公开API ===== + +// 健康检查 +app.get('/api/health', (req, res) => { + res.json({ success: true, message: 'Server is running' }); +}); + +// 获取公开的系统配置(不需要登录) +app.get('/api/config', (req, res) => { + const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); + res.json({ + success: true, + config: { + max_upload_size: maxUploadSize + } + }); +}); + +// 生成验证码API +app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { + try { + const captcha = svgCaptcha.create({ + size: 6, // 验证码长度 + noise: 3, // 干扰线条数 + color: true, // 使用彩色 + background: '#f7f7f7', // 背景色 + width: 140, + height: 44, + fontSize: 52, + charPreset: 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 去掉易混淆字符,字母+数字 + }); + + // 将验证码文本存储在session中 + req.session.captcha = captcha.text.toLowerCase(); + req.session.captchaTime = Date.now(); + + // 保存session后再返回响应(修复:确保session保存成功) + req.session.save((err) => { + if (err) { + console.error('[验证码] Session保存失败:', err); + return res.status(500).json({ + success: false, + message: '验证码生成失败' + }); + } + console.log('[验证码] 生成成功, SessionID:', req.sessionID); + res.type('svg'); + res.send(captcha.data); + }); + } catch (error) { + console.error('生成验证码失败:', error); + res.status(500).json({ + success: false, + message: '生成验证码失败' + }); + } +}); + +// 获取 CSRF Token(用于前端初始化) +app.get('/api/csrf-token', (req, res) => { + let csrfToken = req.cookies[CSRF_COOKIE_NAME]; + + // 如果没有 token,生成一个新的 + if (!csrfToken) { + csrfToken = generateCsrfToken(); + const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + res.cookie(CSRF_COOKIE_NAME, csrfToken, { + httpOnly: false, + secure: isSecureEnv, + sameSite: isSecureEnv ? 'strict' : 'lax', + maxAge: 24 * 60 * 60 * 1000 + }); + } + + res.json({ + success: true, + csrfToken: csrfToken + }); +}); + +// 密码强度验证函数 +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 }; +} + +// 用户注册(简化版) +app.post('/api/register', + [ + body('username') + .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), + body('email').isEmail().withMessage('邮箱格式不正确'), + body('password') + .isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符') + .custom((value) => { + const result = validatePasswordStrength(value); + if (!result.valid) { + throw new Error(result.message); + } + return true; + }), + body('captcha').notEmpty().withMessage('请输入验证码') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + // 验证验证码 + const { captcha } = req.body; + const captchaResult = verifyCaptcha(req, captcha); + if (!captchaResult.valid) { + return res.status(400).json({ + success: false, + message: captchaResult.message + }); + } + + checkMailRateLimit(req, 'verify'); + const { username, email, password } = req.body; + + // 检查用户名是否存在 + if (UserDB.findByUsername(username)) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + + // 检查邮箱是否存在 + if (UserDB.findByEmail(email)) { + return res.status(400).json({ + success: false, + message: '邮箱已被使用' + }); + } + + // 检查SMTP配置 + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ + success: false, + message: '管理员尚未配置SMTP,暂时无法注册' + }); + } + + const verifyToken = generateRandomToken(24); + const expiresAtMs = Date.now() + 30 * 60 * 1000; // 30分钟 + const safeUsernameForMail = escapeHtml(username); + + // 创建用户(不需要FTP配置),标记未验证 + const userId = UserDB.create({ + username, + email, + password, + is_verified: 0, + verification_token: verifyToken, + verification_expires_at: expiresAtMs + }); + + const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; + + try { + await sendMail( + email, + '邮箱验证 - 玩玩云', + `

您好,${safeUsernameForMail}:

+

请点击下面的链接验证您的邮箱,30分钟内有效:

+

${verifyLink}

+

如果不是您本人操作,请忽略此邮件。

` + ); + } catch (mailErr) { + console.error('发送验证邮件失败:', mailErr); + return res.status(500).json({ + success: false, + message: '注册成功,但发送验证邮件失败,请稍后重试或联系管理员', + needVerify: true + }); + } + + // 记录注册日志 + logAuth(req, 'register', `新用户注册: ${username}`, { userId, email }); + + res.json({ + success: true, + message: '注册成功,请查收邮箱完成验证', + user_id: userId + }); + } catch (error) { + console.error('注册失败:', error); + logAuth(req, 'register_failed', `用户注册失败: ${req.body.username || 'unknown'}`, { error: error.message }, 'error'); + // 安全修复:不向客户端泄露具体错误信息 + const safeMessage = error.message?.includes('UNIQUE constraint') + ? '用户名或邮箱已被注册' + : '注册失败,请稍后重试'; + res.status(500).json({ + success: false, + message: safeMessage + }); + } + } +); + +// 重新发送邮箱验证邮件 +app.post('/api/resend-verification', [ + body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'), + body('username') + .optional({ checkFalsy: true }) + .isLength({ min: 3 }).withMessage('用户名格式不正确') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), + body('captcha').notEmpty().withMessage('请输入验证码') +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + try { + // 验证验证码 + const { captcha } = req.body; + const captchaResult = verifyCaptcha(req, captcha); + if (!captchaResult.valid) { + return res.status(400).json({ + success: false, + message: captchaResult.message + }); + } + + checkMailRateLimit(req, 'verify'); + + const { email, username } = req.body; + const user = email ? UserDB.findByEmail(email) : UserDB.findByUsername(username); + + if (!user) { + return res.status(400).json({ success: false, message: '用户不存在' }); + } + if (user.is_verified) { + return res.status(400).json({ success: false, message: '该邮箱已验证,无需重复验证' }); + } + + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); + } + + const verifyToken = generateRandomToken(24); + const expiresAtMs = Date.now() + 30 * 60 * 1000; + VerificationDB.setVerification(user.id, verifyToken, expiresAtMs); + + const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; + const safeUsernameForMail = escapeHtml(user.username); + await sendMail( + user.email, + '邮箱验证 - 玩玩云', + `

您好,${safeUsernameForMail}:

+

请点击下面的链接验证您的邮箱,30分钟内有效:

+

${verifyLink}

+

如果不是您本人操作,请忽略此邮件。

` + ); + + res.json({ success: true, message: '验证邮件已发送,请查收' }); + } catch (error) { + const status = error.status || 500; + console.error('重发验证邮件失败:', error); + res.status(status).json({ success: false, message: error.message || '发送失败' }); + } +}); + +// 验证邮箱 +app.get('/api/verify-email', async (req, res) => { + const { token } = req.query; + + // 参数验证:token 不能为空且长度合理(48字符的hex字符串) + if (!token || typeof token !== 'string') { + return res.status(400).json({ success: false, message: '缺少token' }); + } + + // token 格式验证:应该是 hex 字符串,长度合理 + if (!/^[a-f0-9]{32,96}$/i.test(token)) { + return res.status(400).json({ success: false, message: '无效的token格式' }); + } + + try { + const user = VerificationDB.consumeVerificationToken(token); + if (!user) { + return res.status(400).json({ success: false, message: '无效或已过期的验证链接' }); + } + + // 记录验证成功日志 + logAuth(req, 'email_verified', `邮箱验证成功: ${user.email || user.username}`, { userId: user.id }); + + res.json({ success: true, message: '邮箱验证成功,请登录' }); + } catch (error) { + console.error('邮箱验证失败:', error); + res.status(500).json({ success: false, message: '邮箱验证失败' }); + } +}); + +// 发起密码重置(邮件) +app.post('/api/password/forgot', [ + body('email').isEmail().withMessage('邮箱格式不正确'), + body('captcha').notEmpty().withMessage('请输入验证码') +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + const { email, captcha } = req.body; + try { + // 验证验证码 + const captchaResult = verifyCaptcha(req, captcha); + if (!captchaResult.valid) { + return res.status(400).json({ + success: false, + message: captchaResult.message + }); + } + + checkMailRateLimit(req, 'pwd_forgot'); + + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); + } + + // 安全修复:无论邮箱是否存在,都返回相同的成功消息(防止邮箱枚举) + const user = UserDB.findByEmail(email); + + // 只有当用户存在、已验证、未封禁时才发送邮件 + if (user && user.is_verified && user.is_active && !user.is_banned) { + const token = generateRandomToken(24); + const expiresAtMs = Date.now() + 30 * 60 * 1000; + PasswordResetTokenDB.create(user.id, token, expiresAtMs); + + const resetLink = `${getSecureBaseUrl(req)}/app.html?resetToken=${token}`; + const safeUsernameForMail = escapeHtml(user.username); + + // 异步发送邮件,不等待结果(避免通过响应时间判断邮箱是否存在) + sendMail( + email, + '密码重置 - 玩玩云', + `

您好,${safeUsernameForMail}:

+

请点击下面的链接重置密码,30分钟内有效:

+

${resetLink}

+

如果不是您本人操作,请忽略此邮件。

` + ).catch(err => { + console.error('发送密码重置邮件失败:', err.message); + }); + } else { + // 记录但不暴露邮箱是否存在 + console.log('[密码重置] 邮箱不存在或账号不可用:', email); + } + + // 无论邮箱是否存在,都返回相同的成功消息 + res.json({ success: true, message: '如果该邮箱已注册,您将收到密码重置链接' }); + } catch (error) { + const status = error.status || 500; + console.error('密码重置请求失败:', error); + res.status(status).json({ success: false, message: error.message || '发送失败' }); + } +}); + +// 使用邮件Token重置密码 +app.post('/api/password/reset', [ + body('token').notEmpty().withMessage('缺少token'), + body('new_password') + .isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符') + .custom((value) => { + const result = validatePasswordStrength(value); + if (!result.valid) { + throw new Error(result.message); + } + return true; + }) +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + const { token, new_password } = req.body; + try { + const tokenRow = PasswordResetTokenDB.use(token); + if (!tokenRow) { + return res.status(400).json({ success: false, message: '无效或已过期的重置链接' }); + } + + const user = UserDB.findById(tokenRow.user_id); + if (!user) { + return res.status(404).json({ success: false, message: '用户不存在' }); + } + if (user.is_banned || !user.is_active) { + return res.status(403).json({ success: false, message: '账号不可用,无法重置密码' }); + } + if (!user.is_verified) { + return res.status(400).json({ success: false, message: '邮箱未验证,无法重置密码' }); + } + + // 更新密码 + const hashed = require('bcryptjs').hashSync(new_password, 10); + db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(hashed, tokenRow.user_id); + + res.json({ success: true, message: '密码重置成功,请重新登录' }); + } catch (error) { + console.error('密码重置失败:', error); + res.status(500).json({ success: false, message: '密码重置失败' }); + } +}); + +// 用户登录 +app.post('/api/login', + loginRateLimitMiddleware, + [ + body('username').notEmpty().withMessage('用户名不能为空'), + body('password').notEmpty().withMessage('密码不能为空') + ], + (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + const { username, password, captcha } = req.body; + + try { + // 检查是否需要验证码 + const ipKey = req.rateLimitKeys?.ipKey; + const usernameKey = req.rateLimitKeys?.usernameKey; + const ipFailures = ipKey ? loginLimiter.getFailureCount(ipKey) : 0; + const usernameFailures = usernameKey ? loginLimiter.getFailureCount(usernameKey) : 0; + const needCaptcha = ipFailures >= 2 || usernameFailures >= 2; + + // 如果需要验证码,则验证验证码 + if (needCaptcha) { + console.log('[登录验证] 需要验证码, IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures); + + if (!captcha) { + return res.status(400).json({ + success: false, + message: '请输入验证码', + needCaptcha: true + }); + } + + // 验证验证码 + const sessionCaptcha = req.session.captcha; + const captchaTime = req.session.captchaTime; + + // 安全:不记录验证码明文 + console.log('[登录验证] 正在验证验证码...'); + + if (!sessionCaptcha || !captchaTime) { + console.log('[登录验证] 验证码不存在于Session中'); + return res.status(400).json({ + success: false, + message: '验证码已过期,请刷新验证码', + needCaptcha: true + }); + } + + // 验证码有效期5分钟 + if (Date.now() - captchaTime > 5 * 60 * 1000) { + console.log('[登录验证] 验证码已超过5分钟'); + return res.status(400).json({ + success: false, + message: '验证码已过期,请刷新验证码', + needCaptcha: true + }); + } + + if (captcha.toLowerCase() !== sessionCaptcha) { + console.log('[登录验证] 验证码不匹配'); + return res.status(400).json({ + success: false, + message: '验证码错误', + needCaptcha: true + }); + } + + console.log('[登录验证] 验证码验证通过'); + // 验证通过后清除session中的验证码 + delete req.session.captcha; + delete req.session.captchaTime; + } + + const user = UserDB.findByUsername(username); + + if (!user) { + // 记录失败尝试 + if (req.rateLimitKeys) { + const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); + if (req.rateLimitKeys.usernameKey) { + loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); + } + return res.status(401).json({ + success: false, + message: '用户名或密码错误', + needCaptcha: result.needCaptcha + }); + } + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + if (user.is_banned) { + return res.status(403).json({ + success: false, + message: '账号已被封禁' + }); + } + + if (!user.is_verified) { + return res.status(403).json({ + success: false, + message: '邮箱未验证,请查收邮件或重新发送验证邮件', + needVerify: true, + email: user.email + }); + } + + if (!UserDB.verifyPassword(password, user.password)) { + // 记录登录失败安全日志 + logSecurity(req, 'login_failed', `登录失败(密码错误): ${username}`, { userId: user.id }); + + // 记录失败尝试 + if (req.rateLimitKeys) { + const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); + if (req.rateLimitKeys.usernameKey) { + loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); + } + return res.status(401).json({ + success: false, + message: '用户名或密码错误', + needCaptcha: result.needCaptcha + }); + } + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + const token = generateToken(user); + const refreshToken = generateRefreshToken(user); + + // 清除失败记录 + if (req.rateLimitKeys) { + loginLimiter.recordSuccess(req.rateLimitKeys.ipKey); + if (req.rateLimitKeys.usernameKey) { + loginLimiter.recordSuccess(req.rateLimitKeys.usernameKey); + } + } + + // 增强Cookie安全设置 + const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + const cookieOptions = { + httpOnly: true, + secure: isSecureEnv, + sameSite: isSecureEnv ? 'strict' : 'lax', + path: '/' + }; + + // 设置 access token Cookie(2小时有效) + res.cookie('token', token, { + ...cookieOptions, + maxAge: 2 * 60 * 60 * 1000 + }); + + // 设置 refresh token Cookie(7天有效) + res.cookie('refreshToken', refreshToken, { + ...cookieOptions, + maxAge: 7 * 24 * 60 * 60 * 1000 + }); + + // 记录登录成功日志 + logAuth(req, 'login', `用户登录成功: ${user.username}`, { userId: user.id, isAdmin: user.is_admin }); + + res.json({ + success: true, + message: '登录成功', + expiresIn: 2 * 60 * 60 * 1000, // 告知前端access token有效期(毫秒) + user: { + id: user.id, + username: user.username, + email: user.email, + is_admin: user.is_admin, + has_oss_config: user.has_oss_config, + // 存储相关字段 + 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, + // OSS配置来源(重要:用于前端判断是否使用OSS直连上传) + oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none') + } + }); + } catch (error) { + console.error('登录失败:', error); + logAuth(req, 'login_error', `登录异常: ${req.body.username || 'unknown'}`, { error: error.message }, 'error'); + // 安全修复:不向客户端泄露具体错误信息 + res.status(500).json({ + success: false, + message: '登录失败,请稍后重试' + }); + } + } +); + +// 刷新Access Token(从 HttpOnly Cookie 读取 refreshToken) +app.post('/api/refresh-token', (req, res) => { + // 优先从 Cookie 读取,兼容从请求体读取(向后兼容) + const refreshToken = req.cookies?.refreshToken || req.body?.refreshToken; + + if (!refreshToken) { + return res.status(400).json({ + success: false, + message: '缺少刷新令牌' + }); + } + + const result = refreshAccessToken(refreshToken); + + if (!result.success) { + return res.status(401).json({ + success: false, + message: result.message + }); + } + + // 更新Cookie中的token + const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + res.cookie('token', result.token, { + httpOnly: true, + secure: isSecureEnv, + sameSite: isSecureEnv ? 'strict' : 'lax', + maxAge: 2 * 60 * 60 * 1000, + path: '/' + }); + + res.json({ + success: true, + expiresIn: 2 * 60 * 60 * 1000 + }); +}); + +// 登出(清除Cookie) +app.post('/api/logout', (req, res) => { + // 清除所有认证Cookie + res.clearCookie('token', { path: '/' }); + res.clearCookie('refreshToken', { path: '/' }); + res.json({ success: true, message: '已登出' }); +}); + +// ===== 需要认证的API ===== + +// 获取当前用户信息 +app.get('/api/user/profile', authMiddleware, (req, res) => { + // 不返回敏感信息(密码和 OSS 密钥) + const { password, oss_access_key_secret, ...safeUser } = req.user; + + // 检查是否使用统一 OSS 配置 + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + + res.json({ + success: true, + user: { + ...safeUser, + // 添加配置来源信息 + oss_config_source: hasUnifiedConfig ? 'unified' : (safeUser.has_oss_config ? 'personal' : 'none') + } + }); +}); + +// 获取用户主题偏好(包含全局默认主题) +app.get('/api/user/theme', authMiddleware, (req, res) => { + try { + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + const userTheme = req.user.theme_preference; // null表示跟随全局 + + res.json({ + success: true, + theme: { + global: globalTheme, + user: userTheme, + effective: userTheme || globalTheme // 用户设置优先,否则使用全局 + } + }); + } catch (error) { + res.status(500).json({ success: false, message: '获取主题失败' }); + } +}); + +// 设置用户主题偏好 +app.post('/api/user/theme', authMiddleware, (req, res) => { + try { + const { theme } = req.body; + const validThemes = ['dark', 'light', null]; // null表示跟随全局 + + if (!validThemes.includes(theme)) { + return res.status(400).json({ + success: false, + message: '无效的主题设置,可选: dark, light, null(跟随全局)' + }); + } + + UserDB.update(req.user.id, { theme_preference: theme }); + + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + res.json({ + success: true, + message: '主题偏好已更新', + theme: { + global: globalTheme, + user: theme, + effective: theme || globalTheme + } + }); + } catch (error) { + console.error('更新主题失败:', error); + res.status(500).json({ success: false, message: '更新主题失败' }); + } +}); + +// 更新OSS配置 +app.post('/api/user/update-oss', + authMiddleware, + [ + body('oss_provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('oss_region').notEmpty().withMessage('地域不能为空'), + body('oss_access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('oss_access_key_secret').notEmpty().withMessage('Access Key Secret不能为空'), + body('oss_bucket').notEmpty().withMessage('存储桶名称不能为空'), + body('oss_endpoint').optional({ checkFalsy: true }).isURL().withMessage('Endpoint必须是有效的URL') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + // 检查是否已配置系统级统一 OSS + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (hasUnifiedConfig && !req.user.is_admin) { + return res.status(403).json({ + success: false, + message: '系统已配置统一 OSS,普通用户无法配置个人 OSS。如需修改,请联系管理员' + }); + } + + const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body; + + // 如果用户已配置OSS且密钥为空,使用现有密钥 + let actualSecret = oss_access_key_secret; + if (!oss_access_key_secret && req.user.has_oss_config && req.user.oss_access_key_secret) { + actualSecret = req.user.oss_access_key_secret; + } else if (!oss_access_key_secret) { + return res.status(400).json({ + success: false, + message: 'Access Key Secret不能为空' + }); + } + + // 验证OSS连接 + try { + // OssStorageClient 已在文件顶部导入 + const testUser = { + id: req.user.id, + has_oss_config: 1, // 标记为已配置,允许使用个人配置 + oss_provider, + oss_region, + oss_access_key_id, + oss_access_key_secret: actualSecret, + oss_bucket, + oss_endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容(验证配置是否正确) + await ossClient.list('/'); + await ossClient.end(); + } catch (error) { + return res.status(400).json({ + success: false, + message: 'OSS连接失败,请检查配置: ' + error.message + }); + } + + // 安全修复:加密存储 OSS Access Key Secret + let encryptedSecret; + try { + encryptedSecret = encryptSecret(actualSecret); + } catch (error) { + console.error('[安全] 加密 OSS 密钥失败:', error); + return res.status(500).json({ + success: false, + message: '加密配置失败' + }); + } + + // 更新用户配置(存储加密后的密钥) + UserDB.update(req.user.id, { + oss_provider, + oss_region, + oss_access_key_id, + oss_access_key_secret: encryptedSecret, + oss_bucket, + oss_endpoint: oss_endpoint || null, + has_oss_config: 1 + }); + + res.json({ + success: true, + message: 'OSS配置已更新' + }); + } catch (error) { + console.error('更新OSS配置失败:', error); + res.status(500).json({ + success: false, + message: '更新配置失败: ' + error.message + }); + } + } +); + +// 测试 OSS 连接(不保存配置,仅验证) +app.post('/api/user/test-oss', + authMiddleware, + [ + body('oss_provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('oss_region').notEmpty().withMessage('地域不能为空'), + body('oss_access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('oss_bucket').notEmpty().withMessage('存储桶名称不能为空') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint } = req.body; + + // 如果密钥为空且用户已配置OSS,使用现有密钥 + let actualSecret = oss_access_key_secret; + if (!oss_access_key_secret && req.user.has_oss_config && req.user.oss_access_key_secret) { + actualSecret = req.user.oss_access_key_secret; + } else if (!oss_access_key_secret) { + return res.status(400).json({ + success: false, + message: 'Access Key Secret不能为空' + }); + } + + // 验证 OSS 连接 + // OssStorageClient 已在文件顶部导入 + const testUser = { + id: req.user.id, + has_oss_config: 1, // 标记为已配置,允许使用个人配置 + oss_provider, + oss_region, + oss_access_key_id, + oss_access_key_secret: actualSecret, + oss_bucket, + oss_endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容(验证配置是否正确) + await ossClient.list('/'); + await ossClient.end(); + + res.json({ + success: true, + message: 'OSS 连接测试成功' + }); + } catch (error) { + console.error('[OSS测试] 连接失败:', error); + res.status(400).json({ + success: false, + message: 'OSS 连接失败: ' + error.message + }); + } + } +); + +// 获取 OSS 存储空间使用情况(带缓存) +// ===== P0 性能优化:优先使用数据库缓存,避免全量统计 ===== +app.get('/api/user/oss-usage', authMiddleware, async (req, res) => { + try { + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置OSS服务' + }); + } + + // ===== P0 优化:优先使用数据库缓存 ===== + // 从数据库 storage_used 字段读取(上传/删除时增量更新) + const user = UserDB.findById(req.user.id); + const storageUsed = user.storage_used || 0; + + return res.json({ + success: true, + usage: { + totalSize: storageUsed, + totalSizeFormatted: formatFileSize(storageUsed), + fileCount: null, // 缓存模式不提供文件数 + cached: true + }, + cached: true + }); + + } catch (error) { + console.error('[OSS统计] 获取失败:', error); + res.status(500).json({ + success: false, + message: '获取存储使用情况失败: ' + error.message + }); + } +}); + +// 获取 OSS 存储空间详细统计(全量统计,仅限管理员或需要精确统计时使用) +// ===== P0 性能优化:此接口较慢,建议只在必要时调用 ===== +app.get('/api/user/oss-usage-full', authMiddleware, async (req, res) => { + try { + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置OSS服务' + }); + } + + // 先检查内存缓存(5分钟 TTL) + const cached = getOssUsageCache(req.user.id); + if (cached) { + return res.json({ + success: true, + usage: cached, + cached: true + }); + } + + // 执行全量统计(较慢,仅在缓存未命中时执行) + const ossClient = new OssStorageClient(req.user); + await ossClient.connect(); + + let totalSize = 0; + let fileCount = 0; + let continuationToken = null; + + do { + const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); + const command = new ListObjectsV2Command({ + Bucket: req.user.oss_bucket, + Prefix: `user_${req.user.id}/`, + 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); + + await ossClient.end(); + + const usageData = { + totalSize, + totalSizeFormatted: formatFileSize(totalSize), + fileCount, + dirCount: 0 // OSS 没有目录概念 + }; + + // 存入内存缓存 + setOssUsageCache(req.user.id, usageData); + + res.json({ + success: true, + usage: usageData, + cached: false + }); + + } catch (error) { + console.error('[OSS统计] 全量统计失败:', error); + res.status(500).json({ + success: false, + message: '获取OSS空间使用情况失败: ' + error.message + }); + } +}); + +// 修改管理员账号信息(仅管理员可修改用户名) +app.post('/api/admin/update-profile', + authMiddleware, + adminMiddleware, + [ + body('username') + .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { username } = req.body; + + // 检查用户名是否被占用(排除自己) + if (username !== req.user.username) { + const existingUser = UserDB.findByUsername(username); + if (existingUser && existingUser.id !== req.user.id) { + return res.status(400).json({ + success: false, + message: '用户名已被使用' + }); + } + + // 更新用户名 + UserDB.update(req.user.id, { username }); + + // 获取更新后的用户信息 + const updatedUser = UserDB.findById(req.user.id); + + // 生成新的token(因为用户名变了) + const newToken = generateToken(updatedUser); + + res.json({ + success: true, + message: '用户名已更新', + token: newToken, + user: { + id: updatedUser.id, + username: updatedUser.username, + email: updatedUser.email, + is_admin: updatedUser.is_admin + } + }); + } else { + res.json({ + success: true, + message: '没有需要更新的信息' + }); + } + } catch (error) { + console.error('更新账号信息失败:', error); + // 安全修复:不向客户端泄露具体错误信息 + res.status(500).json({ + success: false, + message: '更新失败,请稍后重试' + }); + } + } +); + +// 修改当前用户密码(需要验证当前密码) +app.post('/api/user/change-password', + authMiddleware, + [ + body('current_password').notEmpty().withMessage('当前密码不能为空'), + body('new_password') + .isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符') + .custom((value) => { + const result = validatePasswordStrength(value); + if (!result.valid) { + throw new Error(result.message); + } + return true; + }) + ], + (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { current_password, new_password } = req.body; + + // 获取当前用户信息 + const user = UserDB.findById(req.user.id); + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 验证当前密码 + if (!UserDB.verifyPassword(current_password, user.password)) { + return res.status(401).json({ + success: false, + message: '当前密码错误' + }); + } + + // 更新密码 + UserDB.update(req.user.id, { password: new_password }); + + res.json({ + success: true, + message: '密码修改成功' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '修改密码失败,请稍后重试', '修改密码失败') + }); + } + } +); + +// 修改当前用户名 +app.post('/api/user/update-username', + authMiddleware, + [ + body('username') + .isLength({ min: 3 }).withMessage('用户名至少3个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') + ], + (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { username } = req.body; + + // 检查用户名是否已存在 + const existingUser = UserDB.findByUsername(username); + if (existingUser && existingUser.id !== req.user.id) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + + // 更新用户名 + UserDB.update(req.user.id, { username }); + + res.json({ + success: true, + message: '用户名修改成功' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '修改用户名失败,请稍后重试', '修改用户名失败') + }); + } + } +); + +// 切换存储方式 +app.post('/api/user/switch-storage', + authMiddleware, + [ + body('storage_type').isIn(['local', 'oss']).withMessage('无效的存储类型') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { storage_type } = req.body; + + // 检查权限 + if (req.user.storage_permission === 'local_only' && storage_type !== 'local') { + return res.status(403).json({ + success: false, + message: '您只能使用本地存储' + }); + } + + if (req.user.storage_permission === 'oss_only' && storage_type !== 'oss') { + return res.status(403).json({ + success: false, + message: '您只能使用OSS存储' + }); + } + + // 检查OSS配置(包括个人配置和系统级统一配置) + if (storage_type === 'oss') { + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: 'OSS服务未配置,请联系管理员配置系统级OSS服务' + }); + } + } + + // 更新存储类型 + UserDB.update(req.user.id, { current_storage_type: storage_type }); + + res.json({ + success: true, + message: '存储方式已切换', + storage_type + }); + } catch (error) { + console.error('切换存储失败:', error); + res.status(500).json({ + success: false, + message: '切换存储失败: ' + error.message + }); + } + } +); + +// 获取文件列表(添加速率限制) +app.get('/api/files', authMiddleware, async (req, res) => { + // 速率限制检查 + const rateLimitKey = `file_list:${req.user.id}`; + const rateLimitResult = fileListLimiter.recordFailure(rateLimitKey); + if (rateLimitResult.blocked) { + return res.status(429).json({ + success: false, + message: `请求过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` + }); + } + + const rawPath = req.query.path || '/'; + + // 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径 + if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) { + return res.status(400).json({ + success: false, + message: '路径包含非法字符' + }); + } + + // 规范化路径 + const dirPath = path.posix.normalize(rawPath); + let storage; + + try { + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + const list = await storage.list(dirPath); + + const storageType = req.user.current_storage_type || 'oss'; + + const formattedList = list.map(item => { + return { + name: item.name, + displayName: decodeHtmlEntities(item.name || ''), + type: item.type === 'd' ? 'directory' : 'file', + size: item.size, + sizeFormatted: formatFileSize(item.size), + modifiedAt: new Date(item.modifyTime), + isDirectory: item.type === 'd', + httpDownloadUrl: null // OSS 使用 API 下载,不需要 httpDownloadUrl + }; + }); + + formattedList.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }); + + res.json({ + success: true, + path: dirPath, + items: formattedList, + storageType: storageType, + storagePermission: req.user.storage_permission || 'oss_only' + }); + } catch (error) { + console.error('获取文件列表失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '获取文件列表') + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 重命名文件 +app.post('/api/files/rename', authMiddleware, async (req, res) => { + const oldName = decodeHtmlEntities(req.body.oldName); + const newName = decodeHtmlEntities(req.body.newName); + const path = decodeHtmlEntities(req.body.path) || '/'; + let storage; + + if (!oldName || !newName) { + return res.status(400).json({ + success: false, + message: '缺少文件名参数' + }); + } + + try { + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + const oldPath = path === '/' ? `/${oldName}` : `${path}/${oldName}`; + const newPath = path === '/' ? `/${newName}` : `${path}/${newName}`; + + await storage.rename(oldPath, newPath); + + // 清除 OSS 使用情况缓存(如果用户使用 OSS) + if (req.user.current_storage_type === 'oss') { + clearOssUsageCache(req.user.id); + } + + res.json({ + success: true, + message: '文件重命名成功' + }); + } catch (error) { + console.error('重命名文件失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '重命名文件失败,请稍后重试', '重命名文件') + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 创建文件夹(支持本地存储和OSS) +app.post('/api/files/mkdir', authMiddleware, async (req, res) => { + const path = decodeHtmlEntities(req.body.path) || '/'; + const folderName = decodeHtmlEntities(req.body.folderName); + let storage; + + // 参数验证 + if (!folderName || folderName.trim() === '') { + return res.status(400).json({ + success: false, + message: '文件夹名称不能为空' + }); + } + + // 文件名长度检查 + if (folderName.length > 255) { + return res.status(400).json({ + success: false, + message: '文件夹名称过长(最大255个字符)' + }); + } + + // 文件名安全检查 - 防止路径遍历攻击 + if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) { + return res.status(400).json({ + success: false, + message: '文件夹名称不能包含特殊字符 (/ \\ .. :)' + }); + } + + try { + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + // 构造文件夹路径 + const basePath = path || '/'; + const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`; + + // 根据存储类型创建文件夹 + if (req.user.current_storage_type === 'local') { + // 本地存储:使用 fs.mkdirSync + const fullPath = storage.getFullPath(folderPath); + + // 检查是否已存在 + if (fs.existsSync(fullPath)) { + return res.status(400).json({ + success: false, + message: '文件夹已存在' + }); + } + + // 创建文件夹 (不使用recursive,只创建当前层级) + fs.mkdirSync(fullPath, { mode: 0o755 }); + + console.log(`[创建文件夹成功] 本地存储 - 用户${req.user.id}: ${folderPath}`); + } else { + // OSS 存储:使用 storage.mkdir() 创建空对象模拟文件夹 + await storage.mkdir(folderPath); + console.log(`[创建文件夹成功] OSS存储 - 用户${req.user.id}: ${folderPath}`); + + // 清除 OSS 使用情况缓存 + clearOssUsageCache(req.user.id); + } + + res.json({ + success: true, + message: '文件夹创建成功' + }); + } catch (error) { + console.error('[创建文件夹失败]', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '创建文件夹失败,请稍后重试', '创建文件夹') + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 获取文件夹详情(大小统计) - 支持本地存储和OSS +app.post('/api/files/folder-info', authMiddleware, async (req, res) => { + const dirPath = decodeHtmlEntities(req.body.path) || '/'; + const folderName = decodeHtmlEntities(req.body.folderName); + let storage; + + if (!folderName) { + return res.status(400).json({ + success: false, + message: '缺少文件夹名称参数' + }); + } + + try { + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + // 构造文件夹路径 + const basePath = dirPath || '/'; + const folderPath = basePath === '/' ? `/${folderName}` : `${basePath}/${folderName}`; + + if (req.user.current_storage_type === 'local') { + // 本地存储实现 + const fullPath = storage.getFullPath(folderPath); + + // 检查是否存在且是文件夹 + if (!fs.existsSync(fullPath)) { + return res.status(404).json({ + success: false, + message: '文件夹不存在' + }); + } + + const stats = fs.statSync(fullPath); + if (!stats.isDirectory()) { + return res.status(400).json({ + success: false, + message: '指定路径不是文件夹' + }); + } + + // 计算文件夹大小 + const folderSize = storage.calculateFolderSize(fullPath); + + // 计算文件数量 + function countFiles(countDirPath) { + let fileCount = 0; + let folderCount = 0; + + const items = fs.readdirSync(countDirPath, { withFileTypes: true }); + + for (const item of items) { + const itemPath = path.join(countDirPath, item.name); + + if (item.isDirectory()) { + folderCount++; + const subCounts = countFiles(itemPath); + fileCount += subCounts.fileCount; + folderCount += subCounts.folderCount; + } else { + fileCount++; + } + } + + return { fileCount, folderCount }; + } + + const counts = countFiles(fullPath); + + res.json({ + success: true, + data: { + name: folderName, + path: folderPath, + size: folderSize, + fileCount: counts.fileCount, + folderCount: counts.folderCount + } + }); + } else { + // OSS 存储实现 + const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); + const folderKey = `user_${req.user.id}${folderPath}`; + // 确保前缀以斜杠结尾 + const prefix = folderKey.endsWith('/') ? folderKey : `${folderKey}/`; + + let totalSize = 0; + let fileCount = 0; + let continuationToken = null; + + do { + const command = new ListObjectsV2Command({ + Bucket: req.user.oss_bucket, + Prefix: prefix, + ContinuationToken: continuationToken + }); + + const response = await storage.s3Client.send(command); + + if (response.Contents) { + for (const obj of response.Contents) { + // 跳过文件夹标记对象(以斜杠结尾且大小为0) + if (obj.Key.endsWith('/') && obj.Size === 0) { + continue; + } + totalSize += obj.Size || 0; + fileCount++; + } + } + + continuationToken = response.NextContinuationToken; + } while (continuationToken); + + res.json({ + success: true, + data: { + name: folderName, + path: folderPath, + size: totalSize, + fileCount: fileCount, + folderCount: 0 // OSS 没有真正的文件夹概念 + } + }); + } + } catch (error) { + console.error('[获取文件夹详情失败]', error); + res.status(500).json({ + success: false, + message: '获取文件夹详情失败: ' + error.message + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 删除文件 +// ===== P0 性能优化:更新存储使用量缓存 ===== +app.post('/api/files/delete', authMiddleware, async (req, res) => { + const rawFileName = req.body.fileName; + const rawPath = req.body.path; + const fileName = decodeHtmlEntities(rawFileName); + const path = decodeHtmlEntities(rawPath) || '/'; + let storage; + let deletedSize = 0; + + if (!fileName) { + return res.status(400).json({ + success: false, + message: '缺少文件名参数' + }); + } + + try { + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + const tried = new Set(); + const candidates = [fileName]; + + // 兼容被二次编码的实体(如 &#x60; -> `) + if (typeof rawFileName === 'string') { + const entityName = rawFileName.replace(/&/g, '&'); + if (entityName && !candidates.includes(entityName)) { + candidates.push(entityName); + } + if (rawFileName && !candidates.includes(rawFileName)) { + candidates.push(rawFileName); + } + } + + const pathsToDelete = candidates.map(name => (path === '/' ? `/${name}` : `${path}/${name}`)); + + try { + for (const targetPath of pathsToDelete) { + if (tried.has(targetPath)) continue; + tried.add(targetPath); + try { + // 删除文件并获取文件大小(用于更新缓存) + const deleteResult = await storage.delete(targetPath); + + // 如果返回了文件大小,记录下来 + if (deleteResult && deleteResult.size !== undefined) { + deletedSize = deleteResult.size; + } + + break; + } catch (err) { + if (err.code === 'ENOENT') { + // 尝试下一个候选路径 + if (targetPath === pathsToDelete[pathsToDelete.length - 1]) throw err; + } else { + throw err; + } + } + } + } catch (err) { + throw err; + } + + // ===== P0 性能优化:更新存储使用量 ===== + if (req.user.current_storage_type === 'oss' && deletedSize > 0) { + // 减少存储使用量 + await StorageUsageCache.updateUsage(req.user.id, -deletedSize); + + // 同时更新旧的内存缓存(保持兼容性) + clearOssUsageCache(req.user.id); + + console.log(`[删除文件] 用户 ${req.user.id} 释放空间: ${deletedSize} 字节`); + } else if (req.user.current_storage_type === 'oss') { + // 如果没有获取到文件大小,清除缓存(下次查询时会重新统计) + clearOssUsageCache(req.user.id); + } + + res.json({ + success: true, + message: '删除成功' + }); + } catch (error) { + console.error('删除文件失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '删除文件失败,请稍后重试', '删除文件') + }); + } finally { + if (storage) await storage.end(); + } +}); + +// ========== OSS 直连相关接口(Presigned URL)========== + +// 生成 OSS 上传签名 URL(用户直连 OSS 上传,不经过后端) +app.get('/api/files/upload-signature', authMiddleware, async (req, res) => { + const filename = req.query.filename; + const uploadPath = req.query.path || '/'; // 上传目标路径 + const contentType = req.query.contentType || 'application/octet-stream'; + + if (!filename) { + return res.status(400).json({ + success: false, + message: '缺少文件名参数' + }); + } + + // 文件名长度限制(最大255字符) + if (filename.length > 255) { + return res.status(400).json({ + success: false, + message: '文件名过长,最大支持255个字符' + }); + } + + // 文件名安全校验 + if (!isSafePathSegment(filename)) { + return res.status(400).json({ + success: false, + message: '文件名包含非法字符' + }); + } + + // 文件扩展名安全检查(防止上传危险文件) + if (!isFileExtensionSafe(filename)) { + console.warn(`[安全] 拒绝上传危险文件: ${filename}, 用户: ${req.user.username}`); + return res.status(400).json({ + success: false, + message: '不允许上传此类型的文件(安全限制)' + }); + } + + // 路径安全验证:防止目录遍历攻击 + if (uploadPath.includes('..') || uploadPath.includes('\x00')) { + return res.status(400).json({ + success: false, + message: '上传路径非法' + }); + } + + // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置 OSS 服务' + }); + } + + try { + const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); + const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + + // 获取有效的 OSS 配置(系统配置优先) + const unifiedConfig = SettingsDB.getUnifiedOssConfig(); + const effectiveBucket = unifiedConfig ? unifiedConfig.bucket : req.user.oss_bucket; + + // 构建 S3 客户端 + const client = new S3Client(buildS3Config(req.user)); + + // 构建对象 Key(与 OssStorageClient.getObjectKey 格式一致) + // 格式:user_${id}/${path}/${filename} + const sanitizedFilename = sanitizeFilename(filename); + let normalizedPath = uploadPath.replace(/\\/g, '/').replace(/\/+/g, '/'); + // 移除开头的斜杠 + normalizedPath = normalizedPath.replace(/^\/+/, ''); + // 移除结尾的斜杠 + normalizedPath = normalizedPath.replace(/\/+$/, ''); + + // 构建完整的 objectKey + let objectKey; + if (normalizedPath === '' || normalizedPath === '.') { + // 根目录上传 + objectKey = `user_${req.user.id}/${sanitizedFilename}`; + } else { + // 子目录上传 + objectKey = `user_${req.user.id}/${normalizedPath}/${sanitizedFilename}`; + } + + // 创建 PutObject 命令 + const command = new PutObjectCommand({ + Bucket: effectiveBucket, + Key: objectKey, + ContentType: contentType + }); + + // 生成签名 URL(15分钟有效) + const signedUrl = await getSignedUrl(client, command, { expiresIn: 900 }); + + res.json({ + success: true, + uploadUrl: signedUrl, + objectKey: objectKey, + expiresIn: 900 + }); + } catch (error) { + console.error('[OSS签名] 生成上传签名失败:', error); + res.status(500).json({ + success: false, + message: '生成上传签名失败: ' + error.message + }); + } +}); + +// OSS 上传完成通知(用于更新缓存和数据库) +// ===== P0 性能优化:使用增量更新替代全量统计 ===== +app.post('/api/files/upload-complete', authMiddleware, async (req, res) => { + const { objectKey, size, path } = req.body; + + if (!objectKey) { + return res.status(400).json({ + success: false, + message: '缺少对象Key参数' + }); + } + + if (!size || size < 0) { + return res.status(400).json({ + success: false, + message: '文件大小参数无效' + }); + } + + // 安全检查:验证用户是否配置了OSS(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置OSS服务,无法完成上传' + }); + } + + try { + // 更新存储使用量缓存(增量更新) + await StorageUsageCache.updateUsage(req.user.id, size); + + // 同时更新旧的内存缓存(保持兼容性) + clearOssUsageCache(req.user.id); + + console.log(`[上传完成] 用户 ${req.user.id} 文件大小: ${size} 字节`); + res.json({ + success: true, + message: '上传完成已记录' + }); + } catch (error) { + console.error('[OSS上传] 记录上传完成失败:', error); + res.status(500).json({ + success: false, + message: '记录上传完成失败: ' + error.message + }); + } +}); + +// 生成 OSS 下载签名 URL(用户直连 OSS 下载,不经过后端) +app.get('/api/files/download-url', authMiddleware, async (req, res) => { + const filePath = req.query.path; + + if (!filePath) { + return res.status(400).json({ + success: false, + message: '缺少文件路径参数' + }); + } + + // 路径安全验证:防止目录遍历攻击 + const normalizedPath = path.posix.normalize(filePath); + if (normalizedPath.includes('..') || filePath.includes('\x00')) { + return res.status(400).json({ + success: false, + message: '文件路径非法' + }); + } + + // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!req.user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '未配置 OSS 服务' + }); + } + + try { + const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); + const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + + // 获取有效的 OSS 配置(系统配置优先) + const unifiedConfig = SettingsDB.getUnifiedOssConfig(); + const effectiveBucket = unifiedConfig ? unifiedConfig.bucket : req.user.oss_bucket; + + // 构建 S3 客户端 + const client = new S3Client(buildS3Config(req.user)); + + // 构建对象 Key(复用 OssStorageClient 的 getObjectKey 方法,确保路径格式正确) + const tempClient = new OssStorageClient(req.user); + const objectKey = tempClient.getObjectKey(normalizedPath); + + // 创建 GetObject 命令 + const command = new GetObjectCommand({ + Bucket: effectiveBucket, + Key: objectKey, + ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"` + }); + + // 生成签名 URL(1小时有效) + const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }); + + res.json({ + success: true, + downloadUrl: signedUrl, + expiresIn: 3600 + }); + } catch (error) { + console.error('[OSS签名] 生成下载签名失败:', error); + res.status(500).json({ + success: false, + message: '生成下载签名失败: ' + error.message + }); + } +}); + +// 辅助函数:构建 S3 配置(复用 OssStorageClient.buildConfig) +function buildS3Config(user) { + // 创建临时 OssStorageClient 实例并复用其 buildConfig 方法 + // OssStorageClient 已在文件顶部导入 + const tempClient = new OssStorageClient(user); + const config = tempClient.getEffectiveConfig(); // 先获取有效配置(系统配置优先) + return tempClient.buildConfig(config); // 然后传递给buildConfig +} + +// 辅助函数:清理文件名(增强版安全处理) +function sanitizeFilename(filename) { + if (!filename || typeof filename !== 'string') { + return 'unnamed_file'; + } + + let sanitized = filename; + + // 1. 移除空字节和控制字符 + sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, ''); + + // 2. 移除或替换危险字符(Windows/Linux 文件系统不允许的字符) + sanitized = sanitized.replace(/[<>:"/\\|?*]/g, '_'); + + // 3. 移除前导/尾随的点和空格(防止隐藏文件和路径混淆) + sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, ''); + + // 4. 限制文件名长度(防止过长文件名攻击) + if (sanitized.length > 200) { + const ext = path.extname(sanitized); + const base = path.basename(sanitized, ext); + sanitized = base.substring(0, 200 - ext.length) + ext; + } + + // 5. 如果处理后为空,使用默认名称 + if (!sanitized || sanitized.length === 0) { + sanitized = 'unnamed_file'; + } + + return sanitized; +} + +// ========== 本地存储上传接口(保留用于本地存储模式)========== + +// 上传文件(添加速率限制)- 仅用于本地存储 +app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => { + // 速率限制检查 + const rateLimitKey = `upload:${req.user.id}`; + const rateLimitResult = uploadLimiter.recordFailure(rateLimitKey); + if (rateLimitResult.blocked) { + // 清理已上传的临时文件 + if (req.file && fs.existsSync(req.file.path)) { + safeDeleteFile(req.file.path); + } + return res.status(429).json({ + success: false, + message: `上传过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` + }); + } + + if (!req.file) { + return res.status(400).json({ + success: false, + message: '没有上传文件' + }); + } + + // 检查文件大小限制 + const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); + if (req.file.size > maxUploadSize) { + // 删除已上传的临时文件 + if (fs.existsSync(req.file.path)) { + safeDeleteFile(req.file.path); + } + + return res.status(413).json({ + success: false, + message: '文件超过上传限制', + maxSize: maxUploadSize, + fileSize: req.file.size + }); + } + + const remotePath = req.body.path || '/'; + // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 + const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + + // 文件名长度限制(最大255字符) + if (originalFilename.length > 255) { + safeDeleteFile(req.file.path); + return res.status(400).json({ + success: false, + message: '文件名过长,最大支持255个字符' + }); + } + + // 文件名安全校验 + if (!isSafePathSegment(originalFilename)) { + safeDeleteFile(req.file.path); + return res.status(400).json({ + success: false, + message: '文件名包含非法字符' + }); + } + + // 文件扩展名安全检查(防止上传危险文件) + if (!isFileExtensionSafe(originalFilename)) { + console.warn(`[安全] 拒绝上传危险文件: ${originalFilename}, 用户: ${req.user.username}`); + safeDeleteFile(req.file.path); + return res.status(400).json({ + success: false, + message: '不允许上传此类型的文件(安全限制)' + }); + } + + // 路径安全校验 + const normalizedPath = path.posix.normalize(remotePath || '/'); + if (normalizedPath.includes('..')) { + return res.status(400).json({ + success: false, + message: '上传路径非法' + }); + } + const safePath = normalizedPath === '.' ? '/' : normalizedPath; + + const remoteFilePath = safePath === '/' + ? `/${originalFilename}` + : `${safePath}/${originalFilename}`; + + let storage; + + try { + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + // storage.put() 内部已经实现了临时文件+重命名逻辑 + await storage.put(req.file.path, remoteFilePath); + console.log(`[上传] 文件上传成功: ${remoteFilePath}`); + + // 清除 OSS 使用情况缓存(如果用户使用 OSS) + if (req.user.current_storage_type === 'oss') { + clearOssUsageCache(req.user.id); + } + + // 删除本地临时文件 + safeDeleteFile(req.file.path); + + res.json({ + success: true, + message: '文件上传成功', + filename: originalFilename, + path: remoteFilePath + }); + } catch (error) { + console.error('文件上传失败:', error); + + // 删除临时文件 + if (fs.existsSync(req.file.path)) { + safeDeleteFile(req.file.path); + } + + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '文件上传失败,请稍后重试', '文件上传') + }); + } finally { + if (storage) await storage.end(); + } +}); + + +// 下载文件 +app.get('/api/files/download', authMiddleware, async (req, res) => { + const filePath = req.query.path; + let storage; + let storageEnded = false; // 防止重复关闭 + + // 安全关闭存储连接的辅助函数 + const safeEndStorage = async () => { + if (storage && !storageEnded) { + storageEnded = true; + try { + await storage.end(); + } catch (err) { + console.error('关闭存储连接失败:', err); + } + } + }; + + if (!filePath) { + return res.status(400).json({ + success: false, + message: '缺少文件路径参数' + }); + } + + // 路径安全验证:防止目录遍历攻击 + const normalizedPath = path.posix.normalize(filePath); + if (normalizedPath.includes('..') || filePath.includes('\x00')) { + return res.status(400).json({ + success: false, + message: '文件路径非法' + }); + } + + try { + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const storageInterface = new StorageInterface(req.user); + storage = await storageInterface.connect(); + + // 获取文件名 + const fileName = filePath.split('/').pop(); + + // 先获取文件信息(获取文件大小) + const fileStats = await storage.stat(filePath); + const fileSize = fileStats.size; + console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); + + // 设置响应头(包含文件大小,浏览器可显示下载进度) + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); + + // 创建文件流并传输(流式下载,服务器不保存临时文件) + const stream = await storage.createReadStream(filePath); + + stream.on('error', (error) => { + console.error('文件流错误:', error); + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: '文件下载失败: ' + error.message + }); + } + // 发生错误时关闭存储连接 + safeEndStorage(); + }); + + // 在传输完成后关闭存储连接 + stream.on('close', () => { + console.log('[下载] 文件传输完成,关闭存储连接'); + safeEndStorage(); + }); + + stream.pipe(res); + + } catch (error) { + console.error('下载文件失败:', error); + + // 如果stream还未创建或发生错误,关闭storage连接 + await safeEndStorage(); + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件') + }); + } + } +}); + +// 生成上传工具(生成新密钥并创建配置文件) +app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => { + try { + // 生成新的API密钥(32位随机字符串) + const newApiKey = crypto.randomBytes(16).toString('hex'); + + // 更新用户的upload_api_key + UserDB.update(req.user.id, { upload_api_key: newApiKey }); + + // 创建配置文件内容 + const config = { + username: req.user.username, + api_key: newApiKey, + api_base_url: getSecureBaseUrl(req) + }; + + res.json({ + success: true, + message: '上传工具已生成', + config: config + }); + } catch (error) { + console.error('生成上传工具失败:', error); + res.status(500).json({ + success: false, + message: '生成上传工具失败: ' + error.message + }); + } +}); + +// 下载上传工具(zip包含exe+config.json+README.txt) +app.get('/api/upload/download-tool', authMiddleware, async (req, res) => { + let tempZipPath = null; + + try { + console.log(`[上传工具] 用户 ${req.user.username} 请求下载上传工具`); + + // 生成新的API密钥 + const newApiKey = crypto.randomBytes(16).toString('hex'); + + // 更新用户的upload_api_key + UserDB.update(req.user.id, { upload_api_key: newApiKey }); + + // 创建配置文件内容 + const config = { + username: req.user.username, + api_key: newApiKey, + api_base_url: getSecureBaseUrl(req) + }; + console.log("[上传工具配置]", JSON.stringify(config, null, 2)); + + // 检查exe文件是否存在 + const toolDir = path.join(__dirname, '..', 'upload-tool'); + const exePath = path.join(toolDir, 'dist', '玩玩云上传工具.exe'); + const readmePath = path.join(toolDir, 'README.txt'); + + if (!fs.existsSync(exePath)) { + console.error('[上传工具] exe文件不存在:', exePath); + return res.status(500).json({ + success: false, + message: '上传工具尚未打包,请联系管理员运行 upload-tool/build.bat' + }); + } + + // 创建临时zip文件路径 + const uploadsDir = path.join(__dirname, 'uploads'); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + tempZipPath = path.join(uploadsDir, `tool_${req.user.username}_${Date.now()}.zip`); + + console.log('[上传工具] 开始创建zip包到临时文件:', tempZipPath); + + // 创建文件写入流 + const output = fs.createWriteStream(tempZipPath); + const archive = archiver('zip', { + store: true // 使用STORE模式,不压缩,速度最快 + }); + + // 等待zip文件创建完成 + await new Promise((resolve, reject) => { + output.on('close', () => { + console.log(`[上传工具] zip创建完成,大小: ${archive.pointer()} 字节`); + resolve(); + }); + + archive.on('error', (err) => { + console.error('[上传工具] archiver错误:', err); + reject(err); + }); + + // 连接archive到文件流 + archive.pipe(output); + + // 添加exe文件 + console.log('[上传工具] 添加exe文件...'); + archive.file(exePath, { name: '玩玩云上传工具.exe' }); + + // 添加config.json + console.log('[上传工具] 添加config.json...'); + archive.append(JSON.stringify(config, null, 2), { name: 'config.json' }); + + // 添加README.txt + if (fs.existsSync(readmePath)) { + console.log('[上传工具] 添加README.txt...'); + archive.file(readmePath, { name: 'README.txt' }); + } + + // 完成打包 + console.log('[上传工具] 执行finalize...'); + archive.finalize(); + }); + + // 获取文件大小 + const stats = fs.statSync(tempZipPath); + const fileSize = stats.size; + console.log(`[上传工具] 准备发送文件,大小: ${fileSize} 字节`); + + // 设置响应头(包含Content-Length) + const filename = `玩玩云上传工具_${req.user.username}.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + + // 创建文件读取流并发送 + const fileStream = fs.createReadStream(tempZipPath); + + fileStream.on('end', () => { + console.log(`[上传工具] 用户 ${req.user.username} 下载完成`); + // 删除临时文件 + if (tempZipPath && fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + console.log('[上传工具] 临时文件已删除'); + } + }); + + fileStream.on('error', (err) => { + console.error('[上传工具] 文件流错误:', err); + // 删除临时文件 + if (tempZipPath && fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + } + }); + + fileStream.pipe(res); + + } catch (error) { + console.error('[上传工具] 异常:', error); + + // 删除临时文件 + if (tempZipPath && fs.existsSync(tempZipPath)) { + fs.unlinkSync(tempZipPath); + console.log('[上传工具] 临时文件已删除(异常)'); + } + + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: '下载失败: ' + error.message + }); + } + } +}); + +// 通过API密钥获取OSS配置(供上传工具调用) +// 添加速率限制防止暴力枚举 +app.post('/api/upload/get-config', async (req, res) => { + // 获取客户端IP用于速率限制 + const clientIP = apiKeyLimiter.getClientKey(req); + const rateLimitKey = `api_key:${clientIP}`; + + // 检查是否被封锁 + if (apiKeyLimiter.isBlocked(rateLimitKey)) { + const result = apiKeyLimiter.recordFailure(rateLimitKey); + console.warn(`[安全] API密钥暴力枚举检测 - IP: ${clientIP}`); + return res.status(429).json({ + success: false, + message: `请求过于频繁,请在 ${result.waitMinutes} 分钟后重试`, + blocked: true + }); + } + + try { + const { api_key } = req.body; + + if (!api_key) { + return res.status(400).json({ + success: false, + message: 'API密钥不能为空' + }); + } + + // 查找拥有此API密钥的用户 + const user = db.prepare('SELECT * FROM users WHERE upload_api_key = ?').get(api_key); + + if (!user) { + // 记录失败尝试 + const result = apiKeyLimiter.recordFailure(rateLimitKey); + console.warn(`[安全] API密钥验证失败 - IP: ${clientIP}, 剩余尝试: ${result.remainingAttempts}`); + return res.status(401).json({ + success: false, + message: 'API密钥无效或已过期' + }); + } + + // 验证成功,清除失败记录 + apiKeyLimiter.recordSuccess(rateLimitKey); + + if (user.is_banned) { + return res.status(403).json({ + success: false, + message: '账号已被封禁' + }); + } + + // 确定用户的存储类型 + const storageType = user.current_storage_type || 'local'; + + // 返回配置信息(新版上传工具 v3.0 使用服务器 API 上传,无需返回 OSS 凭证) + res.json({ + success: true, + config: { + storage_type: storageType, + username: user.username, + // OSS 配置(仅用于显示) + oss_provider: user.oss_provider, + oss_bucket: user.oss_bucket + } + }); + } catch (error) { + console.error('获取OSS配置失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误,请稍后重试' + }); + } +}); + +// 创建分享链接 +app.post('/api/share/create', authMiddleware, (req, res) => { + try { + const { share_type, file_path, file_name, password, expiry_days } = req.body; + + // 参数验证:share_type 只能是 'file' 或 'directory' + const validShareTypes = ['file', 'directory']; + const actualShareType = share_type || 'file'; + if (!validShareTypes.includes(actualShareType)) { + return res.status(400).json({ + success: false, + message: '无效的分享类型,只能是 file 或 directory' + }); + } + + // 参数验证:file_path 不能为空 + if (!file_path) { + return res.status(400).json({ + success: false, + message: actualShareType === 'file' ? '文件路径不能为空' : '目录路径不能为空' + }); + } + + // 参数验证:expiry_days 必须为正整数或 null + if (expiry_days !== undefined && expiry_days !== null) { + const days = parseInt(expiry_days, 10); + if (isNaN(days) || days <= 0 || days > 365) { + return res.status(400).json({ + success: false, + message: '有效期必须是1-365之间的整数' + }); + } + } + + // 参数验证:密码长度限制 + if (password && (typeof password !== 'string' || password.length > 32)) { + return res.status(400).json({ + success: false, + message: '密码长度不能超过32个字符' + }); + } + + // 路径安全验证:防止路径遍历攻击 + if (file_path.includes('..') || file_path.includes('\x00')) { + return res.status(400).json({ + success: false, + message: '路径包含非法字符' + }); + } + + SystemLogDB.log({ + level: 'info', + category: 'share', + action: 'create_share', + message: '创建分享请求', + details: { share_type: actualShareType, file_path, file_name, expiry_days } + }); + + const result = ShareDB.create(req.user.id, { + share_type: actualShareType, + file_path: file_path || '', + file_name: file_name || '', + password: password || null, + expiry_days: expiry_days || null + }); + + // 更新分享的存储类型 + db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?') + .run(req.user.current_storage_type || 'oss', result.id); + + const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`; + + // 记录分享创建日志 + logShare(req, 'create_share', + `用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${file_path}`, + { shareCode: result.share_code, sharePath: file_path, shareType: actualShareType, hasPassword: !!password } + ); + + res.json({ + success: true, + message: '分享链接创建成功', + share_code: result.share_code, + share_url: shareUrl, + share_type: result.share_type, + expires_at: result.expires_at, + }); + } catch (error) { + console.error('创建分享链接失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '创建分享链接失败,请稍后重试', '创建分享') + }); + } +}); + +// 获取我的分享列表 +app.get('/api/share/my', authMiddleware, (req, res) => { + try { + const shares = ShareDB.getUserShares(req.user.id); + + res.json({ + success: true, + shares: shares.map(share => ({ + ...share, + share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}` + })) + }); + } catch (error) { + console.error('获取分享列表失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '获取分享列表失败,请稍后重试', '获取分享列表') + }); + } +}); + +// 删除分享(增强IDOR防护) +app.delete('/api/share/:id', authMiddleware, (req, res) => { + try { + const shareId = parseInt(req.params.id, 10); + + // 验证ID格式 + if (isNaN(shareId) || shareId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的分享ID' + }); + } + + // 先获取分享信息以获得share_code + const share = ShareDB.findById(shareId); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 严格的权限检查 + if (share.user_id !== req.user.id) { + // 记录可疑的越权尝试 + console.warn(`[安全] IDOR尝试 - 用户 ${req.user.id}(${req.user.username}) 试图删除用户 ${share.user_id} 的分享 ${shareId}`); + return res.status(403).json({ + success: false, + message: '无权限删除此分享' + }); + } + + // 删除缓存 + if (shareFileCache.has(share.share_code)) { + shareFileCache.delete(share.share_code); + console.log(`[缓存清除] 分享码: ${share.share_code}`); + } + + // 删除数据库记录 + ShareDB.delete(shareId, req.user.id); + + res.json({ + success: true, + message: '分享已删除' + }); + } catch (error) { + console.error('删除分享失败:', error); + res.status(500).json({ + success: false, + message: getSafeErrorMessage(error, '删除分享失败,请稍后重试', '删除分享') + }); + } +}); + +// ===== 分享链接访问(公开) ===== + +// 获取公共主题设置(用于分享页面,无需认证) +app.get('/api/public/theme', (req, res) => { + try { + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + res.json({ + success: true, + theme: globalTheme + }); + } catch (error) { + res.json({ success: true, theme: 'dark' }); // 出错默认暗色 + } +}); + +// 获取分享页面主题(基于分享者偏好或全局设置) +app.get('/api/share/:code/theme', (req, res) => { + try { + const { code } = req.params; + const share = ShareDB.findByCode(code); + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + + if (!share) { + return res.json({ + success: true, + theme: globalTheme + }); + } + + // 优先使用分享者的主题偏好,否则使用全局主题 + const effectiveTheme = share.theme_preference || globalTheme; + res.json({ + success: true, + theme: effectiveTheme + }); + } catch (error) { + res.json({ success: true, theme: 'dark' }); + } +}); + +// 访问分享链接 - 验证密码(支持本地存储和OSS) +app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) => { + const { code } = req.params; + const { password } = req.body; + let storage; + + try { + + const share = ShareDB.findByCode(code); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 如果设置了密码,验证密码 + if (share.share_password) { + if (!password) { + return res.status(401).json({ + success: false, + message: '需要密码', + needPassword: true + }); + } + + if (!ShareDB.verifyPassword(password, share.share_password)) { + // 记录密码错误 + if (req.shareRateLimitKey) { + shareLimiter.recordFailure(req.shareRateLimitKey); + } + return res.status(401).json({ + success: false, + message: '密码错误' + }); + } + } + + // 清除失败记录(密码验证成功) + if (req.shareRateLimitKey) { + shareLimiter.recordSuccess(req.shareRateLimitKey); + } + + // 增加查看次数 + ShareDB.incrementViewCount(code); + + // 构建返回数据 + const responseData = { + success: true, + share: { + share_path: share.share_path, + share_type: share.share_type, + username: share.username, + created_at: share.created_at, + expires_at: share.expires_at // 添加到期时间 + } + }; + + // 如果是单文件分享,查询存储获取文件信息(带缓存) + if (share.share_type === 'file') { + const filePath = share.share_path; + const lastSlashIndex = filePath.lastIndexOf('/'); + const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/'; + const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath; + + // 检查缓存 + if (shareFileCache.has(code)) { + responseData.file = shareFileCache.get(code); + } else { + // 缓存未命中,查询存储 + try { + // 获取分享者的用户信息 + const shareOwner = UserDB.findById(share.user_id); + if (!shareOwner) { + throw new Error('分享者不存在'); + } + // 使用分享创建时记录的存储类型,而非用户当前的存储类型 + // 这样即使用户切换了存储,分享链接仍然有效 + const storageType = share.storage_type || 'oss'; + + // 使用统一存储接口 + const { StorageInterface } = require('./storage'); + const userForStorage = { + ...shareOwner, + current_storage_type: storageType + }; + + const storageInterface = new StorageInterface(userForStorage); + storage = await storageInterface.connect(); + + const list = await storage.list(dirPath); + const fileInfo = list.find(item => item.name === fileName); + + // 检查文件是否存在 + if (!fileInfo) { + shareFileCache.delete(code); + throw new Error("分享的文件已被删除或不存在"); + } + + if (fileInfo) { + const fileData = { + name: fileName, + type: 'file', + isDirectory: false, + httpDownloadUrl: null, // OSS 使用 API 下载 + size: fileInfo.size, + sizeFormatted: formatFileSize(fileInfo.size), + modifiedAt: new Date(fileInfo.modifyTime) + }; + + // 存入缓存 + shareFileCache.set(code, fileData); + console.log(`[缓存存储] 分享码: ${code},文件: ${fileName}`); + + responseData.file = fileData; + } + } catch (storageError) { + console.error('获取文件信息失败:', storageError); + + // 如果是文件不存在的错误,重新抛出 + if (storageError.message && storageError.message.includes("分享的文件已被删除或不存在")) { + throw storageError; + } + // 存储失败时仍返回基本信息,只是没有大小 + responseData.file = { + name: fileName, + type: 'file', + isDirectory: false, + httpDownloadUrl: null, + size: 0, + sizeFormatted: '-' + }; + } + } + } + + res.json(responseData); + } catch (error) { + console.error('验证分享失败:', error); + res.status(500).json({ + success: false, + message: '验证失败: ' + error.message + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 获取分享的文件列表(支持本地存储和OSS) +app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => { + const { code } = req.params; + const { password, path: subPath } = req.body; + + let storage; + + try { + + // ===== 调试日志: 获取分享文件列表 ===== + console.log('[获取文件列表]', { + timestamp: new Date().toISOString(), + shareCode: code, + subPath: subPath, + hasPassword: !!password, + requestIP: req.ip + }); + + const share = ShareDB.findByCode(code); + + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 验证密码 + if (share.share_password && !ShareDB.verifyPassword(password, share.share_password)) { + // 记录密码错误 + if (req.shareRateLimitKey) { + shareLimiter.recordFailure(req.shareRateLimitKey); + } + return res.status(401).json({ + success: false, + message: '密码错误' + }); + } + + // 清除失败记录(密码验证成功或无密码) + if (req.shareRateLimitKey && share.share_password) { + shareLimiter.recordSuccess(req.shareRateLimitKey); + } + + // 获取分享者的用户信息 + const shareOwner = UserDB.findById(share.user_id); + if (!shareOwner) { + return res.status(404).json({ + success: false, + message: '分享者不存在' + }); + } + + // 构造安全的请求路径,防止越权遍历 + const baseSharePath = (share.share_path || '/').replace(/\\/g, '/'); + const requestedPath = subPath + ? path.posix.normalize(`${baseSharePath}/${subPath}`) + : baseSharePath; + + // 校验请求路径是否在分享范围内 + if (!isPathWithinShare(requestedPath, share)) { + return res.status(403).json({ + success: false, + message: '无权访问该路径' + }); + } + + // 使用统一存储接口,根据分享的storage_type选择存储后端 + const { StorageInterface } = require('./storage'); + const storageType = share.storage_type || 'oss'; + console.log(`[分享列表] 存储类型: ${storageType}, 分享路径: ${share.share_path}`); + + // 临时构造用户对象以使用存储接口 + const userForStorage = { + ...shareOwner, + current_storage_type: storageType + }; + + const storageInterface = new StorageInterface(userForStorage); + storage = await storageInterface.connect(); + + let formattedList = []; + + // 如果是单文件分享 + if (share.share_type === 'file') { + // share_path 就是文件路径 + const filePath = share.share_path; + + // 提取父目录和文件名 + const lastSlashIndex = filePath.lastIndexOf('/'); + const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : '/'; + const fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath; + + // 列出父目录 + const list = await storage.list(dirPath); + + // 只返回这个文件 + const fileInfo = list.find(item => item.name === fileName); + + if (fileInfo) { + formattedList = [{ + name: fileInfo.name, + type: 'file', + size: fileInfo.size, + sizeFormatted: formatFileSize(fileInfo.size), + modifiedAt: new Date(fileInfo.modifyTime), + isDirectory: false, + httpDownloadUrl: null // OSS 使用 API 下载 + }]; + } + } + // 如果是目录分享(分享所有文件) + else { + const list = await storage.list(requestedPath); + + formattedList = list.map(item => { + return { + name: item.name, + type: item.type === 'd' ? 'directory' : 'file', + size: item.size, + sizeFormatted: formatFileSize(item.size), + modifiedAt: new Date(item.modifyTime), + isDirectory: item.type === 'd', + httpDownloadUrl: null // OSS 使用 API 下载 + }; + }); + + formattedList.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }); + } + + res.json({ + success: true, + path: share.share_path, + items: formattedList + }); + } catch (error) { + console.error('获取分享文件列表失败:', error); + res.status(500).json({ + success: false, + message: '获取文件列表失败: ' + error.message + }); + } finally { + if (storage) await storage.end(); + } +}); + +// 记录下载次数(添加限流保护防止滥用) +app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => { + const { code } = req.params; + + // 参数验证:code 不能为空 + if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) { + return res.status(400).json({ + success: false, + message: '无效的分享码' + }); + } + + try { + const share = ShareDB.findByCode(code); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 增加下载次数 + ShareDB.incrementDownloadCount(code); + + res.json({ + success: true, + message: '下载统计已记录' + }); + } catch (error) { + console.error('记录下载失败:', error); + res.status(500).json({ + success: false, + message: '记录下载失败: ' + error.message + }); + } +}); + +// 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护) +app.get('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, res) => { + const { code } = req.params; + const { path: filePath, password } = req.query; + + // 参数验证:code 不能为空 + if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) { + return res.status(400).json({ + success: false, + message: '无效的分享码' + }); + } + + if (!filePath) { + return res.status(400).json({ + success: false, + message: '缺少文件路径参数' + }); + } + + try { + const share = ShareDB.findByCode(code); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 验证密码(如果需要) + if (share.share_password) { + if (!password || !ShareDB.verifyPassword(password, share.share_password)) { + return res.status(401).json({ + success: false, + message: '密码错误或未提供密码' + }); + } + } + + // 安全验证:检查请求路径是否在分享范围内 + if (!isPathWithinShare(filePath, share)) { + return res.status(403).json({ + success: false, + message: '无权访问该文件' + }); + } + + // 获取分享者的用户信息 + const shareOwner = UserDB.findById(share.user_id); + if (!shareOwner) { + return res.status(404).json({ + success: false, + message: '分享者不存在' + }); + } + + // 检查是否使用 OSS(包括个人配置和系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!shareOwner.has_oss_config && !hasUnifiedConfig) { + // 本地存储模式:返回后端下载 URL + return res.json({ + success: true, + downloadUrl: `${req.protocol}://${req.get('host')}/api/share/${code}/download-file?path=${encodeURIComponent(filePath)}${password ? '&password=' + encodeURIComponent(password) : ''}`, + direct: false + }); + } + + // OSS 模式:生成签名 URL + const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); + const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + + // 获取有效的 OSS 配置(系统配置优先) + const unifiedConfig = SettingsDB.getUnifiedOssConfig(); + const effectiveBucket = unifiedConfig ? unifiedConfig.bucket : shareOwner.oss_bucket; + + // 构建 S3 客户端 + const client = new S3Client(buildS3Config(shareOwner)); + + // 构建对象 Key(复用 OssStorageClient 的 getObjectKey 方法,确保路径格式正确) + const tempClient = new OssStorageClient(shareOwner); + const objectKey = tempClient.getObjectKey(filePath); + + // 创建 GetObject 命令 + const command = new GetObjectCommand({ + Bucket: effectiveBucket, + Key: objectKey, + ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"` + }); + + // 生成签名 URL(1小时有效) + const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }); + + res.json({ + success: true, + downloadUrl: signedUrl, + direct: true, + expiresIn: 3600 + }); + + } catch (error) { + console.error('[分享签名] 生成下载签名失败:', error); + res.status(500).json({ + success: false, + message: '生成下载签名失败: ' + error.message + }); + } +}); + +// 分享文件下载(支持本地存储,公开API,需要分享码和密码验证) +// 注意:OSS 模式请使用 /api/share/:code/download-url 获取直连下载链接 +app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { + const { code } = req.params; + const { path: filePath, password } = req.query; + let storage; + let storageEnded = false; // 防止重复关闭 + + // 安全关闭存储连接的辅助函数 + const safeEndStorage = async () => { + if (storage && !storageEnded) { + storageEnded = true; + try { + await storage.end(); + } catch (err) { + console.error('关闭存储连接失败:', err); + } + } + }; + + if (!filePath) { + return res.status(400).json({ + success: false, + message: '缺少文件路径参数' + }); + } + + // 路径安全验证:防止目录遍历攻击 + if (filePath.includes('\x00')) { + return res.status(400).json({ + success: false, + message: '文件路径非法' + }); + } + + try { + const share = ShareDB.findByCode(code); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 验证密码(如果需要) + if (share.share_password) { + if (!password || !ShareDB.verifyPassword(password, share.share_password)) { + // 只在密码错误时记录失败 + if (req.shareRateLimitKey) { + shareLimiter.recordFailure(req.shareRateLimitKey); + } + return res.status(401).json({ + success: false, + message: '密码错误或未提供密码' + }); + } + + // 密码验证成功,清除失败记录 + if (req.shareRateLimitKey) { + shareLimiter.recordSuccess(req.shareRateLimitKey); + } + } + + // 安全验证:检查请求路径是否在分享范围内(防止越权访问) + if (!isPathWithinShare(filePath, share)) { + console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`); + return res.status(403).json({ + success: false, + message: '无权访问该文件' + }); + } + + // 获取分享者的用户信息 + const shareOwner = UserDB.findById(share.user_id); + if (!shareOwner) { + return res.status(404).json({ + success: false, + message: '分享者不存在' + }); + } + + // 使用统一存储接口,根据分享的storage_type选择存储后端 + // 注意:必须使用分享创建时记录的 storage_type,而不是分享者当前的存储类型 + // 这样即使用户后来切换了存储类型,之前创建的分享仍然可以正常工作 + const { StorageInterface } = require('./storage'); + const storageType = share.storage_type || 'oss'; + console.log(`[分享下载] 存储类型: ${storageType} (分享记录), 文件路径: ${filePath}`); + + // 临时构造用户对象以使用存储接口 + const userForStorage = { + ...shareOwner, + current_storage_type: storageType + }; + + const storageInterface = new StorageInterface(userForStorage); + storage = await storageInterface.connect(); + + // 获取文件名 + const fileName = filePath.split('/').pop(); + + // 获取文件信息(获取文件大小) + const fileStats = await storage.stat(filePath); + const fileSize = fileStats.size; + console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`); + + // 增加下载次数 + ShareDB.incrementDownloadCount(code); + + // 设置响应头(包含文件大小,浏览器可显示下载进度) + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', fileSize); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`); + + // 创建文件流并传输(流式下载,服务器不保存临时文件) + const stream = await storage.createReadStream(filePath); + + stream.on('error', (error) => { + console.error('文件流错误:', error); + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: '文件下载失败: ' + error.message + }); + } + // 发生错误时关闭存储连接 + safeEndStorage(); + }); + + // 在传输完成后关闭存储连接 + stream.on('close', () => { + console.log('[分享下载] 文件传输完成,关闭存储连接'); + safeEndStorage(); + }); + + stream.pipe(res); + + } catch (error) { + console.error('分享下载文件失败:', error); + if (!res.headersSent) { + res.status(500).json({ + success: false, + message: '下载文件失败: ' + error.message + }); + } + // 如果发生错误,关闭存储连接 + await safeEndStorage(); + } +}); + +// ===== 管理员API ===== + +// 获取系统设置 +app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { + try { + const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); + const smtpHost = SettingsDB.get('smtp_host'); + const smtpPort = SettingsDB.get('smtp_port'); + const smtpSecure = SettingsDB.get('smtp_secure') === 'true'; + const smtpUser = SettingsDB.get('smtp_user'); + const smtpFrom = SettingsDB.get('smtp_from') || smtpUser; + const smtpHasPassword = !!SettingsDB.get('smtp_password'); + const globalTheme = SettingsDB.get('global_theme') || 'dark'; + + res.json({ + success: true, + settings: { + max_upload_size: maxUploadSize, + global_theme: globalTheme, + smtp: { + host: smtpHost || '', + port: smtpPort ? parseInt(smtpPort, 10) : 465, + secure: smtpSecure, + user: smtpUser || '', + from: smtpFrom || '', + has_password: smtpHasPassword + } + } + }); + } catch (error) { + console.error('获取系统设置失败:', error); + res.status(500).json({ + success: false, + message: '获取系统设置失败: ' + error.message + }); + } +}); + +// 更新系统设置 +app.post('/api/admin/settings', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(系统设置影响全局) + (req, res) => { + try { + const { max_upload_size, smtp, global_theme } = req.body; + + if (max_upload_size !== undefined) { + const size = parseInt(max_upload_size); + if (isNaN(size) || size < 0) { + return res.status(400).json({ + success: false, + message: '无效的文件大小' + }); + } + SettingsDB.set('max_upload_size', size.toString()); + } + + // 更新全局主题 + if (global_theme !== undefined) { + const validThemes = ['dark', 'light']; + if (!validThemes.includes(global_theme)) { + return res.status(400).json({ + success: false, + message: '无效的主题设置' + }); + } + SettingsDB.set('global_theme', global_theme); + } + + if (smtp) { + if (!smtp.host || !smtp.port || !smtp.user) { + return res.status(400).json({ success: false, message: 'SMTP配置不完整' }); + } + SettingsDB.set('smtp_host', smtp.host); + SettingsDB.set('smtp_port', smtp.port); + SettingsDB.set('smtp_secure', smtp.secure ? 'true' : 'false'); + SettingsDB.set('smtp_user', smtp.user); + SettingsDB.set('smtp_from', smtp.from || smtp.user); + if (smtp.password) { + SettingsDB.set('smtp_password', smtp.password); + } + } + + res.json({ + success: true, + message: '系统设置已更新' + }); + } catch (error) { + console.error('更新系统设置失败:', error); + res.status(500).json({ + success: false, + message: '更新系统设置失败: ' + error.message + }); + } +}); + +// 测试SMTP +app.post('/api/admin/settings/test-smtp', authMiddleware, adminMiddleware, async (req, res) => { + const { to } = req.body; + try { + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置' }); + } + const target = to || req.user.email || smtpConfig.user; + await sendMail( + target, + 'SMTP测试 - 玩玩云', + `

您好,这是一封测试邮件,说明SMTP配置可用。

时间:${new Date().toISOString()}

` + ); + res.json({ success: true, message: `测试邮件已发送至 ${target}` }); + } catch (error) { + console.error('测试SMTP失败:', error); + res.status(500).json({ success: false, message: '测试邮件发送失败: ' + (error.response?.message || error.message) }); + } +}); + +// ===== 统一 OSS 配置管理(管理员专用) ===== + +// 获取统一 OSS 配置 +app.get('/api/admin/unified-oss-config', authMiddleware, adminMiddleware, (req, res) => { + try { + const config = SettingsDB.getUnifiedOssConfig(); + + if (!config) { + return res.json({ + success: true, + configured: false, + config: null + }); + } + + // 返回配置(隐藏 Secret Key) + res.json({ + success: true, + configured: true, + config: { + provider: config.provider, + region: config.region, + access_key_id: config.access_key_id, + bucket: config.bucket, + endpoint: config.endpoint, + has_secret: !!config.access_key_secret + } + }); + } catch (error) { + console.error('获取统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '获取配置失败: ' + error.message + }); + } +}); + +// 设置/更新统一 OSS 配置 +app.post('/api/admin/unified-oss-config', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + [ + body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('region').notEmpty().withMessage('地域不能为空'), + body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('access_key_secret').notEmpty().withMessage('Access Key Secret不能为空'), + body('bucket').notEmpty().withMessage('存储桶名称不能为空'), + body('endpoint').optional({ checkFalsy: true }).isURL().withMessage('Endpoint必须是有效的URL') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; + + // 验证 OSS 连接 + try { + const testUser = { + id: 0, // 系统测试用户 + oss_provider: provider, + oss_region: region, + oss_access_key_id: access_key_id, + oss_access_key_secret: access_key_secret, + oss_bucket: bucket, + oss_endpoint: endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容(验证配置是否正确) + await ossClient.list('/'); + await ossClient.end(); + } catch (error) { + return res.status(400).json({ + success: false, + message: 'OSS连接失败,请检查配置: ' + error.message + }); + } + + // 保存统一 OSS 配置 + SettingsDB.setUnifiedOssConfig({ + provider, + region, + access_key_id, + access_key_secret, + bucket, + endpoint: endpoint || '' + }); + + // 记录系统日志 + SystemLogDB.log({ + level: SystemLogDB.LEVELS.INFO, + category: SystemLogDB.CATEGORIES.SYSTEM, + action: 'update_unified_oss_config', + message: '管理员更新了统一 OSS 配置', + userId: req.user.id, + username: req.user.username, + details: { + provider, + region, + bucket, + endpoint: endpoint || '' + } + }); + + res.json({ + success: true, + message: '统一 OSS 配置已更新,所有用户将使用此配置' + }); + } catch (error) { + console.error('更新统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '更新配置失败: ' + error.message + }); + } + } +); + +// 测试统一 OSS 配置(不保存) +app.post('/api/admin/unified-oss-config/test', + authMiddleware, + adminMiddleware, + [ + body('provider').isIn(['aliyun', 'tencent', 'aws']).withMessage('无效的OSS服务商'), + body('region').notEmpty().withMessage('地域不能为空'), + body('access_key_id').notEmpty().withMessage('Access Key ID不能为空'), + body('bucket').notEmpty().withMessage('存储桶名称不能为空') + ], + async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { provider, region, access_key_id, access_key_secret, bucket, endpoint } = req.body; + + // 验证 OSS 连接 + const testUser = { + id: 0, + oss_provider: provider, + oss_region: region, + oss_access_key_id: access_key_id, + oss_access_key_secret: access_key_secret, + oss_bucket: bucket, + oss_endpoint: endpoint + }; + const ossClient = new OssStorageClient(testUser); + await ossClient.connect(); + + // 尝试列出 bucket 内容 + await ossClient.list('/'); + await ossClient.end(); + + res.json({ + success: true, + message: 'OSS 连接测试成功' + }); + } catch (error) { + console.error('[OSS测试] 连接失败:', error); + res.status(400).json({ + success: false, + message: 'OSS 连接失败: ' + error.message + }); + } + } +); + +// 删除统一 OSS 配置 +app.delete('/api/admin/unified-oss-config', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + (req, res) => { + try { + SettingsDB.clearUnifiedOssConfig(); + + // 记录系统日志 + SystemLogDB.log({ + level: SystemLogDB.LEVELS.INFO, + category: SystemLogDB.CATEGORIES.SYSTEM, + action: 'delete_unified_oss_config', + message: '管理员删除了统一 OSS 配置', + userId: req.user.id, + username: req.user.username + }); + + res.json({ + success: true, + message: '统一 OSS 配置已删除,用户将使用个人配置' + }); + } catch (error) { + console.error('删除统一 OSS 配置失败:', error); + res.status(500).json({ + success: false, + message: '删除配置失败: ' + error.message + }); + } +}); + +// 系统健康检测API +app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, res) => { + try { + const checks = []; + let overallStatus = 'healthy'; // healthy, warning, critical + + // 1. JWT密钥安全检查 + const jwtSecure = isJwtSecretSecure(); + checks.push({ + name: 'JWT密钥', + category: 'security', + status: jwtSecure ? 'pass' : 'fail', + message: jwtSecure ? 'JWT密钥已正确配置(随机生成)' : 'JWT密钥使用默认值或长度不足,存在安全风险!', + suggestion: jwtSecure ? null : '请在.env中设置随机生成的JWT_SECRET,至少32字符' + }); + if (!jwtSecure) overallStatus = 'critical'; + + // 2. CORS配置检查 + const allowedOrigins = process.env.ALLOWED_ORIGINS; + const corsConfigured = allowedOrigins && allowedOrigins.trim().length > 0; + checks.push({ + name: 'CORS跨域配置', + category: 'security', + status: corsConfigured ? 'pass' : 'warning', + message: corsConfigured + ? `已配置允许的域名: ${allowedOrigins}` + : 'CORS未配置,允许所有来源(仅适合开发环境)', + suggestion: corsConfigured ? null : '生产环境建议配置ALLOWED_ORIGINS环境变量' + }); + if (!corsConfigured && overallStatus === 'healthy') overallStatus = 'warning'; + + // 3. HTTPS/Cookie安全配置 + const enforceHttps = process.env.ENFORCE_HTTPS === 'true'; + const cookieSecure = process.env.COOKIE_SECURE === 'true'; + const httpsConfigured = enforceHttps && cookieSecure; + checks.push({ + name: 'HTTPS安全配置', + category: 'security', + status: httpsConfigured ? 'pass' : 'warning', + message: httpsConfigured + ? 'HTTPS强制开启,Cookie安全标志已设置' + : `ENFORCE_HTTPS=${enforceHttps}, COOKIE_SECURE=${cookieSecure}`, + suggestion: httpsConfigured ? null : '生产环境建议开启ENFORCE_HTTPS和COOKIE_SECURE' + }); + + // 4. 管理员密码强度检查(检查是否为默认值) + const adminUsername = process.env.ADMIN_USERNAME; + const adminConfigured = adminUsername && adminUsername !== 'admin'; + checks.push({ + name: '管理员账号配置', + category: 'security', + status: adminConfigured ? 'pass' : 'warning', + message: adminConfigured + ? '管理员用户名已自定义' + : '管理员使用默认用户名"admin"', + suggestion: adminConfigured ? null : '建议使用自定义管理员用户名' + }); + + // 5. SMTP邮件配置检查 + const smtpHost = SettingsDB.get('smtp_host') || process.env.SMTP_HOST; + const smtpUser = SettingsDB.get('smtp_user') || process.env.SMTP_USER; + const smtpPassword = SettingsDB.get('smtp_password') || process.env.SMTP_PASSWORD; + const smtpConfigured = smtpHost && smtpUser && smtpPassword; + checks.push({ + name: 'SMTP邮件服务', + category: 'service', + status: smtpConfigured ? 'pass' : 'warning', + message: smtpConfigured + ? `已配置: ${smtpHost}` + : '未配置SMTP,邮箱验证和密码重置功能不可用', + suggestion: smtpConfigured ? null : '配置SMTP以启用邮箱验证功能' + }); + + // 6. 数据库连接检查 + let dbStatus = 'pass'; + let dbMessage = '数据库连接正常'; + try { + const testQuery = db.prepare('SELECT 1').get(); + if (!testQuery) throw new Error('查询返回空'); + } catch (dbError) { + dbStatus = 'fail'; + dbMessage = '数据库连接异常: ' + dbError.message; + overallStatus = 'critical'; + } + checks.push({ + name: '数据库连接', + category: 'service', + status: dbStatus, + message: dbMessage, + suggestion: dbStatus === 'fail' ? '检查数据库文件权限和路径配置' : null + }); + + // 7. 存储目录检查 + const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + let storageStatus = 'pass'; + let storageMessage = `存储目录正常: ${storageRoot}`; + try { + if (!fs.existsSync(storageRoot)) { + fs.mkdirSync(storageRoot, { recursive: true }); + storageMessage = `存储目录已创建: ${storageRoot}`; + } + // 检查写入权限 + const testFile = path.join(storageRoot, '.health-check-test'); + fs.writeFileSync(testFile, 'test'); + fs.unlinkSync(testFile); + } catch (storageError) { + storageStatus = 'fail'; + storageMessage = '存储目录不可写: ' + storageError.message; + overallStatus = 'critical'; + } + checks.push({ + name: '存储目录', + category: 'service', + status: storageStatus, + message: storageMessage, + suggestion: storageStatus === 'fail' ? '检查存储目录权限,确保Node进程有写入权限' : null + }); + + // 8. 限流器状态 + const rateLimiterActive = typeof loginLimiter !== 'undefined' && loginLimiter !== null; + checks.push({ + name: '登录防爆破', + category: 'security', + status: rateLimiterActive ? 'pass' : 'warning', + message: rateLimiterActive + ? '限流器已启用(5次/15分钟,封锁30分钟)' + : '限流器未正常初始化', + suggestion: null + }); + + // 9. 信任代理配置(安全检查) + const trustProxy = app.get('trust proxy'); + let trustProxyStatus = 'pass'; + let trustProxyMessage = ''; + let trustProxySuggestion = null; + + if (trustProxy === true) { + // trust proxy = true 是不安全的配置 + trustProxyStatus = 'fail'; + trustProxyMessage = 'trust proxy = true(信任所有代理),客户端可伪造 IP/协议!'; + trustProxySuggestion = '建议设置 TRUST_PROXY=1(单层代理)或具体的代理 IP 段'; + if (overallStatus !== 'critical') overallStatus = 'critical'; + } else if (trustProxy === false || !trustProxy) { + trustProxyStatus = 'info'; + trustProxyMessage = '未启用 trust proxy(直接暴露模式)'; + trustProxySuggestion = '如在 Nginx/CDN 后部署,需配置 TRUST_PROXY=1'; + } else if (typeof trustProxy === 'number') { + trustProxyStatus = 'pass'; + trustProxyMessage = `trust proxy = ${trustProxy}(信任前 ${trustProxy} 跳代理)`; + } else { + trustProxyStatus = 'pass'; + trustProxyMessage = `trust proxy = "${trustProxy}"(信任指定代理)`; + } + + checks.push({ + name: '信任代理配置', + category: 'security', + status: trustProxyStatus, + message: trustProxyMessage, + suggestion: trustProxySuggestion + }); + + // 10. Node环境 + const nodeEnv = process.env.NODE_ENV || 'development'; + checks.push({ + name: '运行环境', + category: 'config', + status: nodeEnv === 'production' ? 'pass' : 'info', + message: `当前环境: ${nodeEnv}`, + suggestion: nodeEnv !== 'production' ? '生产部署建议设置NODE_ENV=production' : null + }); + + // 11. CSRF 保护检查 + const csrfEnabled = process.env.ENABLE_CSRF === 'true'; + checks.push({ + name: 'CSRF保护', + category: 'security', + status: csrfEnabled ? 'pass' : 'warning', + message: csrfEnabled + ? 'CSRF保护已启用(Double Submit Cookie模式)' + : 'CSRF保护未启用(通过ENABLE_CSRF=true开启)', + suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击' + }); + + // 12. Session密钥检查 + const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32; + checks.push({ + name: 'Session密钥', + category: 'security', + status: sessionSecure ? 'pass' : 'fail', + message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!', + suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET,至少32字符' + }); + if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical'; + + // 统计 + const summary = { + total: checks.length, + pass: checks.filter(c => c.status === 'pass').length, + warning: checks.filter(c => c.status === 'warning').length, + fail: checks.filter(c => c.status === 'fail').length, + info: checks.filter(c => c.status === 'info').length + }; + + res.json({ + success: true, + overallStatus, + summary, + checks, + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.1.0' + }); + } catch (error) { + console.error('健康检测失败:', error); + res.status(500).json({ + success: false, + message: '健康检测失败: ' + error.message + }); + } +}); + +// ===== 第二轮修复:WAL 文件管理接口 ===== + +/** + * 获取 WAL 文件信息 + * GET /api/admin/wal-info + */ +app.get('/api/admin/wal-info', authMiddleware, adminMiddleware, (req, res) => { + try { + const walSize = WalManager.getWalFileSize(); + const walSizeMB = (walSize / 1024 / 1024).toFixed(2); + + res.json({ + success: true, + data: { + walSize, + walSizeMB: parseFloat(walSizeMB), + status: walSize > 100 * 1024 * 1024 ? 'warning' : 'normal' + } + }); + } catch (error) { + console.error('获取 WAL 信息失败:', error); + res.status(500).json({ + success: false, + message: '获取 WAL 信息失败: ' + error.message + }); + } +}); + +/** + * 手动执行 WAL 检查点(清理 WAL 文件) + * POST /api/admin/wal-checkpoint + */ +app.post('/api/admin/wal-checkpoint', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:WAL 检查点是敏感操作 + (req, res) => { + try { + const beforeSize = WalManager.getWalFileSize(); + const success = WalManager.performCheckpoint(); + const afterSize = WalManager.getWalFileSize(); + + if (!success) { + return res.status(500).json({ + success: false, + message: 'WAL 检查点执行失败' + }); + } + + // 记录 WAL 清理操作 + logSystem(req, 'wal_checkpoint', `管理员手动执行 WAL 检查点`, { + beforeSize, + afterSize, + freed: beforeSize - afterSize + }); + + res.json({ + success: true, + message: `WAL 检查点完成: ${(beforeSize / 1024 / 1024).toFixed(2)}MB → ${(afterSize / 1024 / 1024).toFixed(2)}MB`, + data: { + beforeSize, + afterSize, + freed: beforeSize - afterSize + } + }); + } catch (error) { + console.error('执行 WAL 检查点失败:', error); + res.status(500).json({ + success: false, + message: '执行 WAL 检查点失败: ' + error.message + }); + } + } +); + +// 获取服务器存储统计信息 +app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { + try { + // 获取本地存储目录(与 storage.js 保持一致) + const localStorageDir = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + + // 确保存储目录存在 + if (!fs.existsSync(localStorageDir)) { + fs.mkdirSync(localStorageDir, { recursive: true }); + } + + // 获取磁盘信息(使用df命令) + let totalDisk = 0; + let usedDisk = 0; + let availableDisk = 0; + + try { + // 获取本地存储目录所在分区的磁盘信息(避免使用shell) + const { stdout: dfOutput } = await execFileAsync('df', ['-B', '1', localStorageDir], { encoding: 'utf8' }); + // 取最后一行数据 + const lines = dfOutput.trim().split('\n'); + const parts = lines[lines.length - 1].trim().split(/\s+/); + + if (parts.length >= 4) { + totalDisk = parseInt(parts[1], 10) || 0; // 总大小 + usedDisk = parseInt(parts[2], 10) || 0; // 已使用 + availableDisk = parseInt(parts[3], 10) || 0; // 可用 + } + } catch (dfError) { + console.error('获取磁盘信息失败:', dfError.message); + // 如果df命令失败,尝试使用Windows的wmic命令 + try { + // 获取本地存储目录所在的驱动器号 + const driveLetter = localStorageDir.charAt(0); + const normalizedDrive = driveLetter.toUpperCase(); + if (!/^[A-Z]$/.test(normalizedDrive)) { + throw new Error('Invalid drive letter'); + } + const { stdout: wmicOutput } = await execFileAsync( + 'wmic', + ['logicaldisk', 'where', `DeviceID='${normalizedDrive}:'`, 'get', 'Size,FreeSpace', '/value'], + { encoding: 'utf8' } + ); + + const freeMatch = wmicOutput.match(/FreeSpace=(\d+)/); + const sizeMatch = wmicOutput.match(/Size=(\d+)/); + + if (sizeMatch && freeMatch) { + totalDisk = parseInt(sizeMatch[1]) || 0; + availableDisk = parseInt(freeMatch[1]) || 0; + usedDisk = totalDisk - availableDisk; + } + } catch (wmicError) { + console.error('获取Windows磁盘信息失败:', wmicError.message); + } + } + + // 从数据库获取所有用户的本地存储配额和使用情况 + const users = UserDB.getAll(); + let totalUserQuotas = 0; + let totalUserUsed = 0; + + users.forEach(user => { + // 只统计使用本地存储的用户(local_only 或 user_choice) + const storagePermission = user.storage_permission || 'oss_only'; + if (storagePermission === 'local_only' || storagePermission === 'user_choice') { + totalUserQuotas += user.local_storage_quota || 0; + totalUserUsed += user.local_storage_used || 0; + } + }); + + res.json({ + success: true, + stats: { + totalDisk, // 磁盘总容量 + usedDisk, // 磁盘已使用 + availableDisk, // 磁盘可用空间 + totalUserQuotas, // 用户配额总和 + totalUserUsed, // 用户实际使用总和 + totalUsers: users.length // 用户总数 + } + }); + + } catch (error) { + console.error('获取存储统计失败:', error); + res.status(500).json({ + success: false, + message: '获取存储统计失败: ' + error.message + }); + } +}); + +// 获取所有用户 +app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + try { + const users = UserDB.getAll(); + + res.json({ + success: true, + users: users.map(u => ({ + id: u.id, + username: u.username, + email: u.email, + is_admin: u.is_admin, + is_active: u.is_active, + is_verified: u.is_verified, + is_banned: u.is_banned, + has_oss_config: u.has_oss_config, + created_at: u.created_at, + // 新增:存储相关字段 + storage_permission: u.storage_permission || 'oss_only', + current_storage_type: u.current_storage_type || 'oss', + local_storage_quota: u.local_storage_quota || 1073741824, + local_storage_used: u.local_storage_used || 0 + })) + }); + } catch (error) { + console.error('获取用户列表失败:', error); + res.status(500).json({ + success: false, + message: '获取用户列表失败: ' + error.message + }); + } +}); + +// 获取系统日志 +app.get('/api/admin/logs', authMiddleware, adminMiddleware, (req, res) => { + try { + const { + page = 1, + pageSize = 50, + level, + category, + userId, + startDate, + endDate, + keyword + } = req.query; + + const result = SystemLogDB.query({ + page: parseInt(page), + pageSize: Math.min(parseInt(pageSize) || 50, 200), // 限制最大每页200条 + level: level || null, + category: category || null, + userId: userId ? parseInt(userId) : null, + startDate: startDate || null, + endDate: endDate || null, + keyword: keyword || null + }); + + res.json({ + success: true, + ...result + }); + } catch (error) { + console.error('获取系统日志失败:', error); + res.status(500).json({ + success: false, + message: '获取系统日志失败: ' + error.message + }); + } +}); + +// 获取日志统计 +app.get('/api/admin/logs/stats', authMiddleware, adminMiddleware, (req, res) => { + try { + const categoryStats = SystemLogDB.getStatsByCategory(); + const dateStats = SystemLogDB.getStatsByDate(7); + + res.json({ + success: true, + stats: { + byCategory: categoryStats, + byDate: dateStats + } + }); + } catch (error) { + console.error('获取日志统计失败:', error); + res.status(500).json({ + success: false, + message: '获取日志统计失败: ' + error.message + }); + } +}); + +// 清理旧日志 +app.post('/api/admin/logs/cleanup', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(日志清理影响审计追踪) + (req, res) => { + try { + const { keepDays = 90 } = req.body; + const days = Math.max(7, Math.min(parseInt(keepDays) || 90, 365)); // 最少保留7天,最多365天 + + const deletedCount = SystemLogDB.cleanup(days); + + // 记录清理操作 + logSystem(req, 'logs_cleanup', `管理员清理了 ${deletedCount} 条日志(保留 ${days} 天)`, { deletedCount, keepDays: days }); + + res.json({ + success: true, + message: `已清理 ${deletedCount} 条日志`, + deletedCount + }); + } catch (error) { + console.error('清理日志失败:', error); + res.status(500).json({ + success: false, + message: '清理日志失败: ' + error.message + }); + } +}); + +// ===== 第二轮修复:存储缓存一致性检查和修复接口 ===== + +/** + * 检查单个用户的存储缓存完整性 + * GET /api/admin/storage-cache/check/:userId + */ +app.get('/api/admin/storage-cache/check/:userId', + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const { userId } = req.params; + const user = UserDB.findById(userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!user.has_oss_config && !hasUnifiedConfig) { + return res.json({ + success: true, + message: '用户未配置 OSS,无需检查', + data: { + userId: user.id, + username: user.username, + hasOssConfig: false, + consistent: true + } + }); + } + + // 创建 OSS 客户端 + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.checkIntegrity(userId, ossClient); + + // 记录检查操作 + logSystem(req, 'storage_cache_check', `管理员检查用户 ${user.username} 的存储缓存`, { + userId, + username: user.username, + ...result + }); + + res.json({ + success: true, + message: result.consistent ? '缓存一致' : '缓存不一致', + data: { + userId: user.id, + username: user.username, + ...result + } + }); + } catch (error) { + console.error('检查存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '检查存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 重建单个用户的存储缓存 + * POST /api/admin/storage-cache/rebuild/:userId + */ +app.post('/api/admin/storage-cache/rebuild/:userId', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:重建缓存是敏感操作 + async (req, res) => { + try { + const { userId } = req.params; + const user = UserDB.findById(userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '用户未配置 OSS' + }); + } + + // 创建 OSS 客户端 + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.rebuildCache(userId, ossClient); + + // 记录修复操作 + logSystem(req, 'storage_cache_rebuild', `管理员重建用户 ${user.username} 的存储缓存`, { + userId, + username: user.username, + ...result + }); + + res.json({ + success: true, + message: `缓存已重建: ${formatFileSize(result.previous)} → ${formatFileSize(result.current)} (${result.fileCount} 个文件)`, + data: { + userId: user.id, + username: user.username, + ...result + } + }); + } catch (error) { + console.error('重建存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '重建存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 批量检查所有用户的存储缓存一致性 + * GET /api/admin/storage-cache/check-all + */ +app.get('/api/admin/storage-cache/check-all', + authMiddleware, + adminMiddleware, + async (req, res) => { + try { + const users = UserDB.getAll(); + + // 创建获取 OSS 客户端的函数 + const getOssClient = (user) => createOssClientForUser(user); + + const results = await StorageUsageCache.checkAllUsersIntegrity(users, getOssClient); + + // 统计 + const total = results.length; + const inconsistent = results.filter(r => !r.consistent && !r.error).length; + const errors = results.filter(r => r.error).length; + + // 记录批量检查操作 + logSystem(req, 'storage_cache_check_all', `管理员批量检查存储缓存`, { + total, + inconsistent, + errors + }); + + res.json({ + success: true, + message: `检查完成: ${total} 个用户,${inconsistent} 个不一致,${errors} 个错误`, + data: { + summary: { + total, + consistent: total - inconsistent - errors, + inconsistent, + errors + }, + results + } + }); + } catch (error) { + console.error('批量检查存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '批量检查存储缓存失败: ' + error.message + }); + } + } +); + +/** + * 自动检测并修复所有用户的缓存不一致 + * POST /api/admin/storage-cache/auto-fix + */ +app.post('/api/admin/storage-cache/auto-fix', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:批量修复是敏感操作 + async (req, res) => { + try { + const { threshold = 0 } = req.body; // 差异阈值(字节) + + const users = UserDB.getAll(); + const fixResults = []; + + // 检查是否有系统级统一OSS配置 + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + + for (const user of users) { + // 跳过没有配置 OSS 的用户(个人配置或系统级配置都没有) + if (!user.has_oss_config && !hasUnifiedConfig) { + continue; + } + + try { + const ossClient = createOssClientForUser(user); + const result = await StorageUsageCache.autoDetectAndFix(user.id, ossClient, threshold); + + fixResults.push({ + userId: user.id, + username: user.username, + ...result + }); + } catch (error) { + console.error(`自动修复用户 ${user.id} 失败:`, error.message); + fixResults.push({ + userId: user.id, + username: user.username, + error: error.message + }); + } + } + + // 统计 + const total = fixResults.length; + const fixed = fixResults.filter(r => r.autoFixed).length; + const errors = fixResults.filter(r => r.error).length; + + // 记录批量修复操作 + logSystem(req, 'storage_cache_auto_fix', `管理员自动修复存储缓存`, { + total, + fixed, + errors, + threshold + }); + + res.json({ + success: true, + message: `自动修复完成: ${total} 个用户,${fixed} 个已修复,${errors} 个错误`, + data: { + summary: { + total, + fixed, + skipped: total - fixed - errors, + errors + }, + results: fixResults + } + }); + } catch (error) { + console.error('自动修复存储缓存失败:', error); + res.status(500).json({ + success: false, + message: '自动修复存储缓存失败: ' + error.message + }); + } + } +); + +// 封禁/解封用户 +app.post('/api/admin/users/:id/ban', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(封禁用户是敏感操作) + (req, res) => { + try { + const { id } = req.params; + const { banned } = req.body; + + // 参数验证:验证 ID 格式 + const userId = parseInt(id, 10); + if (isNaN(userId) || userId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的用户ID' + }); + } + + // 参数验证:验证 banned 是否为布尔值 + if (typeof banned !== 'boolean') { + return res.status(400).json({ + success: false, + message: 'banned 参数必须为布尔值' + }); + } + + // 安全检查:不能封禁自己 + if (userId === req.user.id) { + return res.status(400).json({ + success: false, + message: '不能封禁自己的账号' + }); + } + + // 检查目标用户是否存在 + const targetUser = UserDB.findById(userId); + if (!targetUser) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 安全检查:不能封禁其他管理员(除非是超级管理员) + if (targetUser.is_admin && !req.user.is_super_admin) { + return res.status(403).json({ + success: false, + message: '不能封禁管理员账号' + }); + } + + UserDB.setBanStatus(userId, banned); + + // 记录管理员操作日志 + logUser(req, banned ? 'ban_user' : 'unban_user', + `管理员${banned ? '封禁' : '解封'}用户: ${targetUser.username}`, + { targetUserId: userId, targetUsername: targetUser.username } + ); + + res.json({ + success: true, + message: banned ? '用户已封禁' : '用户已解封' + }); + } catch (error) { + console.error('操作失败:', error); + res.status(500).json({ + success: false, + message: '操作失败: ' + error.message + }); + } +}); + +// 删除用户(级联删除文件和分享) +app.delete('/api/admin/users/:id', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证 + async (req, res) => { + try { + const { id } = req.params; + + // 参数验证:验证 ID 格式 + const userId = parseInt(id, 10); + if (isNaN(userId) || userId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的用户ID' + }); + } + + if (userId === req.user.id) { + return res.status(400).json({ + success: false, + message: '不能删除自己的账号' + }); + } + + // 获取用户信息 + const user = UserDB.findById(userId); + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + const deletionLog = { + userId: userId, + username: user.username, + deletedFiles: [], + deletedShares: 0, + warnings: [] + }; + + // 1. 删除本地存储文件(如果用户使用了本地存储) + const storagePermission = user.storage_permission || 'oss_only'; + if (storagePermission === 'local_only' || storagePermission === 'user_choice') { + const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + const userStorageDir = path.join(storageRoot, `user_${userId}`); + + if (fs.existsSync(userStorageDir)) { + try { + // 递归删除用户目录 + const deletedSize = getUserDirectorySize(userStorageDir); + fs.rmSync(userStorageDir, { recursive: true, force: true }); + deletionLog.deletedFiles.push({ + type: 'local', + path: userStorageDir, + size: deletedSize + }); + console.log(`[删除用户] 已删除本地存储目录: ${userStorageDir}`); + } catch (error) { + console.error(`[删除用户] 删除本地存储失败:`, error); + deletionLog.warnings.push(`删除本地存储失败: ${error.message}`); + } + } + } + + // 2. OSS存储文件 - 只记录警告,不实际删除(安全考虑) + if (user.has_oss_config && (storagePermission === 'oss_only' || storagePermission === 'user_choice')) { + deletionLog.warnings.push( + `用户配置了OSS存储 (${user.oss_provider}:${user.oss_bucket}),OSS文件未自动删除,请手动处理` + ); + } + + // 3. 删除用户的所有分享记录 + try { + const userShares = ShareDB.getUserShares(userId); + deletionLog.deletedShares = userShares.length; + + userShares.forEach(share => { + ShareDB.delete(share.id); + // 清除分享缓存 + if (shareFileCache.has(share.share_code)) { + shareFileCache.delete(share.share_code); + } + }); + + console.log(`[删除用户] 已删除 ${deletionLog.deletedShares} 条分享记录`); + } catch (error) { + console.error(`[删除用户] 删除分享记录失败:`, error); + deletionLog.warnings.push(`删除分享记录失败: ${error.message}`); + } + + // 4. 删除用户记录 + UserDB.delete(userId); + + // 构建响应消息 + let message = `用户 ${user.username} 已删除`; + if (deletionLog.deletedFiles.length > 0) { + const totalSize = deletionLog.deletedFiles.reduce((sum, f) => sum + f.size, 0); + message += `,已清理本地文件 ${formatFileSize(totalSize)}`; + } + if (deletionLog.deletedShares > 0) { + message += `,已删除 ${deletionLog.deletedShares} 条分享`; + } + + // 记录管理员删除用户操作日志 + logUser(req, 'delete_user', `管理员删除用户: ${user.username}`, { + targetUserId: userId, + targetUsername: user.username, + targetEmail: user.email, + deletedShares: deletionLog.deletedShares, + deletedFiles: deletionLog.deletedFiles.length, + warnings: deletionLog.warnings + }); + + res.json({ + success: true, + message, + details: deletionLog + }); + } catch (error) { + console.error('删除用户失败:', error); + res.status(500).json({ + success: false, + message: '删除用户失败: ' + error.message + }); + } +}); + +// 辅助函数:计算目录大小 +function getUserDirectorySize(dirPath) { + let totalSize = 0; + + function calculateSize(currentPath) { + try { + const stats = fs.statSync(currentPath); + + if (stats.isDirectory()) { + const files = fs.readdirSync(currentPath); + files.forEach(file => { + calculateSize(path.join(currentPath, file)); + }); + } else { + totalSize += stats.size; + } + } catch (error) { + console.error(`计算大小失败: ${currentPath}`, error); + } + } + + calculateSize(dirPath); + return totalSize; +} + +// 设置用户存储权限(管理员) +app.post('/api/admin/users/:id/storage-permission', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(修改存储权限影响用户数据访问) + [ + body('storage_permission').isIn(['local_only', 'oss_only', 'user_choice']).withMessage('无效的存储权限') + ], + (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const { id } = req.params; + const { storage_permission, local_storage_quota } = req.body; + + // 参数验证:验证 ID 格式 + const userId = parseInt(id, 10); + if (isNaN(userId) || userId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的用户ID' + }); + } + + const updates = { storage_permission }; + + // 如果提供了配额,更新配额(单位:字节) + if (local_storage_quota !== undefined) { + updates.local_storage_quota = parseInt(local_storage_quota, 10); + } + + // 根据权限设置自动调整存储类型 + const user = UserDB.findById(userId); + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + if (storage_permission === 'local_only') { + updates.current_storage_type = 'local'; + } else if (storage_permission === 'oss_only') { + // 只有配置了OSS才切换到OSS(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (user.has_oss_config || hasUnifiedConfig) { + updates.current_storage_type = 'oss'; + } + } + // user_choice 不自动切换,保持用户当前选择 + + UserDB.update(userId, updates); + + res.json({ + success: true, + message: '存储权限已更新' + }); + } catch (error) { + console.error('设置存储权限失败:', error); + res.status(500).json({ + success: false, + message: '设置存储权限失败: ' + error.message + }); + } + } +); + +// ===== 管理员文件审查功能 ===== + +// 查看用户文件列表(管理员,只读) +app.get('/api/admin/users/:id/files', authMiddleware, adminMiddleware, async (req, res) => { + const { id } = req.params; + const dirPath = req.query.path || '/'; + let ossClient; + + // 参数验证:验证 ID 格式 + const userId = parseInt(id, 10); + if (isNaN(userId) || userId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的用户ID' + }); + } + + try { + const user = UserDB.findById(userId); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); + if (!user.has_oss_config && !hasUnifiedConfig) { + return res.status(400).json({ + success: false, + message: '该用户未配置OSS服务' + }); + } + + // OssStorageClient 已在文件顶部导入 + ossClient = new OssStorageClient(user); + await ossClient.connect(); + const list = await ossClient.list(dirPath); + + const formattedList = list.map(item => ({ + name: item.name, + type: item.type === 'd' ? 'directory' : 'file', + size: item.size, + sizeFormatted: formatFileSize(item.size), + modifiedAt: new Date(item.modifyTime), + isDirectory: item.type === 'd' + })); + + formattedList.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }); + + res.json({ + success: true, + username: user.username, + path: dirPath, + items: formattedList + }); + } catch (error) { + console.error('管理员查看用户文件失败:', error); + res.status(500).json({ + success: false, + message: '获取文件列表失败: ' + error.message + }); + } finally { + if (ossClient) await ossClient.end(); + } +}); + +// 获取所有分享(管理员) +app.get('/api/admin/shares', authMiddleware, adminMiddleware, (req, res) => { + try { + const shares = ShareDB.getAll(); + + res.json({ + success: true, + shares + }); + } catch (error) { + console.error('获取分享列表失败:', error); + res.status(500).json({ + success: false, + message: '获取分享列表失败: ' + error.message + }); + } +}); + +// 删除分享(管理员) +app.delete('/api/admin/shares/:id', + authMiddleware, + adminMiddleware, + requirePasswordConfirmation, // 安全修复:添加密码二次验证(删除用户分享是敏感操作) + (req, res) => { + try { + // 参数验证:验证 ID 格式 + const shareId = parseInt(req.params.id, 10); + if (isNaN(shareId) || shareId <= 0) { + return res.status(400).json({ + success: false, + message: '无效的分享ID' + }); + } + + // 先获取分享信息以获得share_code + const share = ShareDB.findById(shareId); + + if (share) { + // 删除缓存 + if (shareFileCache.has(share.share_code)) { + shareFileCache.delete(share.share_code); + console.log(`[缓存清除] 分享码: ${share.share_code} (管理员操作)`); + } + + // 删除数据库记录 + ShareDB.delete(shareId); + + // 记录管理员操作日志 + logShare(req, 'admin_delete_share', + `管理员删除分享: ${share.share_code}`, + { shareId, shareCode: share.share_code, sharePath: share.share_path, ownerId: share.user_id } + ); + + res.json({ + success: true, + message: '分享已删除' + }); + } else { + res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + } catch (error) { + console.error('删除分享失败:', error); + res.status(500).json({ + success: false, + message: '删除分享失败: ' + error.message + }); + } +}); + +// 分享页面访问路由 +app.get("/s/:code", (req, res) => { + const shareCode = req.params.code; + // 使用相对路径重定向,浏览器会自动使用当前的协议和host + const frontendUrl = `/share.html?code=${shareCode}`; + console.log(`[分享] 重定向到: ${frontendUrl}`); + res.redirect(frontendUrl); +}); + +// 启动时清理旧临时文件 +cleanupOldTempFiles(); + +// 启动服务器 +app.listen(PORT, '0.0.0.0', () => { + console.log(`\n========================================`); + console.log(`玩玩云已启动`); + console.log(`服务器地址: http://localhost:${PORT}`); + console.log(`外网访问地址: http://0.0.0.0:${PORT}`); + console.log(`========================================\n`); +}); diff --git a/backend/start.bat b/backend/start.bat new file mode 100644 index 0000000..719cb35 --- /dev/null +++ b/backend/start.bat @@ -0,0 +1,10 @@ +@echo off +echo ======================================== +echo FTP 网盘管理平台 - 启动脚本 +echo ======================================== +echo. + +cd /d %~dp0 +node server.js + +pause diff --git a/backend/storage.js b/backend/storage.js new file mode 100644 index 0000000..6214736 --- /dev/null +++ b/backend/storage.js @@ -0,0 +1,1717 @@ +const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); +const fs = require('fs'); +const path = require('path'); +const { Readable } = require('stream'); +const { UserDB, SettingsDB } = require('./database'); + +// ===== 工具函数 ===== + +/** + * 格式化文件大小 + */ +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +/** + * 将 OSS/网络错误转换为友好的错误信息 + * @param {Error} error - 原始错误 + * @param {string} operation - 操作描述 + * @returns {Error} 带有友好消息的错误 + */ +function formatOssError(error, operation = '操作') { + // 常见的 AWS S3 / OSS 错误 + 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 内部错误,请稍后重试', + 'BucketNotEmpty': '存储桶不为空', + 'InvalidBucketName': '无效的存储桶名称', + 'InvalidObjectName': '无效的对象名称', + 'TooManyBuckets': '存储桶数量超过限制' + }; + + // 网络错误 + const networkErrors = { + 'ECONNREFUSED': '无法连接到 OSS 服务,请检查网络', + 'ENOTFOUND': 'OSS 服务地址无法解析,请检查 endpoint 配置', + 'ETIMEDOUT': '连接 OSS 服务超时,请检查网络', + 'ECONNRESET': '与 OSS 服务的连接被重置,请重试', + 'EPIPE': '与 OSS 服务的连接中断,请重试', + 'EHOSTUNREACH': '无法访问 OSS 服务主机,请检查网络' + }; + + // 检查 AWS SDK 错误名称 + 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]}`); + } + + // HTTP 状态码错误 + if (error.$metadata?.httpStatusCode) { + const statusCode = error.$metadata.httpStatusCode; + const statusMessages = { + 400: '请求参数错误', + 401: '认证失败,请检查 Access Key', + 403: '没有权限执行此操作', + 404: '资源不存在', + 409: '资源冲突', + 429: '请求过于频繁,请稍后重试', + 500: 'OSS 服务内部错误', + 502: 'OSS 网关错误', + 503: 'OSS 服务暂时不可用' + }; + if (statusMessages[statusCode]) { + return new Error(`${operation}失败: ${statusMessages[statusCode]}`); + } + } + + // 返回原始错误信息 + return new Error(`${operation}失败: ${error.message}`); +} + +// ===== 统一存储接口 ===== + +/** + * 存储接口工厂 + * 根据用户的存储类型返回对应的存储客户端 + */ +class StorageInterface { + constructor(user) { + this.user = user; + this.type = user.current_storage_type || 'oss'; + } + + /** + * 创建并返回存储客户端 + */ + async connect() { + if (this.type === 'local') { + const client = new LocalStorageClient(this.user); + await client.init(); + return client; + } else { + // OSS 客户端会自动检查是否有可用配置(系统配置或用户配置) + // 不再在这里强制检查 has_oss_config + const client = new OssStorageClient(this.user); + await client.connect(); + return client; + } + } +} + +// ===== 本地存储客户端 ===== + +class LocalStorageClient { + constructor(user) { + this.user = user; + // 使用环境变量或默认路径(不硬编码) + const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + this.basePath = path.join(storageRoot, `user_${user.id}`); + } + + /** + * 初始化用户存储目录 + */ + async init() { + if (!fs.existsSync(this.basePath)) { + fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 }); + console.log(`[本地存储] 创建用户目录: ${this.basePath}`); + } + } + + /** + * 列出目录内容 + * @param {string} dirPath - 目录路径 + * @returns {Promise} 文件列表 + */ + async list(dirPath) { + const fullPath = this.getFullPath(dirPath); + + // 确保目录存在 + if (!fs.existsSync(fullPath)) { + fs.mkdirSync(fullPath, { recursive: true }); + return []; + } + + // 检查是否是目录 + const pathStats = fs.statSync(fullPath); + if (!pathStats.isDirectory()) { + throw new Error('指定路径不是目录'); + } + + const items = fs.readdirSync(fullPath, { withFileTypes: true }); + const result = []; + + for (const item of items) { + try { + const itemPath = path.join(fullPath, item.name); + const stats = fs.statSync(itemPath); + result.push({ + name: item.name, + type: item.isDirectory() ? 'd' : '-', + size: stats.size, + modifyTime: stats.mtimeMs + }); + } catch (error) { + // 跳过无法访问的文件(权限问题或符号链接断裂等) + console.warn(`[本地存储] 无法获取文件信息,跳过: ${item.name}`, error.message); + } + } + + return result; + } + + /** + * 上传文件 + */ + async put(localPath, remotePath) { + const destPath = this.getFullPath(remotePath); + + // 获取新文件大小 + const newFileSize = fs.statSync(localPath).size; + + // 如果目标文件存在,计算实际需要的额外空间 + let oldFileSize = 0; + if (fs.existsSync(destPath)) { + try { + oldFileSize = fs.statSync(destPath).size; + } catch (err) { + // 文件可能已被删除,忽略错误 + } + } + + // 检查配额:只检查净增量(新文件大小 - 旧文件大小) + const netIncrease = newFileSize - oldFileSize; + if (netIncrease > 0) { + this.checkQuota(netIncrease); + } + + // 确保目标目录存在 + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + // 使用临时文件+重命名模式,避免文件被占用问题 + const tempPath = `${destPath}.uploading_${Date.now()}`; + + try { + // 复制到临时文件 + fs.copyFileSync(localPath, tempPath); + + // 如果目标文件存在,先删除 + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + + // 重命名临时文件为目标文件 + fs.renameSync(tempPath, destPath); + + // 更新已使用空间(使用净增量) + if (netIncrease !== 0) { + this.updateUsedSpace(netIncrease); + } + } catch (error) { + // 清理临时文件 + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + throw error; + } + } + + /** + * 删除文件或文件夹 + */ + async delete(filePath) { + const fullPath = this.getFullPath(filePath); + + // 检查文件是否存在 + if (!fs.existsSync(fullPath)) { + console.warn(`[本地存储] 删除目标不存在,跳过: ${filePath}`); + return; // 文件不存在,直接返回(幂等操作) + } + + let stats; + try { + stats = fs.statSync(fullPath); + } catch (error) { + if (error.code === 'ENOENT') { + // 文件在检查后被删除,直接返回 + return; + } + throw error; + } + + if (stats.isDirectory()) { + // 删除文件夹 - 递归删除 + // 先计算文件夹内所有文件的总大小 + const folderSize = this.calculateFolderSize(fullPath); + + // 删除文件夹及其内容 + fs.rmSync(fullPath, { recursive: true, force: true }); + + // 更新已使用空间 + if (folderSize > 0) { + this.updateUsedSpace(-folderSize); + } + console.log(`[本地存储] 删除文件夹: ${filePath} (释放 ${this.formatSize(folderSize)})`); + } else { + const fileSize = stats.size; + // 删除文件 + fs.unlinkSync(fullPath); + + // 更新已使用空间 + this.updateUsedSpace(-fileSize); + console.log(`[本地存储] 删除文件: ${filePath} (释放 ${this.formatSize(fileSize)})`); + } + } + + /** + * 计算文件夹大小 + */ + calculateFolderSize(folderPath) { + let totalSize = 0; + + const items = fs.readdirSync(folderPath, { withFileTypes: true }); + + for (const item of items) { + const itemPath = path.join(folderPath, item.name); + + if (item.isDirectory()) { + // 递归计算子文件夹 + totalSize += this.calculateFolderSize(itemPath); + } else { + // 累加文件大小 + const stats = fs.statSync(itemPath); + totalSize += stats.size; + } + } + + return totalSize; + } + + /** + * 重命名文件或目录 + * @param {string} oldPath - 原路径 + * @param {string} newPath - 新路径 + */ + async rename(oldPath, newPath) { + const oldFullPath = this.getFullPath(oldPath); + const newFullPath = this.getFullPath(newPath); + + // 检查源和目标是否相同 + if (oldFullPath === newFullPath) { + console.log(`[本地存储] 源路径和目标路径相同,跳过: ${oldPath}`); + return; + } + + // 检查源文件是否存在 + if (!fs.existsSync(oldFullPath)) { + throw new Error('源文件或目录不存在'); + } + + // 检查目标是否已存在(防止覆盖) + if (fs.existsSync(newFullPath)) { + throw new Error('目标位置已存在同名文件或目录'); + } + + // 确保新路径的目录存在 + const newDir = path.dirname(newFullPath); + if (!fs.existsSync(newDir)) { + fs.mkdirSync(newDir, { recursive: true }); + } + + fs.renameSync(oldFullPath, newFullPath); + console.log(`[本地存储] 重命名: ${oldPath} -> ${newPath}`); + } + + /** + * 获取文件信息 + * @param {string} filePath - 文件路径 + * @returns {Promise} 文件状态信息,包含 isDirectory 属性 + */ + async stat(filePath) { + const fullPath = this.getFullPath(filePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`文件或目录不存在: ${filePath}`); + } + + const stats = fs.statSync(fullPath); + // 返回与 OssStorageClient.stat 一致的格式 + return { + size: stats.size, + modifyTime: stats.mtimeMs, + isDirectory: stats.isDirectory(), + // 保留原始 stats 对象的方法兼容性 + isFile: () => stats.isFile(), + _raw: stats + }; + } + + /** + * 创建文件读取流 + * @param {string} filePath - 文件路径 + * @returns {ReadStream} 文件读取流 + */ + createReadStream(filePath) { + const fullPath = this.getFullPath(filePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`文件不存在: ${filePath}`); + } + + return fs.createReadStream(fullPath); + } + + /** + * 创建文件夹 + * @param {string} dirPath - 目录路径 + */ + async mkdir(dirPath) { + const fullPath = this.getFullPath(dirPath); + + // 检查是否已存在 + if (fs.existsSync(fullPath)) { + const stats = fs.statSync(fullPath); + if (stats.isDirectory()) { + // 目录已存在,直接返回 + return; + } + throw new Error('同名文件已存在'); + } + + // 创建目录 + fs.mkdirSync(fullPath, { recursive: true, mode: 0o755 }); + console.log(`[本地存储] 创建文件夹: ${dirPath}`); + } + + /** + * 检查文件或目录是否存在 + * @param {string} filePath - 文件路径 + * @returns {Promise} + */ + async exists(filePath) { + try { + const fullPath = this.getFullPath(filePath); + return fs.existsSync(fullPath); + } catch (error) { + return false; + } + } + + /** + * 关闭连接(本地存储无需关闭) + */ + async end() { + // 本地存储无需关闭连接 + } + + // ===== 辅助方法 ===== + + /** + * 获取完整路径(带安全检查) + * 增强的路径遍历防护 + */ + getFullPath(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 this.basePath; + } + + // 5. 拼接完整路径 + const fullPath = path.join(this.basePath, normalized); + + // 6. 解析真实路径(处理符号链接)后再次验证 + const resolvedBasePath = path.resolve(this.basePath); + const resolvedFullPath = path.resolve(fullPath); + + // 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击) + if (!resolvedFullPath.startsWith(resolvedBasePath)) { + console.warn('[安全] 检测到路径遍历攻击:', { + input: relativePath, + resolved: resolvedFullPath, + base: resolvedBasePath + }); + throw new Error('非法路径访问'); + } + + return fullPath; + } + + /** + * 检查配额 + */ + checkQuota(additionalSize) { + const newUsed = (this.user.local_storage_used || 0) + additionalSize; + if (newUsed > this.user.local_storage_quota) { + const used = this.formatSize(this.user.local_storage_used); + const quota = this.formatSize(this.user.local_storage_quota); + const need = this.formatSize(additionalSize); + throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`); + } + } + + /** + * 更新已使用空间 + */ + updateUsedSpace(delta) { + const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta); + UserDB.update(this.user.id, { local_storage_used: newUsed }); + // 更新内存中的值 + this.user.local_storage_used = newUsed; + } + + /** + * 恢复未完成的重命名操作(启动时调用) + * 扫描OSS存储中的待处理重命名标记文件,执行回滚或完成操作 + * + * **重命名操作的两个阶段:** + * 1. copying 阶段:正在复制文件到新位置 + * - 恢复策略:删除已复制的目标文件,保留原文件 + * 2. deleting 阶段:正在删除原文件 + * - 恢复策略:确保原文件被完全删除(补充删除逻辑) + * + * @private + */ + async recoverPendingRenames() { + try { + console.log('[OSS存储] 检查未完成的重命名操作...'); + + const bucket = this.getBucket(); + const markerPrefix = this.prefix + '.rename_pending_'; + + // 列出所有待处理的标记文件 + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: markerPrefix, + MaxKeys: 100 + }); + + const response = await this.s3Client.send(listCommand); + + if (!response.Contents || response.Contents.length === 0) { + console.log('[OSS存储] 没有未完成的重命名操作'); + return; + } + + console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`); + + for (const marker of response.Contents) { + try { + // 从标记文件名中解析元数据 + // 格式: .rename_pending_{timestamp}_{oldKeyHash}.json + const markerKey = marker.Key; + + // 读取标记文件内容 + const getMarkerCommand = new GetObjectCommand({ + Bucket: bucket, + Key: markerKey + }); + + const markerResponse = await this.s3Client.send(getMarkerCommand); + const markerContent = await streamToBuffer(markerResponse.Body); + const metadata = JSON.parse(markerContent.toString()); + + const { oldPrefix, newPrefix, timestamp, phase } = metadata; + + // 检查标记是否过期(超过1小时视为失败,需要恢复) + const age = Date.now() - timestamp; + const TIMEOUT = 60 * 60 * 1000; // 1小时 + + if (age > TIMEOUT) { + console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`); + + // 根据不同阶段执行不同的恢复策略 + if (phase === 'copying') { + // ===== 第一阶段:复制阶段超时 ===== + // 策略:删除已复制的目标文件,保留原文件 + console.log(`[OSS存储] [copying阶段] 执行回滚: 删除已复制的文件 ${newPrefix}`); + await this._rollbackRename(oldPrefix, newPrefix); + + } else if (phase === 'deleting') { + // ===== 第二阶段:删除阶段超时(第二轮修复) ===== + // 策略:补充完整的删除逻辑,确保原文件被清理干净 + console.log(`[OSS存储] [deleting阶段] 执行补充删除: 清理剩余原文件 ${oldPrefix}`); + + try { + // 步骤1:列出原位置的所有剩余文件 + let continuationToken = null; + let remainingCount = 0; + const MAX_KEYS_PER_REQUEST = 1000; + + do { + const listOldCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: oldPrefix, + MaxKeys: MAX_KEYS_PER_REQUEST, + ContinuationToken: continuationToken + }); + + const listOldResponse = await this.s3Client.send(listOldCommand); + continuationToken = listOldResponse.NextContinuationToken; + + if (listOldResponse.Contents && listOldResponse.Contents.length > 0) { + // 步骤2:批量删除剩余的原文件 + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: listOldResponse.Contents.map(obj => ({ Key: obj.Key })), + Quiet: true + } + }); + + const deleteResult = await this.s3Client.send(deleteCommand); + remainingCount += listOldResponse.Contents.length; + + console.log(`[OSS存储] [deleting阶段] 已删除 ${listOldResponse.Contents.length} 个剩余原文件`); + + // 检查删除结果 + if (deleteResult.Errors && deleteResult.Errors.length > 0) { + console.warn(`[OSS存储] [deleting阶段] 部分文件删除失败:`, deleteResult.Errors); + } + } + } while (continuationToken); + + if (remainingCount > 0) { + console.log(`[OSS存储] [deleting阶段] 补充删除完成: 清理了 ${remainingCount} 个原文件`); + } else { + console.log(`[OSS存储] [deleting阶段] 原位置 ${oldPrefix} 已是空的,无需清理`); + } + + } catch (cleanupError) { + console.error(`[OSS存储] [deleting阶段] 补充删除失败: ${cleanupError.message}`); + // 继续执行,不中断流程 + } + + } else { + // 未知阶段,记录警告 + console.warn(`[OSS存储] 未知阶段 ${phase},跳过恢复`); + } + + // 删除标记文件(完成恢复后清理) + await this.s3Client.send(new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: [{ Key: markerKey }], + Quiet: true + } + })); + + console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`); + } else { + console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase}, 剩余: ${Math.floor((TIMEOUT - age) / 1000)}秒)`); + } + } catch (error) { + console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message); + // 继续处理下一个标记文件 + } + } + + console.log('[OSS存储] 重命名操作恢复完成'); + } catch (error) { + console.error('[OSS存储] 恢复重命名操作时出错:', error.message); + } + } + + /** + * 回滚重命名操作(删除已复制的目标文件) + * @param {string} oldPrefix - 原前缀 + * @param {string} newPrefix - 新前缀 + * @private + */ + async _rollbackRename(oldPrefix, newPrefix) { + const bucket = this.getBucket(); + const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`; + + try { + // 列出所有已复制的对象 + let continuationToken = null; + let deletedCount = 0; + + do { + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: newPrefixWithSlash, + ContinuationToken: continuationToken, + MaxKeys: 1000 + }); + + const listResponse = await this.s3Client.send(listCommand); + continuationToken = listResponse.NextContinuationToken; + + if (listResponse.Contents && listResponse.Contents.length > 0) { + // 批量删除 + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })), + Quiet: true + } + }); + + await this.s3Client.send(deleteCommand); + deletedCount += listResponse.Contents.length; + } + } while (continuationToken); + + if (deletedCount > 0) { + console.log(`[OSS存储] 回滚完成: 删除了 ${deletedCount} 个对象`); + } + } catch (error) { + console.error(`[OSS存储] 回滚失败: ${error.message}`); + throw error; + } + } + + /** + * 格式化文件大小 + */ + formatSize(bytes) { + return formatFileSize(bytes); + } +} + +// ===== OSS存储客户端 ===== + +/** + * OSS 存储客户端(基于 S3 协议) + * 支持阿里云 OSS、腾讯云 COS、AWS S3 + * + * **优先级规则:** + * 1. 如果系统配置了统一 OSS(管理员配置),优先使用系统配置 + * 2. 否则使用用户自己的 OSS 配置(如果有的话) + * 3. 用户文件通过 `user_{userId}/` 前缀完全隔离 + */ +class OssStorageClient { + constructor(user) { + this.user = user; + this.s3Client = null; + this.prefix = `user_${user.id}/`; // 用户隔离前缀 + this.useUnifiedConfig = false; // 标记是否使用统一配置 + } + + /** + * 获取有效的 OSS 配置(优先使用系统配置) + * @returns {Object} OSS 配置对象 + * @throws {Error} 如果没有可用的配置 + */ + getEffectiveConfig() { + // 1. 优先检查系统级统一配置 + const unifiedConfig = SettingsDB.getUnifiedOssConfig(); + if (unifiedConfig) { + console.log(`[OSS存储] 用户 ${this.user.id} 使用系统级统一 OSS 配置`); + this.useUnifiedConfig = true; + return { + oss_provider: unifiedConfig.provider, + oss_region: unifiedConfig.region, + oss_access_key_id: unifiedConfig.access_key_id, + oss_access_key_secret: unifiedConfig.access_key_secret, + oss_bucket: unifiedConfig.bucket, + oss_endpoint: unifiedConfig.endpoint + }; + } + + // 2. 回退到用户自己的配置 + if (this.user.has_oss_config) { + console.log(`[OSS存储] 用户 ${this.user.id} 使用个人 OSS 配置`); + this.useUnifiedConfig = false; + return { + oss_provider: this.user.oss_provider, + oss_region: this.user.oss_region, + oss_access_key_id: this.user.oss_access_key_id, + oss_access_key_secret: this.user.oss_access_key_secret, + oss_bucket: this.user.oss_bucket, + oss_endpoint: this.user.oss_endpoint + }; + } + + // 3. 没有可用配置 + throw new Error('OSS 存储未配置,请联系管理员配置系统级 OSS 服务'); + } + + /** + * 验证 OSS 配置是否完整 + * @throws {Error} 配置不完整时抛出错误 + */ + validateConfig(config) { + const { oss_provider, oss_access_key_id, oss_access_key_secret, oss_bucket } = config; + + if (!oss_provider || !['aliyun', 'tencent', 'aws'].includes(oss_provider)) { + throw new Error('无效的 OSS 服务商,必须是 aliyun、tencent 或 aws'); + } + if (!oss_access_key_id || oss_access_key_id.trim() === '') { + throw new Error('OSS Access Key ID 不能为空'); + } + if (!oss_access_key_secret || oss_access_key_secret.trim() === '') { + throw new Error('OSS Access Key Secret 不能为空'); + } + if (!oss_bucket || oss_bucket.trim() === '') { + throw new Error('OSS 存储桶名称不能为空'); + } + } + + /** + * 根据服务商构建 S3 配置 + * @param {Object} config - OSS 配置对象 + * @returns {Object} S3 客户端配置 + */ + buildConfig(config) { + // 先验证配置 + this.validateConfig(config); + + const { oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_endpoint } = config; + + // AWS S3 默认配置 + let s3Config = { + region: oss_region || 'us-east-1', + credentials: { + accessKeyId: oss_access_key_id, + secretAccessKey: oss_access_key_secret + }, + // 请求超时配置 + requestHandler: { + requestTimeout: 30000, // 30秒超时 + httpsAgent: { timeout: 30000 } + }, + // 重试配置 + maxAttempts: 3, + // 禁用AWS特定的计算功能,提升阿里云OSS兼容性 + disableNormalizeBucketName: true, + // 禁用MD5校验和计算(阿里云OSS不完全支持AWS的checksum特性) + checksumValidation: false + }; + + // 阿里云 OSS + if (oss_provider === 'aliyun') { + // 规范化 region:确保格式为 oss-cn-xxx + let region = oss_region || 'oss-cn-hangzhou'; + if (!region.startsWith('oss-')) { + region = 'oss-' + region; + } + s3Config.region = region; + + if (!oss_endpoint) { + // 默认 endpoint 格式:https://{region}.aliyuncs.com + s3Config.endpoint = `https://${region}.aliyuncs.com`; + } else { + // 确保 endpoint 以 https:// 或 http:// 开头 + s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; + } + // 阿里云 OSS 使用 virtual-hosted-style,但需要设置 forcePathStyle 为 false + s3Config.forcePathStyle = false; + // 阿里云OSS特定配置:禁用AWS特定的计算功能 + s3Config.disableNormalizeBucketName = true; + s3Config.checksumValidation = false; + } + // 腾讯云 COS + else if (oss_provider === 'tencent') { + s3Config.region = oss_region || 'ap-guangzhou'; + if (!oss_endpoint) { + // 默认 endpoint 格式:https://cos.{region}.myqcloud.com + s3Config.endpoint = `https://cos.${s3Config.region}.myqcloud.com`; + } else { + s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; + } + // 腾讯云 COS 使用 virtual-hosted-style + s3Config.forcePathStyle = false; + } + // AWS S3 或其他兼容服务 + else { + if (oss_endpoint) { + s3Config.endpoint = oss_endpoint.startsWith('http') ? oss_endpoint : `https://${oss_endpoint}`; + // 自定义 endpoint(如 MinIO)通常需要 path-style + s3Config.forcePathStyle = true; + } + // AWS 使用默认 endpoint,无需额外配置 + } + + return s3Config; + } + + /** + * 连接 OSS 服务(初始化 S3 客户端) + */ + async connect() { + try { + // 获取有效的 OSS 配置(系统配置优先) + const ossConfig = this.getEffectiveConfig(); + const s3Config = this.buildConfig(ossConfig); + + // 保存当前使用的配置(供其他方法使用) + this.currentConfig = ossConfig; + + this.s3Client = new S3Client(s3Config); + console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`); + return this; + } catch (error) { + console.error(`[OSS存储] 连接失败:`, error.message); + throw new Error(`OSS 连接失败: ${error.message}`); + } + } + + /** + * 获取当前使用的 bucket 名称 + * @returns {string} + */ + getBucket() { + if (this.currentConfig && this.currentConfig.oss_bucket) { + return this.currentConfig.oss_bucket; + } + // 回退到用户配置(向后兼容) + return this.user.oss_bucket; + } + + /** + * 获取当前使用的 provider + * @returns {string} + */ + getProvider() { + if (this.currentConfig && this.currentConfig.oss_provider) { + return this.currentConfig.oss_provider; + } + // 回退到用户配置(向后兼容) + return this.user.oss_provider; + } + + /** + * 获取对象的完整 Key(带用户前缀) + * 增强安全检查,防止路径遍历攻击 + */ + getObjectKey(relativePath) { + // 0. 输入类型验证 + if (relativePath === null || relativePath === undefined) { + return this.prefix; // null/undefined 返回根目录 + } + + if (typeof relativePath !== 'string') { + throw new Error('无效的路径类型'); + } + + // 1. 检查空字节注入(%00, \x00)和其他危险字符 + if (relativePath.includes('\x00') || relativePath.includes('%00')) { + console.warn('[OSS安全] 检测到空字节注入尝试:', relativePath); + throw new Error('路径包含非法字符'); + } + + // 2. 先进行 URL 解码(防止双重编码绕过) + let decoded = relativePath; + try { + decoded = decodeURIComponent(relativePath); + } catch (e) { + // 解码失败使用原始值 + } + + // 3. 检查解码后的空字节 + if (decoded.includes('\x00')) { + console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath); + throw new Error('路径包含非法字符'); + } + + // 4. 规范化路径:统一使用正斜杠(OSS 使用正斜杠作为分隔符) + let normalized = decoded + .replace(/\\/g, '/') // 将反斜杠转换为正斜杠 + .replace(/\/+/g, '/'); // 合并多个连续斜杠 + + // 5. 严格检查:路径中不允许包含 ..(防止目录遍历) + // 检查各种变体:../, /../, /.. + if (normalized.includes('..')) { + console.warn('[OSS安全] 检测到目录遍历尝试:', relativePath); + throw new Error('路径包含非法字符'); + } + + // 6. 移除开头的斜杠 + normalized = normalized.replace(/^\/+/, ''); + + // 7. 移除结尾的斜杠(除非是根目录) + if (normalized.length > 0 && normalized !== '/') { + normalized = normalized.replace(/\/+$/, ''); + } + + // 8. 空路径或 . 表示根目录 + if (normalized === '' || normalized === '.') { + return this.prefix; + } + + // 9. 拼接用户前缀(确保不会产生双斜杠) + const objectKey = this.prefix + normalized; + + // 10. 最终验证:确保生成的 key 以用户前缀开头(双重保险) + if (!objectKey.startsWith(this.prefix)) { + console.warn('[OSS安全] Key 前缀验证失败:', { input: relativePath, key: objectKey, prefix: this.prefix }); + throw new Error('非法路径访问'); + } + + return objectKey; + } + + /** + * 列出目录内容 + * 支持分页,可列出超过 1000 个文件的目录 + * @param {string} dirPath - 目录路径 + * @param {number} maxItems - 最大返回数量,默认 10000,设为 0 表示不限制 + */ + async list(dirPath, maxItems = 10000) { + try { + let prefix = this.getObjectKey(dirPath); + const bucket = this.getBucket(); + + // 确保前缀以斜杠结尾(除非是根目录) + if (prefix && !prefix.endsWith('/')) { + prefix = prefix + '/'; + } + + const items = []; + const dirSet = new Set(); // 用于去重目录 + let continuationToken = undefined; + const MAX_KEYS_PER_REQUEST = 1000; + + do { + const command = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix, + Delimiter: '/', // 使用分隔符模拟目录结构 + MaxKeys: MAX_KEYS_PER_REQUEST, + ContinuationToken: continuationToken + }); + + const response = await this.s3Client.send(command); + continuationToken = response.NextContinuationToken; + + // 处理"子目录"(CommonPrefixes) + if (response.CommonPrefixes) { + for (const prefixObj of response.CommonPrefixes) { + const dirName = prefixObj.Prefix.substring(prefix.length).replace(/\/$/, ''); + if (dirName && !dirSet.has(dirName)) { + dirSet.add(dirName); + items.push({ + name: dirName, + type: 'd', + size: 0, + modifyTime: Date.now() + }); + } + } + } + + // 处理文件(Contents) + if (response.Contents) { + for (const obj of response.Contents) { + const key = obj.Key; + // 跳过目录标记本身(以斜杠结尾的空对象) + if (key === prefix || key.endsWith('/')) { + continue; + } + const fileName = key.substring(prefix.length); + // 跳过包含子路径的文件(不应该出现,但以防万一) + if (fileName && !fileName.includes('/')) { + items.push({ + name: fileName, + type: '-', + size: obj.Size || 0, + modifyTime: obj.LastModified ? obj.LastModified.getTime() : Date.now() + }); + } + } + } + + // 检查是否达到最大数量限制 + if (maxItems > 0 && items.length >= maxItems) { + console.log(`[OSS存储] 列出目录达到限制: ${dirPath} (${items.length}/${maxItems})`); + break; + } + + } while (continuationToken); + + return items; + } catch (error) { + console.error(`[OSS存储] 列出目录失败: ${dirPath}`, error.message); + + // 判断错误类型并给出友好的错误信息 + if (error.name === 'NoSuchBucket') { + throw new Error('OSS 存储桶不存在,请检查配置'); + } else if (error.name === 'AccessDenied') { + throw new Error('OSS 访问被拒绝,请检查权限配置'); + } else if (error.name === 'InvalidAccessKeyId') { + throw new Error('OSS Access Key 无效,请重新配置'); + } + throw new Error(`列出目录失败: ${error.message}`); + } + } + + /** + * 上传文件(直接上传,简单高效) + * @param {string} localPath - 本地文件路径 + * @param {string} remotePath - 远程文件路径 + */ + async put(localPath, remotePath) { + try { + const key = this.getObjectKey(remotePath); + const bucket = this.getBucket(); + + // 检查本地文件是否存在 + if (!fs.existsSync(localPath)) { + throw new Error(`本地文件不存在: ${localPath}`); + } + + const fileStats = fs.statSync(localPath); + const fileSize = fileStats.size; + + // 检查文件大小(AWS S3 单次上传最大 5GB) + const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB + if (fileSize > MAX_SINGLE_UPLOAD_SIZE) { + throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`); + } + + // 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码 + // 这样可以确保与阿里云OSS的兼容性 + const fileContent = fs.readFileSync(localPath); + + // 直接上传 + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fileContent, + ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题 + // 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头) + ChecksumAlgorithm: undefined + }); + + await this.s3Client.send(command); + console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`); + + } catch (error) { + console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message); + + // 判断错误类型并给出友好的错误信息 + if (error.name === 'NoSuchBucket') { + throw new Error('OSS 存储桶不存在,请检查配置'); + } else if (error.name === 'AccessDenied') { + throw new Error('OSS 访问被拒绝,请检查权限配置'); + } else if (error.name === 'EntityTooLarge') { + throw new Error('文件过大,超过了 OSS 允许的最大大小'); + } else if (error.code === 'ENOENT') { + throw new Error(`本地文件不存在: ${localPath}`); + } + throw new Error(`文件上传失败: ${error.message}`); + } + } + + /** + * 删除文件或文件夹 + * ===== P0 性能优化:返回删除的文件大小,用于更新存储使用量缓存 ===== + * @returns {Promise<{size: number}>} 返回删除的文件总大小(字节) + */ + async delete(filePath) { + try { + const key = this.getObjectKey(filePath); + const bucket = this.getBucket(); + + // 检查是文件还是目录(忽略不存在的文件) + let statResult; + try { + statResult = await this.stat(filePath); + } catch (statError) { + if (statError.message && statResult?.message.includes('不存在')) { + console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`); + return { size: 0 }; // 文件不存在,返回大小为 0 + } + throw statError; // 其他错误继续抛出 + } + + let totalDeletedSize = 0; + + if (statResult.isDirectory) { + // 删除目录:列出所有对象并批量删除 + // 使用分页循环处理超过 1000 个对象的情况 + let continuationToken = null; + let totalDeletedCount = 0; + const MAX_DELETE_BATCH = 1000; // AWS S3 单次最多删除 1000 个对象 + + do { + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: key, + MaxKeys: MAX_DELETE_BATCH, + ContinuationToken: continuationToken + }); + + const listResponse = await this.s3Client.send(listCommand); + continuationToken = listResponse.NextContinuationToken; + + if (listResponse.Contents && listResponse.Contents.length > 0) { + // 累加删除的文件大小 + for (const obj of listResponse.Contents) { + totalDeletedSize += obj.Size || 0; + } + + // 批量删除当前批次的对象 + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })), + Quiet: false + } + }); + + const deleteResult = await this.s3Client.send(deleteCommand); + + // 检查删除结果 + if (deleteResult.Errors && deleteResult.Errors.length > 0) { + console.warn(`[OSS存储] 部分对象删除失败:`, deleteResult.Errors); + } + + totalDeletedCount += listResponse.Contents.length; + } + } while (continuationToken); + + if (totalDeletedCount > 0) { + console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`); + } + + return { size: totalDeletedSize }; + } else { + // 删除单个文件(使用DeleteObjectCommand) + // 获取文件大小 + const size = statResult.size || 0; + totalDeletedSize = size; + + const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + const command = new DeleteObjectCommand({ + Bucket: bucket, + Key: key + }); + + await this.s3Client.send(command); + console.log(`[OSS存储] 删除文件: ${key} (${size} 字节)`); + + return { size: totalDeletedSize }; + } + } catch (error) { + console.error(`[OSS存储] 删除失败: ${filePath}`, error.message); + + // 判断错误类型并给出友好的错误信息 + if (error.name === 'NoSuchBucket') { + throw new Error('OSS 存储桶不存在,请检查配置'); + } else if (error.name === 'AccessDenied') { + throw new Error('OSS 访问被拒绝,请检查权限配置'); + } + throw new Error(`删除文件失败: ${error.message}`); + } + } + + /** + * 重命名文件或目录(OSS 不支持直接重命名,需要复制后删除) + * 支持文件和目录的重命名 + * @param {string} oldPath - 原路径 + * @param {string} newPath - 新路径 + */ + async rename(oldPath, newPath) { + const oldKey = this.getObjectKey(oldPath); + const newKey = this.getObjectKey(newPath); + const bucket = this.getBucket(); // 使用getBucket()方法以支持系统级统一OSS配置 + + // 验证源和目标不同 + if (oldKey === newKey) { + console.log(`[OSS存储] 源路径和目标路径相同,跳过: ${oldKey}`); + return; + } + + let copySuccess = false; + + try { + // 检查源文件是否存在 + const statResult = await this.stat(oldPath); + + // 如果是目录,执行目录重命名 + if (statResult.isDirectory) { + await this._renameDirectory(oldPath, newPath); + return; + } + + // 使用 CopyObjectCommand 复制文件 + // CopySource 格式:bucket/key,需要对 key 中的特殊字符进行编码 + // 但保留路径分隔符(/)不编码 + const encodedOldKey = oldKey.split('/').map(segment => encodeURIComponent(segment)).join('/'); + const copySource = `${bucket}/${encodedOldKey}`; + + const copyCommand = new CopyObjectCommand({ + Bucket: bucket, + CopySource: copySource, + Key: newKey + }); + + await this.s3Client.send(copyCommand); + copySuccess = true; + + // 复制成功后删除原文件(使用DeleteObjectCommand删除单个文件) + const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucket, + Key: oldKey + }); + await this.s3Client.send(deleteCommand); + + console.log(`[OSS存储] 重命名: ${oldKey} -> ${newKey}`); + } catch (error) { + console.error(`[OSS存储] 重命名失败: ${oldPath} -> ${newPath}`, error.message); + + // 如果复制成功但删除失败,尝试回滚(删除新复制的文件) + if (copySuccess) { + try { + console.log(`[OSS存储] 尝试回滚:删除已复制的文件 ${newKey}`); + const { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucket, + Key: newKey + }); + await this.s3Client.send(deleteCommand); + console.log(`[OSS存储] 回滚成功:已删除 ${newKey}`); + } catch (rollbackError) { + console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`); + } + } + + // 判断错误类型并给出友好的错误信息 + if (error.name === 'NoSuchBucket') { + throw new Error('OSS 存储桶不存在,请检查配置'); + } else if (error.name === 'AccessDenied') { + throw new Error('OSS 访问被拒绝,请检查权限配置'); + } else if (error.name === 'NoSuchKey') { + throw new Error('源文件不存在'); + } + throw new Error(`重命名文件失败: ${error.message}`); + } + } + + /** + * 重命名目录(内部方法) + * 通过遍历目录下所有对象,逐个复制到新位置后删除原对象 + * 使用事务标记机制防止竞态条件 + * @param {string} oldPath - 原目录路径 + * @param {string} newPath - 新目录路径 + * @private + */ + async _renameDirectory(oldPath, newPath) { + const oldPrefix = this.getObjectKey(oldPath); + const newPrefix = this.getObjectKey(newPath); + const bucket = this.getBucket(); + + // 确保前缀以斜杠结尾 + const oldPrefixWithSlash = oldPrefix.endsWith('/') ? oldPrefix : `${oldPrefix}/`; + const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`; + + // 生成事务标记文件 + const timestamp = Date.now(); + const markerKey = `${this.prefix}.rename_pending_${timestamp}.json`; + + // 标记文件内容(用于恢复) + const markerContent = JSON.stringify({ + oldPrefix: oldPrefixWithSlash, + newPrefix: newPrefixWithSlash, + timestamp: timestamp, + phase: 'copying' // 标记当前阶段:copying(复制中)、deleting(删除中) + }); + + let continuationToken = null; + let copiedKeys = []; + let totalCount = 0; + + try { + // 步骤1:创建事务标记文件(标识重命名操作开始) + console.log(`[OSS存储] 创建重命名事务标记: ${markerKey}`); + const putMarkerCommand = new PutObjectCommand({ + Bucket: bucket, + Key: markerKey, + Body: markerContent, + ContentType: 'application/json' + }); + await this.s3Client.send(putMarkerCommand); + + // 步骤2:复制所有对象到新位置 + do { + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: oldPrefixWithSlash, + ContinuationToken: continuationToken, + MaxKeys: 1000 + }); + + const listResponse = await this.s3Client.send(listCommand); + continuationToken = listResponse.NextContinuationToken; + + if (listResponse.Contents && listResponse.Contents.length > 0) { + for (const obj of listResponse.Contents) { + // 计算新的 key(替换前缀) + const newKey = newPrefixWithSlash + obj.Key.substring(oldPrefixWithSlash.length); + + // 复制对象 + const encodedOldKey = obj.Key.split('/').map(segment => encodeURIComponent(segment)).join('/'); + const copyCommand = new CopyObjectCommand({ + Bucket: bucket, + CopySource: `${bucket}/${encodedOldKey}`, + Key: newKey + }); + + await this.s3Client.send(copyCommand); + copiedKeys.push({ oldKey: obj.Key, newKey }); + totalCount++; + } + } + } while (continuationToken); + + // 步骤3:更新标记文件状态为 deleting(复制完成,开始删除) + const updatedMarkerContent = JSON.stringify({ + oldPrefix: oldPrefixWithSlash, + newPrefix: newPrefixWithSlash, + timestamp: timestamp, + phase: 'deleting' + }); + await this.s3Client.send(new PutObjectCommand({ + Bucket: bucket, + Key: markerKey, + Body: updatedMarkerContent, + ContentType: 'application/json' + })); + + // 步骤4:删除所有原对象(批量删除) + if (copiedKeys.length > 0) { + for (let i = 0; i < copiedKeys.length; i += 1000) { + const batch = copiedKeys.slice(i, i + 1000); + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: batch.map(item => ({ Key: item.oldKey })), + Quiet: true + } + }); + await this.s3Client.send(deleteCommand); + } + } + + // 步骤5:删除事务标记文件(操作完成) + await this.s3Client.send(new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: [{ Key: markerKey }], + Quiet: true + } + })); + + console.log(`[OSS存储] 重命名目录完成: ${oldPath} -> ${newPath} (${totalCount} 个对象)`); + + } catch (error) { + // 如果出错,尝试回滚 + console.error(`[OSS存储] 目录重命名失败: ${error.message}`); + + if (copiedKeys.length > 0) { + console.warn(`[OSS存储] 尝试回滚已复制的 ${copiedKeys.length} 个对象...`); + try { + // 回滚:删除已复制的新对象 + for (let i = 0; i < copiedKeys.length; i += 1000) { + const batch = copiedKeys.slice(i, i + 1000); + const deleteCommand = new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: batch.map(item => ({ Key: item.newKey })), + Quiet: true + } + }); + await this.s3Client.send(deleteCommand); + } + console.log(`[OSS存储] 回滚成功`); + } catch (rollbackError) { + console.error(`[OSS存储] 回滚失败: ${rollbackError.message}`); + } + } + + // 清理事务标记文件(如果还存在) + try { + await this.s3Client.send(new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Objects: [{ Key: markerKey }], + Quiet: true + } + })); + } catch (markerError) { + // 忽略标记文件删除错误 + } + + throw new Error(`重命名目录失败: ${error.message}`); + } + } + + /** + * 获取文件信息 + */ + async stat(filePath) { + const key = this.getObjectKey(filePath); + const bucket = this.getBucket(); // 使用getBucket()方法以支持系统级统一OSS配置 + + try { + const command = new HeadObjectCommand({ + Bucket: bucket, + Key: key + }); + + const response = await this.s3Client.send(command); + return { + size: response.ContentLength || 0, + modifyTime: response.LastModified ? response.LastModified.getTime() : Date.now(), + isDirectory: false + }; + } catch (error) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + // 可能是目录,尝试列出前缀 + const listCommand = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: key.endsWith('/') ? key : key + '/', + MaxKeys: 1 + }); + + try { + const listResponse = await this.s3Client.send(listCommand); + if (listResponse.Contents && listResponse.Contents.length > 0) { + return { isDirectory: true, size: 0, modifyTime: Date.now() }; + } + } catch (listError) { + // 忽略列表错误 + } + } + throw new Error(`对象不存在: ${key}`); + } + } + + /** + * 创建文件读取流(异步方法) + * @returns {Promise} 返回可读流 Promise + */ + async createReadStream(filePath) { + const key = this.getObjectKey(filePath); + const bucket = this.user.oss_bucket; + + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key + }); + + try { + const response = await this.s3Client.send(command); + // AWS SDK v3 返回的 Body 是一个 IncomingMessage 类型的流 + return response.Body; + } catch (error) { + console.error(`[OSS存储] 创建读取流失败: ${key}`, error.message); + throw error; + } + } + + /** + * 创建文件夹(通过创建空对象模拟) + * OSS 中文件夹实际上是一个以斜杠结尾的空对象 + */ + async mkdir(dirPath) { + try { + const key = this.getObjectKey(dirPath); + const bucket = this.getBucket(); + + // OSS 中文件夹通过以斜杠结尾的空对象模拟 + const folderKey = key.endsWith('/') ? key : `${key}/`; + + const command = new PutObjectCommand({ + Bucket: bucket, + Key: folderKey, + Body: '', // 空内容 + ContentType: 'application/x-directory' + }); + + await this.s3Client.send(command); + console.log(`[OSS存储] 创建文件夹: ${folderKey}`); + } catch (error) { + console.error(`[OSS存储] 创建文件夹失败: ${dirPath}`, error.message); + if (error.name === 'AccessDenied') { + throw new Error('OSS 访问被拒绝,请检查权限配置'); + } + throw new Error(`创建文件夹失败: ${error.message}`); + } + } + + /** + * 获取签名下载 URL(用于分享链接,支持私有 bucket) + * @param {string} filePath - 文件路径 + * @param {number} expiresIn - 过期时间(秒),默认 3600 秒(1小时) + * @returns {Promise} 签名 URL + */ + async getPresignedUrl(filePath, expiresIn = 3600) { + const key = this.getObjectKey(filePath); + const bucket = this.user.oss_bucket; + + try { + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key + }); + + // 使用 AWS SDK 的 getSignedUrl 生成真正的签名 URL + const signedUrl = await getSignedUrl(this.s3Client, command, { + expiresIn: Math.min(expiresIn, 604800) // 最大 7 天 + }); + + return signedUrl; + } catch (error) { + console.error(`[OSS存储] 生成签名 URL 失败: ${filePath}`, error.message); + throw new Error(`生成签名 URL 失败: ${error.message}`); + } + } + + /** + * 获取公开 URL(仅适用于公共读的 bucket) + * @deprecated 建议使用 getPresignedUrl 代替 + */ + getPublicUrl(filePath) { + const key = this.getObjectKey(filePath); + const bucket = this.getBucket(); + const provider = this.getProvider(); + const region = this.s3Client.config.region; + + let baseUrl; + if (provider === 'aliyun') { + // 阿里云 OSS 公开 URL 格式 + const ossRegion = region.startsWith('oss-') ? region : `oss-${region}`; + baseUrl = `https://${bucket}.${ossRegion}.aliyuncs.com`; + } else if (provider === 'tencent') { + // 腾讯云 COS 公开 URL 格式 + baseUrl = `https://${bucket}.cos.${region}.myqcloud.com`; + } else { + // AWS S3 公开 URL 格式 + baseUrl = `https://${bucket}.s3.${region}.amazonaws.com`; + } + + // 对 key 中的特殊字符进行 URL 编码,但保留路径分隔符 + const encodedKey = key.split('/').map(segment => encodeURIComponent(segment)).join('/'); + return `${baseUrl}/${encodedKey}`; + } + + /** + * 获取上传签名 URL(用于前端直传) + * @param {string} filePath - 文件路径 + * @param {number} expiresIn - 过期时间(秒),默认 900 秒(15分钟) + * @param {string} contentType - 文件 MIME 类型 + * @returns {Promise} 签名 URL + */ + async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') { + const key = this.getObjectKey(filePath); + const bucket = this.user.oss_bucket; + + try { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType + }); + + const signedUrl = await getSignedUrl(this.s3Client, command, { + expiresIn: Math.min(expiresIn, 3600) // 上传 URL 最大 1 小时 + }); + + return signedUrl; + } catch (error) { + console.error(`[OSS存储] 生成上传签名 URL 失败: ${filePath}`, error.message); + throw new Error(`生成上传签名 URL 失败: ${error.message}`); + } + } + + /** + * 检查文件或目录是否存在 + * @param {string} filePath - 文件路径 + * @returns {Promise} + */ + async exists(filePath) { + try { + await this.stat(filePath); + return true; + } catch (error) { + return false; + } + } + + /** + * 格式化文件大小 + */ + formatSize(bytes) { + return formatFileSize(bytes); + } + + /** + * 关闭连接(S3Client 无需显式关闭) + */ + async end() { + this.s3Client = null; + } +} + +/** + * 将流转换为 Buffer(辅助函数) + */ +async function streamToBuffer(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); +} + + +module.exports = { + StorageInterface, + LocalStorageClient, + OssStorageClient, + formatFileSize, // 导出共享的工具函数 + formatOssError // 导出 OSS 错误格式化函数 +}; diff --git a/backend/storage/.gitkeep b/backend/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/boundary-tests.js b/backend/tests/boundary-tests.js new file mode 100644 index 0000000..c9bdb67 --- /dev/null +++ b/backend/tests/boundary-tests.js @@ -0,0 +1,934 @@ +/** + * 边界条件和异常处理测试套件 + * + * 测试范围: + * 1. 输入边界测试(空字符串、超长字符串、特殊字符、SQL注入、XSS) + * 2. 文件操作边界测试(空文件、超大文件、特殊字符文件名、深层目录) + * 3. 网络异常测试(超时、断连、OSS连接失败) + * 4. 并发操作测试(多文件上传、多文件删除、重复提交) + * 5. 状态一致性测试(刷新恢复、Token过期、存储切换) + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +// 主函数包装器(支持 async/await) +async function runTests() { + +// 测试结果收集器 +const testResults = { + passed: 0, + failed: 0, + errors: [] +}; + +// 测试辅助函数 +function test(name, fn) { + try { + fn(); + testResults.passed++; + console.log(` [PASS] ${name}`); + } catch (error) { + testResults.failed++; + testResults.errors.push({ name, error: error.message }); + console.log(` [FAIL] ${name}: ${error.message}`); + } +} + +async function asyncTest(name, fn) { + try { + await fn(); + testResults.passed++; + console.log(` [PASS] ${name}`); + } catch (error) { + testResults.failed++; + testResults.errors.push({ name, error: error.message }); + console.log(` [FAIL] ${name}: ${error.message}`); + } +} + +// ============================================================ +// 1. 输入边界测试 +// ============================================================ + +console.log('\n========== 1. 输入边界测试 ==========\n'); + +// 测试 sanitizeInput 函数 +function testSanitizeInput() { + console.log('--- 测试 XSS 过滤函数 sanitizeInput ---'); + + // 从 server.js 复制的 sanitizeInput 函数 + function sanitizeInput(str) { + if (typeof str !== 'string') return str; + + let sanitized = str + .replace(/[&<>"']/g, (char) => { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return map[char]; + }); + + sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); + sanitized = sanitized.replace(/\x00/g, ''); + + return sanitized; + } + + // 空字符串测试 + test('空字符串输入应该返回空字符串', () => { + assert.strictEqual(sanitizeInput(''), ''); + }); + + // 超长字符串测试 + test('超长字符串应该被正确处理', () => { + const longStr = 'a'.repeat(100000); + const result = sanitizeInput(longStr); + assert.strictEqual(result.length, 100000); + }); + + // 特殊字符测试 + test('HTML 特殊字符应该被转义', () => { + assert.strictEqual(sanitizeInput('', + '', + 'click', + '
hover
', + 'javascript:alert(1)', + 'data:text/html,' + ]; + + xssTests.forEach(xss => { + const result = sanitizeInput(xss); + assert.ok(!result.includes(''), false); + }); + + test('合法 token 应该被接受', () => { + assert.strictEqual(isValidTokenFormat('a'.repeat(48)), true); + assert.strictEqual(isValidTokenFormat('abcdef123456'.repeat(4)), true); + assert.strictEqual(isValidTokenFormat('ABCDEF123456'.repeat(4)), true); + }); +} + +testTokenValidation(); + +// ============================================================ +// 5. 并发和竞态条件测试 +// ============================================================ + +console.log('\n========== 5. 并发和竞态条件测试 ==========\n'); + +async function testRateLimiter() { + console.log('--- 测试速率限制器 ---'); + + // 简化版 RateLimiter + class RateLimiter { + constructor(options = {}) { + this.maxAttempts = options.maxAttempts || 5; + this.windowMs = options.windowMs || 15 * 60 * 1000; + this.blockDuration = options.blockDuration || 30 * 60 * 1000; + this.attempts = new Map(); + this.blockedKeys = new Map(); + } + + isBlocked(key) { + const blockInfo = this.blockedKeys.get(key); + if (!blockInfo) return false; + if (Date.now() > blockInfo.expiresAt) { + this.blockedKeys.delete(key); + this.attempts.delete(key); + return false; + } + return true; + } + + recordFailure(key) { + const now = Date.now(); + + if (this.isBlocked(key)) { + return { blocked: true }; + } + + let attemptInfo = this.attempts.get(key); + if (!attemptInfo || now > attemptInfo.windowEnd) { + attemptInfo = { count: 0, windowEnd: now + this.windowMs }; + } + + attemptInfo.count++; + this.attempts.set(key, attemptInfo); + + if (attemptInfo.count >= this.maxAttempts) { + this.blockedKeys.set(key, { + expiresAt: now + this.blockDuration + }); + return { blocked: true, remainingAttempts: 0 }; + } + + return { + blocked: false, + remainingAttempts: this.maxAttempts - attemptInfo.count + }; + } + + recordSuccess(key) { + this.attempts.delete(key); + this.blockedKeys.delete(key); + } + + getFailureCount(key) { + const attemptInfo = this.attempts.get(key); + if (!attemptInfo || Date.now() > attemptInfo.windowEnd) { + return 0; + } + return attemptInfo.count; + } + } + + const limiter = new RateLimiter({ maxAttempts: 3, windowMs: 1000, blockDuration: 1000 }); + + await asyncTest('首次请求应该不被阻止', async () => { + const result = limiter.recordFailure('test-ip-1'); + assert.strictEqual(result.blocked, false); + assert.strictEqual(result.remainingAttempts, 2); + }); + + await asyncTest('达到限制后应该被阻止', async () => { + const key = 'test-ip-2'; + limiter.recordFailure(key); + limiter.recordFailure(key); + const result = limiter.recordFailure(key); + assert.strictEqual(result.blocked, true); + assert.strictEqual(limiter.isBlocked(key), true); + }); + + await asyncTest('成功后应该清除计数', async () => { + const key = 'test-ip-3'; + limiter.recordFailure(key); + limiter.recordFailure(key); + limiter.recordSuccess(key); + assert.strictEqual(limiter.getFailureCount(key), 0); + assert.strictEqual(limiter.isBlocked(key), false); + }); + + await asyncTest('阻止过期后应该自动解除', async () => { + const key = 'test-ip-4'; + limiter.recordFailure(key); + limiter.recordFailure(key); + limiter.recordFailure(key); + + // 模拟时间过期 + const blockInfo = limiter.blockedKeys.get(key); + if (blockInfo) { + blockInfo.expiresAt = Date.now() - 1; + } + + assert.strictEqual(limiter.isBlocked(key), false); + }); +} + +await testRateLimiter(); + +// ============================================================ +// 6. 数据库操作边界测试 +// ============================================================ + +console.log('\n========== 6. 数据库操作边界测试 ==========\n'); + +function testDatabaseFieldWhitelist() { + console.log('--- 测试数据库字段白名单 ---'); + + const ALLOWED_FIELDS = [ + 'username', 'email', 'password', + 'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint', + 'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config', + 'is_verified', 'verification_token', 'verification_expires_at', + 'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used', + 'theme_preference' + ]; + + function filterUpdates(updates) { + const filtered = {}; + for (const [key, value] of Object.entries(updates)) { + if (ALLOWED_FIELDS.includes(key)) { + filtered[key] = value; + } + } + return filtered; + } + + test('合法字段应该被保留', () => { + const updates = { username: 'newname', email: 'new@email.com' }; + const filtered = filterUpdates(updates); + assert.strictEqual(filtered.username, 'newname'); + assert.strictEqual(filtered.email, 'new@email.com'); + }); + + test('非法字段应该被过滤', () => { + const updates = { + username: 'newname', + id: 999, // 尝试修改 ID + is_admin: 1, // 合法字段 + sql_injection: "'; DROP TABLE users; --" // 非法字段 + }; + const filtered = filterUpdates(updates); + assert.ok(!('id' in filtered)); + assert.ok(!('sql_injection' in filtered)); + assert.strictEqual(filtered.username, 'newname'); + assert.strictEqual(filtered.is_admin, 1); + }); + + test('原型污染尝试应该被阻止', () => { + // 测试通过 JSON.parse 创建的包含 __proto__ 的对象 + const maliciousJson = '{"username":"test","__proto__":{"isAdmin":true},"constructor":{"prototype":{}}}'; + const updates = JSON.parse(maliciousJson); + const filtered = filterUpdates(updates); + + // 即使 JSON.parse 创建了 __proto__ 属性,也不应该被处理 + // 因为 Object.entries 不会遍历 __proto__ + assert.strictEqual(filtered.username, 'test'); + assert.ok(!('isAdmin' in filtered)); + // 确保不会污染原型 + assert.ok(!({}.isAdmin)); + }); + + test('空对象应该返回空对象', () => { + const filtered = filterUpdates({}); + assert.strictEqual(Object.keys(filtered).length, 0); + }); +} + +testDatabaseFieldWhitelist(); + +// ============================================================ +// 7. HTML 实体解码测试 +// ============================================================ + +console.log('\n========== 7. HTML 实体解码测试 ==========\n'); + +function testHtmlEntityDecoding() { + console.log('--- 测试 HTML 实体解码 ---'); + + function decodeHtmlEntities(str) { + if (typeof str !== 'string') return str; + + const entityMap = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + '#x27': "'", + '#x2F': '/', + '#x60': '`' + }; + + const decodeOnce = (input) => + input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => { + if (code[0] === '#') { + const isHex = code[1]?.toLowerCase() === 'x'; + const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10); + if (!Number.isNaN(num)) { + return String.fromCharCode(num); + } + return match; + } + const mapped = entityMap[code]; + return mapped !== undefined ? mapped : match; + }); + + let output = str; + let decoded = decodeOnce(output); + while (decoded !== output) { + output = decoded; + decoded = decodeOnce(output); + } + return output; + } + + test('基本 HTML 实体应该被解码', () => { + assert.strictEqual(decodeHtmlEntities('<'), '<'); + assert.strictEqual(decodeHtmlEntities('>'), '>'); + assert.strictEqual(decodeHtmlEntities('&'), '&'); + assert.strictEqual(decodeHtmlEntities('"'), '"'); + }); + + test('数字实体应该被解码', () => { + assert.strictEqual(decodeHtmlEntities('''), "'"); + assert.strictEqual(decodeHtmlEntities('''), "'"); + assert.strictEqual(decodeHtmlEntities('`'), '`'); + }); + + test('嵌套实体应该被完全解码', () => { + assert.strictEqual(decodeHtmlEntities('&#x60;'), '`'); + assert.strictEqual(decodeHtmlEntities('&amp;'), '&'); + }); + + test('普通文本应该保持不变', () => { + assert.strictEqual(decodeHtmlEntities('hello world'), 'hello world'); + assert.strictEqual(decodeHtmlEntities('test123'), 'test123'); + }); + + test('非字符串输入应该原样返回', () => { + assert.strictEqual(decodeHtmlEntities(null), null); + assert.strictEqual(decodeHtmlEntities(undefined), undefined); + assert.strictEqual(decodeHtmlEntities(123), 123); + }); +} + +testHtmlEntityDecoding(); + +// ============================================================ +// 8. 分享路径权限测试 +// ============================================================ + +console.log('\n========== 8. 分享路径权限测试 ==========\n'); + +function testSharePathAccess() { + console.log('--- 测试分享路径访问权限 ---'); + + function isPathWithinShare(requestPath, share) { + if (!requestPath || !share) { + return false; + } + + const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/'); + const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/'); + + if (share.share_type === 'file') { + return normalizedRequest === normalizedShare; + } else { + const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/'; + return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); + } + } + + test('单文件分享只允许访问该文件', () => { + const share = { share_type: 'file', share_path: '/documents/secret.pdf' }; + assert.strictEqual(isPathWithinShare('/documents/secret.pdf', share), true); + assert.strictEqual(isPathWithinShare('/documents/other.pdf', share), false); + assert.strictEqual(isPathWithinShare('/documents/secret.pdf.bak', share), false); + }); + + test('目录分享允许访问子目录', () => { + const share = { share_type: 'directory', share_path: '/shared' }; + assert.strictEqual(isPathWithinShare('/shared', share), true); + assert.strictEqual(isPathWithinShare('/shared/file.txt', share), true); + assert.strictEqual(isPathWithinShare('/shared/sub/file.txt', share), true); + }); + + test('目录分享不允许访问父目录', () => { + const share = { share_type: 'directory', share_path: '/shared' }; + assert.strictEqual(isPathWithinShare('/other', share), false); + assert.strictEqual(isPathWithinShare('/shared_extra', share), false); + assert.strictEqual(isPathWithinShare('/', share), false); + }); + + test('路径遍历攻击应该被阻止', () => { + const share = { share_type: 'directory', share_path: '/shared' }; + assert.strictEqual(isPathWithinShare('/shared/../etc/passwd', share), false); + assert.strictEqual(isPathWithinShare('/shared/../../root', share), false); + }); + + test('空或无效输入应该返回 false', () => { + assert.strictEqual(isPathWithinShare('', { share_type: 'file', share_path: '/test' }), false); + assert.strictEqual(isPathWithinShare(null, { share_type: 'file', share_path: '/test' }), false); + assert.strictEqual(isPathWithinShare('/test', null), false); + }); +} + +testSharePathAccess(); + +// ============================================================ +// 测试总结 +// ============================================================ + +console.log('\n========================================'); +console.log('测试总结'); +console.log('========================================'); +console.log(`通过: ${testResults.passed}`); +console.log(`失败: ${testResults.failed}`); + +if (testResults.errors.length > 0) { + console.log('\n失败的测试:'); + testResults.errors.forEach((e, i) => { + console.log(` ${i + 1}. ${e.name}: ${e.error}`); + }); +} + +console.log('\n'); + +// 返回测试结果 +return testResults; +} + +// 运行测试 +runTests().then(testResults => { + // 如果有失败,退出码为 1 + process.exit(testResults.failed > 0 ? 1 : 0); +}).catch(err => { + console.error('测试执行错误:', err); + process.exit(1); +}); diff --git a/backend/tests/network-concurrent-tests.js b/backend/tests/network-concurrent-tests.js new file mode 100644 index 0000000..0407613 --- /dev/null +++ b/backend/tests/network-concurrent-tests.js @@ -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); +}); diff --git a/backend/tests/run-all-tests.js b/backend/tests/run-all-tests.js new file mode 100644 index 0000000..dd9dcaf --- /dev/null +++ b/backend/tests/run-all-tests.js @@ -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); +}); diff --git a/backend/tests/state-consistency-tests.js b/backend/tests/state-consistency-tests.js new file mode 100644 index 0000000..90762b5 --- /dev/null +++ b/backend/tests/state-consistency-tests.js @@ -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); +}); diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js new file mode 100644 index 0000000..b34d122 --- /dev/null +++ b/backend/utils/encryption.js @@ -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 +}; diff --git a/backend/utils/storage-cache.js b/backend/utils/storage-cache.js new file mode 100644 index 0000000..df9702b --- /dev/null +++ b/backend/utils/storage-cache.js @@ -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} + */ + 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} + */ + 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} 检查结果列表 + */ + 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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6bb22c3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/app.html b/frontend/app.html new file mode 100644 index 0000000..ffda332 --- /dev/null +++ b/frontend/app.html @@ -0,0 +1,3508 @@ + + + + + + + + + + 玩玩云 - 文件管理平台 + + + + + + +
+ +
+
+ +
+
+ + + +
+ + + + + + diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..fc35f84 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,3185 @@ +const { createApp } = Vue; + +createApp({ + data() { + // 预先确定管理员标签页,避免刷新时状态丢失导致闪烁 + const initialAdminTab = (() => { + const saved = localStorage.getItem('adminTab'); + return (saved && ['overview', 'settings', 'monitor', 'users'].includes(saved)) ? saved : 'overview'; + })(); + + return { + // API配置 + // API配置 - 通过nginx代理访问 + apiBase: window.location.protocol + '//' + window.location.host, + + // 应用状态 + appReady: false, // 应用是否初始化完成(防止UI闪烁) + + // 用户状态 + isLoggedIn: false, + user: null, + token: null, // 仅用于内部状态跟踪,实际认证通过 HttpOnly Cookie + tokenRefreshTimer: null, + + // 视图状态 + currentView: 'files', + isLogin: true, + fileViewMode: 'grid', // 文件显示模式: grid 大图标, list 列表 + shareViewMode: 'list', // 分享显示模式: grid 大图标, list 列表 + debugMode: false, // 调试模式(管理员可切换) + adminTab: initialAdminTab, // 管理员页面当前标签:overview, settings, monitor, users + + // 表单数据 + loginForm: { + username: '', + password: '', + captcha: '' + }, + registerForm: { + username: '', + email: '', + password: '', + captcha: '' + }, + registerCaptchaUrl: '', + + // 验证码相关 + showCaptcha: false, + captchaUrl: '', + + // OSS配置表单 + ossConfigForm: { + oss_provider: 'aliyun', + oss_region: '', + oss_access_key_id: '', + oss_access_key_secret: '', + oss_bucket: '', + oss_endpoint: '' + }, + showOssConfigModal: false, + ossConfigSaving: false, // OSS 配置保存中状态 + ossConfigTesting: false, // OSS 配置测试中状态 + + // 修改密码表单 + changePasswordForm: { + current_password: '', + new_password: '' + }, + // 用户名修改表单 + usernameForm: { + newUsername: '' + }, + // 用户资料表单 + profileForm: { + email: '' + }, + // 管理员资料表单 + adminProfileForm: { + username: '' + }, + // 分享表单(通用) + shareForm: { + path: '', + password: '', + expiryDays: null + }, + currentPath: '/', + files: [], + loading: false, + + // 分享管理 + shares: [], + showShareAllModal: false, + showShareFileModal: false, + creatingShare: false, // 创建分享中状态 + shareAllForm: { + password: "", + expiryType: "never", + customDays: 7 + }, + shareFileForm: { + fileName: "", + filePath: "", + isDirectory: false, // 新增:标记是否为文件夹 + password: "", + expiryType: "never", + customDays: 7 + }, + shareResult: null, + shareFilters: { + keyword: '', + type: 'all', // all/file/directory/all_files + status: 'all', // all/active/expiring/expired/protected/public + sort: 'created_desc' // created_desc/created_asc/views_desc/downloads_desc/expire_asc + }, + + // 文件重命名 + showRenameModal: false, + renameForm: { + oldName: "", + newName: "", + path: "" + }, + + // 创建文件夹 + showCreateFolderModal: false, + creatingFolder: false, // 创建文件夹中状态 + createFolderForm: { + folderName: "" + }, + + // 文件夹详情 + showFolderInfoModal: false, + folderInfo: null, + + // 上传 + showUploadModal: false, + uploadProgress: 0, + uploadedBytes: 0, + totalBytes: 0, + uploadingFileName: '', + isDragging: false, + modalMouseDownTarget: null, // 模态框鼠标按下的目标 + + // 管理员 + adminUsers: [], + showResetPwdModal: false, + resetPwdUser: {}, + newPassword: '', + + // 文件审查 + showFileInspectionModal: false, + inspectionUser: null, + inspectionFiles: [], + inspectionPath: '/', + inspectionLoading: false, + inspectionViewMode: 'grid', // 文件审查显示模式: grid 大图标, list 列表 + + // 忘记密码 + showForgotPasswordModal: false, + forgotPasswordForm: { + email: '', + captcha: '' + }, + forgotPasswordCaptchaUrl: '', + showResetPasswordModal: false, + resetPasswordForm: { + token: '', + new_password: '' + }, + showResendVerify: false, + resendVerifyEmail: '', + resendVerifyCaptcha: '', + resendVerifyCaptchaUrl: '', + + // 加载状态 + loginLoading: false, // 登录中 + registerLoading: false, // 注册中 + passwordChanging: false, // 修改密码中 + usernameChanging: false, // 修改用户名中 + passwordResetting: false, // 重置密码中 + resendingVerify: false, // 重发验证邮件中 + + // 系统设置 + systemSettings: { + maxUploadSizeMB: 100, + smtp: { + host: '', + port: 465, + secure: true, + user: '', + from: '', + password: '', + has_password: false + } + }, + + // 健康检测 + healthCheck: { + loading: initialAdminTab === 'monitor', + lastCheck: null, + overallStatus: null, // healthy, warning, critical + summary: { total: 0, pass: 0, warning: 0, fail: 0, info: 0 }, + checks: [] + }, + + // 系统日志 + systemLogs: { + loading: initialAdminTab === 'monitor', + logs: [], + total: 0, + page: 1, + pageSize: 30, + totalPages: 0, + filters: { + level: '', + category: '', + keyword: '' + } + }, + + // 监控页整体加载遮罩(避免刷新时闪一下空态) + monitorTabLoading: initialAdminTab === 'monitor', + + // Toast通知 + toasts: [], + toastIdCounter: 0, + + // 上传限制(字节),默认10GB + maxUploadSize: 10737418240, + + // 提示信息 + errorMessage: '', + successMessage: '', + verifyMessage: '', + + // 存储相关 + storageType: 'oss', // 当前使用的存储类型 + storagePermission: 'oss_only', // 存储权限 + localQuota: 0, // 本地存储配额(字节) + localUsed: 0, // 本地存储已使用(字节) + + + // 右键菜单 + showContextMenu: false, + contextMenuX: 0, + contextMenuY: 0, + contextMenuFile: null, + + // 长按检测 + longPressTimer: null, + longPressStartX: 0, + longPressStartY: 0, + longPressFile: null, + + // 媒体预览 + showImageViewer: false, + showVideoPlayer: false, + showAudioPlayer: false, + currentMediaUrl: '', + currentMediaName: '', + currentMediaType: '', // 'image', 'video', 'audio' + longPressDuration: 500, // 长按时间(毫秒) + // 管理员编辑用户存储权限 + showEditStorageModal: false, + editStorageForm: { + userId: null, + username: '', + storage_permission: 'oss_only', + local_storage_quota_value: 1, // 配额数值 + quota_unit: 'GB' // 配额单位:MB 或 GB + }, + + // 服务器存储统计 + serverStorageStats: { + totalDisk: 0, + usedDisk: 0, + availableDisk: 0, + totalUserQuotas: 0, + totalUserUsed: 0, + totalUsers: 0 + }, + + // 定期检查用户配置更新的定时器 + profileCheckInterval: null, + + // 存储切换状态 + storageSwitching: false, + storageSwitchTarget: null, + suppressStorageToast: false, + profileInitialized: false, + + // OSS配置引导弹窗 + showOssGuideModal: false, + + // OSS空间使用统计 + ossUsage: null, // { totalSize, totalSizeFormatted, fileCount, dirCount } + ossUsageLoading: false, + ossUsageError: null, + + // 主题设置 + currentTheme: 'dark', // 当前生效的主题: 'dark' 或 'light' + globalTheme: 'dark', // 全局默认主题(管理员设置) + userThemePreference: null // 用户主题偏好: 'dark', 'light', 或 null(跟随全局) + }; + }, + + computed: { + pathParts() { + return this.currentPath.split('/').filter(p => p !== ''); + }, + + // 格式化配额显示 + localQuotaFormatted() { + return this.formatBytes(this.localQuota); + }, + + localUsedFormatted() { + return this.formatBytes(this.localUsed); + }, + + // 配额使用百分比 + quotaPercentage() { + if (this.localQuota === 0) return 0; + return Math.round((this.localUsed / this.localQuota) * 100); + }, + + // 存储类型显示文本 + storageTypeText() { + return this.storageType === 'local' ? '本地存储' : 'OSS存储'; + }, + + // 分享筛选+排序后的列表 + filteredShares() { + let list = [...this.shares]; + const keyword = this.shareFilters.keyword.trim().toLowerCase(); + + if (keyword) { + list = list.filter(s => + (s.share_path || '').toLowerCase().includes(keyword) || + (s.share_code || '').toLowerCase().includes(keyword) || + (s.share_url || '').toLowerCase().includes(keyword) + ); + } + + if (this.shareFilters.type !== 'all') { + const targetType = this.shareFilters.type === 'all_files' ? 'all' : this.shareFilters.type; + list = list.filter(s => (s.share_type || 'file') === targetType); + } + + if (this.shareFilters.status !== 'all') { + list = list.filter(s => { + if (this.shareFilters.status === 'expired') return this.isExpired(s.expires_at); + if (this.shareFilters.status === 'expiring') return this.isExpiringSoon(s.expires_at) && !this.isExpired(s.expires_at); + if (this.shareFilters.status === 'active') return !this.isExpired(s.expires_at); + if (this.shareFilters.status === 'protected') return !!s.share_password; + if (this.shareFilters.status === 'public') return !s.share_password; + return true; + }); + } + + list.sort((a, b) => { + const getTime = s => s.created_at ? new Date(s.created_at).getTime() : 0; + const getExpire = s => s.expires_at ? new Date(s.expires_at).getTime() : Number.MAX_SAFE_INTEGER; + + switch (this.shareFilters.sort) { + case 'created_asc': + return getTime(a) - getTime(b); + case 'views_desc': + return (b.view_count || 0) - (a.view_count || 0); + case 'downloads_desc': + return (b.download_count || 0) - (a.download_count || 0); + case 'expire_asc': + return getExpire(a) - getExpire(b); + default: + return getTime(b) - getTime(a); // created_desc + } + }); + + return list; + } + }, + + methods: { + // ========== 工具函数 ========== + // 防抖函数 - 避免频繁调用 + debounce(fn, delay) { + let timer = null; + return function(...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; + }, + + // 创建防抖版本的 loadUserProfile(延迟2秒,避免频繁请求) + debouncedLoadUserProfile() { + if (!this._debouncedLoadUserProfile) { + this._debouncedLoadUserProfile = this.debounce(() => { + this.loadUserProfile(); + }, 2000); + } + this._debouncedLoadUserProfile(); + }, + + // ========== 主题管理 ========== + // 初始化主题 + async initTheme() { + // 先从localStorage读取,避免页面闪烁 + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + this.applyTheme(savedTheme); + } + // 如果没有登录,从公开API获取全局主题 + if (!this.token) { + try { + const res = await axios.get(`${this.apiBase}/api/public/theme`); + if (res.data.success) { + this.globalTheme = res.data.theme; + this.applyTheme(res.data.theme); + } + } catch (e) { + console.log('无法加载全局主题'); + } + } + }, + + // 加载用户主题设置(登录后调用) + async loadUserTheme() { + try { + const res = await axios.get(`${this.apiBase}/api/user/theme`); + if (res.data.success) { + this.globalTheme = res.data.theme.global; + this.userThemePreference = res.data.theme.user; + this.currentTheme = res.data.theme.effective; + this.applyTheme(this.currentTheme); + localStorage.setItem('theme', this.currentTheme); + } + } catch (error) { + console.error('加载主题设置失败:', error); + } + }, + + // 应用主题到DOM + applyTheme(theme) { + this.currentTheme = theme; + if (theme === 'light') { + document.body.classList.add('light-theme'); + } else { + document.body.classList.remove('light-theme'); + } + }, + + // 切换用户主题偏好 + async setUserTheme(theme) { + try { + const res = await axios.post(`${this.apiBase}/api/user/theme`, + { theme }, + ); + if (res.data.success) { + this.userThemePreference = res.data.theme.user; + this.currentTheme = res.data.theme.effective; + this.applyTheme(this.currentTheme); + localStorage.setItem('theme', this.currentTheme); + this.showToast('success', '主题已更新', theme === null ? '已设为跟随全局' : (theme === 'dark' ? '已切换到暗色主题' : '已切换到亮色主题')); + } + } catch (error) { + this.showToast('error', '主题更新失败', error.response?.data?.message || '请稍后重试'); + } + }, + + // 获取主题显示文本 + getThemeText(theme) { + if (theme === null) return '跟随全局'; + return theme === 'dark' ? '暗色主题' : '亮色主题'; + }, + + // 设置全局主题(管理员) + async setGlobalTheme(theme) { + try { + console.log('[主题] 设置全局主题:', theme); + const res = await axios.post(`${this.apiBase}/api/admin/settings`, + { global_theme: theme }, + ); + console.log('[主题] API响应:', res.data); + if (res.data.success) { + this.globalTheme = theme; + console.log('[主题] globalTheme已更新为:', this.globalTheme); + // 如果用户没有设置个人偏好,则跟随全局 + if (this.userThemePreference === null) { + this.currentTheme = theme; + this.applyTheme(theme); + localStorage.setItem('theme', theme); + } else { + console.log('[主题] 用户有个人偏好,不更改当前显示主题:', this.userThemePreference); + } + // 提示信息 + let toastMsg = theme === 'dark' ? '默认暗色主题' : '默认亮色主题'; + if (this.userThemePreference !== null) { + toastMsg += '(你设置了个人偏好,不受全局影响)'; + } + this.showToast('success', '全局主题已更新', toastMsg); + } else { + console.error('[主题] API返回失败:', res.data); + this.showToast('error', '设置失败', res.data.message || '未知错误'); + } + } catch (error) { + console.error('[主题] 设置全局主题失败:', error); + this.showToast('error', '设置失败', error.response?.data?.message || '请稍后重试'); + } + }, + + // 提取URL中的token(兼容缺少 ? 的场景) + getTokenFromUrl(key) { + const currentHref = window.location.href; + const url = new URL(currentHref); + let token = url.searchParams.get(key); + + if (!token) { + const match = currentHref.match(new RegExp(`${key}=([\\w-]+)`)); + if (match && match[1]) { + token = match[1]; + } + } + return token; + }, + + // 清理URL中的token(同时处理路径和查询参数) + sanitizeUrlToken(key) { + const url = new URL(window.location.href); + url.searchParams.delete(key); + if (url.pathname.includes(`${key}=`)) { + url.pathname = url.pathname.split(`${key}=`)[0]; + } + window.history.replaceState({}, document.title, url.toString()); + }, + + // 模态框点击外部关闭优化 - 防止拖动选择文本时误关闭 + handleModalMouseDown(e) { + // 记录鼠标按下时的目标 + this.modalMouseDownTarget = e.target; + }, + handleModalMouseUp(modalName, e) { + // 只有在同一个overlay元素上按下和释放鼠标时才关闭 + if (e && e.target === this.modalMouseDownTarget) { + this[modalName] = false; + this.shareResult = null; // 重置分享结果 + } + this.modalMouseDownTarget = null; + }, + + // 格式化文件大小 + formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]; + }, + + // 拖拽上传处理 + handleDragEnter(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = true; + }, + handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = true; + }, +handleDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + + // 使用更可靠的检测:检查鼠标实际位置 + const container = e.currentTarget; + const rect = container.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + // 如果鼠标位置在容器边界外,隐藏覆盖层 + // 添加5px的容差,避免边界问题 + const margin = 5; + const isOutside = + x < rect.left - margin || + x > rect.right + margin || + y < rect.top - margin || + y > rect.bottom + margin; + + if (isOutside) { + this.isDragging = false; + return; + } + + // 备用检测:检查 relatedTarget + const related = e.relatedTarget; + if (!related || !container.contains(related)) { + this.isDragging = false; + } + }, + + async handleDrop(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = false; + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + await this.uploadFile(file); + } + }, + + // ===== 认证相关 ===== + + toggleAuthMode() { + this.isLogin = !this.isLogin; + this.errorMessage = ''; + this.successMessage = ''; + // 切换到注册模式时加载验证码 + if (!this.isLogin) { + this.refreshRegisterCaptcha(); + } + }, + + async handleLogin() { + this.errorMessage = ''; + this.loginLoading = true; + try { + const response = await axios.post(`${this.apiBase}/api/login`, this.loginForm); + + if (response.data.success) { + // token 和 refreshToken 都通过 HttpOnly Cookie 自动管理 + this.user = response.data.user; + this.isLoggedIn = true; + this.showResendVerify = false; + this.resendVerifyEmail = ''; + + // 登录成功后隐藏验证码并清空验证码输入 + this.showCaptcha = false; + this.loginForm.captcha = ''; + + // 保存用户信息到localStorage(非敏感信息,用于页面刷新后恢复) + // 注意:token 通过 HttpOnly Cookie 传递,不再存储在 localStorage + localStorage.setItem('user', JSON.stringify(this.user)); + + // 启动token自动刷新(在过期前5分钟刷新) + const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000); + this.startTokenRefresh(expiresIn); + + // 直接从登录响应中获取存储信息 + this.storagePermission = this.user.storage_permission || 'oss_only'; + this.storageType = this.user.current_storage_type || 'oss'; + this.localQuota = this.user.local_storage_quota || 0; + this.localUsed = this.user.local_storage_used || 0; + + console.log('[登录] 存储权限:', this.storagePermission, '存储类型:', this.storageType, 'OSS配置:', this.user.oss_config_source); + + // 智能存储类型修正:如果当前是OSS但未配置(包括个人配置和系统级配置),且用户有本地存储权限,自动切换到本地 + if (this.storageType === 'oss' && (!this.user || this.user.oss_config_source === 'none')) { + if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') { + console.log('[登录] OSS未配置但用户有本地存储权限,自动切换到本地存储'); + this.storageType = 'local'; + // 异步更新到后端(不等待,避免阻塞登录流程) + axios.post(`${this.apiBase}/api/user/switch-storage`, { storage_type: 'local' }) + .catch(err => console.error('[登录] 自动切换存储类型失败:', err)); + } + } + + // 启动定期检查用户配置 + this.startProfileSync(); + // 加载用户主题设置 + this.loadUserTheme(); + // 管理员直接跳转到管理后台 + if (this.user.is_admin) { + this.currentView = 'admin'; + } + // 普通用户:检查存储权限 + else { + // 如果用户可以使用本地存储,直接进入文件页面 + if (this.storagePermission === 'local_only' || this.storagePermission === 'user_choice') { + this.currentView = 'files'; + this.loadFiles('/'); + } + // 如果仅OSS模式,需要检查是否配置了OSS(包括系统级统一配置) + else if (this.storagePermission === 'oss_only') { + if (this.user?.oss_config_source !== 'none') { + this.currentView = 'files'; + this.loadFiles('/'); + } else { + this.currentView = 'settings'; + this.showToast('info', '欢迎', '请先配置您的OSS服务'); + this.openOssConfigModal(); + } + } else { + // 默认行为:跳转到文件页面 + this.currentView = 'files'; + this.loadFiles('/'); + } + } + } + } catch (error) { + this.errorMessage = error.response?.data?.message || '登录失败'; + + // 检查是否需要显示验证码 + if (error.response?.data?.needCaptcha) { + this.showCaptcha = true; + this.refreshCaptcha(); + } + + // 邮箱未验证提示 + if (error.response?.data?.needVerify) { + this.showResendVerify = true; + this.resendVerifyEmail = error.response?.data?.email || this.loginForm.username || ''; + } else { + this.showResendVerify = false; + this.resendVerifyEmail = ''; + } + } finally { + this.loginLoading = false; + } + }, + + // 通用验证码加载函数(带防抖) + async loadCaptcha(targetField) { + // 防抖:2秒内不重复请求 + const now = Date.now(); + if (this._lastCaptchaTime && (now - this._lastCaptchaTime) < 2000) { + console.log('[验证码] 请求过于频繁,跳过'); + return; + } + this._lastCaptchaTime = now; + + try { + const response = await axios.get(`${this.apiBase}/api/captcha?t=${now}`, { + responseType: 'blob' + }); + this[targetField] = URL.createObjectURL(response.data); + } catch (error) { + console.error('获取验证码失败:', error); + // 如果是429错误,不清除已有验证码 + if (error.response?.status !== 429) { + this[targetField] = ''; + } + } + }, + + // 刷新验证码(登录) + refreshCaptcha() { + this.loadCaptcha('captchaUrl'); + }, + + // 刷新注册验证码 + refreshRegisterCaptcha() { + this.loadCaptcha('registerCaptchaUrl'); + }, + + // 刷新忘记密码验证码 + refreshForgotPasswordCaptcha() { + this.loadCaptcha('forgotPasswordCaptchaUrl'); + }, + + // 刷新重发验证邮件验证码 + refreshResendVerifyCaptcha() { + this.loadCaptcha('resendVerifyCaptchaUrl'); + }, + + async resendVerification() { + if (!this.resendVerifyEmail) { + this.showToast('error', '错误', '请输入邮箱或用户名后再重试'); + return; + } + if (!this.resendVerifyCaptcha) { + this.showToast('error', '错误', '请输入验证码'); + return; + } + this.resendingVerify = true; + try { + const payload = { captcha: this.resendVerifyCaptcha }; + if (this.resendVerifyEmail.includes('@')) { + payload.email = this.resendVerifyEmail; + } else { + payload.username = this.resendVerifyEmail; + } + const response = await axios.post(`${this.apiBase}/api/resend-verification`, payload); + if (response.data.success) { + this.showToast('success', '成功', '验证邮件已发送,请查收'); + this.showResendVerify = false; + this.resendVerifyEmail = ''; + this.resendVerifyCaptcha = ''; + this.resendVerifyCaptchaUrl = ''; + } + } catch (error) { + console.error('重发验证邮件失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '发送失败'); + // 刷新验证码 + this.resendVerifyCaptcha = ''; + this.refreshResendVerifyCaptcha(); + } finally { + this.resendingVerify = false; + } + }, + + async handleVerifyToken(token) { + try { + const response = await axios.get(`${this.apiBase}/api/verify-email`, { params: { token } }); + if (response.data.success) { + this.verifyMessage = '邮箱验证成功,请登录'; + this.isLogin = true; + // 清理URL + this.sanitizeUrlToken('verifyToken'); + } + } catch (error) { + console.error('邮箱验证失败:', error); + this.verifyMessage = error.response?.data?.message || '验证失败'; + } + }, + + async handleRegister() { + this.errorMessage = ''; + this.successMessage = ''; + this.registerLoading = true; + + try { + const response = await axios.post(`${this.apiBase}/api/register`, this.registerForm); + + if (response.data.success) { + this.successMessage = '注册成功!请查收邮箱完成验证后再登录'; + this.isLogin = true; + + // 清空表单 + this.registerForm = { + username: '', + email: '', + password: '', + captcha: '' + }; + this.registerCaptchaUrl = ''; + } + } catch (error) { + const errorData = error.response?.data; + if (errorData?.errors) { + this.errorMessage = errorData.errors.map(e => e.msg).join(', '); + } else { + this.errorMessage = errorData?.message || '注册失败'; + } + // 刷新验证码 + this.registerForm.captcha = ''; + this.refreshRegisterCaptcha(); + } finally { + this.registerLoading = false; + } + }, + + async updateOssConfig() { + // 防止重复提交 + if (this.ossConfigSaving) { + return; + } + + // 前端验证 + if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) { + this.showToast('error', '配置错误', '请选择有效的 OSS 服务商'); + return; + } + if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') { + this.showToast('error', '配置错误', '地域/Region 不能为空'); + return; + } + if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') { + this.showToast('error', '配置错误', 'Access Key ID 不能为空'); + return; + } + if (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '') { + this.showToast('error', '配置错误', 'Access Key Secret 不能为空'); + return; + } + if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') { + this.showToast('error', '配置错误', 'Bucket 名称不能为空'); + return; + } + + this.ossConfigSaving = true; + + try { + const response = await axios.post( + `${this.apiBase}/api/user/update-oss`, + this.ossConfigForm, + ); + + if (response.data.success) { + // 更新用户信息 + this.user.has_oss_config = 1; + + // 如果用户有 user_choice 权限,自动切换到 OSS 存储 + if (this.storagePermission === 'user_choice' || this.storagePermission === 'oss_only') { + try { + const switchResponse = await axios.post( + `${this.apiBase}/api/user/switch-storage`, + { storage_type: 'oss' }, + ); + + if (switchResponse.data.success) { + this.storageType = 'oss'; + console.log('[OSS配置] 已自动切换到OSS存储模式'); + } + } catch (err) { + console.error('[OSS配置] 自动切换存储模式失败:', err); + } + } + + // 关闭配置弹窗 + this.showOssConfigModal = false; + + // 刷新到文件页面 + this.currentView = 'files'; + this.loadFiles('/'); + + // 显示成功提示 + this.showToast('success', '配置成功', 'OSS存储配置已保存!'); + } + } catch (error) { + console.error('OSS配置保存失败:', error); + this.showToast('error', '配置失败', error.response?.data?.message || error.message || '请检查配置信息后重试'); + } finally { + this.ossConfigSaving = false; + } + }, + + // 测试 OSS 连接(不保存配置) + async testOssConnection() { + // 防止重复提交 + if (this.ossConfigTesting) { + return; + } + + // 前端验证 + if (!this.ossConfigForm.oss_provider || !['aliyun', 'tencent', 'aws'].includes(this.ossConfigForm.oss_provider)) { + this.showToast('error', '配置错误', '请选择有效的 OSS 服务商'); + return; + } + if (!this.ossConfigForm.oss_region || this.ossConfigForm.oss_region.trim() === '') { + this.showToast('error', '配置错误', '地域/Region 不能为空'); + return; + } + if (!this.ossConfigForm.oss_access_key_id || this.ossConfigForm.oss_access_key_id.trim() === '') { + this.showToast('error', '配置错误', 'Access Key ID 不能为空'); + return; + } + // 如果用户已有配置,Secret 可以为空(使用现有密钥) + if (this.user?.oss_config_source === 'none' && (!this.ossConfigForm.oss_access_key_secret || this.ossConfigForm.oss_access_key_secret.trim() === '')) { + this.showToast('error', '配置错误', 'Access Key Secret 不能为空'); + return; + } + if (!this.ossConfigForm.oss_bucket || this.ossConfigForm.oss_bucket.trim() === '') { + this.showToast('error', '配置错误', 'Bucket 名称不能为空'); + return; + } + + this.ossConfigTesting = true; + + try { + const response = await axios.post( + `${this.apiBase}/api/user/test-oss`, + this.ossConfigForm, + ); + + if (response.data.success) { + this.showToast('success', '连接成功', 'OSS 配置验证通过,可以保存'); + } + } catch (error) { + console.error('OSS连接测试失败:', error); + this.showToast('error', '连接失败', error.response?.data?.message || error.message || '请检查配置信息'); + } finally { + this.ossConfigTesting = false; + } + }, + + async updateAdminProfile() { + try { + const response = await axios.post( + `${this.apiBase}/api/admin/update-profile`, + { + username: this.adminProfileForm.username + }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '用户名已更新!即将重新登录'); + + // 更新用户信息(后端已通过 Cookie 更新 token) + if (response.data.user) { + this.user = response.data.user; + localStorage.setItem('user', JSON.stringify(response.data.user)); + } + + // 延迟后重新登录 + setTimeout(() => this.logout(), 1500); + } + } catch (error) { + this.showToast('error', '错误', '修改失败: ' + (error.response?.data?.message || error.message)); + } + }, + + async changePassword() { + if (!this.changePasswordForm.current_password) { + this.showToast('warning', '提示', '请输入当前密码'); + return; + } + + if (this.changePasswordForm.new_password.length < 6) { + this.showToast('warning', '提示', '新密码至少6个字符'); + return; + } + + this.passwordChanging = true; + try { + const response = await axios.post( + `${this.apiBase}/api/user/change-password`, + { + current_password: this.changePasswordForm.current_password, + new_password: this.changePasswordForm.new_password + }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '密码修改成功!'); + this.changePasswordForm.new_password = ''; + this.changePasswordForm.current_password = ''; + } + } catch (error) { + this.showToast('error', '错误', '密码修改失败: ' + (error.response?.data?.message || error.message)); + } finally { + this.passwordChanging = false; + } + }, + + async loadOssConfig() { + try { + const response = await axios.get( + `${this.apiBase}/api/user/profile`, + ); + + if (response.data.success && response.data.user) { + const user = response.data.user; + // 填充OSS配置表单(密钥不回显) + this.ossConfigForm.oss_provider = user.oss_provider || 'aliyun'; + this.ossConfigForm.oss_region = user.oss_region || ''; + this.ossConfigForm.oss_access_key_id = user.oss_access_key_id || ''; + this.ossConfigForm.oss_access_key_secret = ''; // 密钥不回显 + this.ossConfigForm.oss_bucket = user.oss_bucket || ''; + this.ossConfigForm.oss_endpoint = user.oss_endpoint || ''; + } + } catch (error) { + console.error('加载OSS配置失败:', error); + } + }, + + // 上传工具配置引导已移除(OSS 不需要配置文件导入) + + async updateUsername() { + if (!this.usernameForm.newUsername || this.usernameForm.newUsername.length < 3) { + this.showToast('warning', '提示', '用户名至少3个字符'); + return; + } + + this.usernameChanging = true; + try { + const response = await axios.post( + `${this.apiBase}/api/user/update-username`, + { username: this.usernameForm.newUsername }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '用户名修改成功!'); + // 更新本地用户信息 + this.user.username = this.usernameForm.newUsername; + localStorage.setItem('user', JSON.stringify(this.user)); + this.usernameForm.newUsername = ''; + } + } catch (error) { + this.showToast('error', '错误', '用户名修改失败: ' + (error.response?.data?.message || error.message)); + } finally { + this.usernameChanging = false; + } + }, + + async updateProfile() { + try { + const response = await axios.post( + `${this.apiBase}/api/user/update-profile`, + { email: this.profileForm.email }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '邮箱已更新!'); + // 更新本地用户信息 + if (response.data.user) { + this.user = response.data.user; + localStorage.setItem('user', JSON.stringify(this.user)); + } + } + } catch (error) { + this.showToast('error', '错误', '更新失败: ' + (error.response?.data?.message || error.message)); + } + }, + + async logout() { + // 调用后端清除 HttpOnly Cookie + try { + await axios.post(`${this.apiBase}/api/logout`); + } catch (err) { + console.error('[登出] 清除Cookie失败:', err); + } + + this.isLoggedIn = false; + this.user = null; + this.token = null; + this.stopTokenRefresh(); + localStorage.removeItem('user'); + localStorage.removeItem('lastView'); + localStorage.removeItem('adminTab'); + this.showResendVerify = false; + this.resendVerifyEmail = ''; + + // 停止定期检查 + this.stopProfileSync(); + }, + + // 获取公开的系统配置(上传限制等) + async loadPublicConfig() { + try { + const response = await axios.get(`${this.apiBase}/api/config`); + if (response.data.success) { + this.maxUploadSize = response.data.config.max_upload_size || 10737418240; + } + } catch (error) { + console.error('获取系统配置失败:', error); + // 使用默认值 + } + }, + + // 检查登录状态(通过 HttpOnly Cookie 验证) + async checkLoginStatus() { + // 直接调用API验证,Cookie会自动携带 + try { + const response = await axios.get(`${this.apiBase}/api/user/profile`); + + if (response.data.success && response.data.user) { + // Cookie有效,用户已登录 + this.user = response.data.user; + this.isLoggedIn = true; + + // 更新localStorage中的用户信息(非敏感信息) + localStorage.setItem('user', JSON.stringify(this.user)); + + // 从最新的用户信息初始化存储相关字段 + this.storagePermission = this.user.storage_permission || 'oss_only'; + this.storageType = this.user.current_storage_type || 'oss'; + this.localQuota = this.user.local_storage_quota || 0; + this.localUsed = this.user.local_storage_used || 0; + + console.log('[页面加载] Cookie验证成功,存储权限:', this.storagePermission, '存储类型:', this.storageType); + + // 启动token自动刷新(假设剩余1.5小时,实际由服务端控制) + this.startTokenRefresh(1.5 * 60 * 60 * 1000); + + // 启动定期检查用户配置 + this.startProfileSync(); + // 加载用户主题设置 + this.loadUserTheme(); + + // 读取上次停留的视图(需合法才生效) + const savedView = localStorage.getItem('lastView'); + let targetView = null; + if (savedView && this.isViewAllowed(savedView)) { + targetView = savedView; + } else if (this.user.is_admin) { + targetView = 'admin'; + } else if (this.storagePermission === 'oss_only' && this.user?.oss_config_source === 'none') { + targetView = 'settings'; + } else { + targetView = 'files'; + } + + // 强制切换到目标视图并加载数据 + this.switchView(targetView, true); + } + } catch (error) { + // 401表示未登录或Cookie过期,静默处理(用户需要重新登录) + if (error.response?.status === 401) { + console.log('[页面加载] 未登录或Cookie已过期'); + } else { + console.warn('[页面加载] 验证登录状态失败:', error.message); + } + // 清理可能残留的用户信息 + localStorage.removeItem('user'); + } finally { + // 无论登录验证成功还是失败,都标记应用已准备就绪 + this.appReady = true; + } + }, + + // 尝试刷新token,失败则登出 + async tryRefreshOrLogout() { + // refreshToken 通过 Cookie 自动管理,直接尝试刷新 + const refreshed = await this.doRefreshToken(); + if (refreshed) { + await this.checkLoginStatus(); + return; + } + this.handleTokenExpired(); + }, + + // 处理token过期/失效 + handleTokenExpired() { + console.log('[认证] Cookie已失效,清除登录状态'); + this.isLoggedIn = false; + this.user = null; + this.token = null; + this.stopTokenRefresh(); + localStorage.removeItem('user'); + localStorage.removeItem('lastView'); + this.stopProfileSync(); + }, + + // 启动token自动刷新定时器 + startTokenRefresh(expiresIn) { + this.stopTokenRefresh(); // 先清除旧的定时器 + + // 在token过期前5分钟刷新 + const refreshTime = Math.max(expiresIn - 5 * 60 * 1000, 60 * 1000); + console.log(`[认证] Token将在 ${Math.round(refreshTime / 60000)} 分钟后刷新`); + + this.tokenRefreshTimer = setTimeout(async () => { + await this.doRefreshToken(); + }, refreshTime); + }, + + // 停止token刷新定时器 + stopTokenRefresh() { + if (this.tokenRefreshTimer) { + clearTimeout(this.tokenRefreshTimer); + this.tokenRefreshTimer = null; + } + }, + + // 执行token刷新(refreshToken 通过 HttpOnly Cookie 自动发送) + async doRefreshToken() { + try { + console.log('[认证] 正在刷新access token...'); + // refreshToken 通过 Cookie 自动携带,无需手动传递 + const response = await axios.post(`${this.apiBase}/api/refresh-token`); + + if (response.data.success) { + // 后端已自动更新 HttpOnly Cookie 中的 token + console.log('[认证] Token刷新成功(Cookie已更新)'); + + // 继续下一次刷新 + const expiresIn = response.data.expiresIn || (2 * 60 * 60 * 1000); + this.startTokenRefresh(expiresIn); + return true; + } + } catch (error) { + console.error('[认证] Token刷新失败:', error.response?.data?.message || error.message); + // 刷新失败,需要重新登录 + this.handleTokenExpired(); + } + return false; + }, + + // 检查URL参数 + checkUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + const action = urlParams.get('action'); + + if (action === 'login') { + this.isLogin = true; + } else if (action === 'register') { + this.isLogin = false; + } + }, + + // ===== 文件管理 ===== + + async loadFiles(path) { + this.loading = true; + // 确保路径不为undefined + this.currentPath = path || '/'; + + try { + const response = await axios.get(`${this.apiBase}/api/files`, { + params: { path } + }); + + if (response.data.success) { + this.files = response.data.items; + + // 更新存储类型信息 + if (response.data.storageType) { + this.storageType = response.data.storageType; + } + if (response.data.storagePermission) { + this.storagePermission = response.data.storagePermission; + } + + // 更新用户本地存储信息(使用防抖避免频繁请求) + this.debouncedLoadUserProfile(); + } + } catch (error) { + console.error('加载文件失败:', error); + this.showToast('error', '加载失败', error.response?.data?.message || error.message); + + if (error.response?.status === 401) { + this.logout(); + } + } finally { + this.loading = false; + } + }, + + async handleFileClick(file) { + if (file.isDirectory) { + const newPath = this.currentPath === '/' + ? `/${file.name}` + : `${this.currentPath}/${file.name}`; + this.loadFiles(newPath); + } else { + // 检查文件类型,打开相应的预览(异步) + if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) { + await this.openImageViewer(file); + } else if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) { + await this.openVideoPlayer(file); + } else if (file.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) { + await this.openAudioPlayer(file); + } + // 其他文件类型不做任何操作,用户可以通过右键菜单下载 + } + }, + navigateToPath(path) { + this.loadFiles(path); + }, + + navigateToIndex(index) { + const parts = this.pathParts.slice(0, index + 1); + const path = '/' + parts.join('/'); + this.loadFiles(path); + }, + + // 返回上一级目录 + navigateUp() { + if (this.currentPath === '/') return; + const parts = this.currentPath.split('/').filter(p => p !== ''); + parts.pop(); + const newPath = parts.length === 0 ? '/' : '/' + parts.join('/'); + this.loadFiles(newPath); + }, + + downloadFile(file) { + // 构建文件路径 + const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`; + + // OSS 模式:使用签名 URL 直连下载(不经过后端) + if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { + this.downloadFromOSS(filePath); + } else { + // 本地存储模式:通过后端下载 + this.downloadFromLocal(filePath); + } + }, + + // OSS 直连下载(使用签名URL,不经过后端,节省后端带宽) + async downloadFromOSS(filePath) { + try { + // 获取签名 URL + const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, { + params: { path: filePath } + }); + + if (data.success) { + // 直连 OSS 下载(不经过后端,充分利用OSS带宽和CDN) + window.open(data.downloadUrl, '_blank'); + } else { + // 处理后端返回的错误 + console.error('获取下载链接失败:', data.message); + this.showToast('error', '下载失败', data.message || '获取下载链接失败'); + } + } catch (error) { + console.error('获取下载链接失败:', error); + const errorMsg = error.response?.data?.message || error.message || '获取下载链接失败'; + this.showToast('error', '下载失败', errorMsg); + } + }, + + // 本地存储下载 + downloadFromLocal(filePath) { + const url = `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`; + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', ''); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + + // ===== 文件操作 ===== + + openRenameModal(file) { + this.renameForm.oldName = file.name; + this.renameForm.newName = file.name; + this.renameForm.path = this.currentPath; + this.showRenameModal = true; + }, + + async renameFile() { + if (!this.renameForm.newName || this.renameForm.newName === this.renameForm.oldName) { + this.showToast('warning', '提示', '请输入新的文件名'); + return; + } + + try { + const response = await axios.post( + `${this.apiBase}/api/files/rename`, + this.renameForm, + ); + + if (response.data.success) { + this.showToast('success', '成功', '文件已重命名'); + this.showRenameModal = false; + this.loadFiles(this.currentPath); + } + } catch (error) { + console.error('重命名失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '重命名失败'); + } + }, + + // 创建文件夹 + async createFolder() { + if (this.creatingFolder) return; // 防止重复提交 + + const folderName = this.createFolderForm.folderName.trim(); + + if (!folderName) { + this.showToast('error', '错误', '请输入文件夹名称'); + return; + } + + // 前端验证文件夹名称 + if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) { + this.showToast('error', '错误', '文件夹名称不能包含特殊字符 (/ \\ .. :)'); + return; + } + + this.creatingFolder = true; + try { + const response = await axios.post(`${this.apiBase}/api/files/mkdir`, { + path: this.currentPath, + folderName: folderName + }); + + if (response.data.success) { + this.showToast('success', '成功', '文件夹创建成功'); + this.showCreateFolderModal = false; + this.createFolderForm.folderName = ''; + await this.loadFiles(this.currentPath); // 刷新文件列表 + await this.refreshStorageUsage(); // 刷新空间统计(OSS会增加空对象) + } + } catch (error) { + console.error('[创建文件夹失败]', error); + this.showToast('error', '错误', error.response?.data?.message || '创建文件夹失败'); + } finally { + this.creatingFolder = false; + } + }, + + // 显示文件夹详情 + async showFolderInfo(file) { + if (!file.isDirectory) { + this.showToast('error', '错误', '只能查看文件夹详情'); + return; + } + + this.showFolderInfoModal = true; + this.folderInfo = null; // 先清空,显示加载中 + + try { + const response = await axios.post(`${this.apiBase}/api/files/folder-info`, { + path: this.currentPath, + folderName: file.name + }); + + if (response.data.success) { + this.folderInfo = response.data.data; + } + } catch (error) { + console.error('[获取文件夹详情失败]', error); + this.showToast('error', '错误', error.response?.data?.message || '获取文件夹详情失败'); + this.showFolderInfoModal = false; + } + }, + + confirmDeleteFile(file) { + const fileType = file.isDirectory ? '文件夹' : '文件'; + const warning = file.isDirectory ? "\n⚠️ 警告:文件夹内所有文件将被永久删除!" : ""; + if (confirm(`确定要删除${fileType} "${file.name}" 吗?此操作无法撤销!${warning}`)) { + this.deleteFile(file); + } + }, + + // ===== 右键菜单和长按功能 ===== + + // 显示右键菜单(PC端) + showFileContextMenu(file, event) { + // 文件和文件夹都可以显示右键菜单 + event.preventDefault(); + this.contextMenuFile = file; + this.contextMenuX = event.clientX; + this.contextMenuY = event.clientY; + this.showContextMenu = true; + + // 点击其他地方关闭菜单 + this.$nextTick(() => { + document.addEventListener('click', this.hideContextMenu, { once: true }); + }); + }, + + // 隐藏右键菜单 + hideContextMenu() { + this.showContextMenu = false; + this.contextMenuFile = null; + }, + + // 长按开始(移动端) + handleLongPressStart(file, event) { + if (file.isDirectory) return; // 文件夹不响应长按 + + // 记录初始触摸位置,用于检测是否在滑动 + const touch = event.touches[0]; + this.longPressStartX = touch.clientX; + this.longPressStartY = touch.clientY; + this.longPressFile = file; + + this.longPressTimer = setTimeout(() => { + // 触发长按菜单 + this.contextMenuFile = file; + + // 使用记录的触摸位置 + this.contextMenuX = this.longPressStartX; + this.contextMenuY = this.longPressStartY; + this.showContextMenu = true; + + // 触摸震动反馈(如果支持) + if (navigator.vibrate) { + navigator.vibrate(50); + } + + // 点击其他地方关闭菜单 + this.$nextTick(() => { + document.addEventListener('click', this.hideContextMenu, { once: true }); + }); + }, this.longPressDuration); + }, + + // 长按移动检测(移动端)- 滑动时取消长按 + handleLongPressMove(event) { + if (!this.longPressTimer) return; + + const touch = event.touches[0]; + const moveX = Math.abs(touch.clientX - this.longPressStartX); + const moveY = Math.abs(touch.clientY - this.longPressStartY); + + // 如果移动超过10px,认为是滑动,取消长按 + if (moveX > 10 || moveY > 10) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + }, + + // 长按取消(移动端) + handleLongPressEnd() { + if (this.longPressTimer) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + }, + + // 从菜单执行操作 + async contextMenuAction(action) { + if (!this.contextMenuFile) return; + + switch (action) { + case 'preview': + // 根据文件类型打开对应的预览(异步) + if (this.contextMenuFile.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) { + await this.openImageViewer(this.contextMenuFile); + } else if (this.contextMenuFile.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)) { + await this.openVideoPlayer(this.contextMenuFile); + } else if (this.contextMenuFile.name.match(/\.(mp3|wav|flac|aac|ogg|m4a)$/i)) { + await this.openAudioPlayer(this.contextMenuFile); + } + break; + case 'download': + this.downloadFile(this.contextMenuFile); + break; + case 'rename': + this.openRenameModal(this.contextMenuFile); + break; + case 'info': + this.showFolderInfo(this.contextMenuFile); + break; + case 'share': + this.openShareFileModal(this.contextMenuFile); + break; + case 'delete': + this.confirmDeleteFile(this.contextMenuFile); + break; + } + + this.hideContextMenu(); + }, + + // ===== 媒体预览功能 ===== + + // 获取媒体文件URL(OSS直连或后端代理) + async getMediaUrl(file) { + const filePath = this.currentPath === '/' + ? `/${file.name}` + : `${this.currentPath}/${file.name}`; + + // OSS 模式:返回签名 URL(用于媒体预览) + if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { + try { + const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, { + params: { path: filePath } + }); + return data.success ? data.downloadUrl : null; + } catch (error) { + console.error('获取媒体URL失败:', error); + return null; + } + } + + // 本地存储模式:通过后端 API + return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`; + }, + + // 获取文件缩略图URL(同步方法,用于本地存储模式) + // 注意:OSS 模式下缩略图需要单独处理,此处返回本地存储的直接URL + getThumbnailUrl(file) { + if (!file || file.isDirectory) return null; + + // 检查是否是图片或视频 + const isImage = file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i); + const isVideo = file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i); + + if (!isImage && !isVideo) return null; + + // 本地存储模式:返回同步的下载 URL + // OSS 模式下缩略图功能暂不支持(需要预签名 URL,建议点击文件预览) + if (this.storageType !== 'oss') { + const filePath = this.currentPath === '/' + ? `/${file.name}` + : `${this.currentPath}/${file.name}`; + return `${this.apiBase}/api/files/download?path=${encodeURIComponent(filePath)}`; + } + + // OSS 模式暂不支持同步缩略图,返回 null + return null; + }, + + // 打开图片预览 + async openImageViewer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'image'; + this.showImageViewer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } + }, + + // 打开视频播放器 + async openVideoPlayer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'video'; + this.showVideoPlayer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } + }, + + // 打开音频播放器 + async openAudioPlayer(file) { + const url = await this.getMediaUrl(file); + if (url) { + this.currentMediaUrl = url; + this.currentMediaName = file.name; + this.currentMediaType = 'audio'; + this.showAudioPlayer = true; + } else { + this.showToast('error', '错误', '无法获取文件预览链接'); + } + }, + + // 关闭媒体预览 + closeMediaViewer() { + this.showImageViewer = false; + this.showVideoPlayer = false; + this.showAudioPlayer = false; + this.currentMediaUrl = ''; + this.currentMediaName = ''; + this.currentMediaType = ''; + }, + + // 下载当前预览的媒体文件 + downloadCurrentMedia() { + if (!this.currentMediaUrl) return; + + // 创建临时a标签触发下载 + const link = document.createElement('a'); + link.href = this.currentMediaUrl; + link.setAttribute('download', this.currentMediaName); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + + + // 判断文件是否支持预览 + isPreviewable(file) { + if (!file || file.isDirectory) return false; + return file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp|mp4|avi|mov|wmv|flv|mkv|webm|mp3|wav|flac|aac|ogg|m4a)$/i); + }, + async deleteFile(file) { + try { + const response = await axios.post( + `${this.apiBase}/api/files/delete`, + { + fileName: file.name, + path: this.currentPath, + isDirectory: file.isDirectory + }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '文件已删除'); + // 刷新文件列表和空间统计 + await this.loadFiles(this.currentPath); + await this.refreshStorageUsage(); + } + } catch (error) { + console.error('删除失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '删除失败'); + } + }, + + // ===== 分享功能 ===== + + openShareFileModal(file) { + this.shareFileForm.fileName = file.name; + this.shareFileForm.filePath = this.currentPath === '/' + ? file.name + : `${this.currentPath}/${file.name}`; + this.shareFileForm.isDirectory = file.isDirectory; // 设置是否为文件夹 + this.shareFileForm.password = ''; + this.shareFileForm.expiryType = 'never'; + this.shareFileForm.customDays = 7; + this.shareResult = null; // 清空上次的分享结果 + this.showShareFileModal = true; + }, + + async createShareAll() { + if (this.creatingShare) return; // 防止重复提交 + this.creatingShare = true; + + try { + const expiryDays = this.shareAllForm.expiryType === 'never' ? null : + this.shareAllForm.expiryType === 'custom' ? this.shareAllForm.customDays : + parseInt(this.shareAllForm.expiryType); + + const response = await axios.post( + `${this.apiBase}/api/share/create`, + { + share_type: 'all', + password: this.shareAllForm.password || null, + expiry_days: expiryDays + }, + ); + + if (response.data.success) { + this.shareResult = response.data; + this.showToast('success', '成功', '分享链接已创建'); + this.loadShares(); + } + } catch (error) { + console.error('创建分享失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '创建分享失败'); + } finally { + this.creatingShare = false; + } + }, + + async createShareFile() { + if (this.creatingShare) return; // 防止重复提交 + this.creatingShare = true; + + try { + const expiryDays = this.shareFileForm.expiryType === 'never' ? null : + this.shareFileForm.expiryType === 'custom' ? this.shareFileForm.customDays : + parseInt(this.shareFileForm.expiryType); + + // 根据是否为文件夹决定share_type + const shareType = this.shareFileForm.isDirectory ? 'directory' : 'file'; + + const response = await axios.post( + `${this.apiBase}/api/share/create`, + { + share_type: shareType, // 修复:文件夹使用directory类型 + file_path: this.shareFileForm.filePath, + file_name: this.shareFileForm.fileName, + password: this.shareFileForm.password || null, + expiry_days: expiryDays + }, + ); + + if (response.data.success) { + this.shareResult = response.data; + const itemType = this.shareFileForm.isDirectory ? '文件夹' : '文件'; + this.showToast('success', '成功', `${itemType}分享链接已创建`); + this.loadShares(); + } + } catch (error) { + console.error('创建分享失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '创建分享失败'); + } finally { + this.creatingShare = false; + } + }, + + + // ===== 文件上传 ===== + + handleFileSelect(event) { + const files = event.target.files; + if (files && files.length > 0) { + // 支持多文件上传 + Array.from(files).forEach(file => { + this.uploadFile(file); + }); + // 清空input,允许重复上传相同文件 + event.target.value = ''; + } + }, + + handleFileDrop(event) { + this.isDragging = false; + const file = event.dataTransfer.files[0]; + if (file) { + this.uploadFile(file); + } + }, + + async uploadFile(file) { + // 文件大小限制预检查 + if (file.size > this.maxUploadSize) { + const fileSizeMB = Math.round(file.size / (1024 * 1024)); + const maxSizeMB = Math.round(this.maxUploadSize / (1024 * 1024)); + this.showToast( + 'error', + '文件超过上传限制', + `文件大小 ${fileSizeMB}MB 超过系统限制 ${maxSizeMB}MB,请选择更小的文件` + ); + return; + } + + // 设置上传状态 + this.uploadingFileName = file.name; + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = file.size; + + try { + if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { + // ===== OSS 直连上传(不经过后端) ===== + await this.uploadToOSSDirect(file); + } else { + // ===== 本地存储上传(经过后端) ===== + await this.uploadToLocal(file); + } + } catch (error) { + console.error('上传失败:', error); + + // 重置上传进度 + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + + this.showToast('error', '上传失败', error.message || '上传失败,请重试'); + } + }, + + // OSS 直连上传 + async uploadToOSSDirect(file) { + try { + // 1. 获取签名 URL(传递当前路径) + const { data: signData } = await axios.get(`${this.apiBase}/api/files/upload-signature`, { + params: { + filename: file.name, + path: this.currentPath, + contentType: file.type || 'application/octet-stream' + } + }); + + if (!signData.success) { + throw new Error(signData.message || '获取上传签名失败'); + } + + // 2. 直连 OSS 上传(不经过后端!) + await axios.put(signData.uploadUrl, file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream' + }, + onUploadProgress: (progressEvent) => { + this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + this.uploadedBytes = progressEvent.loaded; + this.totalBytes = progressEvent.total; + }, + timeout: 30 * 60 * 1000 // 30分钟超时 + }); + + // 3. 通知后端上传完成 + await axios.post(`${this.apiBase}/api/files/upload-complete`, { + objectKey: signData.objectKey, + size: file.size, + path: this.currentPath + }); + + // 4. 显示成功提示 + this.showToast('success', '上传成功', `文件 ${file.name} 已上传到 OSS`); + + // 5. 重置上传进度 + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + + // 6. 刷新文件列表和空间统计 + await this.loadFiles(this.currentPath); + await this.refreshStorageUsage(); + + } catch (error) { + // 处理 CORS 错误 + if (error.message?.includes('CORS') || error.message?.includes('Cross-Origin')) { + throw new Error('OSS 跨域配置错误,请联系管理员检查 Bucket CORS 设置'); + } + throw error; + } + }, + + // 本地存储上传(经过后端) + async uploadToLocal(file) { + // 本地存储配额预检查 + const estimatedUsage = this.localUsed + file.size; + if (estimatedUsage > this.localQuota) { + this.showToast( + 'error', + '配额不足', + `文件大小 ${this.formatBytes(file.size)},剩余配额 ${this.formatBytes(this.localQuota - this.localUsed)}` + ); + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + return; + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('path', this.currentPath); + + const response = await axios.post(`${this.apiBase}/api/upload`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30 * 60 * 1000, + onUploadProgress: (progressEvent) => { + this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total); + this.uploadedBytes = progressEvent.loaded; + this.totalBytes = progressEvent.total; + } + }); + + if (response.data.success) { + this.showToast('success', '上传成功', `文件 ${file.name} 已上传`); + this.uploadProgress = 0; + this.uploadedBytes = 0; + this.totalBytes = 0; + this.uploadingFileName = ''; + await this.loadFiles(this.currentPath); + await this.refreshStorageUsage(); + } + }, + + // ===== 分享管理 ===== + + async loadShares() { + try { + const response = await axios.get(`${this.apiBase}/api/share/my`); + + if (response.data.success) { + this.shares = response.data.shares; + } + } catch (error) { + console.error('加载分享列表失败:', error); + this.showToast('error', '加载失败', error.response?.data?.message || error.message); + } + }, + + async createShare() { + this.shareForm.path = this.currentPath; + + try { + const response = await axios.post(`${this.apiBase}/api/share/create`, this.shareForm); + + if (response.data.success) { + this.shareResult = response.data; + this.loadShares(); + } + } catch (error) { + console.error('创建分享失败:', error); + this.showToast('error', '创建失败', error.response?.data?.message || error.message); + } + }, + + async deleteShare(id) { + if (!confirm('确定要删除这个分享吗?')) return; + + try { + const response = await axios.delete(`${this.apiBase}/api/share/${id}`); + + if (response.data.success) { + this.showToast('success', '成功', '分享已删除'); + this.loadShares(); + } + } catch (error) { + console.error('删除分享失败:', error); + this.showToast('error', '删除失败', error.response?.data?.message || error.message); + } + }, + + // 格式化到期时间显示 + formatExpireTime(expiresAt) { + if (!expiresAt) return '永久有效'; + + const expireDate = new Date(expiresAt); + const now = new Date(); + const diffMs = expireDate - now; + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // 格式化日期 + const dateStr = expireDate.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + if (diffMs < 0) { + return `已过期 (${dateStr})`; + } else if (diffMinutes < 60) { + return `${diffMinutes}分钟后过期 (${dateStr})`; + } else if (diffHours < 24) { + return `${diffHours}小时后过期 (${dateStr})`; + } else if (diffDays === 1) { + return `明天过期 (${dateStr})`; + } else if (diffDays <= 7) { + return `${diffDays}天后过期 (${dateStr})`; + } else { + return dateStr; + } + }, + + // 判断是否即将过期(3天内) + isExpiringSoon(expiresAt) { + if (!expiresAt) return false; + const expireDate = new Date(expiresAt); + const now = new Date(); + const diffMs = expireDate - now; + const diffDays = diffMs / (1000 * 60 * 60 * 24); + return diffDays > 0 && diffDays <= 3; + }, + + // 判断是否已过期 + isExpired(expiresAt) { + if (!expiresAt) return false; + const expireDate = new Date(expiresAt); + const now = new Date(); + return expireDate <= now; + }, + + // 分享类型标签 + getShareTypeLabel(type) { + switch (type) { + case 'directory': return '文件夹'; + case 'all': return '全部文件'; + case 'file': + default: return '文件'; + } + }, + + // 分享状态标签 + getShareStatus(share) { + if (this.isExpired(share.expires_at)) { + return { text: '已过期', class: 'danger', icon: 'fa-clock' }; + } + if (this.isExpiringSoon(share.expires_at)) { + return { text: '即将到期', class: 'warn', icon: 'fa-hourglass-half' }; + } + return { text: '有效', class: 'success', icon: 'fa-check-circle' }; + }, + + // 分享保护标签 + getShareProtection(share) { + if (share.share_password) { + return { text: '已加密', class: 'info', icon: 'fa-lock' }; + } + return { text: '公开', class: 'info', icon: 'fa-unlock' }; + }, + + // 存储来源 + getStorageLabel(storageType) { + if (!storageType) return '默认'; + return storageType === 'local' ? '本地存储' : storageType.toUpperCase(); + }, + + // 格式化时间 + formatDateTime(value) { + if (!value) return '--'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return d.toLocaleString(); + }, + + // HTML实体解码(前端兜底,防止已实体化的文件名显示乱码) + decodeHtmlEntities(str) { + if (typeof str !== 'string') return ''; + 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; + }, + + getFileDisplayName(file) { + if (!file) return ''; + const base = (typeof file.displayName === 'string' && file.displayName !== '') + ? file.displayName + : (typeof file.name === 'string' ? file.name : ''); + const decoded = this.decodeHtmlEntities(base); + return decoded || base || ''; + }, + + openShare(url) { + if (!url) return; + const newWindow = window.open(url, '_blank', 'noopener'); + if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') { + // 弹窗被拦截时提示用户手动打开,避免当前页跳转 + this.showToast('info', '提示', '浏览器阻止了新标签页,请允许弹窗或手动打开链接'); + } + }, + + copyShareLink(url) { + // 复制分享链接到剪贴板 + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(() => { + this.showToast('success', '成功', '分享链接已复制到剪贴板'); + }).catch(() => { + this.fallbackCopyToClipboard(url); + }); + } else { + this.fallbackCopyToClipboard(url); + } + }, + + fallbackCopyToClipboard(text) { + // 备用复制方法 + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + this.showToast('success', '成功', '分享链接已复制到剪贴板'); + } catch (err) { + this.showToast('error', '错误', '复制失败,请手动复制'); + } + document.body.removeChild(textArea); + }, + + // ===== 管理员功能 ===== + + async loadUsers() { + try { + const response = await axios.get(`${this.apiBase}/api/admin/users`); + + if (response.data.success) { + this.adminUsers = response.data.users; + } + } catch (error) { + console.error('加载用户列表失败:', error); + this.showToast('error', '加载失败', error.response?.data?.message || error.message); + } + }, + + async banUser(userId, banned) { + const action = banned ? '封禁' : '解封'; + if (!confirm(`确定要${action}这个用户吗?`)) return; + + try { + const response = await axios.post( + `${this.apiBase}/api/admin/users/${userId}/ban`, + { banned }, + ); + + if (response.data.success) { + this.showToast('success', '成功', response.data.message); + this.loadUsers(); + } + } catch (error) { + console.error('操作失败:', error); + this.showToast('error', '操作失败', error.response?.data?.message || error.message); + } + }, + + async deleteUser(userId) { + if (!confirm('确定要删除这个用户吗?此操作不可恢复!')) return; + + try { + const response = await axios.delete(`${this.apiBase}/api/admin/users/${userId}`); + + if (response.data.success) { + this.showToast('success', '成功', '用户已删除'); + this.loadUsers(); + } + } catch (error) { + console.error('删除用户失败:', error); + this.showToast('error', '删除失败', error.response?.data?.message || error.message); + } + }, + + // ===== 忘记密码功能 ===== + + async requestPasswordReset() { + if (!this.forgotPasswordForm.email) { + this.showToast('error', '错误', '请输入注册邮箱'); + return; + } + if (!this.forgotPasswordForm.captcha) { + this.showToast('error', '错误', '请输入验证码'); + return; + } + + this.passwordResetting = true; + try { + const response = await axios.post( + `${this.apiBase}/api/password/forgot`, + this.forgotPasswordForm + ); + + if (response.data.success) { + this.showToast('success', '成功', '如果邮箱存在,将收到重置邮件'); + this.showForgotPasswordModal = false; + this.forgotPasswordForm = { email: '', captcha: '' }; + this.forgotPasswordCaptchaUrl = ''; + } + } catch (error) { + console.error('提交密码重置请求失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '提交失败'); + // 刷新验证码 + this.forgotPasswordForm.captcha = ''; + this.refreshForgotPasswordCaptcha(); + } finally { + this.passwordResetting = false; + } + }, + + async submitResetPassword() { + if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password || this.resetPasswordForm.new_password.length < 6) { + this.showToast('error', '错误', '请输入有效的重置链接和新密码(至少6位)'); + return; + } + this.passwordResetting = true; + try { + const response = await axios.post(`${this.apiBase}/api/password/reset`, this.resetPasswordForm); + if (response.data.success) { + this.verifyMessage = '密码已重置,请登录'; + this.isLogin = true; + this.showResetPasswordModal = false; + this.resetPasswordForm = { token: '', new_password: '' }; + // 清理URL中的token + this.sanitizeUrlToken('resetToken'); + } + } catch (error) { + console.error('密码重置失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '重置失败'); + } finally { + this.passwordResetting = false; + } + }, + + // ===== 管理员:文件审查功能 ===== + + async openFileInspection(user) { + this.inspectionUser = user; + this.inspectionPath = '/'; + this.showFileInspectionModal = true; + await this.loadUserFiles('/'); + }, + + async loadUserFiles(path) { + this.inspectionLoading = true; + this.inspectionPath = path; + + try { + const response = await axios.get( + `${this.apiBase}/api/admin/users/${this.inspectionUser.id}/files`, + { + params: { path } + } + ); + + if (response.data.success) { + this.inspectionFiles = response.data.items; + } + } catch (error) { + console.error('加载用户文件失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '加载文件失败'); + } finally { + this.inspectionLoading = false; + } + }, + + handleInspectionFileClick(file) { + if (file.isDirectory) { + const newPath = this.inspectionPath === '/' + ? `/${file.name}` + : `${this.inspectionPath}/${file.name}`; + this.loadUserFiles(newPath); + } + }, + + navigateInspectionToRoot() { + this.loadUserFiles('/'); + }, + + navigateInspectionUp() { + if (this.inspectionPath === '/') return; + const lastSlash = this.inspectionPath.lastIndexOf('/'); + const parentPath = lastSlash > 0 ? this.inspectionPath.substring(0, lastSlash) : '/'; + this.loadUserFiles(parentPath); + }, + + // ===== 存储管理 ===== + + // 加载用户个人资料(包含存储信息) + async loadUserProfile() { + try { + const response = await axios.get( + `${this.apiBase}/api/user/profile`, + ); + + if (response.data.success && response.data.user) { + const user = response.data.user; + // 同步用户信息(含 has_oss_config) + this.user = { ...(this.user || {}), ...user }; + + // 检测存储配置是否被管理员更改 + const oldStorageType = this.storageType; + const oldStoragePermission = this.storagePermission; + const newStorageType = user.current_storage_type || 'oss'; + const newStoragePermission = user.storage_permission || 'oss_only'; + + // 更新本地数据 + this.localQuota = user.local_storage_quota || 0; + this.localUsed = user.local_storage_used || 0; + this.storagePermission = newStoragePermission; + this.storageType = newStorageType; + + // 首次加载仅同步,不提示 + if (!this.profileInitialized) { + this.profileInitialized = true; + return; + } + + // 如果存储类型被管理员更改,通知用户并重新加载文件 + if (oldStorageType !== newStorageType || oldStoragePermission !== newStoragePermission) { + console.log('[存储配置更新] 旧类型:', oldStorageType, '新类型:', newStorageType); + console.log('[存储配置更新] 旧权限:', oldStoragePermission, '新权限:', newStoragePermission); + + if (!this.suppressStorageToast) { + this.showToast('info', '存储配置已更新', `管理员已将您的存储方式更改为${newStorageType === 'local' ? '本地存储' : 'OSS存储'}`); + } else { + this.suppressStorageToast = false; + } + + // 如果当前在文件页面,重新加载文件列表 + if (this.currentView === 'files') { + await this.loadFiles(this.currentPath); + } + } + } + } catch (error) { + console.error('加载用户资料失败:', error); + } + }, + + // 加载OSS空间使用统计 + async loadOssUsage() { + // 检查是否有可用的OSS配置(个人配置或系统级统一配置) + if (!this.user || this.user?.oss_config_source === 'none') { + this.ossUsage = null; + return; + } + + this.ossUsageLoading = true; + this.ossUsageError = null; + + try { + const response = await axios.get( + `${this.apiBase}/api/user/oss-usage`, + ); + + if (response.data.success) { + this.ossUsage = response.data.usage; + } + } catch (error) { + console.error('获取OSS空间使用情况失败:', error); + this.ossUsageError = error.response?.data?.message || '获取失败'; + } finally { + this.ossUsageLoading = false; + } + }, + + // 刷新存储空间使用统计(根据当前存储类型) + async refreshStorageUsage() { + if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { + // 刷新 OSS 空间统计 + await this.loadOssUsage(); + } else if (this.storageType === 'local') { + // 刷新本地存储统计(通过重新获取用户信息) + await this.loadUserProfile(); + } + }, + + // 启动定期检查用户配置 + startProfileSync() { + // 清除已有的定时器 + if (this.profileCheckInterval) { + clearInterval(this.profileCheckInterval); + } + + // 每30秒检查一次用户配置是否有更新 + this.profileCheckInterval = setInterval(() => { + // 注意:token 通过 HttpOnly Cookie 管理,仅检查 isLoggedIn + if (this.isLoggedIn) { + this.loadUserProfile(); + } + }, 30000); // 30秒 + + console.log('[配置同步] 已启动定期检查(30秒间隔)'); + }, + + // 停止定期检查 + stopProfileSync() { + if (this.profileCheckInterval) { + clearInterval(this.profileCheckInterval); + this.profileCheckInterval = null; + console.log('[配置同步] 已停止定期检查'); + } + }, + + // 用户切换存储方式 + async switchStorage(type) { + if (this.storageSwitching || type === this.storageType) { + return; + } + + // 不再弹出配置引导弹窗,直接尝试切换 + // 如果后端检测到没有OSS配置,会返回错误提示 + + this.storageSwitching = true; + this.storageSwitchTarget = type; + + try { + const response = await axios.post( + `${this.apiBase}/api/user/switch-storage`, + { storage_type: type }, + ); + + if (response.data.success) { + this.storageType = type; + // 用户主动切换后,下一次配置同步不提示管理员修改 + this.suppressStorageToast = true; + this.showToast('success', '成功', `已切换到${type === 'local' ? '本地存储' : 'OSS存储'}`); + + // 重新加载文件列表 + if (this.currentView === 'files') { + this.loadFiles(this.currentPath); + } + } + } catch (error) { + console.error('切换存储失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '切换存储失败'); + } finally { + this.storageSwitching = false; + this.storageSwitchTarget = null; + } + }, + + ensureOssConfigSection() { + this.openOssConfigModal(); + }, + + openOssGuideModal() { + this.showOssGuideModal = true; + }, + + closeOssGuideModal() { + this.showOssGuideModal = false; + }, + + proceedOssGuide() { + this.showOssGuideModal = false; + this.ensureOssConfigSection(); + }, + + openOssConfigModal() { + // 只有管理员才能配置OSS + if (!this.user?.is_admin) { + this.showToast('error', '权限不足', '只有管理员才能配置OSS服务'); + return; + } + this.showOssGuideModal = false; + this.showOssConfigModal = true; + if (this.user && !this.user.is_admin) { + this.loadOssConfig(); + } + }, + + closeOssConfigModal() { + this.showOssConfigModal = false; + }, + + // 检查视图权限 + isViewAllowed(view) { + if (!this.isLoggedIn) return false; + const commonViews = ['files', 'shares', 'settings']; + if (view === 'admin') { + return !!(this.user && this.user.is_admin); + } + return commonViews.includes(view); + }, + + // 切换视图并自动刷新数据 + switchView(view, force = false) { + if (this.isLoggedIn && !this.isViewAllowed(view)) { + return; + } + // 如果已经在当前视图,不重复刷新 + if (!force && this.currentView === view) { + return; + } + + this.currentView = view; + + // 根据视图类型自动加载对应数据 + switch (view) { + case 'files': + // 切换到文件视图时,重新加载文件列表 + this.loadFiles(this.currentPath); + break; + case 'shares': + // 切换到分享视图时,重新加载分享列表 + this.loadShares(); + break; + case 'admin': + // 切换到管理后台时,重新加载用户列表、健康检测和系统日志 + if (this.user && this.user.is_admin) { + this.loadUsers(); + this.loadServerStorageStats(); + if (this.adminTab === 'monitor') { + this.initMonitorTab(); + } else { + this.loadHealthCheck(); + this.loadSystemLogs(1); + } + } + break; + case 'settings': + // 设置页面不需要额外加载数据 + break; + } + }, + + // 管理员:打开编辑用户存储权限模态框 + openEditStorageModal(user) { + this.editStorageForm.userId = user.id; + this.editStorageForm.username = user.username; + this.editStorageForm.storage_permission = user.storage_permission || 'oss_only'; + + // 智能识别配额单位 + const quotaBytes = user.local_storage_quota || 1073741824; + const quotaMB = quotaBytes / 1024 / 1024; + const quotaGB = quotaMB / 1024; + + // 如果配额能被1024整除且大于等于1GB,使用GB单位,否则使用MB + if (quotaMB >= 1024 && quotaMB % 1024 === 0) { + this.editStorageForm.local_storage_quota_value = quotaGB; + this.editStorageForm.quota_unit = 'GB'; + } else { + this.editStorageForm.local_storage_quota_value = Math.round(quotaMB); + this.editStorageForm.quota_unit = 'MB'; + } + + this.showEditStorageModal = true; + }, + + // 管理员:更新用户存储权限 + async updateUserStorage() { + try { + // 根据单位计算字节数 + let quotaBytes; + if (this.editStorageForm.quota_unit === 'GB') { + quotaBytes = this.editStorageForm.local_storage_quota_value * 1024 * 1024 * 1024; + } else { + quotaBytes = this.editStorageForm.local_storage_quota_value * 1024 * 1024; + } + + const response = await axios.post( + `${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`, + { + storage_permission: this.editStorageForm.storage_permission, + local_storage_quota: quotaBytes + }, + ); + + if (response.data.success) { + this.showToast('success', '成功', '存储权限已更新'); + this.showEditStorageModal = false; + this.loadUsers(); + } + } catch (error) { + console.error('更新存储权限失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '更新失败'); + } + }, + + // ===== 工具函数 ===== + + formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }, + + formatDate(dateString) { + if (!dateString) return '-'; + + // SQLite 返回的是 UTC 时间字符串,需要显式处理 + // 如果字符串不包含时区信息,手动添加 'Z' 标记为 UTC + let dateStr = dateString; + if (!dateStr.includes('Z') && !dateStr.includes('+') && !dateStr.includes('T')) { + // SQLite 格式: "2025-11-13 16:37:19" -> ISO格式: "2025-11-13T16:37:19Z" + dateStr = dateStr.replace(' ', 'T') + 'Z'; + } + + const date = new Date(dateStr); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}`; + }, + + // ===== Toast通知 ===== + + showToast(type, title, message) { + const toast = { + id: ++this.toastIdCounter, + type, + title, + message, + icon: type === 'error' ? 'fas fa-circle-exclamation' : type === 'success' ? 'fas fa-circle-check' : 'fas fa-circle-info', + hiding: false + }; + + // 清除之前的所有通知,只保留最新的一个 + this.toasts = [toast]; + + // 4.5秒后开始淡出动画 + setTimeout(() => { + const index = this.toasts.findIndex(t => t.id === toast.id); + if (index !== -1) { + this.toasts[index].hiding = true; + + // 0.5秒后移除(动画时长) + setTimeout(() => { + const removeIndex = this.toasts.findIndex(t => t.id === toast.id); + if (removeIndex !== -1) { + this.toasts.splice(removeIndex, 1); + } + }, 500); + } + }, 4500); + }, + + // ===== 系统设置管理 ===== + + async loadSystemSettings() { + try { + const response = await axios.get(`${this.apiBase}/api/admin/settings`); + + if (response.data.success) { + const settings = response.data.settings; + this.systemSettings.maxUploadSizeMB = Math.round(settings.max_upload_size / (1024 * 1024)); + // 加载全局主题设置 + console.log('[主题] 从服务器加载全局主题:', settings.global_theme); + if (settings.global_theme) { + this.globalTheme = settings.global_theme; + console.log('[主题] globalTheme已设置为:', this.globalTheme); + } + if (settings.smtp) { + this.systemSettings.smtp.host = settings.smtp.host || ''; + this.systemSettings.smtp.port = settings.smtp.port || 465; + this.systemSettings.smtp.secure = !!settings.smtp.secure; + this.systemSettings.smtp.user = settings.smtp.user || ''; + this.systemSettings.smtp.from = settings.smtp.from || settings.smtp.user || ''; + this.systemSettings.smtp.has_password = !!settings.smtp.has_password; + this.systemSettings.smtp.password = ''; + } + } + } catch (error) { + console.error('加载系统设置失败:', error); + this.showToast('error', '错误', '加载系统设置失败'); + } + }, + + async loadServerStorageStats() { + try { + const response = await axios.get(`${this.apiBase}/api/admin/storage-stats`); + + if (response.data.success) { + this.serverStorageStats = response.data.stats; + } + } catch (error) { + console.error('加载服务器存储统计失败:', error); + this.showToast('error', '错误', '加载服务器存储统计失败'); + } + }, + + async updateSystemSettings() { + try { + const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024; + + const payload = { + max_upload_size: maxUploadSize, + smtp: { + host: this.systemSettings.smtp.host, + port: this.systemSettings.smtp.port, + secure: this.systemSettings.smtp.secure, + user: this.systemSettings.smtp.user, + from: this.systemSettings.smtp.from || this.systemSettings.smtp.user + } + }; + if (this.systemSettings.smtp.password) { + payload.smtp.password = this.systemSettings.smtp.password; + } + + const response = await axios.post( + `${this.apiBase}/api/admin/settings`, + payload + ); + + if (response.data.success) { + this.showToast('success', '成功', '系统设置已更新'); + this.systemSettings.smtp.password = ''; + } + } catch (error) { + console.error('更新系统设置失败:', error); + this.showToast('error', '错误', '更新系统设置失败'); + } + }, + + async testSmtp() { + try { + const response = await axios.post( + `${this.apiBase}/api/admin/settings/test-smtp`, + { to: this.systemSettings.smtp.user }, + ); + this.showToast('success', '成功', response.data.message || '测试邮件已发送'); + } catch (error) { + console.error('测试SMTP失败:', error); + this.showToast('error', '错误', error.response?.data?.message || '测试失败'); + } + }, + + // 打开监控标签页(带整体loading遮罩) + openMonitorTab() { + this.adminTab = 'monitor'; + this.initMonitorTab(); + }, + + // 统一加载监控数据,避免初次渲染空态闪烁 + async initMonitorTab() { + this.monitorTabLoading = true; + this.healthCheck.loading = true; + this.systemLogs.loading = true; + + try { + await Promise.all([ + this.loadHealthCheck(), + this.loadSystemLogs(1) + ]); + } catch (e) { + // 子方法内部已处理错误 + } finally { + this.monitorTabLoading = false; + } + }, + + // ===== 健康检测 ===== + + async loadHealthCheck() { + this.healthCheck.loading = true; + try { + const response = await axios.get(`${this.apiBase}/api/admin/health-check`); + + if (response.data.success) { + this.healthCheck.overallStatus = response.data.overallStatus; + this.healthCheck.summary = response.data.summary; + this.healthCheck.checks = response.data.checks; + this.healthCheck.lastCheck = response.data.timestamp; + } + } catch (error) { + console.error('健康检测失败:', error); + this.showToast('error', '错误', '健康检测失败'); + } finally { + this.healthCheck.loading = false; + } + }, + + getHealthStatusColor(status) { + const colors = { + pass: 'bg-green-100 text-green-800', + warning: 'bg-yellow-100 text-yellow-800', + fail: 'bg-red-100 text-red-800', + info: 'bg-blue-100 text-blue-800' + }; + return colors[status] || 'bg-gray-100 text-gray-800'; + }, + + getHealthStatusIcon(status) { + const icons = { + pass: '✓', + warning: '⚠', + fail: '✗', + info: 'ℹ' + }; + return icons[status] || '?'; + }, + + getOverallStatusColor(status) { + const colors = { + healthy: 'text-green-600', + warning: 'text-yellow-600', + critical: 'text-red-600' + }; + return colors[status] || 'text-gray-600'; + }, + + getOverallStatusText(status) { + const texts = { + healthy: '系统健康', + warning: '存在警告', + critical: '存在问题' + }; + return texts[status] || '未知'; + }, + + // ===== 系统日志 ===== + + async loadSystemLogs(page = 1) { + this.systemLogs.loading = true; + try { + const params = new URLSearchParams({ + page: page, + pageSize: this.systemLogs.pageSize + }); + + if (this.systemLogs.filters.level) { + params.append('level', this.systemLogs.filters.level); + } + if (this.systemLogs.filters.category) { + params.append('category', this.systemLogs.filters.category); + } + if (this.systemLogs.filters.keyword) { + params.append('keyword', this.systemLogs.filters.keyword); + } + + const response = await axios.get(`${this.apiBase}/api/admin/logs?${params}`); + + if (response.data.success) { + this.systemLogs.logs = response.data.logs; + this.systemLogs.total = response.data.total; + this.systemLogs.page = response.data.page; + this.systemLogs.totalPages = response.data.totalPages; + } + } catch (error) { + console.error('加载系统日志失败:', error); + this.showToast('error', '错误', '加载系统日志失败'); + } finally { + this.systemLogs.loading = false; + } + }, + + filterLogs() { + this.loadSystemLogs(1); + }, + + clearLogFilters() { + this.systemLogs.filters = { level: '', category: '', keyword: '' }; + this.loadSystemLogs(1); + }, + + getLogLevelColor(level) { + const colors = { + debug: 'background: #6c757d; color: white;', + info: 'background: #17a2b8; color: white;', + warn: 'background: #ffc107; color: black;', + error: 'background: #dc3545; color: white;' + }; + return colors[level] || 'background: #6c757d; color: white;'; + }, + + getLogLevelText(level) { + const texts = { debug: '调试', info: '信息', warn: '警告', error: '错误' }; + return texts[level] || level; + }, + + getLogCategoryText(category) { + const texts = { + auth: '认证', + user: '用户', + file: '文件', + share: '分享', + system: '系统', + security: '安全' + }; + return texts[category] || category; + }, + + getLogCategoryIcon(category) { + const icons = { + auth: 'fa-key', + user: 'fa-user', + file: 'fa-file', + share: 'fa-share-alt', + system: 'fa-cog', + security: 'fa-shield-alt' + }; + return icons[category] || 'fa-info'; + }, + + formatLogTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, + + async cleanupLogs() { + if (!confirm('确定要清理90天前的日志吗?此操作不可恢复。')) return; + + try { + const response = await axios.post( + `${this.apiBase}/api/admin/logs/cleanup`, + { keepDays: 90 }, + ); + + if (response.data.success) { + this.showToast('success', '成功', response.data.message); + this.loadSystemLogs(1); + } + } catch (error) { + console.error('清理日志失败:', error); + this.showToast('error', '错误', '清理日志失败'); + } + }, + + // ===== 调试模式管理 ===== + + // 切换调试模式 + toggleDebugMode() { + this.debugMode = !this.debugMode; + + // 保存到 localStorage + if (this.debugMode) { + localStorage.setItem('debugMode', 'true'); + this.showToast('success', '调试模式已启用', 'F12和开发者工具快捷键已启用'); + // 刷新页面以应用更改 + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + localStorage.removeItem('debugMode'); + this.showToast('info', '调试模式已禁用', '页面将重新加载以应用更改'); + // 刷新页面以应用更改 + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } + }, + + mounted() { + // 配置axios全局设置 - 确保验证码session cookie正确传递 + axios.defaults.withCredentials = true; + + // 设置 axios 请求拦截器,自动添加 CSRF Token + axios.interceptors.request.use(config => { + // 从 Cookie 中读取 CSRF token + const csrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('csrf_token=')) + ?.split('=')[1]; + + if (csrfToken && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase())) { + config.headers['X-CSRF-Token'] = csrfToken; + } + return config; + }); + + // 初始化调试模式状态 + this.debugMode = localStorage.getItem('debugMode') === 'true'; + + // 初始化主题(从localStorage加载,避免闪烁) + this.initTheme(); + + // 处理URL中的验证/重置token(兼容缺少?的旧链接) + const verifyToken = this.getTokenFromUrl('verifyToken'); + const resetToken = this.getTokenFromUrl('resetToken'); + if (verifyToken) { + this.handleVerifyToken(verifyToken); + this.sanitizeUrlToken('verifyToken'); + } + if (resetToken) { + this.resetPasswordForm.token = resetToken; + this.showResetPasswordModal = true; + this.sanitizeUrlToken('resetToken'); + } + + // 阻止全局拖拽默认行为(防止拖到区域外打开新页面) + window.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + window.addEventListener("drop", (e) => { + e.preventDefault(); + }); + + + // 添加全局 dragend 监听(拖拽结束时总是隐藏覆盖层) + window.addEventListener("dragend", () => { + this.isDragging = false; + }); + + // 添加 ESC 键监听(按 ESC 关闭拖拽覆盖层) + window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && this.isDragging) { + this.isDragging = false; + } + }); + + // 设置axios响应拦截器,处理401错误(token过期/失效) + axios.interceptors.response.use( + response => response, + error => { + if (error.response && error.response.status === 401) { + // 排除登录接口本身的401(密码错误等) + const isLoginApi = error.config?.url?.includes('/api/login'); + if (!isLoginApi && this.isLoggedIn) { + console.warn('[认证] 收到401响应,Token已失效'); + this.handleTokenExpired(); + this.showToast('warning', '登录已过期', '请重新登录'); + } + } + return Promise.reject(error); + } + ); + + // 检查URL参数 + this.checkUrlParams(); + // 获取系统配置(上传限制等) + this.loadPublicConfig(); + + // 如果用户在监控页面刷新,提前设置loading状态(防止显示"无数据"闪烁) + if (this.adminTab === 'monitor') { + this.healthCheck.loading = true; + this.systemLogs.loading = true; + this.monitorTabLoading = true; + } + + // 检查登录状态 + this.checkLoginStatus(); + }, + + watch: { + currentView(newView) { + if (newView === 'shares') { + this.loadShares(); + } else if (newView === 'admin' && this.user?.is_admin) { + this.loadUsers(); + this.loadSystemSettings(); + this.loadServerStorageStats(); + } else if (newView === 'settings' && this.user && !this.user.is_admin) { + // 普通用户进入设置页面时加载OSS配置 + this.loadOssConfig(); + } + + // 记住最后停留的视图(需合法且已登录) + if (this.isLoggedIn && this.isViewAllowed(newView)) { + localStorage.setItem('lastView', newView); + } + }, + // 记住管理员当前标签页 + adminTab(newTab) { + if (this.isLoggedIn && this.user?.is_admin) { + localStorage.setItem('adminTab', newTab); + } + } + } +}).mount('#app'); diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..f1c3b4f Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d1740ed --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,669 @@ + + + + + + 玩玩云 - 现代化云存储平台 + + + + + + +
+
+
+
+
+
+ + + + + +
+
+ +
+
+ + 安全 · 高效 · 简洁 +
+

+ 现代化
云存储平台 +

+

+ 简单、安全、高效的文件管理解决方案。支持 OSS 云存储和服务器本地存储双模式,随时随地管理和分享你的文件。 +

+ +
+
+
5GB
+
单文件上限
+
+
+
双模式
+
存储方案
+
+
+
24/7
+
全天候服务
+
+
+
+ + +
+
+
+ +
+

OSS 云存储

+

支持阿里云、腾讯云、AWS S3,数据完全自主掌控

+
+
+
+ +
+

极速上传

+

拖拽上传,实时进度,支持大文件直连上传

+
+
+
+ +
+

安全分享

+

一键生成链接,支持密码保护和有效期

+
+
+
+ +
+

企业安全

+

JWT 认证,bcrypt 加密,全链路安全

+
+
+
+
+ + +
+
+
+ + Node.js +
+
+ + Vue.js +
+
+ + SQLite +
+
+ + Docker +
+
+ +
+ + + + diff --git a/frontend/libs/axios.min.js b/frontend/libs/axios.min.js new file mode 100644 index 0000000..2b482fa --- /dev/null +++ b/frontend/libs/axios.min.js @@ -0,0 +1,3 @@ +/*! Axios v1.13.2 Copyright (c) 2025 Matt Zabriskie and contributors */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,(function(){"use strict";function e(e){var r,n;function o(r,n){try{var a=e[r](n),u=a.value,s=u instanceof t;Promise.resolve(s?u.v:u).then((function(t){if(s){var n="return"===r?"return":"next";if(!u.k||t.done)return o(n,t);t=e[n](t).value}i(a.done?"return":"normal",t)}),(function(e){o("throw",e)}))}catch(e){i("throw",e)}}function i(e,t){switch(e){case"return":r.resolve({value:t,done:!0});break;case"throw":r.reject(t);break;default:r.resolve({value:t,done:!1})}(r=r.next)?o(r.key,r.arg):n=null}this._invoke=function(e,t){return new Promise((function(i,a){var u={key:e,arg:t,resolve:i,reject:a,next:null};n?n=n.next=u:(r=n=u,o(e,t))}))},"function"!=typeof e.return&&(this.return=void 0)}function t(e,t){this.v=e,this.k=t}function r(e){var r={},n=!1;function o(r,o){return n=!0,o=new Promise((function(t){t(e[r](o))})),{done:!1,value:new t(o,1)}}return r["undefined"!=typeof Symbol&&Symbol.iterator||"@@iterator"]=function(){return this},r.next=function(e){return n?(n=!1,e):o("next",e)},"function"==typeof e.throw&&(r.throw=function(e){if(n)throw n=!1,e;return o("throw",e)}),"function"==typeof e.return&&(r.return=function(e){return n?(n=!1,e):o("return",e)}),r}function n(e){var t,r,n,i=2;for("undefined"!=typeof Symbol&&(r=Symbol.asyncIterator,n=Symbol.iterator);i--;){if(r&&null!=(t=e[r]))return t.call(e);if(n&&null!=(t=e[n]))return new o(t.call(e));r="@@asyncIterator",n="@@iterator"}throw new TypeError("Object is not async iterable")}function o(e){function t(e){if(Object(e)!==e)return Promise.reject(new TypeError(e+" is not an object."));var t=e.done;return Promise.resolve(e.value).then((function(e){return{value:e,done:t}}))}return o=function(e){this.s=e,this.n=e.next},o.prototype={s:null,n:null,next:function(){return t(this.n.apply(this.s,arguments))},return:function(e){var r=this.s.return;return void 0===r?Promise.resolve({value:e,done:!0}):t(r.apply(this.s,arguments))},throw:function(e){var r=this.s.return;return void 0===r?Promise.reject(e):t(r.apply(this.s,arguments))}},new o(e)}function i(e){return new t(e,0)}function a(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function u(e){for(var t=1;t=0;--i){var a=this.tryEntries[i],u=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var s=n.call(a,"catchLoc"),c=n.call(a,"finallyLoc");if(s&&c){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),A(r),y}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var o=n.arg;A(r)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,r,n){return this.delegate={iterator:L(t),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=e),y}},t}function c(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:String(t)}function f(e){return f="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},f(e)}function l(t){return function(){return new e(t.apply(this,arguments))}}function p(e,t,r,n,o,i,a){try{var u=e[i](a),s=u.value}catch(e){return void r(e)}u.done?t(s):Promise.resolve(s).then(n,o)}function d(e){return function(){var t=this,r=arguments;return new Promise((function(n,o){var i=e.apply(t,r);function a(e){p(i,n,o,a,u,"next",e)}function u(e){p(i,n,o,a,u,"throw",e)}a(void 0)}))}}function h(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function v(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);r2&&void 0!==arguments[2]?arguments[2]:{},i=o.allOwnKeys,a=void 0!==i&&i;if(null!=e)if("object"!==f(e)&&(e=[e]),L(e))for(r=0,n=e.length;r0;)if(t===(r=n[o]).toLowerCase())return r;return null}var Q="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:global,Z=function(e){return!N(e)&&e!==Q};var ee,te=(ee="undefined"!=typeof Uint8Array&&R(Uint8Array),function(e){return ee&&e instanceof ee}),re=A("HTMLFormElement"),ne=function(e){var t=Object.prototype.hasOwnProperty;return function(e,r){return t.call(e,r)}}(),oe=A("RegExp"),ie=function(e,t){var r=Object.getOwnPropertyDescriptors(e),n={};$(r,(function(r,o){var i;!1!==(i=t(r,o,e))&&(n[o]=i||r)})),Object.defineProperties(e,n)};var ae,ue,se,ce,fe=A("AsyncFunction"),le=(ae="function"==typeof setImmediate,ue=F(Q.postMessage),ae?setImmediate:ue?(se="axios@".concat(Math.random()),ce=[],Q.addEventListener("message",(function(e){var t=e.source,r=e.data;t===Q&&r===se&&ce.length&&ce.shift()()}),!1),function(e){ce.push(e),Q.postMessage(se,"*")}):function(e){return setTimeout(e)}),pe="undefined"!=typeof queueMicrotask?queueMicrotask.bind(Q):"undefined"!=typeof process&&process.nextTick||le,de={isArray:L,isArrayBuffer:_,isBuffer:C,isFormData:function(e){var t;return e&&("function"==typeof FormData&&e instanceof FormData||F(e.append)&&("formdata"===(t=j(e))||"object"===t&&F(e.toString)&&"[object FormData]"===e.toString()))},isArrayBufferView:function(e){return"undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&_(e.buffer)},isString:U,isNumber:B,isBoolean:function(e){return!0===e||!1===e},isObject:D,isPlainObject:I,isEmptyObject:function(e){if(!D(e)||C(e))return!1;try{return 0===Object.keys(e).length&&Object.getPrototypeOf(e)===Object.prototype}catch(e){return!1}},isReadableStream:K,isRequest:V,isResponse:G,isHeaders:X,isUndefined:N,isDate:q,isFile:M,isBlob:z,isRegExp:oe,isFunction:F,isStream:function(e){return D(e)&&F(e.pipe)},isURLSearchParams:J,isTypedArray:te,isFileList:H,forEach:$,merge:function e(){for(var t=Z(this)&&this||{},r=t.caseless,n=t.skipUndefined,o={},i=function(t,i){var a=r&&Y(o,i)||i;I(o[a])&&I(t)?o[a]=e(o[a],t):I(t)?o[a]=e({},t):L(t)?o[a]=t.slice():n&&N(t)||(o[a]=t)},a=0,u=arguments.length;a3&&void 0!==arguments[3]?arguments[3]:{},o=n.allOwnKeys;return $(t,(function(t,n){r&&F(t)?e[n]=O(t,r):e[n]=t}),{allOwnKeys:o}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,r,n){e.prototype=Object.create(t.prototype,n),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),r&&Object.assign(e.prototype,r)},toFlatObject:function(e,t,r,n){var o,i,a,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)a=o[i],n&&!n(a,e,t)||u[a]||(t[a]=e[a],u[a]=!0);e=!1!==r&&R(e)}while(e&&(!r||r(e,t))&&e!==Object.prototype);return t},kindOf:j,kindOfTest:A,endsWith:function(e,t,r){e=String(e),(void 0===r||r>e.length)&&(r=e.length),r-=t.length;var n=e.indexOf(t,r);return-1!==n&&n===r},toArray:function(e){if(!e)return null;if(L(e))return e;var t=e.length;if(!B(t))return null;for(var r=new Array(t);t-- >0;)r[t]=e[t];return r},forEachEntry:function(e,t){for(var r,n=(e&&e[k]).call(e);(r=n.next())&&!r.done;){var o=r.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var r,n=[];null!==(r=e.exec(t));)n.push(r);return n},isHTMLForm:re,hasOwnProperty:ne,hasOwnProp:ne,reduceDescriptors:ie,freezeMethods:function(e){ie(e,(function(t,r){if(F(e)&&-1!==["arguments","caller","callee"].indexOf(r))return!1;var n=e[r];F(n)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not rewrite read-only method '"+r+"'")}))}))},toObjectSet:function(e,t){var r={},n=function(e){e.forEach((function(e){r[e]=!0}))};return L(e)?n(e):n(String(e).split(t)),r},toCamelCase:function(e){return e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))},noop:function(){},toFiniteNumber:function(e,t){return null!=e&&Number.isFinite(e=+e)?e:t},findKey:Y,global:Q,isContextDefined:Z,isSpecCompliantForm:function(e){return!!(e&&F(e.append)&&"FormData"===e[T]&&e[k])},toJSONObject:function(e){var t=new Array(10);return function e(r,n){if(D(r)){if(t.indexOf(r)>=0)return;if(C(r))return r;if(!("toJSON"in r)){t[n]=r;var o=L(r)?[]:{};return $(r,(function(t,r){var i=e(t,n+1);!N(i)&&(o[r]=i)})),t[n]=void 0,o}}return r}(e,0)},isAsyncFn:fe,isThenable:function(e){return e&&(D(e)||F(e))&&F(e.then)&&F(e.catch)},setImmediate:le,asap:pe,isIterable:function(e){return null!=e&&F(e[k])}};function he(e,t,r,n,o){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack,this.message=e,this.name="AxiosError",t&&(this.code=t),r&&(this.config=r),n&&(this.request=n),o&&(this.response=o,this.status=o.status?o.status:null)}de.inherits(he,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:de.toJSONObject(this.config),code:this.code,status:this.status}}});var ve=he.prototype,ye={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach((function(e){ye[e]={value:e}})),Object.defineProperties(he,ye),Object.defineProperty(ve,"isAxiosError",{value:!0}),he.from=function(e,t,r,n,o,i){var a=Object.create(ve);de.toFlatObject(e,a,(function(e){return e!==Error.prototype}),(function(e){return"isAxiosError"!==e}));var u=e&&e.message?e.message:"Error",s=null==t&&e?e.code:t;return he.call(a,u,s,r,n,o),e&&null==a.cause&&Object.defineProperty(a,"cause",{value:e,configurable:!0}),a.name=e&&e.name||"Error",i&&Object.assign(a,i),a};function me(e){return de.isPlainObject(e)||de.isArray(e)}function be(e){return de.endsWith(e,"[]")?e.slice(0,-2):e}function ge(e,t,r){return e?e.concat(t).map((function(e,t){return e=be(e),!r&&t?"["+e+"]":e})).join(r?".":""):t}var we=de.toFlatObject(de,{},null,(function(e){return/^is[A-Z]/.test(e)}));function Ee(e,t,r){if(!de.isObject(e))throw new TypeError("target must be an object");t=t||new FormData;var n=(r=de.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,(function(e,t){return!de.isUndefined(t[e])}))).metaTokens,o=r.visitor||c,i=r.dots,a=r.indexes,u=(r.Blob||"undefined"!=typeof Blob&&Blob)&&de.isSpecCompliantForm(t);if(!de.isFunction(o))throw new TypeError("visitor must be a function");function s(e){if(null===e)return"";if(de.isDate(e))return e.toISOString();if(de.isBoolean(e))return e.toString();if(!u&&de.isBlob(e))throw new he("Blob is not supported. Use a Buffer instead.");return de.isArrayBuffer(e)||de.isTypedArray(e)?u&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function c(e,r,o){var u=e;if(e&&!o&&"object"===f(e))if(de.endsWith(r,"{}"))r=n?r:r.slice(0,-2),e=JSON.stringify(e);else if(de.isArray(e)&&function(e){return de.isArray(e)&&!e.some(me)}(e)||(de.isFileList(e)||de.endsWith(r,"[]"))&&(u=de.toArray(e)))return r=be(r),u.forEach((function(e,n){!de.isUndefined(e)&&null!==e&&t.append(!0===a?ge([r],n,i):null===a?r:r+"[]",s(e))})),!1;return!!me(e)||(t.append(ge(o,r,i),s(e)),!1)}var l=[],p=Object.assign(we,{defaultVisitor:c,convertValue:s,isVisitable:me});if(!de.isObject(e))throw new TypeError("data must be an object");return function e(r,n){if(!de.isUndefined(r)){if(-1!==l.indexOf(r))throw Error("Circular reference detected in "+n.join("."));l.push(r),de.forEach(r,(function(r,i){!0===(!(de.isUndefined(r)||null===r)&&o.call(t,r,de.isString(i)?i.trim():i,n,p))&&e(r,n?n.concat(i):[i])})),l.pop()}}(e),t}function Oe(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,(function(e){return t[e]}))}function Se(e,t){this._pairs=[],e&&Ee(e,this,t)}var xe=Se.prototype;function Re(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function ke(e,t,r){if(!t)return e;var n=r&&r.encode||Re;de.isFunction(r)&&(r={serialize:r});var o,i=r&&r.serialize;if(o=i?i(t,r):de.isURLSearchParams(t)?t.toString():new Se(t,r).toString(n)){var a=e.indexOf("#");-1!==a&&(e=e.slice(0,a)),e+=(-1===e.indexOf("?")?"?":"&")+o}return e}xe.append=function(e,t){this._pairs.push([e,t])},xe.toString=function(e){var t=e?function(t){return e.call(this,t,Oe)}:Oe;return this._pairs.map((function(e){return t(e[0])+"="+t(e[1])}),"").join("&")};var Te=function(){function e(){h(this,e),this.handlers=[]}return y(e,[{key:"use",value:function(e,t,r){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!r&&r.synchronous,runWhen:r?r.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){de.forEach(this.handlers,(function(t){null!==t&&e(t)}))}}]),e}(),je={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},Ae={isBrowser:!0,classes:{URLSearchParams:"undefined"!=typeof URLSearchParams?URLSearchParams:Se,FormData:"undefined"!=typeof FormData?FormData:null,Blob:"undefined"!=typeof Blob?Blob:null},protocols:["http","https","file","blob","url","data"]},Pe="undefined"!=typeof window&&"undefined"!=typeof document,Le="object"===("undefined"==typeof navigator?"undefined":f(navigator))&&navigator||void 0,Ne=Pe&&(!Le||["ReactNative","NativeScript","NS"].indexOf(Le.product)<0),Ce="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope&&"function"==typeof self.importScripts,_e=Pe&&window.location.href||"http://localhost",Ue=u(u({},Object.freeze({__proto__:null,hasBrowserEnv:Pe,hasStandardBrowserWebWorkerEnv:Ce,hasStandardBrowserEnv:Ne,navigator:Le,origin:_e})),Ae);function Fe(e){function t(e,r,n,o){var i=e[o++];if("__proto__"===i)return!0;var a=Number.isFinite(+i),u=o>=e.length;return i=!i&&de.isArray(n)?n.length:i,u?(de.hasOwnProp(n,i)?n[i]=[n[i],r]:n[i]=r,!a):(n[i]&&de.isObject(n[i])||(n[i]=[]),t(e,r,n[i],o)&&de.isArray(n[i])&&(n[i]=function(e){var t,r,n={},o=Object.keys(e),i=o.length;for(t=0;t-1,i=de.isObject(e);if(i&&de.isHTMLForm(e)&&(e=new FormData(e)),de.isFormData(e))return o?JSON.stringify(Fe(e)):e;if(de.isArrayBuffer(e)||de.isBuffer(e)||de.isStream(e)||de.isFile(e)||de.isBlob(e)||de.isReadableStream(e))return e;if(de.isArrayBufferView(e))return e.buffer;if(de.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(n.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return Ee(e,new Ue.classes.URLSearchParams,u({visitor:function(e,t,r,n){return Ue.isNode&&de.isBuffer(e)?(this.append(t,e.toString("base64")),!1):n.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((r=de.isFileList(e))||n.indexOf("multipart/form-data")>-1){var a=this.env&&this.env.FormData;return Ee(r?{"files[]":e}:e,a&&new a,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,r){if(de.isString(e))try{return(t||JSON.parse)(e),de.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(r||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||Be.transitional,r=t&&t.forcedJSONParsing,n="json"===this.responseType;if(de.isResponse(e)||de.isReadableStream(e))return e;if(e&&de.isString(e)&&(r&&!this.responseType||n)){var o=!(t&&t.silentJSONParsing)&&n;try{return JSON.parse(e,this.parseReviver)}catch(e){if(o){if("SyntaxError"===e.name)throw he.from(e,he.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ue.classes.FormData,Blob:Ue.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};de.forEach(["delete","get","head","post","put","patch"],(function(e){Be.headers[e]={}}));var De=Be,Ie=de.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),qe=Symbol("internals");function Me(e){return e&&String(e).trim().toLowerCase()}function ze(e){return!1===e||null==e?e:de.isArray(e)?e.map(ze):String(e)}function He(e,t,r,n,o){return de.isFunction(n)?n.call(this,t,r):(o&&(t=r),de.isString(t)?de.isString(n)?-1!==t.indexOf(n):de.isRegExp(n)?n.test(t):void 0:void 0)}var Je=function(e,t){function r(e){h(this,r),e&&this.set(e)}return y(r,[{key:"set",value:function(e,t,r){var n=this;function o(e,t,r){var o=Me(t);if(!o)throw new Error("header name must be a non-empty string");var i=de.findKey(n,o);(!i||void 0===n[i]||!0===r||void 0===r&&!1!==n[i])&&(n[i||t]=ze(e))}var i=function(e,t){return de.forEach(e,(function(e,r){return o(e,r,t)}))};if(de.isPlainObject(e)||e instanceof this.constructor)i(e,t);else if(de.isString(e)&&(e=e.trim())&&!/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim()))i(function(e){var t,r,n,o={};return e&&e.split("\n").forEach((function(e){n=e.indexOf(":"),t=e.substring(0,n).trim().toLowerCase(),r=e.substring(n+1).trim(),!t||o[t]&&Ie[t]||("set-cookie"===t?o[t]?o[t].push(r):o[t]=[r]:o[t]=o[t]?o[t]+", "+r:r)})),o}(e),t);else if(de.isObject(e)&&de.isIterable(e)){var a,u,s,c={},f=function(e,t){var r="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!r){if(Array.isArray(e)||(r=w(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var n=0,o=function(){};return{s:o,n:function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){r=r.call(e)},n:function(){var e=r.next();return a=e.done,e},e:function(e){u=!0,i=e},f:function(){try{a||null==r.return||r.return()}finally{if(u)throw i}}}}(e);try{for(f.s();!(s=f.n()).done;){var l=s.value;if(!de.isArray(l))throw TypeError("Object iterator must return a key-value pair");c[u=l[0]]=(a=c[u])?de.isArray(a)?[].concat(g(a),[l[1]]):[a,l[1]]:l[1]}}catch(e){f.e(e)}finally{f.f()}i(c,t)}else null!=e&&o(t,e,r);return this}},{key:"get",value:function(e,t){if(e=Me(e)){var r=de.findKey(this,e);if(r){var n=this[r];if(!t)return n;if(!0===t)return function(e){for(var t,r=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;t=n.exec(e);)r[t[1]]=t[2];return r}(n);if(de.isFunction(t))return t.call(this,n,r);if(de.isRegExp(t))return t.exec(n);throw new TypeError("parser must be boolean|regexp|function")}}}},{key:"has",value:function(e,t){if(e=Me(e)){var r=de.findKey(this,e);return!(!r||void 0===this[r]||t&&!He(0,this[r],r,t))}return!1}},{key:"delete",value:function(e,t){var r=this,n=!1;function o(e){if(e=Me(e)){var o=de.findKey(r,e);!o||t&&!He(0,r[o],o,t)||(delete r[o],n=!0)}}return de.isArray(e)?e.forEach(o):o(e),n}},{key:"clear",value:function(e){for(var t=Object.keys(this),r=t.length,n=!1;r--;){var o=t[r];e&&!He(0,this[o],o,e,!0)||(delete this[o],n=!0)}return n}},{key:"normalize",value:function(e){var t=this,r={};return de.forEach(this,(function(n,o){var i=de.findKey(r,o);if(i)return t[i]=ze(n),void delete t[o];var a=e?function(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))}(o):String(o).trim();a!==o&&delete t[o],t[a]=ze(n),r[a]=!0})),this}},{key:"concat",value:function(){for(var e,t=arguments.length,r=new Array(t),n=0;n1?r-1:0),o=1;o1&&void 0!==arguments[1]?arguments[1]:Date.now();o=i,r=null,n&&(clearTimeout(n),n=null),e.apply(void 0,g(t))};return[function(){for(var e=Date.now(),t=e-o,u=arguments.length,s=new Array(u),c=0;c=i?a(s,e):(r=s,n||(n=setTimeout((function(){n=null,a(r)}),i-t)))},function(){return r&&a(r)}]}de.inherits(Ge,he,{__CANCEL__:!0});var Qe=function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:3,n=0,o=$e(50,250);return Ye((function(r){var i=r.loaded,a=r.lengthComputable?r.total:void 0,u=i-n,s=o(u);n=i;var c=m({loaded:i,total:a,progress:a?i/a:void 0,bytes:u,rate:s||void 0,estimated:s&&a&&i<=a?(a-i)/s:void 0,event:r,lengthComputable:null!=a},t?"download":"upload",!0);e(c)}),r)},Ze=function(e,t){var r=null!=e;return[function(n){return t[0]({lengthComputable:r,total:e,loaded:n})},t[1]]},et=function(e){return function(){for(var t=arguments.length,r=new Array(t),n=0;n1?t-1:0),n=1;n1?"since :\n"+s.map(xt).join("\n"):" "+xt(s[0]):"as no adapter specified"),"ERR_NOT_SUPPORT")}return n},adapters:St};function Tt(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Ge(null,e)}function jt(e){return Tt(e),e.headers=We.from(e.headers),e.data=Ke.call(e,e.transformRequest),-1!==["post","put","patch"].indexOf(e.method)&&e.headers.setContentType("application/x-www-form-urlencoded",!1),kt.getAdapter(e.adapter||De.adapter,e)(e).then((function(t){return Tt(e),t.data=Ke.call(e,e.transformResponse,t),t.headers=We.from(t.headers),t}),(function(t){return Ve(t)||(Tt(e),t&&t.response&&(t.response.data=Ke.call(e,e.transformResponse,t.response),t.response.headers=We.from(t.response.headers))),Promise.reject(t)}))}var At="1.13.2",Pt={};["object","boolean","number","function","string","symbol"].forEach((function(e,t){Pt[e]=function(r){return f(r)===e||"a"+(t<1?"n ":" ")+e}}));var Lt={};Pt.transitional=function(e,t,r){function n(e,t){return"[Axios v1.13.2] Transitional option '"+e+"'"+t+(r?". "+r:"")}return function(r,o,i){if(!1===e)throw new he(n(o," has been removed"+(t?" in "+t:"")),he.ERR_DEPRECATED);return t&&!Lt[o]&&(Lt[o]=!0,console.warn(n(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(r,o,i)}},Pt.spelling=function(e){return function(t,r){return console.warn("".concat(r," is likely a misspelling of ").concat(e)),!0}};var Nt={assertOptions:function(e,t,r){if("object"!==f(e))throw new he("options must be an object",he.ERR_BAD_OPTION_VALUE);for(var n=Object.keys(e),o=n.length;o-- >0;){var i=n[o],a=t[i];if(a){var u=e[i],s=void 0===u||a(u,i,e);if(!0!==s)throw new he("option "+i+" must be "+s,he.ERR_BAD_OPTION_VALUE)}else if(!0!==r)throw new he("Unknown option "+i,he.ERR_BAD_OPTION)}},validators:Pt},Ct=Nt.validators,_t=function(){function e(t){h(this,e),this.defaults=t||{},this.interceptors={request:new Te,response:new Te}}var t;return y(e,[{key:"request",value:(t=d(s().mark((function e(t,r){var n,o;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.prev=0,e.next=3,this._request(t,r);case 3:return e.abrupt("return",e.sent);case 6:if(e.prev=6,e.t0=e.catch(0),e.t0 instanceof Error){n={},Error.captureStackTrace?Error.captureStackTrace(n):n=new Error,o=n.stack?n.stack.replace(/^.+\n/,""):"";try{e.t0.stack?o&&!String(e.t0.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(e.t0.stack+="\n"+o):e.t0.stack=o}catch(e){}}throw e.t0;case 10:case"end":return e.stop()}}),e,this,[[0,6]])}))),function(e,r){return t.apply(this,arguments)})},{key:"_request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var r=t=it(this.defaults,t),n=r.transitional,o=r.paramsSerializer,i=r.headers;void 0!==n&&Nt.assertOptions(n,{silentJSONParsing:Ct.transitional(Ct.boolean),forcedJSONParsing:Ct.transitional(Ct.boolean),clarifyTimeoutError:Ct.transitional(Ct.boolean)},!1),null!=o&&(de.isFunction(o)?t.paramsSerializer={serialize:o}:Nt.assertOptions(o,{encode:Ct.function,serialize:Ct.function},!0)),void 0!==t.allowAbsoluteUrls||(void 0!==this.defaults.allowAbsoluteUrls?t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:t.allowAbsoluteUrls=!0),Nt.assertOptions(t,{baseUrl:Ct.spelling("baseURL"),withXsrfToken:Ct.spelling("withXSRFToken")},!0),t.method=(t.method||this.defaults.method||"get").toLowerCase();var a=i&&de.merge(i.common,i[t.method]);i&&de.forEach(["delete","get","head","post","put","patch","common"],(function(e){delete i[e]})),t.headers=We.concat(a,i);var u=[],s=!0;this.interceptors.request.forEach((function(e){"function"==typeof e.runWhen&&!1===e.runWhen(t)||(s=s&&e.synchronous,u.unshift(e.fulfilled,e.rejected))}));var c,f=[];this.interceptors.response.forEach((function(e){f.push(e.fulfilled,e.rejected)}));var l,p=0;if(!s){var d=[jt.bind(this),void 0];for(d.unshift.apply(d,u),d.push.apply(d,f),l=d.length,c=Promise.resolve(t);p0;)n._listeners[t](e);n._listeners=null}})),this.promise.then=function(e){var t,r=new Promise((function(e){n.subscribe(e),t=e})).then(e);return r.cancel=function(){n.unsubscribe(t)},r},t((function(e,t,o){n.reason||(n.reason=new Ge(e,t,o),r(n.reason))}))}return y(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}},{key:"toAbortSignal",value:function(){var e=this,t=new AbortController,r=function(e){t.abort(e)};return this.subscribe(r),t.signal.unsubscribe=function(){return e.unsubscribe(r)},t.signal}}],[{key:"source",value:function(){var t;return{token:new e((function(e){t=e})),cancel:t}}}]),e}(),Bt=Ft;var Dt={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(Dt).forEach((function(e){var t=b(e,2),r=t[0],n=t[1];Dt[n]=r}));var It=Dt;var qt=function e(t){var r=new Ut(t),n=O(Ut.prototype.request,r);return de.extend(n,Ut.prototype,r,{allOwnKeys:!0}),de.extend(n,r,null,{allOwnKeys:!0}),n.create=function(r){return e(it(t,r))},n}(De);return qt.Axios=Ut,qt.CanceledError=Ge,qt.CancelToken=Bt,qt.isCancel=Ve,qt.VERSION=At,qt.toFormData=Ee,qt.AxiosError=he,qt.Cancel=qt.CanceledError,qt.all=function(e){return Promise.all(e)},qt.spread=function(e){return function(t){return e.apply(null,t)}},qt.isAxiosError=function(e){return de.isObject(e)&&!0===e.isAxiosError},qt.mergeConfig=it,qt.AxiosHeaders=We,qt.formToJSON=function(e){return Fe(de.isHTMLForm(e)?new FormData(e):e)},qt.getAdapter=kt.getAdapter,qt.HttpStatusCode=It,qt.default=qt,qt})); +//# sourceMappingURL=axios.min.js.map diff --git a/frontend/libs/fontawesome/css/all.min.css b/frontend/libs/fontawesome/css/all.min.css new file mode 100644 index 0000000..1f367c1 --- /dev/null +++ b/frontend/libs/fontawesome/css/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/frontend/libs/fontawesome/webfonts/fa-brands-400.woff2 b/frontend/libs/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..71e3185 Binary files /dev/null and b/frontend/libs/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/frontend/libs/fontawesome/webfonts/fa-regular-400.woff2 b/frontend/libs/fontawesome/webfonts/fa-regular-400.woff2 new file mode 100644 index 0000000..7f02168 Binary files /dev/null and b/frontend/libs/fontawesome/webfonts/fa-regular-400.woff2 differ diff --git a/frontend/libs/fontawesome/webfonts/fa-solid-900.woff2 b/frontend/libs/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..5c16cd3 Binary files /dev/null and b/frontend/libs/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/frontend/libs/vue.global.js b/frontend/libs/vue.global.js new file mode 100644 index 0000000..3b164ab --- /dev/null +++ b/frontend/libs/vue.global.js @@ -0,0 +1,18323 @@ +/** +* vue v3.5.23 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/ +var Vue = (function (exports) { + 'use strict'; + + // @__NO_SIDE_EFFECTS__ + function makeMap(str) { + const map = /* @__PURE__ */ Object.create(null); + for (const key of str.split(",")) map[key] = 1; + return (val) => val in map; + } + + const EMPTY_OBJ = Object.freeze({}) ; + const EMPTY_ARR = Object.freeze([]) ; + const NOOP = () => { + }; + const NO = () => false; + const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter + (key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); + const isModelListener = (key) => key.startsWith("onUpdate:"); + const extend = Object.assign; + const remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } + }; + const hasOwnProperty$1 = Object.prototype.hasOwnProperty; + const hasOwn = (val, key) => hasOwnProperty$1.call(val, key); + const isArray = Array.isArray; + const isMap = (val) => toTypeString(val) === "[object Map]"; + const isSet = (val) => toTypeString(val) === "[object Set]"; + const isDate = (val) => toTypeString(val) === "[object Date]"; + const isRegExp = (val) => toTypeString(val) === "[object RegExp]"; + const isFunction = (val) => typeof val === "function"; + const isString = (val) => typeof val === "string"; + const isSymbol = (val) => typeof val === "symbol"; + const isObject = (val) => val !== null && typeof val === "object"; + const isPromise = (val) => { + return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); + }; + const objectToString = Object.prototype.toString; + const toTypeString = (value) => objectToString.call(value); + const toRawType = (value) => { + return toTypeString(value).slice(8, -1); + }; + const isPlainObject = (val) => toTypeString(val) === "[object Object]"; + const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; + const isReservedProp = /* @__PURE__ */ makeMap( + // the leading comma is intentional so empty string "" is also included + ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" + ); + const isBuiltInDirective = /* @__PURE__ */ makeMap( + "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" + ); + const cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return ((str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }); + }; + const camelizeRE = /-\w/g; + const camelize = cacheStringFunction( + (str) => { + return str.replace(camelizeRE, (c) => c.slice(1).toUpperCase()); + } + ); + const hyphenateRE = /\B([A-Z])/g; + const hyphenate = cacheStringFunction( + (str) => str.replace(hyphenateRE, "-$1").toLowerCase() + ); + const capitalize = cacheStringFunction((str) => { + return str.charAt(0).toUpperCase() + str.slice(1); + }); + const toHandlerKey = cacheStringFunction( + (str) => { + const s = str ? `on${capitalize(str)}` : ``; + return s; + } + ); + const hasChanged = (value, oldValue) => !Object.is(value, oldValue); + const invokeArrayFns = (fns, ...arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](...arg); + } + }; + const def = (obj, key, value, writable = false) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + writable, + value + }); + }; + const looseToNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; + }; + const toNumber = (val) => { + const n = isString(val) ? Number(val) : NaN; + return isNaN(n) ? val : n; + }; + let _globalThis; + const getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); + }; + function genCacheKey(source, options) { + return source + JSON.stringify( + options, + (_, val) => typeof val === "function" ? val.toString() : val + ); + } + + const PatchFlagNames = { + [1]: `TEXT`, + [2]: `CLASS`, + [4]: `STYLE`, + [8]: `PROPS`, + [16]: `FULL_PROPS`, + [32]: `NEED_HYDRATION`, + [64]: `STABLE_FRAGMENT`, + [128]: `KEYED_FRAGMENT`, + [256]: `UNKEYED_FRAGMENT`, + [512]: `NEED_PATCH`, + [1024]: `DYNAMIC_SLOTS`, + [2048]: `DEV_ROOT_FRAGMENT`, + [-1]: `CACHED`, + [-2]: `BAIL` + }; + + const slotFlagsText = { + [1]: "STABLE", + [2]: "DYNAMIC", + [3]: "FORWARDED" + }; + + const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol"; + const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED); + + const range = 2; + function generateCodeFrame(source, start = 0, end = source.length) { + start = Math.max(0, Math.min(start, source.length)); + end = Math.max(0, Math.min(end, source.length)); + if (start > end) return ""; + let lines = source.split(/(\r?\n)/); + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); + if (count >= start) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) continue; + const line = j + 1; + res.push( + `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}` + ); + const lineLength = lines[j].length; + const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; + if (j === i) { + const pad = start - (count - (lineLength + newLineSeqLength)); + const length = Math.max( + 1, + end > count ? lineLength - pad : end - start + ); + res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); + } else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + "^".repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join("\n"); + } + + function normalizeStyle(value) { + if (isArray(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isString(value) || isObject(value)) { + return value; + } + } + const listDelimiterRE = /;(?![^(]*\))/g; + const propertyDelimiterRE = /:([^]+)/; + const styleCommentRE = /\/\*[^]*?\*\//g; + function parseStringStyle(cssText) { + const ret = {}; + cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; + } + function stringifyStyle(styles) { + if (!styles) return ""; + if (isString(styles)) return styles; + let ret = ""; + for (const key in styles) { + const value = styles[key]; + if (isString(value) || typeof value === "number") { + const normalizedKey = key.startsWith(`--`) ? key : hyphenate(key); + ret += `${normalizedKey}:${value};`; + } + } + return ret; + } + function normalizeClass(value) { + let res = ""; + if (isString(value)) { + res = value; + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); + } + function normalizeProps(props) { + if (!props) return null; + let { class: klass, style } = props; + if (klass && !isString(klass)) { + props.class = normalizeClass(klass); + } + if (style) { + props.style = normalizeStyle(style); + } + return props; + } + + const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; + const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; + const MATH_TAGS = "annotation,annotation-xml,maction,maligngroup,malignmark,math,menclose,merror,mfenced,mfrac,mfraction,mglyph,mi,mlabeledtr,mlongdiv,mmultiscripts,mn,mo,mover,mpadded,mphantom,mprescripts,mroot,mrow,ms,mscarries,mscarry,msgroup,msline,mspace,msqrt,msrow,mstack,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,none,semantics"; + const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; + const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); + const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); + const isMathMLTag = /* @__PURE__ */ makeMap(MATH_TAGS); + const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); + + const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; + const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); + const isBooleanAttr = /* @__PURE__ */ makeMap( + specialBooleanAttrs + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected` + ); + function includeBooleanAttr(value) { + return !!value || value === ""; + } + const isKnownHtmlAttr = /* @__PURE__ */ makeMap( + `accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,inert,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap` + ); + const isKnownSvgAttr = /* @__PURE__ */ makeMap( + `xmlns,accent-height,accumulate,additive,alignment-baseline,alphabetic,amplitude,arabic-form,ascent,attributeName,attributeType,azimuth,baseFrequency,baseline-shift,baseProfile,bbox,begin,bias,by,calcMode,cap-height,class,clip,clipPathUnits,clip-path,clip-rule,color,color-interpolation,color-interpolation-filters,color-profile,color-rendering,contentScriptType,contentStyleType,crossorigin,cursor,cx,cy,d,decelerate,descent,diffuseConstant,direction,display,divisor,dominant-baseline,dur,dx,dy,edgeMode,elevation,enable-background,end,exponent,fill,fill-opacity,fill-rule,filter,filterRes,filterUnits,flood-color,flood-opacity,font-family,font-size,font-size-adjust,font-stretch,font-style,font-variant,font-weight,format,from,fr,fx,fy,g1,g2,glyph-name,glyph-orientation-horizontal,glyph-orientation-vertical,glyphRef,gradientTransform,gradientUnits,hanging,height,href,hreflang,horiz-adv-x,horiz-origin-x,id,ideographic,image-rendering,in,in2,intercept,k,k1,k2,k3,k4,kernelMatrix,kernelUnitLength,kerning,keyPoints,keySplines,keyTimes,lang,lengthAdjust,letter-spacing,lighting-color,limitingConeAngle,local,marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mask,maskContentUnits,maskUnits,mathematical,max,media,method,min,mode,name,numOctaves,offset,opacity,operator,order,orient,orientation,origin,overflow,overline-position,overline-thickness,panose-1,paint-order,path,pathLength,patternContentUnits,patternTransform,patternUnits,ping,pointer-events,points,pointsAtX,pointsAtY,pointsAtZ,preserveAlpha,preserveAspectRatio,primitiveUnits,r,radius,referrerPolicy,refX,refY,rel,rendering-intent,repeatCount,repeatDur,requiredExtensions,requiredFeatures,restart,result,rotate,rx,ry,scale,seed,shape-rendering,slope,spacing,specularConstant,specularExponent,speed,spreadMethod,startOffset,stdDeviation,stemh,stemv,stitchTiles,stop-color,stop-opacity,strikethrough-position,strikethrough-thickness,string,stroke,stroke-dasharray,stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,stroke-width,style,surfaceScale,systemLanguage,tabindex,tableValues,target,targetX,targetY,text-anchor,text-decoration,text-rendering,textLength,to,transform,transform-origin,type,u1,u2,underline-position,underline-thickness,unicode,unicode-bidi,unicode-range,units-per-em,v-alphabetic,v-hanging,v-ideographic,v-mathematical,values,vector-effect,version,vert-adv-y,vert-origin-x,vert-origin-y,viewBox,viewTarget,visibility,width,widths,word-spacing,writing-mode,x,x-height,x1,x2,xChannelSelector,xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,xml:space,y,y1,y2,yChannelSelector,z,zoomAndPan` + ); + function isRenderableAttrValue(value) { + if (value == null) { + return false; + } + const type = typeof value; + return type === "string" || type === "number" || type === "boolean"; + } + + const cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g; + function getEscapedCssVarName(key, doubleEscape) { + return key.replace( + cssVarNameEscapeSymbolsRE, + (s) => `\\${s}` + ); + } + + function looseCompareArrays(a, b) { + if (a.length !== b.length) return false; + let equal = true; + for (let i = 0; equal && i < a.length; i++) { + equal = looseEqual(a[i], b[i]); + } + return equal; + } + function looseEqual(a, b) { + if (a === b) return true; + let aValidType = isDate(a); + let bValidType = isDate(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? a.getTime() === b.getTime() : false; + } + aValidType = isSymbol(a); + bValidType = isSymbol(b); + if (aValidType || bValidType) { + return a === b; + } + aValidType = isArray(a); + bValidType = isArray(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareArrays(a, b) : false; + } + aValidType = isObject(a); + bValidType = isObject(b); + if (aValidType || bValidType) { + if (!aValidType || !bValidType) { + return false; + } + const aKeysCount = Object.keys(a).length; + const bKeysCount = Object.keys(b).length; + if (aKeysCount !== bKeysCount) { + return false; + } + for (const key in a) { + const aHasKey = a.hasOwnProperty(key); + const bHasKey = b.hasOwnProperty(key); + if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { + return false; + } + } + } + return String(a) === String(b); + } + function looseIndexOf(arr, val) { + return arr.findIndex((item) => looseEqual(item, val)); + } + + const isRef$1 = (val) => { + return !!(val && val["__v_isRef"] === true); + }; + const toDisplayString = (val) => { + return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); + }; + const replacer = (_key, val) => { + if (isRef$1(val)) { + return replacer(_key, val.value); + } else if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce( + (entries, [key, val2], i) => { + entries[stringifySymbol(key, i) + " =>"] = val2; + return entries; + }, + {} + ) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) + }; + } else if (isSymbol(val)) { + return stringifySymbol(val); + } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { + return String(val); + } + return val; + }; + const stringifySymbol = (v, i = "") => { + var _a; + return ( + // Symbol.description in es2019+ so we need to cast here to pass + // the lib: es2016 check + isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v + ); + }; + + function normalizeCssVarValue(value) { + if (value == null) { + return "initial"; + } + if (typeof value === "string") { + return value === "" ? " " : value; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + { + console.warn( + "[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:", + value + ); + } + } + return String(value); + } + + function warn$2(msg, ...args) { + console.warn(`[Vue warn] ${msg}`, ...args); + } + + let activeEffectScope; + class EffectScope { + constructor(detached = false) { + this.detached = detached; + /** + * @internal + */ + this._active = true; + /** + * @internal track `on` calls, allow `on` call multiple times + */ + this._on = 0; + /** + * @internal + */ + this.effects = []; + /** + * @internal + */ + this.cleanups = []; + this._isPaused = false; + this.parent = activeEffectScope; + if (!detached && activeEffectScope) { + this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( + this + ) - 1; + } + } + get active() { + return this._active; + } + pause() { + if (this._active) { + this._isPaused = true; + let i, l; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].pause(); + } + } + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].pause(); + } + } + } + /** + * Resumes the effect scope, including all child scopes and effects. + */ + resume() { + if (this._active) { + if (this._isPaused) { + this._isPaused = false; + let i, l; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].resume(); + } + } + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].resume(); + } + } + } + } + run(fn) { + if (this._active) { + const currentEffectScope = activeEffectScope; + try { + activeEffectScope = this; + return fn(); + } finally { + activeEffectScope = currentEffectScope; + } + } else { + warn$2(`cannot run an inactive effect scope.`); + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + on() { + if (++this._on === 1) { + this.prevScope = activeEffectScope; + activeEffectScope = this; + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + off() { + if (this._on > 0 && --this._on === 0) { + activeEffectScope = this.prevScope; + this.prevScope = void 0; + } + } + stop(fromParent) { + if (this._active) { + this._active = false; + let i, l; + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].stop(); + } + this.effects.length = 0; + for (i = 0, l = this.cleanups.length; i < l; i++) { + this.cleanups[i](); + } + this.cleanups.length = 0; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].stop(true); + } + this.scopes.length = 0; + } + if (!this.detached && this.parent && !fromParent) { + const last = this.parent.scopes.pop(); + if (last && last !== this) { + this.parent.scopes[this.index] = last; + last.index = this.index; + } + } + this.parent = void 0; + } + } + } + function effectScope(detached) { + return new EffectScope(detached); + } + function getCurrentScope() { + return activeEffectScope; + } + function onScopeDispose(fn, failSilently = false) { + if (activeEffectScope) { + activeEffectScope.cleanups.push(fn); + } else if (!failSilently) { + warn$2( + `onScopeDispose() is called when there is no active effect scope to be associated with.` + ); + } + } + + let activeSub; + const pausedQueueEffects = /* @__PURE__ */ new WeakSet(); + class ReactiveEffect { + constructor(fn) { + this.fn = fn; + /** + * @internal + */ + this.deps = void 0; + /** + * @internal + */ + this.depsTail = void 0; + /** + * @internal + */ + this.flags = 1 | 4; + /** + * @internal + */ + this.next = void 0; + /** + * @internal + */ + this.cleanup = void 0; + this.scheduler = void 0; + if (activeEffectScope && activeEffectScope.active) { + activeEffectScope.effects.push(this); + } + } + pause() { + this.flags |= 64; + } + resume() { + if (this.flags & 64) { + this.flags &= -65; + if (pausedQueueEffects.has(this)) { + pausedQueueEffects.delete(this); + this.trigger(); + } + } + } + /** + * @internal + */ + notify() { + if (this.flags & 2 && !(this.flags & 32)) { + return; + } + if (!(this.flags & 8)) { + batch(this); + } + } + run() { + if (!(this.flags & 1)) { + return this.fn(); + } + this.flags |= 2; + cleanupEffect(this); + prepareDeps(this); + const prevEffect = activeSub; + const prevShouldTrack = shouldTrack; + activeSub = this; + shouldTrack = true; + try { + return this.fn(); + } finally { + if (activeSub !== this) { + warn$2( + "Active effect was not restored correctly - this is likely a Vue internal bug." + ); + } + cleanupDeps(this); + activeSub = prevEffect; + shouldTrack = prevShouldTrack; + this.flags &= -3; + } + } + stop() { + if (this.flags & 1) { + for (let link = this.deps; link; link = link.nextDep) { + removeSub(link); + } + this.deps = this.depsTail = void 0; + cleanupEffect(this); + this.onStop && this.onStop(); + this.flags &= -2; + } + } + trigger() { + if (this.flags & 64) { + pausedQueueEffects.add(this); + } else if (this.scheduler) { + this.scheduler(); + } else { + this.runIfDirty(); + } + } + /** + * @internal + */ + runIfDirty() { + if (isDirty(this)) { + this.run(); + } + } + get dirty() { + return isDirty(this); + } + } + let batchDepth = 0; + let batchedSub; + let batchedComputed; + function batch(sub, isComputed = false) { + sub.flags |= 8; + if (isComputed) { + sub.next = batchedComputed; + batchedComputed = sub; + return; + } + sub.next = batchedSub; + batchedSub = sub; + } + function startBatch() { + batchDepth++; + } + function endBatch() { + if (--batchDepth > 0) { + return; + } + if (batchedComputed) { + let e = batchedComputed; + batchedComputed = void 0; + while (e) { + const next = e.next; + e.next = void 0; + e.flags &= -9; + e = next; + } + } + let error; + while (batchedSub) { + let e = batchedSub; + batchedSub = void 0; + while (e) { + const next = e.next; + e.next = void 0; + e.flags &= -9; + if (e.flags & 1) { + try { + ; + e.trigger(); + } catch (err) { + if (!error) error = err; + } + } + e = next; + } + } + if (error) throw error; + } + function prepareDeps(sub) { + for (let link = sub.deps; link; link = link.nextDep) { + link.version = -1; + link.prevActiveLink = link.dep.activeLink; + link.dep.activeLink = link; + } + } + function cleanupDeps(sub) { + let head; + let tail = sub.depsTail; + let link = tail; + while (link) { + const prev = link.prevDep; + if (link.version === -1) { + if (link === tail) tail = prev; + removeSub(link); + removeDep(link); + } else { + head = link; + } + link.dep.activeLink = link.prevActiveLink; + link.prevActiveLink = void 0; + link = prev; + } + sub.deps = head; + sub.depsTail = tail; + } + function isDirty(sub) { + for (let link = sub.deps; link; link = link.nextDep) { + if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) { + return true; + } + } + if (sub._dirty) { + return true; + } + return false; + } + function refreshComputed(computed) { + if (computed.flags & 4 && !(computed.flags & 16)) { + return; + } + computed.flags &= -17; + if (computed.globalVersion === globalVersion) { + return; + } + computed.globalVersion = globalVersion; + if (!computed.isSSR && computed.flags & 128 && (!computed.deps && !computed._dirty || !isDirty(computed))) { + return; + } + computed.flags |= 2; + const dep = computed.dep; + const prevSub = activeSub; + const prevShouldTrack = shouldTrack; + activeSub = computed; + shouldTrack = true; + try { + prepareDeps(computed); + const value = computed.fn(computed._value); + if (dep.version === 0 || hasChanged(value, computed._value)) { + computed.flags |= 128; + computed._value = value; + dep.version++; + } + } catch (err) { + dep.version++; + throw err; + } finally { + activeSub = prevSub; + shouldTrack = prevShouldTrack; + cleanupDeps(computed); + computed.flags &= -3; + } + } + function removeSub(link, soft = false) { + const { dep, prevSub, nextSub } = link; + if (prevSub) { + prevSub.nextSub = nextSub; + link.prevSub = void 0; + } + if (nextSub) { + nextSub.prevSub = prevSub; + link.nextSub = void 0; + } + if (dep.subsHead === link) { + dep.subsHead = nextSub; + } + if (dep.subs === link) { + dep.subs = prevSub; + if (!prevSub && dep.computed) { + dep.computed.flags &= -5; + for (let l = dep.computed.deps; l; l = l.nextDep) { + removeSub(l, true); + } + } + } + if (!soft && !--dep.sc && dep.map) { + dep.map.delete(dep.key); + } + } + function removeDep(link) { + const { prevDep, nextDep } = link; + if (prevDep) { + prevDep.nextDep = nextDep; + link.prevDep = void 0; + } + if (nextDep) { + nextDep.prevDep = prevDep; + link.nextDep = void 0; + } + } + function effect(fn, options) { + if (fn.effect instanceof ReactiveEffect) { + fn = fn.effect.fn; + } + const e = new ReactiveEffect(fn); + if (options) { + extend(e, options); + } + try { + e.run(); + } catch (err) { + e.stop(); + throw err; + } + const runner = e.run.bind(e); + runner.effect = e; + return runner; + } + function stop(runner) { + runner.effect.stop(); + } + let shouldTrack = true; + const trackStack = []; + function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; + } + function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; + } + function cleanupEffect(e) { + const { cleanup } = e; + e.cleanup = void 0; + if (cleanup) { + const prevSub = activeSub; + activeSub = void 0; + try { + cleanup(); + } finally { + activeSub = prevSub; + } + } + } + + let globalVersion = 0; + class Link { + constructor(sub, dep) { + this.sub = sub; + this.dep = dep; + this.version = dep.version; + this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0; + } + } + class Dep { + // TODO isolatedDeclarations "__v_skip" + constructor(computed) { + this.computed = computed; + this.version = 0; + /** + * Link between this dep and the current active effect + */ + this.activeLink = void 0; + /** + * Doubly linked list representing the subscribing effects (tail) + */ + this.subs = void 0; + /** + * For object property deps cleanup + */ + this.map = void 0; + this.key = void 0; + /** + * Subscriber counter + */ + this.sc = 0; + /** + * @internal + */ + this.__v_skip = true; + { + this.subsHead = void 0; + } + } + track(debugInfo) { + if (!activeSub || !shouldTrack || activeSub === this.computed) { + return; + } + let link = this.activeLink; + if (link === void 0 || link.sub !== activeSub) { + link = this.activeLink = new Link(activeSub, this); + if (!activeSub.deps) { + activeSub.deps = activeSub.depsTail = link; + } else { + link.prevDep = activeSub.depsTail; + activeSub.depsTail.nextDep = link; + activeSub.depsTail = link; + } + addSub(link); + } else if (link.version === -1) { + link.version = this.version; + if (link.nextDep) { + const next = link.nextDep; + next.prevDep = link.prevDep; + if (link.prevDep) { + link.prevDep.nextDep = next; + } + link.prevDep = activeSub.depsTail; + link.nextDep = void 0; + activeSub.depsTail.nextDep = link; + activeSub.depsTail = link; + if (activeSub.deps === link) { + activeSub.deps = next; + } + } + } + if (activeSub.onTrack) { + activeSub.onTrack( + extend( + { + effect: activeSub + }, + debugInfo + ) + ); + } + return link; + } + trigger(debugInfo) { + this.version++; + globalVersion++; + this.notify(debugInfo); + } + notify(debugInfo) { + startBatch(); + try { + if (true) { + for (let head = this.subsHead; head; head = head.nextSub) { + if (head.sub.onTrigger && !(head.sub.flags & 8)) { + head.sub.onTrigger( + extend( + { + effect: head.sub + }, + debugInfo + ) + ); + } + } + } + for (let link = this.subs; link; link = link.prevSub) { + if (link.sub.notify()) { + ; + link.sub.dep.notify(); + } + } + } finally { + endBatch(); + } + } + } + function addSub(link) { + link.dep.sc++; + if (link.sub.flags & 4) { + const computed = link.dep.computed; + if (computed && !link.dep.subs) { + computed.flags |= 4 | 16; + for (let l = computed.deps; l; l = l.nextDep) { + addSub(l); + } + } + const currentTail = link.dep.subs; + if (currentTail !== link) { + link.prevSub = currentTail; + if (currentTail) currentTail.nextSub = link; + } + if (link.dep.subsHead === void 0) { + link.dep.subsHead = link; + } + link.dep.subs = link; + } + } + const targetMap = /* @__PURE__ */ new WeakMap(); + const ITERATE_KEY = Symbol( + "Object iterate" + ); + const MAP_KEY_ITERATE_KEY = Symbol( + "Map keys iterate" + ); + const ARRAY_ITERATE_KEY = Symbol( + "Array iterate" + ); + function track(target, type, key) { + if (shouldTrack && activeSub) { + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = new Dep()); + dep.map = depsMap; + dep.key = key; + } + { + dep.track({ + target, + type, + key + }); + } + } + } + function trigger(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + globalVersion++; + return; + } + const run = (dep) => { + if (dep) { + { + dep.trigger({ + target, + type, + key, + newValue, + oldValue, + oldTarget + }); + } + } + }; + startBatch(); + if (type === "clear") { + depsMap.forEach(run); + } else { + const targetIsArray = isArray(target); + const isArrayIndex = targetIsArray && isIntegerKey(key); + if (targetIsArray && key === "length") { + const newLength = Number(newValue); + depsMap.forEach((dep, key2) => { + if (key2 === "length" || key2 === ARRAY_ITERATE_KEY || !isSymbol(key2) && key2 >= newLength) { + run(dep); + } + }); + } else { + if (key !== void 0 || depsMap.has(void 0)) { + run(depsMap.get(key)); + } + if (isArrayIndex) { + run(depsMap.get(ARRAY_ITERATE_KEY)); + } + switch (type) { + case "add": + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isArrayIndex) { + run(depsMap.get("length")); + } + break; + case "delete": + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (isMap(target)) { + run(depsMap.get(ITERATE_KEY)); + } + break; + } + } + } + endBatch(); + } + function getDepFromReactive(object, key) { + const depMap = targetMap.get(object); + return depMap && depMap.get(key); + } + + function reactiveReadArray(array) { + const raw = toRaw(array); + if (raw === array) return raw; + track(raw, "iterate", ARRAY_ITERATE_KEY); + return isShallow(array) ? raw : raw.map(toReactive); + } + function shallowReadArray(arr) { + track(arr = toRaw(arr), "iterate", ARRAY_ITERATE_KEY); + return arr; + } + const arrayInstrumentations = { + __proto__: null, + [Symbol.iterator]() { + return iterator(this, Symbol.iterator, toReactive); + }, + concat(...args) { + return reactiveReadArray(this).concat( + ...args.map((x) => isArray(x) ? reactiveReadArray(x) : x) + ); + }, + entries() { + return iterator(this, "entries", (value) => { + value[1] = toReactive(value[1]); + return value; + }); + }, + every(fn, thisArg) { + return apply(this, "every", fn, thisArg, void 0, arguments); + }, + filter(fn, thisArg) { + return apply(this, "filter", fn, thisArg, (v) => v.map(toReactive), arguments); + }, + find(fn, thisArg) { + return apply(this, "find", fn, thisArg, toReactive, arguments); + }, + findIndex(fn, thisArg) { + return apply(this, "findIndex", fn, thisArg, void 0, arguments); + }, + findLast(fn, thisArg) { + return apply(this, "findLast", fn, thisArg, toReactive, arguments); + }, + findLastIndex(fn, thisArg) { + return apply(this, "findLastIndex", fn, thisArg, void 0, arguments); + }, + // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement + forEach(fn, thisArg) { + return apply(this, "forEach", fn, thisArg, void 0, arguments); + }, + includes(...args) { + return searchProxy(this, "includes", args); + }, + indexOf(...args) { + return searchProxy(this, "indexOf", args); + }, + join(separator) { + return reactiveReadArray(this).join(separator); + }, + // keys() iterator only reads `length`, no optimization required + lastIndexOf(...args) { + return searchProxy(this, "lastIndexOf", args); + }, + map(fn, thisArg) { + return apply(this, "map", fn, thisArg, void 0, arguments); + }, + pop() { + return noTracking(this, "pop"); + }, + push(...args) { + return noTracking(this, "push", args); + }, + reduce(fn, ...args) { + return reduce(this, "reduce", fn, args); + }, + reduceRight(fn, ...args) { + return reduce(this, "reduceRight", fn, args); + }, + shift() { + return noTracking(this, "shift"); + }, + // slice could use ARRAY_ITERATE but also seems to beg for range tracking + some(fn, thisArg) { + return apply(this, "some", fn, thisArg, void 0, arguments); + }, + splice(...args) { + return noTracking(this, "splice", args); + }, + toReversed() { + return reactiveReadArray(this).toReversed(); + }, + toSorted(comparer) { + return reactiveReadArray(this).toSorted(comparer); + }, + toSpliced(...args) { + return reactiveReadArray(this).toSpliced(...args); + }, + unshift(...args) { + return noTracking(this, "unshift", args); + }, + values() { + return iterator(this, "values", toReactive); + } + }; + function iterator(self, method, wrapValue) { + const arr = shallowReadArray(self); + const iter = arr[method](); + if (arr !== self && !isShallow(self)) { + iter._next = iter.next; + iter.next = () => { + const result = iter._next(); + if (!result.done) { + result.value = wrapValue(result.value); + } + return result; + }; + } + return iter; + } + const arrayProto = Array.prototype; + function apply(self, method, fn, thisArg, wrappedRetFn, args) { + const arr = shallowReadArray(self); + const needsWrap = arr !== self && !isShallow(self); + const methodFn = arr[method]; + if (methodFn !== arrayProto[method]) { + const result2 = methodFn.apply(self, args); + return needsWrap ? toReactive(result2) : result2; + } + let wrappedFn = fn; + if (arr !== self) { + if (needsWrap) { + wrappedFn = function(item, index) { + return fn.call(this, toReactive(item), index, self); + }; + } else if (fn.length > 2) { + wrappedFn = function(item, index) { + return fn.call(this, item, index, self); + }; + } + } + const result = methodFn.call(arr, wrappedFn, thisArg); + return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; + } + function reduce(self, method, fn, args) { + const arr = shallowReadArray(self); + let wrappedFn = fn; + if (arr !== self) { + if (!isShallow(self)) { + wrappedFn = function(acc, item, index) { + return fn.call(this, acc, toReactive(item), index, self); + }; + } else if (fn.length > 3) { + wrappedFn = function(acc, item, index) { + return fn.call(this, acc, item, index, self); + }; + } + } + return arr[method](wrappedFn, ...args); + } + function searchProxy(self, method, args) { + const arr = toRaw(self); + track(arr, "iterate", ARRAY_ITERATE_KEY); + const res = arr[method](...args); + if ((res === -1 || res === false) && isProxy(args[0])) { + args[0] = toRaw(args[0]); + return arr[method](...args); + } + return res; + } + function noTracking(self, method, args = []) { + pauseTracking(); + startBatch(); + const res = toRaw(self)[method].apply(self, args); + endBatch(); + resetTracking(); + return res; + } + + const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); + const builtInSymbols = new Set( + /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) + ); + function hasOwnProperty(key) { + if (!isSymbol(key)) key = String(key); + const obj = toRaw(this); + track(obj, "has", key); + return obj.hasOwnProperty(key); + } + class BaseReactiveHandler { + constructor(_isReadonly = false, _isShallow = false) { + this._isReadonly = _isReadonly; + this._isShallow = _isShallow; + } + get(target, key, receiver) { + if (key === "__v_skip") return target["__v_skip"]; + const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_isShallow") { + return isShallow2; + } else if (key === "__v_raw") { + if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype + // this means the receiver is a user proxy of the reactive proxy + Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { + return target; + } + return; + } + const targetIsArray = isArray(target); + if (!isReadonly2) { + let fn; + if (targetIsArray && (fn = arrayInstrumentations[key])) { + return fn; + } + if (key === "hasOwnProperty") { + return hasOwnProperty; + } + } + const res = Reflect.get( + target, + key, + // if this is a proxy wrapping a ref, return methods using the raw ref + // as receiver so that we don't have to call `toRaw` on the ref in all + // its class methods + isRef(target) ? target : receiver + ); + if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track(target, "get", key); + } + if (isShallow2) { + return res; + } + if (isRef(res)) { + const value = targetIsArray && isIntegerKey(key) ? res : res.value; + return isReadonly2 && isObject(value) ? readonly(value) : value; + } + if (isObject(res)) { + return isReadonly2 ? readonly(res) : reactive(res); + } + return res; + } + } + class MutableReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(false, isShallow2); + } + set(target, key, value, receiver) { + let oldValue = target[key]; + if (!this._isShallow) { + const isOldValueReadonly = isReadonly(oldValue); + if (!isShallow(value) && !isReadonly(value)) { + oldValue = toRaw(oldValue); + value = toRaw(value); + } + if (!isArray(target) && isRef(oldValue) && !isRef(value)) { + if (isOldValueReadonly) { + { + warn$2( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target[key] + ); + } + return true; + } else { + oldValue.value = value; + return true; + } + } + } + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + const result = Reflect.set( + target, + key, + value, + isRef(target) ? target : receiver + ); + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + } + return result; + } + deleteProperty(target, key) { + const hadKey = hasOwn(target, key); + const oldValue = target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; + } + has(target, key) { + const result = Reflect.has(target, key); + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, "has", key); + } + return result; + } + ownKeys(target) { + track( + target, + "iterate", + isArray(target) ? "length" : ITERATE_KEY + ); + return Reflect.ownKeys(target); + } + } + class ReadonlyReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(true, isShallow2); + } + set(target, key) { + { + warn$2( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } + deleteProperty(target, key) { + { + warn$2( + `Delete operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } + } + const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); + const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); + const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler(true); + const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); + + const toShallow = (value) => value; + const getProto = (v) => Reflect.getPrototypeOf(v); + function createIterableMethod(method, isReadonly2, isShallow2) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const targetIsMap = isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track( + rawTarget, + "iterate", + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ); + return { + // iterator protocol + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + // iterable protocol + [Symbol.iterator]() { + return this; + } + }; + }; + } + function createReadonlyMethod(type) { + return function(...args) { + { + const key = args[0] ? `on key "${args[0]}" ` : ``; + warn$2( + `${capitalize(type)} operation ${key}failed: target is readonly.`, + toRaw(this) + ); + } + return type === "delete" ? false : type === "clear" ? void 0 : this; + }; + } + function createInstrumentations(readonly, shallow) { + const instrumentations = { + get(key) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!readonly) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "get", key); + } + track(rawTarget, "get", rawKey); + } + const { has } = getProto(rawTarget); + const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive; + if (has.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } + }, + get size() { + const target = this["__v_raw"]; + !readonly && track(toRaw(target), "iterate", ITERATE_KEY); + return target.size; + }, + has(key) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!readonly) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "has", key); + } + track(rawTarget, "has", rawKey); + } + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); + }, + forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw(target); + const wrap = shallow ? toShallow : readonly ? toReadonly : toReactive; + !readonly && track(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + } + }; + extend( + instrumentations, + readonly ? { + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear") + } : { + add(value) { + if (!shallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger(target, "add", value, value); + } + return this; + }, + set(key, value) { + if (!shallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const { has, get } = getProto(target); + let hadKey = has.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has.call(target, key); + } else { + checkIdentityKeys(target, has, key); + } + const oldValue = get.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + return this; + }, + delete(key) { + const target = toRaw(this); + const { has, get } = getProto(target); + let hadKey = has.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has.call(target, key); + } else { + checkIdentityKeys(target, has, key); + } + const oldValue = get ? get.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; + }, + clear() { + const target = toRaw(this); + const hadItems = target.size !== 0; + const oldTarget = isMap(target) ? new Map(target) : new Set(target) ; + const result = target.clear(); + if (hadItems) { + trigger( + target, + "clear", + void 0, + void 0, + oldTarget + ); + } + return result; + } + } + ); + const iteratorMethods = [ + "keys", + "values", + "entries", + Symbol.iterator + ]; + iteratorMethods.forEach((method) => { + instrumentations[method] = createIterableMethod(method, readonly, shallow); + }); + return instrumentations; + } + function createInstrumentationGetter(isReadonly2, shallow) { + const instrumentations = createInstrumentations(isReadonly2, shallow); + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver + ); + }; + } + const mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) + }; + const shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) + }; + const readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) + }; + const shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) + }; + function checkIdentityKeys(target, has, key) { + const rawKey = toRaw(key); + if (rawKey !== key && has.call(target, rawKey)) { + const type = toRawType(target); + warn$2( + `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` + ); + } + } + + const reactiveMap = /* @__PURE__ */ new WeakMap(); + const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); + const readonlyMap = /* @__PURE__ */ new WeakMap(); + const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); + function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1 /* COMMON */; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2 /* COLLECTION */; + default: + return 0 /* INVALID */; + } + } + function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value)); + } + function reactive(target) { + if (isReadonly(target)) { + return target; + } + return createReactiveObject( + target, + false, + mutableHandlers, + mutableCollectionHandlers, + reactiveMap + ); + } + function shallowReactive(target) { + return createReactiveObject( + target, + false, + shallowReactiveHandlers, + shallowCollectionHandlers, + shallowReactiveMap + ); + } + function readonly(target) { + return createReactiveObject( + target, + true, + readonlyHandlers, + readonlyCollectionHandlers, + readonlyMap + ); + } + function shallowReadonly(target) { + return createReactiveObject( + target, + true, + shallowReadonlyHandlers, + shallowReadonlyCollectionHandlers, + shallowReadonlyMap + ); + } + function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!isObject(target)) { + { + warn$2( + `value cannot be made ${isReadonly2 ? "readonly" : "reactive"}: ${String( + target + )}` + ); + } + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const targetType = getTargetType(target); + if (targetType === 0 /* INVALID */) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const proxy = new Proxy( + target, + targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers + ); + proxyMap.set(target, proxy); + return proxy; + } + function isReactive(value) { + if (isReadonly(value)) { + return isReactive(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); + } + function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); + } + function isShallow(value) { + return !!(value && value["__v_isShallow"]); + } + function isProxy(value) { + return value ? !!value["__v_raw"] : false; + } + function toRaw(observed) { + const raw = observed && observed["__v_raw"]; + return raw ? toRaw(raw) : observed; + } + function markRaw(value) { + if (!hasOwn(value, "__v_skip") && Object.isExtensible(value)) { + def(value, "__v_skip", true); + } + return value; + } + const toReactive = (value) => isObject(value) ? reactive(value) : value; + const toReadonly = (value) => isObject(value) ? readonly(value) : value; + + function isRef(r) { + return r ? r["__v_isRef"] === true : false; + } + function ref(value) { + return createRef(value, false); + } + function shallowRef(value) { + return createRef(value, true); + } + function createRef(rawValue, shallow) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); + } + class RefImpl { + constructor(value, isShallow2) { + this.dep = new Dep(); + this["__v_isRef"] = true; + this["__v_isShallow"] = false; + this._rawValue = isShallow2 ? value : toRaw(value); + this._value = isShallow2 ? value : toReactive(value); + this["__v_isShallow"] = isShallow2; + } + get value() { + { + this.dep.track({ + target: this, + type: "get", + key: "value" + }); + } + return this._value; + } + set value(newValue) { + const oldValue = this._rawValue; + const useDirectValue = this["__v_isShallow"] || isShallow(newValue) || isReadonly(newValue); + newValue = useDirectValue ? newValue : toRaw(newValue); + if (hasChanged(newValue, oldValue)) { + this._rawValue = newValue; + this._value = useDirectValue ? newValue : toReactive(newValue); + { + this.dep.trigger({ + target: this, + type: "set", + key: "value", + newValue, + oldValue + }); + } + } + } + } + function triggerRef(ref2) { + if (ref2.dep) { + { + ref2.dep.trigger({ + target: ref2, + type: "set", + key: "value", + newValue: ref2._value + }); + } + } + } + function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; + } + function toValue(source) { + return isFunction(source) ? source() : unref(source); + } + const shallowUnwrapHandlers = { + get: (target, key, receiver) => key === "__v_raw" ? target : unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } + }; + function proxyRefs(objectWithRefs) { + return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); + } + class CustomRefImpl { + constructor(factory) { + this["__v_isRef"] = true; + this._value = void 0; + const dep = this.dep = new Dep(); + const { get, set } = factory(dep.track.bind(dep), dep.trigger.bind(dep)); + this._get = get; + this._set = set; + } + get value() { + return this._value = this._get(); + } + set value(newVal) { + this._set(newVal); + } + } + function customRef(factory) { + return new CustomRefImpl(factory); + } + function toRefs(object) { + if (!isProxy(object)) { + warn$2(`toRefs() expects a reactive object but received a plain one.`); + } + const ret = isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = propertyToRef(object, key); + } + return ret; + } + class ObjectRefImpl { + constructor(_object, _key, _defaultValue) { + this._object = _object; + this._key = _key; + this._defaultValue = _defaultValue; + this["__v_isRef"] = true; + this._value = void 0; + } + get value() { + const val = this._object[this._key]; + return this._value = val === void 0 ? this._defaultValue : val; + } + set value(newVal) { + this._object[this._key] = newVal; + } + get dep() { + return getDepFromReactive(toRaw(this._object), this._key); + } + } + class GetterRefImpl { + constructor(_getter) { + this._getter = _getter; + this["__v_isRef"] = true; + this["__v_isReadonly"] = true; + this._value = void 0; + } + get value() { + return this._value = this._getter(); + } + } + function toRef(source, key, defaultValue) { + if (isRef(source)) { + return source; + } else if (isFunction(source)) { + return new GetterRefImpl(source); + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key, defaultValue); + } else { + return ref(source); + } + } + function propertyToRef(source, key, defaultValue) { + const val = source[key]; + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue); + } + + class ComputedRefImpl { + constructor(fn, setter, isSSR) { + this.fn = fn; + this.setter = setter; + /** + * @internal + */ + this._value = void 0; + /** + * @internal + */ + this.dep = new Dep(this); + /** + * @internal + */ + this.__v_isRef = true; + // TODO isolatedDeclarations "__v_isReadonly" + // A computed is also a subscriber that tracks other deps + /** + * @internal + */ + this.deps = void 0; + /** + * @internal + */ + this.depsTail = void 0; + /** + * @internal + */ + this.flags = 16; + /** + * @internal + */ + this.globalVersion = globalVersion - 1; + /** + * @internal + */ + this.next = void 0; + // for backwards compat + this.effect = this; + this["__v_isReadonly"] = !setter; + this.isSSR = isSSR; + } + /** + * @internal + */ + notify() { + this.flags |= 16; + if (!(this.flags & 8) && // avoid infinite self recursion + activeSub !== this) { + batch(this, true); + return true; + } + } + get value() { + const link = this.dep.track({ + target: this, + type: "get", + key: "value" + }) ; + refreshComputed(this); + if (link) { + link.version = this.dep.version; + } + return this._value; + } + set value(newValue) { + if (this.setter) { + this.setter(newValue); + } else { + warn$2("Write operation failed: computed value is readonly"); + } + } + } + function computed$1(getterOrOptions, debugOptions, isSSR = false) { + let getter; + let setter; + if (isFunction(getterOrOptions)) { + getter = getterOrOptions; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + const cRef = new ComputedRefImpl(getter, setter, isSSR); + if (debugOptions && !isSSR) { + cRef.onTrack = debugOptions.onTrack; + cRef.onTrigger = debugOptions.onTrigger; + } + return cRef; + } + + const TrackOpTypes = { + "GET": "get", + "HAS": "has", + "ITERATE": "iterate" + }; + const TriggerOpTypes = { + "SET": "set", + "ADD": "add", + "DELETE": "delete", + "CLEAR": "clear" + }; + + const INITIAL_WATCHER_VALUE = {}; + const cleanupMap = /* @__PURE__ */ new WeakMap(); + let activeWatcher = void 0; + function getCurrentWatcher() { + return activeWatcher; + } + function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher) { + if (owner) { + let cleanups = cleanupMap.get(owner); + if (!cleanups) cleanupMap.set(owner, cleanups = []); + cleanups.push(cleanupFn); + } else if (!failSilently) { + warn$2( + `onWatcherCleanup() was called when there was no active watcher to associate with.` + ); + } + } + function watch$1(source, cb, options = EMPTY_OBJ) { + const { immediate, deep, once, scheduler, augmentJob, call } = options; + const warnInvalidSource = (s) => { + (options.onWarn || warn$2)( + `Invalid watch source: `, + s, + `A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.` + ); + }; + const reactiveGetter = (source2) => { + if (deep) return source2; + if (isShallow(source2) || deep === false || deep === 0) + return traverse(source2, 1); + return traverse(source2); + }; + let effect; + let getter; + let cleanup; + let boundCleanup; + let forceTrigger = false; + let isMultiSource = false; + if (isRef(source)) { + getter = () => source.value; + forceTrigger = isShallow(source); + } else if (isReactive(source)) { + getter = () => reactiveGetter(source); + forceTrigger = true; + } else if (isArray(source)) { + isMultiSource = true; + forceTrigger = source.some((s) => isReactive(s) || isShallow(s)); + getter = () => source.map((s) => { + if (isRef(s)) { + return s.value; + } else if (isReactive(s)) { + return reactiveGetter(s); + } else if (isFunction(s)) { + return call ? call(s, 2) : s(); + } else { + warnInvalidSource(s); + } + }); + } else if (isFunction(source)) { + if (cb) { + getter = call ? () => call(source, 2) : source; + } else { + getter = () => { + if (cleanup) { + pauseTracking(); + try { + cleanup(); + } finally { + resetTracking(); + } + } + const currentEffect = activeWatcher; + activeWatcher = effect; + try { + return call ? call(source, 3, [boundCleanup]) : source(boundCleanup); + } finally { + activeWatcher = currentEffect; + } + }; + } + } else { + getter = NOOP; + warnInvalidSource(source); + } + if (cb && deep) { + const baseGetter = getter; + const depth = deep === true ? Infinity : deep; + getter = () => traverse(baseGetter(), depth); + } + const scope = getCurrentScope(); + const watchHandle = () => { + effect.stop(); + if (scope && scope.active) { + remove(scope.effects, effect); + } + }; + if (once && cb) { + const _cb = cb; + cb = (...args) => { + _cb(...args); + watchHandle(); + }; + } + let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE; + const job = (immediateFirstRun) => { + if (!(effect.flags & 1) || !effect.dirty && !immediateFirstRun) { + return; + } + if (cb) { + const newValue = effect.run(); + if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue))) { + if (cleanup) { + cleanup(); + } + const currentWatcher = activeWatcher; + activeWatcher = effect; + try { + const args = [ + newValue, + // pass undefined as the old value when it's changed for the first time + oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, + boundCleanup + ]; + oldValue = newValue; + call ? call(cb, 3, args) : ( + // @ts-expect-error + cb(...args) + ); + } finally { + activeWatcher = currentWatcher; + } + } + } else { + effect.run(); + } + }; + if (augmentJob) { + augmentJob(job); + } + effect = new ReactiveEffect(getter); + effect.scheduler = scheduler ? () => scheduler(job, false) : job; + boundCleanup = (fn) => onWatcherCleanup(fn, false, effect); + cleanup = effect.onStop = () => { + const cleanups = cleanupMap.get(effect); + if (cleanups) { + if (call) { + call(cleanups, 4); + } else { + for (const cleanup2 of cleanups) cleanup2(); + } + cleanupMap.delete(effect); + } + }; + { + effect.onTrack = options.onTrack; + effect.onTrigger = options.onTrigger; + } + if (cb) { + if (immediate) { + job(true); + } else { + oldValue = effect.run(); + } + } else if (scheduler) { + scheduler(job.bind(null, true), true); + } else { + effect.run(); + } + watchHandle.pause = effect.pause.bind(effect); + watchHandle.resume = effect.resume.bind(effect); + watchHandle.stop = watchHandle; + return watchHandle; + } + function traverse(value, depth = Infinity, seen) { + if (depth <= 0 || !isObject(value) || value["__v_skip"]) { + return value; + } + seen = seen || /* @__PURE__ */ new Map(); + if ((seen.get(value) || 0) >= depth) { + return value; + } + seen.set(value, depth); + depth--; + if (isRef(value)) { + traverse(value.value, depth, seen); + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], depth, seen); + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v) => { + traverse(v, depth, seen); + }); + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], depth, seen); + } + for (const key of Object.getOwnPropertySymbols(value)) { + if (Object.prototype.propertyIsEnumerable.call(value, key)) { + traverse(value[key], depth, seen); + } + } + } + return value; + } + + const stack$1 = []; + function pushWarningContext(vnode) { + stack$1.push(vnode); + } + function popWarningContext() { + stack$1.pop(); + } + let isWarning = false; + function warn$1(msg, ...args) { + if (isWarning) return; + isWarning = true; + pauseTracking(); + const instance = stack$1.length ? stack$1[stack$1.length - 1].component : null; + const appWarnHandler = instance && instance.appContext.config.warnHandler; + const trace = getComponentTrace(); + if (appWarnHandler) { + callWithErrorHandling( + appWarnHandler, + instance, + 11, + [ + // eslint-disable-next-line no-restricted-syntax + msg + args.map((a) => { + var _a, _b; + return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); + }).join(""), + instance && instance.proxy, + trace.map( + ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` + ).join("\n"), + trace + ] + ); + } else { + const warnArgs = [`[Vue warn]: ${msg}`, ...args]; + if (trace.length && // avoid spamming console during tests + true) { + warnArgs.push(` +`, ...formatTrace(trace)); + } + console.warn(...warnArgs); + } + resetTracking(); + isWarning = false; + } + function getComponentTrace() { + let currentVNode = stack$1[stack$1.length - 1]; + if (!currentVNode) { + return []; + } + const normalizedStack = []; + while (currentVNode) { + const last = normalizedStack[0]; + if (last && last.vnode === currentVNode) { + last.recurseCount++; + } else { + normalizedStack.push({ + vnode: currentVNode, + recurseCount: 0 + }); + } + const parentInstance = currentVNode.component && currentVNode.component.parent; + currentVNode = parentInstance && parentInstance.vnode; + } + return normalizedStack; + } + function formatTrace(trace) { + const logs = []; + trace.forEach((entry, i) => { + logs.push(...i === 0 ? [] : [` +`], ...formatTraceEntry(entry)); + }); + return logs; + } + function formatTraceEntry({ vnode, recurseCount }) { + const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; + const isRoot = vnode.component ? vnode.component.parent == null : false; + const open = ` at <${formatComponentName( + vnode.component, + vnode.type, + isRoot + )}`; + const close = `>` + postfix; + return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; + } + function formatProps(props) { + const res = []; + const keys = Object.keys(props); + keys.slice(0, 3).forEach((key) => { + res.push(...formatProp(key, props[key])); + }); + if (keys.length > 3) { + res.push(` ...`); + } + return res; + } + function formatProp(key, value, raw) { + if (isString(value)) { + value = JSON.stringify(value); + return raw ? value : [`${key}=${value}`]; + } else if (typeof value === "number" || typeof value === "boolean" || value == null) { + return raw ? value : [`${key}=${value}`]; + } else if (isRef(value)) { + value = formatProp(key, toRaw(value.value), true); + return raw ? value : [`${key}=Ref<`, value, `>`]; + } else if (isFunction(value)) { + return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; + } else { + value = toRaw(value); + return raw ? value : [`${key}=`, value]; + } + } + function assertNumber(val, type) { + if (val === void 0) { + return; + } else if (typeof val !== "number") { + warn$1(`${type} is not a valid number - got ${JSON.stringify(val)}.`); + } else if (isNaN(val)) { + warn$1(`${type} is NaN - the duration expression might be incorrect.`); + } + } + + const ErrorCodes = { + "SETUP_FUNCTION": 0, + "0": "SETUP_FUNCTION", + "RENDER_FUNCTION": 1, + "1": "RENDER_FUNCTION", + "NATIVE_EVENT_HANDLER": 5, + "5": "NATIVE_EVENT_HANDLER", + "COMPONENT_EVENT_HANDLER": 6, + "6": "COMPONENT_EVENT_HANDLER", + "VNODE_HOOK": 7, + "7": "VNODE_HOOK", + "DIRECTIVE_HOOK": 8, + "8": "DIRECTIVE_HOOK", + "TRANSITION_HOOK": 9, + "9": "TRANSITION_HOOK", + "APP_ERROR_HANDLER": 10, + "10": "APP_ERROR_HANDLER", + "APP_WARN_HANDLER": 11, + "11": "APP_WARN_HANDLER", + "FUNCTION_REF": 12, + "12": "FUNCTION_REF", + "ASYNC_COMPONENT_LOADER": 13, + "13": "ASYNC_COMPONENT_LOADER", + "SCHEDULER": 14, + "14": "SCHEDULER", + "COMPONENT_UPDATE": 15, + "15": "COMPONENT_UPDATE", + "APP_UNMOUNT_CLEANUP": 16, + "16": "APP_UNMOUNT_CLEANUP" + }; + const ErrorTypeStrings$1 = { + ["sp"]: "serverPrefetch hook", + ["bc"]: "beforeCreate hook", + ["c"]: "created hook", + ["bm"]: "beforeMount hook", + ["m"]: "mounted hook", + ["bu"]: "beforeUpdate hook", + ["u"]: "updated", + ["bum"]: "beforeUnmount hook", + ["um"]: "unmounted hook", + ["a"]: "activated hook", + ["da"]: "deactivated hook", + ["ec"]: "errorCaptured hook", + ["rtc"]: "renderTracked hook", + ["rtg"]: "renderTriggered hook", + [0]: "setup function", + [1]: "render function", + [2]: "watcher getter", + [3]: "watcher callback", + [4]: "watcher cleanup function", + [5]: "native event handler", + [6]: "component event handler", + [7]: "vnode hook", + [8]: "directive hook", + [9]: "transition hook", + [10]: "app errorHandler", + [11]: "app warnHandler", + [12]: "ref function", + [13]: "async component loader", + [14]: "scheduler flush", + [15]: "component update", + [16]: "app unmount cleanup function" + }; + function callWithErrorHandling(fn, instance, type, args) { + try { + return args ? fn(...args) : fn(); + } catch (err) { + handleError(err, instance, type); + } + } + function callWithAsyncErrorHandling(fn, instance, type, args) { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, instance, type, args); + if (res && isPromise(res)) { + res.catch((err) => { + handleError(err, instance, type); + }); + } + return res; + } + if (isArray(fn)) { + const values = []; + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); + } + return values; + } else { + warn$1( + `Invalid value type passed to callWithAsyncErrorHandling(): ${typeof fn}` + ); + } + } + function handleError(err, instance, type, throwInDev = true) { + const contextVNode = instance ? instance.vnode : null; + const { errorHandler, throwUnhandledErrorInProduction } = instance && instance.appContext.config || EMPTY_OBJ; + if (instance) { + let cur = instance.parent; + const exposedInstance = instance.proxy; + const errorInfo = ErrorTypeStrings$1[type] ; + while (cur) { + const errorCapturedHooks = cur.ec; + if (errorCapturedHooks) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { + return; + } + } + } + cur = cur.parent; + } + if (errorHandler) { + pauseTracking(); + callWithErrorHandling(errorHandler, null, 10, [ + err, + exposedInstance, + errorInfo + ]); + resetTracking(); + return; + } + } + logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction); + } + function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) { + { + const info = ErrorTypeStrings$1[type]; + if (contextVNode) { + pushWarningContext(contextVNode); + } + warn$1(`Unhandled error${info ? ` during execution of ${info}` : ``}`); + if (contextVNode) { + popWarningContext(); + } + if (throwInDev) { + throw err; + } else { + console.error(err); + } + } + } + + const queue = []; + let flushIndex = -1; + const pendingPostFlushCbs = []; + let activePostFlushCbs = null; + let postFlushIndex = 0; + const resolvedPromise = /* @__PURE__ */ Promise.resolve(); + let currentFlushPromise = null; + const RECURSION_LIMIT = 100; + function nextTick(fn) { + const p = currentFlushPromise || resolvedPromise; + return fn ? p.then(this ? fn.bind(this) : fn) : p; + } + function findInsertionIndex(id) { + let start = flushIndex + 1; + let end = queue.length; + while (start < end) { + const middle = start + end >>> 1; + const middleJob = queue[middle]; + const middleJobId = getId(middleJob); + if (middleJobId < id || middleJobId === id && middleJob.flags & 2) { + start = middle + 1; + } else { + end = middle; + } + } + return start; + } + function queueJob(job) { + if (!(job.flags & 1)) { + const jobId = getId(job); + const lastJob = queue[queue.length - 1]; + if (!lastJob || // fast path when the job id is larger than the tail + !(job.flags & 2) && jobId >= getId(lastJob)) { + queue.push(job); + } else { + queue.splice(findInsertionIndex(jobId), 0, job); + } + job.flags |= 1; + queueFlush(); + } + } + function queueFlush() { + if (!currentFlushPromise) { + currentFlushPromise = resolvedPromise.then(flushJobs); + } + } + function queuePostFlushCb(cb) { + if (!isArray(cb)) { + if (activePostFlushCbs && cb.id === -1) { + activePostFlushCbs.splice(postFlushIndex + 1, 0, cb); + } else if (!(cb.flags & 1)) { + pendingPostFlushCbs.push(cb); + cb.flags |= 1; + } + } else { + pendingPostFlushCbs.push(...cb); + } + queueFlush(); + } + function flushPreFlushCbs(instance, seen, i = flushIndex + 1) { + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (; i < queue.length; i++) { + const cb = queue[i]; + if (cb && cb.flags & 2) { + if (instance && cb.id !== instance.uid) { + continue; + } + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + queue.splice(i, 1); + i--; + if (cb.flags & 4) { + cb.flags &= -2; + } + cb(); + if (!(cb.flags & 4)) { + cb.flags &= -2; + } + } + } + } + function flushPostFlushCbs(seen) { + if (pendingPostFlushCbs.length) { + const deduped = [...new Set(pendingPostFlushCbs)].sort( + (a, b) => getId(a) - getId(b) + ); + pendingPostFlushCbs.length = 0; + if (activePostFlushCbs) { + activePostFlushCbs.push(...deduped); + return; + } + activePostFlushCbs = deduped; + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { + const cb = activePostFlushCbs[postFlushIndex]; + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + if (cb.flags & 4) { + cb.flags &= -2; + } + if (!(cb.flags & 8)) cb(); + cb.flags &= -2; + } + activePostFlushCbs = null; + postFlushIndex = 0; + } + } + const getId = (job) => job.id == null ? job.flags & 2 ? -1 : Infinity : job.id; + function flushJobs(seen) { + { + seen = seen || /* @__PURE__ */ new Map(); + } + const check = (job) => checkRecursiveUpdates(seen, job) ; + try { + for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job && !(job.flags & 8)) { + if (check(job)) { + continue; + } + if (job.flags & 4) { + job.flags &= ~1; + } + callWithErrorHandling( + job, + job.i, + job.i ? 15 : 14 + ); + if (!(job.flags & 4)) { + job.flags &= ~1; + } + } + } + } finally { + for (; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job) { + job.flags &= -2; + } + } + flushIndex = -1; + queue.length = 0; + flushPostFlushCbs(seen); + currentFlushPromise = null; + if (queue.length || pendingPostFlushCbs.length) { + flushJobs(seen); + } + } + } + function checkRecursiveUpdates(seen, fn) { + const count = seen.get(fn) || 0; + if (count > RECURSION_LIMIT) { + const instance = fn.i; + const componentName = instance && getComponentName(instance.type); + handleError( + `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.`, + null, + 10 + ); + return true; + } + seen.set(fn, count + 1); + return false; + } + + let isHmrUpdating = false; + const hmrDirtyComponents = /* @__PURE__ */ new Map(); + { + getGlobalThis().__VUE_HMR_RUNTIME__ = { + createRecord: tryWrap(createRecord), + rerender: tryWrap(rerender), + reload: tryWrap(reload) + }; + } + const map = /* @__PURE__ */ new Map(); + function registerHMR(instance) { + const id = instance.type.__hmrId; + let record = map.get(id); + if (!record) { + createRecord(id, instance.type); + record = map.get(id); + } + record.instances.add(instance); + } + function unregisterHMR(instance) { + map.get(instance.type.__hmrId).instances.delete(instance); + } + function createRecord(id, initialDef) { + if (map.has(id)) { + return false; + } + map.set(id, { + initialDef: normalizeClassComponent(initialDef), + instances: /* @__PURE__ */ new Set() + }); + return true; + } + function normalizeClassComponent(component) { + return isClassComponent(component) ? component.__vccOpts : component; + } + function rerender(id, newRender) { + const record = map.get(id); + if (!record) { + return; + } + record.initialDef.render = newRender; + [...record.instances].forEach((instance) => { + if (newRender) { + instance.render = newRender; + normalizeClassComponent(instance.type).render = newRender; + } + instance.renderCache = []; + isHmrUpdating = true; + if (!(instance.job.flags & 8)) { + instance.update(); + } + isHmrUpdating = false; + }); + } + function reload(id, newComp) { + const record = map.get(id); + if (!record) return; + newComp = normalizeClassComponent(newComp); + updateComponentDef(record.initialDef, newComp); + const instances = [...record.instances]; + for (let i = 0; i < instances.length; i++) { + const instance = instances[i]; + const oldComp = normalizeClassComponent(instance.type); + let dirtyInstances = hmrDirtyComponents.get(oldComp); + if (!dirtyInstances) { + if (oldComp !== record.initialDef) { + updateComponentDef(oldComp, newComp); + } + hmrDirtyComponents.set(oldComp, dirtyInstances = /* @__PURE__ */ new Set()); + } + dirtyInstances.add(instance); + instance.appContext.propsCache.delete(instance.type); + instance.appContext.emitsCache.delete(instance.type); + instance.appContext.optionsCache.delete(instance.type); + if (instance.ceReload) { + dirtyInstances.add(instance); + instance.ceReload(newComp.styles); + dirtyInstances.delete(instance); + } else if (instance.parent) { + queueJob(() => { + if (!(instance.job.flags & 8)) { + isHmrUpdating = true; + instance.parent.update(); + isHmrUpdating = false; + dirtyInstances.delete(instance); + } + }); + } else if (instance.appContext.reload) { + instance.appContext.reload(); + } else if (typeof window !== "undefined") { + window.location.reload(); + } else { + console.warn( + "[HMR] Root or manually mounted instance modified. Full reload required." + ); + } + if (instance.root.ce && instance !== instance.root) { + instance.root.ce._removeChildStyle(oldComp); + } + } + queuePostFlushCb(() => { + hmrDirtyComponents.clear(); + }); + } + function updateComponentDef(oldComp, newComp) { + extend(oldComp, newComp); + for (const key in oldComp) { + if (key !== "__file" && !(key in newComp)) { + delete oldComp[key]; + } + } + } + function tryWrap(fn) { + return (id, arg) => { + try { + return fn(id, arg); + } catch (e) { + console.error(e); + console.warn( + `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` + ); + } + }; + } + + let devtools$1; + let buffer = []; + let devtoolsNotInstalled = false; + function emit$1(event, ...args) { + if (devtools$1) { + devtools$1.emit(event, ...args); + } else if (!devtoolsNotInstalled) { + buffer.push({ event, args }); + } + } + function setDevtoolsHook$1(hook, target) { + var _a, _b; + devtools$1 = hook; + if (devtools$1) { + devtools$1.enabled = true; + buffer.forEach(({ event, args }) => devtools$1.emit(event, ...args)); + buffer = []; + } else if ( + // handle late devtools injection - only do this if we are in an actual + // browser environment to avoid the timer handle stalling test runner exit + // (#4815) + typeof window !== "undefined" && // some envs mock window but not fully + window.HTMLElement && // also exclude jsdom + // eslint-disable-next-line no-restricted-syntax + !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) + ) { + const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; + replay.push((newHook) => { + setDevtoolsHook$1(newHook, target); + }); + setTimeout(() => { + if (!devtools$1) { + target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; + devtoolsNotInstalled = true; + buffer = []; + } + }, 3e3); + } else { + devtoolsNotInstalled = true; + buffer = []; + } + } + function devtoolsInitApp(app, version) { + emit$1("app:init" /* APP_INIT */, app, version, { + Fragment, + Text, + Comment, + Static + }); + } + function devtoolsUnmountApp(app) { + emit$1("app:unmount" /* APP_UNMOUNT */, app); + } + const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook("component:added" /* COMPONENT_ADDED */); + const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */); + const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook( + "component:removed" /* COMPONENT_REMOVED */ + ); + const devtoolsComponentRemoved = (component) => { + if (devtools$1 && typeof devtools$1.cleanupBuffer === "function" && // remove the component if it wasn't buffered + !devtools$1.cleanupBuffer(component)) { + _devtoolsComponentRemoved(component); + } + }; + // @__NO_SIDE_EFFECTS__ + function createDevtoolsComponentHook(hook) { + return (component) => { + emit$1( + hook, + component.appContext.app, + component.uid, + component.parent ? component.parent.uid : void 0, + component + ); + }; + } + const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook("perf:start" /* PERFORMANCE_START */); + const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook("perf:end" /* PERFORMANCE_END */); + function createDevtoolsPerformanceHook(hook) { + return (component, type, time) => { + emit$1(hook, component.appContext.app, component.uid, component, type, time); + }; + } + function devtoolsComponentEmit(component, event, params) { + emit$1( + "component:emit" /* COMPONENT_EMIT */, + component.appContext.app, + component, + event, + params + ); + } + + let currentRenderingInstance = null; + let currentScopeId = null; + function setCurrentRenderingInstance(instance) { + const prev = currentRenderingInstance; + currentRenderingInstance = instance; + currentScopeId = instance && instance.type.__scopeId || null; + return prev; + } + function pushScopeId(id) { + currentScopeId = id; + } + function popScopeId() { + currentScopeId = null; + } + const withScopeId = (_id) => withCtx; + function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { + if (!ctx) return fn; + if (fn._n) { + return fn; + } + const renderFnWithContext = (...args) => { + if (renderFnWithContext._d) { + setBlockTracking(-1); + } + const prevInstance = setCurrentRenderingInstance(ctx); + let res; + try { + res = fn(...args); + } finally { + setCurrentRenderingInstance(prevInstance); + if (renderFnWithContext._d) { + setBlockTracking(1); + } + } + { + devtoolsComponentUpdated(ctx); + } + return res; + }; + renderFnWithContext._n = true; + renderFnWithContext._c = true; + renderFnWithContext._d = true; + return renderFnWithContext; + } + + function validateDirectiveName(name) { + if (isBuiltInDirective(name)) { + warn$1("Do not use built-in directive ids as custom directive id: " + name); + } + } + function withDirectives(vnode, directives) { + if (currentRenderingInstance === null) { + warn$1(`withDirectives can only be used inside render functions.`); + return vnode; + } + const instance = getComponentPublicInstance(currentRenderingInstance); + const bindings = vnode.dirs || (vnode.dirs = []); + for (let i = 0; i < directives.length; i++) { + let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; + if (dir) { + if (isFunction(dir)) { + dir = { + mounted: dir, + updated: dir + }; + } + if (dir.deep) { + traverse(value); + } + bindings.push({ + dir, + instance, + value, + oldValue: void 0, + arg, + modifiers + }); + } + } + return vnode; + } + function invokeDirectiveHook(vnode, prevVNode, instance, name) { + const bindings = vnode.dirs; + const oldBindings = prevVNode && prevVNode.dirs; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (oldBindings) { + binding.oldValue = oldBindings[i].value; + } + let hook = binding.dir[name]; + if (hook) { + pauseTracking(); + callWithAsyncErrorHandling(hook, instance, 8, [ + vnode.el, + binding, + vnode, + prevVNode + ]); + resetTracking(); + } + } + } + + const TeleportEndKey = Symbol("_vte"); + const isTeleport = (type) => type.__isTeleport; + const isTeleportDisabled = (props) => props && (props.disabled || props.disabled === ""); + const isTeleportDeferred = (props) => props && (props.defer || props.defer === ""); + const isTargetSVG = (target) => typeof SVGElement !== "undefined" && target instanceof SVGElement; + const isTargetMathML = (target) => typeof MathMLElement === "function" && target instanceof MathMLElement; + const resolveTarget = (props, select) => { + const targetSelector = props && props.to; + if (isString(targetSelector)) { + if (!select) { + warn$1( + `Current renderer does not support string target for Teleports. (missing querySelector renderer option)` + ); + return null; + } else { + const target = select(targetSelector); + if (!target && !isTeleportDisabled(props)) { + warn$1( + `Failed to locate Teleport target with selector "${targetSelector}". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree.` + ); + } + return target; + } + } else { + if (!targetSelector && !isTeleportDisabled(props)) { + warn$1(`Invalid Teleport target: ${targetSelector}`); + } + return targetSelector; + } + }; + const TeleportImpl = { + name: "Teleport", + __isTeleport: true, + process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals) { + const { + mc: mountChildren, + pc: patchChildren, + pbc: patchBlockChildren, + o: { insert, querySelector, createText, createComment } + } = internals; + const disabled = isTeleportDisabled(n2.props); + let { shapeFlag, children, dynamicChildren } = n2; + if (isHmrUpdating) { + optimized = false; + dynamicChildren = null; + } + if (n1 == null) { + const placeholder = n2.el = createComment("teleport start") ; + const mainAnchor = n2.anchor = createComment("teleport end") ; + insert(placeholder, container, anchor); + insert(mainAnchor, container, anchor); + const mount = (container2, anchor2) => { + if (shapeFlag & 16) { + mountChildren( + children, + container2, + anchor2, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } + }; + const mountToTarget = () => { + const target = n2.target = resolveTarget(n2.props, querySelector); + const targetAnchor = prepareAnchor(target, n2, createText, insert); + if (target) { + if (namespace !== "svg" && isTargetSVG(target)) { + namespace = "svg"; + } else if (namespace !== "mathml" && isTargetMathML(target)) { + namespace = "mathml"; + } + if (parentComponent && parentComponent.isCE) { + (parentComponent.ce._teleportTargets || (parentComponent.ce._teleportTargets = /* @__PURE__ */ new Set())).add(target); + } + if (!disabled) { + mount(target, targetAnchor); + updateCssVars(n2, false); + } + } else if (!disabled) { + warn$1( + "Invalid Teleport target on mount:", + target, + `(${typeof target})` + ); + } + }; + if (disabled) { + mount(container, mainAnchor); + updateCssVars(n2, true); + } + if (isTeleportDeferred(n2.props)) { + n2.el.__isMounted = false; + queuePostRenderEffect(() => { + mountToTarget(); + delete n2.el.__isMounted; + }, parentSuspense); + } else { + mountToTarget(); + } + } else { + if (isTeleportDeferred(n2.props) && n1.el.__isMounted === false) { + queuePostRenderEffect(() => { + TeleportImpl.process( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + internals + ); + }, parentSuspense); + return; + } + n2.el = n1.el; + n2.targetStart = n1.targetStart; + const mainAnchor = n2.anchor = n1.anchor; + const target = n2.target = n1.target; + const targetAnchor = n2.targetAnchor = n1.targetAnchor; + const wasDisabled = isTeleportDisabled(n1.props); + const currentContainer = wasDisabled ? container : target; + const currentAnchor = wasDisabled ? mainAnchor : targetAnchor; + if (namespace === "svg" || isTargetSVG(target)) { + namespace = "svg"; + } else if (namespace === "mathml" || isTargetMathML(target)) { + namespace = "mathml"; + } + if (dynamicChildren) { + patchBlockChildren( + n1.dynamicChildren, + dynamicChildren, + currentContainer, + parentComponent, + parentSuspense, + namespace, + slotScopeIds + ); + traverseStaticChildren(n1, n2, false); + } else if (!optimized) { + patchChildren( + n1, + n2, + currentContainer, + currentAnchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + false + ); + } + if (disabled) { + if (!wasDisabled) { + moveTeleport( + n2, + container, + mainAnchor, + internals, + 1 + ); + } else { + if (n2.props && n1.props && n2.props.to !== n1.props.to) { + n2.props.to = n1.props.to; + } + } + } else { + if ((n2.props && n2.props.to) !== (n1.props && n1.props.to)) { + const nextTarget = n2.target = resolveTarget( + n2.props, + querySelector + ); + if (nextTarget) { + moveTeleport( + n2, + nextTarget, + null, + internals, + 0 + ); + } else { + warn$1( + "Invalid Teleport target on update:", + target, + `(${typeof target})` + ); + } + } else if (wasDisabled) { + moveTeleport( + n2, + target, + targetAnchor, + internals, + 1 + ); + } + } + updateCssVars(n2, disabled); + } + }, + remove(vnode, parentComponent, parentSuspense, { um: unmount, o: { remove: hostRemove } }, doRemove) { + const { + shapeFlag, + children, + anchor, + targetStart, + targetAnchor, + target, + props + } = vnode; + if (target) { + hostRemove(targetStart); + hostRemove(targetAnchor); + } + doRemove && hostRemove(anchor); + if (shapeFlag & 16) { + const shouldRemove = doRemove || !isTeleportDisabled(props); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + unmount( + child, + parentComponent, + parentSuspense, + shouldRemove, + !!child.dynamicChildren + ); + } + } + }, + move: moveTeleport, + hydrate: hydrateTeleport + }; + function moveTeleport(vnode, container, parentAnchor, { o: { insert }, m: move }, moveType = 2) { + if (moveType === 0) { + insert(vnode.targetAnchor, container, parentAnchor); + } + const { el, anchor, shapeFlag, children, props } = vnode; + const isReorder = moveType === 2; + if (isReorder) { + insert(el, container, parentAnchor); + } + if (!isReorder || isTeleportDisabled(props)) { + if (shapeFlag & 16) { + for (let i = 0; i < children.length; i++) { + move( + children[i], + container, + parentAnchor, + 2 + ); + } + } + } + if (isReorder) { + insert(anchor, container, parentAnchor); + } + } + function hydrateTeleport(node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized, { + o: { nextSibling, parentNode, querySelector, insert, createText } + }, hydrateChildren) { + function hydrateDisabledTeleport(node2, vnode2, targetStart, targetAnchor) { + vnode2.anchor = hydrateChildren( + nextSibling(node2), + vnode2, + parentNode(node2), + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + vnode2.targetStart = targetStart; + vnode2.targetAnchor = targetAnchor; + } + const target = vnode.target = resolveTarget( + vnode.props, + querySelector + ); + const disabled = isTeleportDisabled(vnode.props); + if (target) { + const targetNode = target._lpa || target.firstChild; + if (vnode.shapeFlag & 16) { + if (disabled) { + hydrateDisabledTeleport( + node, + vnode, + targetNode, + targetNode && nextSibling(targetNode) + ); + } else { + vnode.anchor = nextSibling(node); + let targetAnchor = targetNode; + while (targetAnchor) { + if (targetAnchor && targetAnchor.nodeType === 8) { + if (targetAnchor.data === "teleport start anchor") { + vnode.targetStart = targetAnchor; + } else if (targetAnchor.data === "teleport anchor") { + vnode.targetAnchor = targetAnchor; + target._lpa = vnode.targetAnchor && nextSibling(vnode.targetAnchor); + break; + } + } + targetAnchor = nextSibling(targetAnchor); + } + if (!vnode.targetAnchor) { + prepareAnchor(target, vnode, createText, insert); + } + hydrateChildren( + targetNode && nextSibling(targetNode), + vnode, + target, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + } + } + updateCssVars(vnode, disabled); + } else if (disabled) { + if (vnode.shapeFlag & 16) { + hydrateDisabledTeleport(node, vnode, node, nextSibling(node)); + } + } + return vnode.anchor && nextSibling(vnode.anchor); + } + const Teleport = TeleportImpl; + function updateCssVars(vnode, isDisabled) { + const ctx = vnode.ctx; + if (ctx && ctx.ut) { + let node, anchor; + if (isDisabled) { + node = vnode.el; + anchor = vnode.anchor; + } else { + node = vnode.targetStart; + anchor = vnode.targetAnchor; + } + while (node && node !== anchor) { + if (node.nodeType === 1) node.setAttribute("data-v-owner", ctx.uid); + node = node.nextSibling; + } + ctx.ut(); + } + } + function prepareAnchor(target, vnode, createText, insert) { + const targetStart = vnode.targetStart = createText(""); + const targetAnchor = vnode.targetAnchor = createText(""); + targetStart[TeleportEndKey] = targetAnchor; + if (target) { + insert(targetStart, target); + insert(targetAnchor, target); + } + return targetAnchor; + } + + const leaveCbKey = Symbol("_leaveCb"); + const enterCbKey$1 = Symbol("_enterCb"); + function useTransitionState() { + const state = { + isMounted: false, + isLeaving: false, + isUnmounting: false, + leavingVNodes: /* @__PURE__ */ new Map() + }; + onMounted(() => { + state.isMounted = true; + }); + onBeforeUnmount(() => { + state.isUnmounting = true; + }); + return state; + } + const TransitionHookValidator = [Function, Array]; + const BaseTransitionPropsValidators = { + mode: String, + appear: Boolean, + persisted: Boolean, + // enter + onBeforeEnter: TransitionHookValidator, + onEnter: TransitionHookValidator, + onAfterEnter: TransitionHookValidator, + onEnterCancelled: TransitionHookValidator, + // leave + onBeforeLeave: TransitionHookValidator, + onLeave: TransitionHookValidator, + onAfterLeave: TransitionHookValidator, + onLeaveCancelled: TransitionHookValidator, + // appear + onBeforeAppear: TransitionHookValidator, + onAppear: TransitionHookValidator, + onAfterAppear: TransitionHookValidator, + onAppearCancelled: TransitionHookValidator + }; + const recursiveGetSubtree = (instance) => { + const subTree = instance.subTree; + return subTree.component ? recursiveGetSubtree(subTree.component) : subTree; + }; + const BaseTransitionImpl = { + name: `BaseTransition`, + props: BaseTransitionPropsValidators, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const state = useTransitionState(); + return () => { + const children = slots.default && getTransitionRawChildren(slots.default(), true); + if (!children || !children.length) { + return; + } + const child = findNonCommentChild(children); + const rawProps = toRaw(props); + const { mode } = rawProps; + if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { + warn$1(`invalid mode: ${mode}`); + } + if (state.isLeaving) { + return emptyPlaceholder(child); + } + const innerChild = getInnerChild$1(child); + if (!innerChild) { + return emptyPlaceholder(child); + } + let enterHooks = resolveTransitionHooks( + innerChild, + rawProps, + state, + instance, + // #11061, ensure enterHooks is fresh after clone + (hooks) => enterHooks = hooks + ); + if (innerChild.type !== Comment) { + setTransitionHooks(innerChild, enterHooks); + } + let oldInnerChild = instance.subTree && getInnerChild$1(instance.subTree); + if (oldInnerChild && oldInnerChild.type !== Comment && !isSameVNodeType(oldInnerChild, innerChild) && recursiveGetSubtree(instance).type !== Comment) { + let leavingHooks = resolveTransitionHooks( + oldInnerChild, + rawProps, + state, + instance + ); + setTransitionHooks(oldInnerChild, leavingHooks); + if (mode === "out-in" && innerChild.type !== Comment) { + state.isLeaving = true; + leavingHooks.afterLeave = () => { + state.isLeaving = false; + if (!(instance.job.flags & 8)) { + instance.update(); + } + delete leavingHooks.afterLeave; + oldInnerChild = void 0; + }; + return emptyPlaceholder(child); + } else if (mode === "in-out" && innerChild.type !== Comment) { + leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { + const leavingVNodesCache = getLeavingNodesForType( + state, + oldInnerChild + ); + leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; + el[leaveCbKey] = () => { + earlyRemove(); + el[leaveCbKey] = void 0; + delete enterHooks.delayedLeave; + oldInnerChild = void 0; + }; + enterHooks.delayedLeave = () => { + delayedLeave(); + delete enterHooks.delayedLeave; + oldInnerChild = void 0; + }; + }; + } else { + oldInnerChild = void 0; + } + } else if (oldInnerChild) { + oldInnerChild = void 0; + } + return child; + }; + } + }; + function findNonCommentChild(children) { + let child = children[0]; + if (children.length > 1) { + let hasFound = false; + for (const c of children) { + if (c.type !== Comment) { + if (hasFound) { + warn$1( + " can only be used on a single element or component. Use for lists." + ); + break; + } + child = c; + hasFound = true; + } + } + } + return child; + } + const BaseTransition = BaseTransitionImpl; + function getLeavingNodesForType(state, vnode) { + const { leavingVNodes } = state; + let leavingVNodesCache = leavingVNodes.get(vnode.type); + if (!leavingVNodesCache) { + leavingVNodesCache = /* @__PURE__ */ Object.create(null); + leavingVNodes.set(vnode.type, leavingVNodesCache); + } + return leavingVNodesCache; + } + function resolveTransitionHooks(vnode, props, state, instance, postClone) { + const { + appear, + mode, + persisted = false, + onBeforeEnter, + onEnter, + onAfterEnter, + onEnterCancelled, + onBeforeLeave, + onLeave, + onAfterLeave, + onLeaveCancelled, + onBeforeAppear, + onAppear, + onAfterAppear, + onAppearCancelled + } = props; + const key = String(vnode.key); + const leavingVNodesCache = getLeavingNodesForType(state, vnode); + const callHook = (hook, args) => { + hook && callWithAsyncErrorHandling( + hook, + instance, + 9, + args + ); + }; + const callAsyncHook = (hook, args) => { + const done = args[1]; + callHook(hook, args); + if (isArray(hook)) { + if (hook.every((hook2) => hook2.length <= 1)) done(); + } else if (hook.length <= 1) { + done(); + } + }; + const hooks = { + mode, + persisted, + beforeEnter(el) { + let hook = onBeforeEnter; + if (!state.isMounted) { + if (appear) { + hook = onBeforeAppear || onBeforeEnter; + } else { + return; + } + } + if (el[leaveCbKey]) { + el[leaveCbKey]( + true + /* cancelled */ + ); + } + const leavingVNode = leavingVNodesCache[key]; + if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { + leavingVNode.el[leaveCbKey](); + } + callHook(hook, [el]); + }, + enter(el) { + let hook = onEnter; + let afterHook = onAfterEnter; + let cancelHook = onEnterCancelled; + if (!state.isMounted) { + if (appear) { + hook = onAppear || onEnter; + afterHook = onAfterAppear || onAfterEnter; + cancelHook = onAppearCancelled || onEnterCancelled; + } else { + return; + } + } + let called = false; + const done = el[enterCbKey$1] = (cancelled) => { + if (called) return; + called = true; + if (cancelled) { + callHook(cancelHook, [el]); + } else { + callHook(afterHook, [el]); + } + if (hooks.delayedLeave) { + hooks.delayedLeave(); + } + el[enterCbKey$1] = void 0; + }; + if (hook) { + callAsyncHook(hook, [el, done]); + } else { + done(); + } + }, + leave(el, remove) { + const key2 = String(vnode.key); + if (el[enterCbKey$1]) { + el[enterCbKey$1]( + true + /* cancelled */ + ); + } + if (state.isUnmounting) { + return remove(); + } + callHook(onBeforeLeave, [el]); + let called = false; + const done = el[leaveCbKey] = (cancelled) => { + if (called) return; + called = true; + remove(); + if (cancelled) { + callHook(onLeaveCancelled, [el]); + } else { + callHook(onAfterLeave, [el]); + } + el[leaveCbKey] = void 0; + if (leavingVNodesCache[key2] === vnode) { + delete leavingVNodesCache[key2]; + } + }; + leavingVNodesCache[key2] = vnode; + if (onLeave) { + callAsyncHook(onLeave, [el, done]); + } else { + done(); + } + }, + clone(vnode2) { + const hooks2 = resolveTransitionHooks( + vnode2, + props, + state, + instance, + postClone + ); + if (postClone) postClone(hooks2); + return hooks2; + } + }; + return hooks; + } + function emptyPlaceholder(vnode) { + if (isKeepAlive(vnode)) { + vnode = cloneVNode(vnode); + vnode.children = null; + return vnode; + } + } + function getInnerChild$1(vnode) { + if (!isKeepAlive(vnode)) { + if (isTeleport(vnode.type) && vnode.children) { + return findNonCommentChild(vnode.children); + } + return vnode; + } + if (vnode.component) { + return vnode.component.subTree; + } + const { shapeFlag, children } = vnode; + if (children) { + if (shapeFlag & 16) { + return children[0]; + } + if (shapeFlag & 32 && isFunction(children.default)) { + return children.default(); + } + } + } + function setTransitionHooks(vnode, hooks) { + if (vnode.shapeFlag & 6 && vnode.component) { + vnode.transition = hooks; + setTransitionHooks(vnode.component.subTree, hooks); + } else if (vnode.shapeFlag & 128) { + vnode.ssContent.transition = hooks.clone(vnode.ssContent); + vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); + } else { + vnode.transition = hooks; + } + } + function getTransitionRawChildren(children, keepComment = false, parentKey) { + let ret = []; + let keyedFragmentCount = 0; + for (let i = 0; i < children.length; i++) { + let child = children[i]; + const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); + if (child.type === Fragment) { + if (child.patchFlag & 128) keyedFragmentCount++; + ret = ret.concat( + getTransitionRawChildren(child.children, keepComment, key) + ); + } else if (keepComment || child.type !== Comment) { + ret.push(key != null ? cloneVNode(child, { key }) : child); + } + } + if (keyedFragmentCount > 1) { + for (let i = 0; i < ret.length; i++) { + ret[i].patchFlag = -2; + } + } + return ret; + } + + // @__NO_SIDE_EFFECTS__ + function defineComponent(options, extraOptions) { + return isFunction(options) ? ( + // #8236: extend call and options.name access are considered side-effects + // by Rollup, so we have to wrap it in a pure-annotated IIFE. + /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() + ) : options; + } + + function useId() { + const i = getCurrentInstance(); + if (i) { + return (i.appContext.config.idPrefix || "v") + "-" + i.ids[0] + i.ids[1]++; + } else { + warn$1( + `useId() is called when there is no active component instance to be associated with.` + ); + } + return ""; + } + function markAsyncBoundary(instance) { + instance.ids = [instance.ids[0] + instance.ids[2]++ + "-", 0, 0]; + } + + const knownTemplateRefs = /* @__PURE__ */ new WeakSet(); + function useTemplateRef(key) { + const i = getCurrentInstance(); + const r = shallowRef(null); + if (i) { + const refs = i.refs === EMPTY_OBJ ? i.refs = {} : i.refs; + let desc; + if ((desc = Object.getOwnPropertyDescriptor(refs, key)) && !desc.configurable) { + warn$1(`useTemplateRef('${key}') already exists.`); + } else { + Object.defineProperty(refs, key, { + enumerable: true, + get: () => r.value, + set: (val) => r.value = val + }); + } + } else { + warn$1( + `useTemplateRef() is called when there is no active component instance to be associated with.` + ); + } + const ret = readonly(r) ; + { + knownTemplateRefs.add(ret); + } + return ret; + } + + const pendingSetRefMap = /* @__PURE__ */ new WeakMap(); + function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) { + if (isArray(rawRef)) { + rawRef.forEach( + (r, i) => setRef( + r, + oldRawRef && (isArray(oldRawRef) ? oldRawRef[i] : oldRawRef), + parentSuspense, + vnode, + isUnmount + ) + ); + return; + } + if (isAsyncWrapper(vnode) && !isUnmount) { + if (vnode.shapeFlag & 512 && vnode.type.__asyncResolved && vnode.component.subTree.component) { + setRef(rawRef, oldRawRef, parentSuspense, vnode.component.subTree); + } + return; + } + const refValue = vnode.shapeFlag & 4 ? getComponentPublicInstance(vnode.component) : vnode.el; + const value = isUnmount ? null : refValue; + const { i: owner, r: ref } = rawRef; + if (!owner) { + warn$1( + `Missing ref owner context. ref cannot be used on hoisted vnodes. A vnode with ref must be created inside the render function.` + ); + return; + } + const oldRef = oldRawRef && oldRawRef.r; + const refs = owner.refs === EMPTY_OBJ ? owner.refs = {} : owner.refs; + const setupState = owner.setupState; + const rawSetupState = toRaw(setupState); + const canSetSetupRef = setupState === EMPTY_OBJ ? NO : (key) => { + { + if (hasOwn(rawSetupState, key) && !isRef(rawSetupState[key])) { + warn$1( + `Template ref "${key}" used on a non-ref value. It will not work in the production build.` + ); + } + if (knownTemplateRefs.has(rawSetupState[key])) { + return false; + } + } + return hasOwn(rawSetupState, key); + }; + const canSetRef = (ref2) => { + return !knownTemplateRefs.has(ref2); + }; + if (oldRef != null && oldRef !== ref) { + invalidatePendingSetRef(oldRawRef); + if (isString(oldRef)) { + refs[oldRef] = null; + if (canSetSetupRef(oldRef)) { + setupState[oldRef] = null; + } + } else if (isRef(oldRef)) { + if (canSetRef(oldRef)) { + oldRef.value = null; + } + const oldRawRefAtom = oldRawRef; + if (oldRawRefAtom.k) refs[oldRawRefAtom.k] = null; + } + } + if (isFunction(ref)) { + callWithErrorHandling(ref, owner, 12, [value, refs]); + } else { + const _isString = isString(ref); + const _isRef = isRef(ref); + if (_isString || _isRef) { + const doSet = () => { + if (rawRef.f) { + const existing = _isString ? canSetSetupRef(ref) ? setupState[ref] : refs[ref] : canSetRef(ref) || !rawRef.k ? ref.value : refs[rawRef.k]; + if (isUnmount) { + isArray(existing) && remove(existing, refValue); + } else { + if (!isArray(existing)) { + if (_isString) { + refs[ref] = [refValue]; + if (canSetSetupRef(ref)) { + setupState[ref] = refs[ref]; + } + } else { + const newVal = [refValue]; + if (canSetRef(ref)) { + ref.value = newVal; + } + if (rawRef.k) refs[rawRef.k] = newVal; + } + } else if (!existing.includes(refValue)) { + existing.push(refValue); + } + } + } else if (_isString) { + refs[ref] = value; + if (canSetSetupRef(ref)) { + setupState[ref] = value; + } + } else if (_isRef) { + if (canSetRef(ref)) { + ref.value = value; + } + if (rawRef.k) refs[rawRef.k] = value; + } else { + warn$1("Invalid template ref type:", ref, `(${typeof ref})`); + } + }; + if (value) { + const job = () => { + doSet(); + pendingSetRefMap.delete(rawRef); + }; + job.id = -1; + pendingSetRefMap.set(rawRef, job); + queuePostRenderEffect(job, parentSuspense); + } else { + invalidatePendingSetRef(rawRef); + doSet(); + } + } else { + warn$1("Invalid template ref type:", ref, `(${typeof ref})`); + } + } + } + function invalidatePendingSetRef(rawRef) { + const pendingSetRef = pendingSetRefMap.get(rawRef); + if (pendingSetRef) { + pendingSetRef.flags |= 8; + pendingSetRefMap.delete(rawRef); + } + } + + let hasLoggedMismatchError = false; + const logMismatchError = () => { + if (hasLoggedMismatchError) { + return; + } + console.error("Hydration completed but contains mismatches."); + hasLoggedMismatchError = true; + }; + const isSVGContainer = (container) => container.namespaceURI.includes("svg") && container.tagName !== "foreignObject"; + const isMathMLContainer = (container) => container.namespaceURI.includes("MathML"); + const getContainerType = (container) => { + if (container.nodeType !== 1) return void 0; + if (isSVGContainer(container)) return "svg"; + if (isMathMLContainer(container)) return "mathml"; + return void 0; + }; + const isComment = (node) => node.nodeType === 8; + function createHydrationFunctions(rendererInternals) { + const { + mt: mountComponent, + p: patch, + o: { + patchProp, + createText, + nextSibling, + parentNode, + remove, + insert, + createComment + } + } = rendererInternals; + const hydrate = (vnode, container) => { + if (!container.hasChildNodes()) { + warn$1( + `Attempting to hydrate existing markup but container is empty. Performing full mount instead.` + ); + patch(null, vnode, container); + flushPostFlushCbs(); + container._vnode = vnode; + return; + } + hydrateNode(container.firstChild, vnode, null, null, null); + flushPostFlushCbs(); + container._vnode = vnode; + }; + const hydrateNode = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized = false) => { + optimized = optimized || !!vnode.dynamicChildren; + const isFragmentStart = isComment(node) && node.data === "["; + const onMismatch = () => handleMismatch( + node, + vnode, + parentComponent, + parentSuspense, + slotScopeIds, + isFragmentStart + ); + const { type, ref, shapeFlag, patchFlag } = vnode; + let domType = node.nodeType; + vnode.el = node; + { + def(node, "__vnode", vnode, true); + def(node, "__vueParentComponent", parentComponent, true); + } + if (patchFlag === -2) { + optimized = false; + vnode.dynamicChildren = null; + } + let nextNode = null; + switch (type) { + case Text: + if (domType !== 3) { + if (vnode.children === "") { + insert(vnode.el = createText(""), parentNode(node), node); + nextNode = node; + } else { + nextNode = onMismatch(); + } + } else { + if (node.data !== vnode.children) { + warn$1( + `Hydration text mismatch in`, + node.parentNode, + ` + - rendered on server: ${JSON.stringify( + node.data + )} + - expected on client: ${JSON.stringify(vnode.children)}` + ); + logMismatchError(); + node.data = vnode.children; + } + nextNode = nextSibling(node); + } + break; + case Comment: + if (isTemplateNode(node)) { + nextNode = nextSibling(node); + replaceNode( + vnode.el = node.content.firstChild, + node, + parentComponent + ); + } else if (domType !== 8 || isFragmentStart) { + nextNode = onMismatch(); + } else { + nextNode = nextSibling(node); + } + break; + case Static: + if (isFragmentStart) { + node = nextSibling(node); + domType = node.nodeType; + } + if (domType === 1 || domType === 3) { + nextNode = node; + const needToAdoptContent = !vnode.children.length; + for (let i = 0; i < vnode.staticCount; i++) { + if (needToAdoptContent) + vnode.children += nextNode.nodeType === 1 ? nextNode.outerHTML : nextNode.data; + if (i === vnode.staticCount - 1) { + vnode.anchor = nextNode; + } + nextNode = nextSibling(nextNode); + } + return isFragmentStart ? nextSibling(nextNode) : nextNode; + } else { + onMismatch(); + } + break; + case Fragment: + if (!isFragmentStart) { + nextNode = onMismatch(); + } else { + nextNode = hydrateFragment( + node, + vnode, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + } + break; + default: + if (shapeFlag & 1) { + if ((domType !== 1 || vnode.type.toLowerCase() !== node.tagName.toLowerCase()) && !isTemplateNode(node)) { + nextNode = onMismatch(); + } else { + nextNode = hydrateElement( + node, + vnode, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + } + } else if (shapeFlag & 6) { + vnode.slotScopeIds = slotScopeIds; + const container = parentNode(node); + if (isFragmentStart) { + nextNode = locateClosingAnchor(node); + } else if (isComment(node) && node.data === "teleport start") { + nextNode = locateClosingAnchor(node, node.data, "teleport end"); + } else { + nextNode = nextSibling(node); + } + mountComponent( + vnode, + container, + null, + parentComponent, + parentSuspense, + getContainerType(container), + optimized + ); + if (isAsyncWrapper(vnode) && !vnode.type.__asyncResolved) { + let subTree; + if (isFragmentStart) { + subTree = createVNode(Fragment); + subTree.anchor = nextNode ? nextNode.previousSibling : container.lastChild; + } else { + subTree = node.nodeType === 3 ? createTextVNode("") : createVNode("div"); + } + subTree.el = node; + vnode.component.subTree = subTree; + } + } else if (shapeFlag & 64) { + if (domType !== 8) { + nextNode = onMismatch(); + } else { + nextNode = vnode.type.hydrate( + node, + vnode, + parentComponent, + parentSuspense, + slotScopeIds, + optimized, + rendererInternals, + hydrateChildren + ); + } + } else if (shapeFlag & 128) { + nextNode = vnode.type.hydrate( + node, + vnode, + parentComponent, + parentSuspense, + getContainerType(parentNode(node)), + slotScopeIds, + optimized, + rendererInternals, + hydrateNode + ); + } else { + warn$1("Invalid HostVNode type:", type, `(${typeof type})`); + } + } + if (ref != null) { + setRef(ref, null, parentSuspense, vnode); + } + return nextNode; + }; + const hydrateElement = (el, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { + optimized = optimized || !!vnode.dynamicChildren; + const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode; + const forcePatch = type === "input" || type === "option"; + { + if (dirs) { + invokeDirectiveHook(vnode, null, parentComponent, "created"); + } + let needCallTransitionHooks = false; + if (isTemplateNode(el)) { + needCallTransitionHooks = needTransition( + null, + // no need check parentSuspense in hydration + transition + ) && parentComponent && parentComponent.vnode.props && parentComponent.vnode.props.appear; + const content = el.content.firstChild; + if (needCallTransitionHooks) { + const cls = content.getAttribute("class"); + if (cls) content.$cls = cls; + transition.beforeEnter(content); + } + replaceNode(content, el, parentComponent); + vnode.el = el = content; + } + if (shapeFlag & 16 && // skip if element has innerHTML / textContent + !(props && (props.innerHTML || props.textContent))) { + let next = hydrateChildren( + el.firstChild, + vnode, + el, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + let hasWarned = false; + while (next) { + if (!isMismatchAllowed(el, 1 /* CHILDREN */)) { + if (!hasWarned) { + warn$1( + `Hydration children mismatch on`, + el, + ` +Server rendered element contains more child nodes than client vdom.` + ); + hasWarned = true; + } + logMismatchError(); + } + const cur = next; + next = next.nextSibling; + remove(cur); + } + } else if (shapeFlag & 8) { + let clientText = vnode.children; + if (clientText[0] === "\n" && (el.tagName === "PRE" || el.tagName === "TEXTAREA")) { + clientText = clientText.slice(1); + } + const { textContent } = el; + if (textContent !== clientText && // innerHTML normalize \r\n or \r into a single \n in the DOM + textContent !== clientText.replace(/\r\n|\r/g, "\n")) { + if (!isMismatchAllowed(el, 0 /* TEXT */)) { + warn$1( + `Hydration text content mismatch on`, + el, + ` + - rendered on server: ${textContent} + - expected on client: ${clientText}` + ); + logMismatchError(); + } + el.textContent = vnode.children; + } + } + if (props) { + { + const isCustomElement = el.tagName.includes("-"); + for (const key in props) { + if (// #11189 skip if this node has directives that have created hooks + // as it could have mutated the DOM in any possible way + !(dirs && dirs.some((d) => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent)) { + logMismatchError(); + } + if (forcePatch && (key.endsWith("value") || key === "indeterminate") || isOn(key) && !isReservedProp(key) || // force hydrate v-bind with .prop modifiers + key[0] === "." || isCustomElement) { + patchProp(el, key, null, props[key], void 0, parentComponent); + } + } + } + } + let vnodeHooks; + if (vnodeHooks = props && props.onVnodeBeforeMount) { + invokeVNodeHook(vnodeHooks, parentComponent, vnode); + } + if (dirs) { + invokeDirectiveHook(vnode, null, parentComponent, "beforeMount"); + } + if ((vnodeHooks = props && props.onVnodeMounted) || dirs || needCallTransitionHooks) { + queueEffectWithSuspense(() => { + vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode); + needCallTransitionHooks && transition.enter(el); + dirs && invokeDirectiveHook(vnode, null, parentComponent, "mounted"); + }, parentSuspense); + } + } + return el.nextSibling; + }; + const hydrateChildren = (node, parentVNode, container, parentComponent, parentSuspense, slotScopeIds, optimized) => { + optimized = optimized || !!parentVNode.dynamicChildren; + const children = parentVNode.children; + const l = children.length; + let hasWarned = false; + for (let i = 0; i < l; i++) { + const vnode = optimized ? children[i] : children[i] = normalizeVNode(children[i]); + const isText = vnode.type === Text; + if (node) { + if (isText && !optimized) { + if (i + 1 < l && normalizeVNode(children[i + 1]).type === Text) { + insert( + createText( + node.data.slice(vnode.children.length) + ), + container, + nextSibling(node) + ); + node.data = vnode.children; + } + } + node = hydrateNode( + node, + vnode, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + } else if (isText && !vnode.children) { + insert(vnode.el = createText(""), container); + } else { + if (!isMismatchAllowed(container, 1 /* CHILDREN */)) { + if (!hasWarned) { + warn$1( + `Hydration children mismatch on`, + container, + ` +Server rendered element contains fewer child nodes than client vdom.` + ); + hasWarned = true; + } + logMismatchError(); + } + patch( + null, + vnode, + container, + null, + parentComponent, + parentSuspense, + getContainerType(container), + slotScopeIds + ); + } + } + return node; + }; + const hydrateFragment = (node, vnode, parentComponent, parentSuspense, slotScopeIds, optimized) => { + const { slotScopeIds: fragmentSlotScopeIds } = vnode; + if (fragmentSlotScopeIds) { + slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds; + } + const container = parentNode(node); + const next = hydrateChildren( + nextSibling(node), + vnode, + container, + parentComponent, + parentSuspense, + slotScopeIds, + optimized + ); + if (next && isComment(next) && next.data === "]") { + return nextSibling(vnode.anchor = next); + } else { + logMismatchError(); + insert(vnode.anchor = createComment(`]`), container, next); + return next; + } + }; + const handleMismatch = (node, vnode, parentComponent, parentSuspense, slotScopeIds, isFragment) => { + if (!isMismatchAllowed(node.parentElement, 1 /* CHILDREN */)) { + warn$1( + `Hydration node mismatch: +- rendered on server:`, + node, + node.nodeType === 3 ? `(text)` : isComment(node) && node.data === "[" ? `(start of fragment)` : ``, + ` +- expected on client:`, + vnode.type + ); + logMismatchError(); + } + vnode.el = null; + if (isFragment) { + const end = locateClosingAnchor(node); + while (true) { + const next2 = nextSibling(node); + if (next2 && next2 !== end) { + remove(next2); + } else { + break; + } + } + } + const next = nextSibling(node); + const container = parentNode(node); + remove(node); + patch( + null, + vnode, + container, + next, + parentComponent, + parentSuspense, + getContainerType(container), + slotScopeIds + ); + if (parentComponent) { + parentComponent.vnode.el = vnode.el; + updateHOCHostEl(parentComponent, vnode.el); + } + return next; + }; + const locateClosingAnchor = (node, open = "[", close = "]") => { + let match = 0; + while (node) { + node = nextSibling(node); + if (node && isComment(node)) { + if (node.data === open) match++; + if (node.data === close) { + if (match === 0) { + return nextSibling(node); + } else { + match--; + } + } + } + } + return node; + }; + const replaceNode = (newNode, oldNode, parentComponent) => { + const parentNode2 = oldNode.parentNode; + if (parentNode2) { + parentNode2.replaceChild(newNode, oldNode); + } + let parent = parentComponent; + while (parent) { + if (parent.vnode.el === oldNode) { + parent.vnode.el = parent.subTree.el = newNode; + } + parent = parent.parent; + } + }; + const isTemplateNode = (node) => { + return node.nodeType === 1 && node.tagName === "TEMPLATE"; + }; + return [hydrate, hydrateNode]; + } + function propHasMismatch(el, key, clientValue, vnode, instance) { + let mismatchType; + let mismatchKey; + let actual; + let expected; + if (key === "class") { + if (el.$cls) { + actual = el.$cls; + delete el.$cls; + } else { + actual = el.getAttribute("class"); + } + expected = normalizeClass(clientValue); + if (!isSetEqual(toClassSet(actual || ""), toClassSet(expected))) { + mismatchType = 2 /* CLASS */; + mismatchKey = `class`; + } + } else if (key === "style") { + actual = el.getAttribute("style") || ""; + expected = isString(clientValue) ? clientValue : stringifyStyle(normalizeStyle(clientValue)); + const actualMap = toStyleMap(actual); + const expectedMap = toStyleMap(expected); + if (vnode.dirs) { + for (const { dir, value } of vnode.dirs) { + if (dir.name === "show" && !value) { + expectedMap.set("display", "none"); + } + } + } + if (instance) { + resolveCssVars(instance, vnode, expectedMap); + } + if (!isMapEqual(actualMap, expectedMap)) { + mismatchType = 3 /* STYLE */; + mismatchKey = "style"; + } + } else if (el instanceof SVGElement && isKnownSvgAttr(key) || el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) { + if (isBooleanAttr(key)) { + actual = el.hasAttribute(key); + expected = includeBooleanAttr(clientValue); + } else if (clientValue == null) { + actual = el.hasAttribute(key); + expected = false; + } else { + if (el.hasAttribute(key)) { + actual = el.getAttribute(key); + } else if (key === "value" && el.tagName === "TEXTAREA") { + actual = el.value; + } else { + actual = false; + } + expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false; + } + if (actual !== expected) { + mismatchType = 4 /* ATTRIBUTE */; + mismatchKey = key; + } + } + if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) { + const format = (v) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"`; + const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`; + const postSegment = ` + - rendered on server: ${format(actual)} + - expected on client: ${format(expected)} + Note: this mismatch is check-only. The DOM will not be rectified in production due to performance overhead. + You should fix the source of the mismatch.`; + { + warn$1(preSegment, el, postSegment); + } + return true; + } + return false; + } + function toClassSet(str) { + return new Set(str.trim().split(/\s+/)); + } + function isSetEqual(a, b) { + if (a.size !== b.size) { + return false; + } + for (const s of a) { + if (!b.has(s)) { + return false; + } + } + return true; + } + function toStyleMap(str) { + const styleMap = /* @__PURE__ */ new Map(); + for (const item of str.split(";")) { + let [key, value] = item.split(":"); + key = key.trim(); + value = value && value.trim(); + if (key && value) { + styleMap.set(key, value); + } + } + return styleMap; + } + function isMapEqual(a, b) { + if (a.size !== b.size) { + return false; + } + for (const [key, value] of a) { + if (value !== b.get(key)) { + return false; + } + } + return true; + } + function resolveCssVars(instance, vnode, expectedMap) { + const root = instance.subTree; + if (instance.getCssVars && (vnode === root || root && root.type === Fragment && root.children.includes(vnode))) { + const cssVars = instance.getCssVars(); + for (const key in cssVars) { + const value = normalizeCssVarValue(cssVars[key]); + expectedMap.set(`--${getEscapedCssVarName(key)}`, value); + } + } + if (vnode === root && instance.parent) { + resolveCssVars(instance.parent, instance.vnode, expectedMap); + } + } + const allowMismatchAttr = "data-allow-mismatch"; + const MismatchTypeString = { + [0 /* TEXT */]: "text", + [1 /* CHILDREN */]: "children", + [2 /* CLASS */]: "class", + [3 /* STYLE */]: "style", + [4 /* ATTRIBUTE */]: "attribute" + }; + function isMismatchAllowed(el, allowedType) { + if (allowedType === 0 /* TEXT */ || allowedType === 1 /* CHILDREN */) { + while (el && !el.hasAttribute(allowMismatchAttr)) { + el = el.parentElement; + } + } + const allowedAttr = el && el.getAttribute(allowMismatchAttr); + if (allowedAttr == null) { + return false; + } else if (allowedAttr === "") { + return true; + } else { + const list = allowedAttr.split(","); + if (allowedType === 0 /* TEXT */ && list.includes("children")) { + return true; + } + return list.includes(MismatchTypeString[allowedType]); + } + } + + const requestIdleCallback = getGlobalThis().requestIdleCallback || ((cb) => setTimeout(cb, 1)); + const cancelIdleCallback = getGlobalThis().cancelIdleCallback || ((id) => clearTimeout(id)); + const hydrateOnIdle = (timeout = 1e4) => (hydrate) => { + const id = requestIdleCallback(hydrate, { timeout }); + return () => cancelIdleCallback(id); + }; + function elementIsVisibleInViewport(el) { + const { top, left, bottom, right } = el.getBoundingClientRect(); + const { innerHeight, innerWidth } = window; + return (top > 0 && top < innerHeight || bottom > 0 && bottom < innerHeight) && (left > 0 && left < innerWidth || right > 0 && right < innerWidth); + } + const hydrateOnVisible = (opts) => (hydrate, forEach) => { + const ob = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + ob.disconnect(); + hydrate(); + break; + } + }, opts); + forEach((el) => { + if (!(el instanceof Element)) return; + if (elementIsVisibleInViewport(el)) { + hydrate(); + ob.disconnect(); + return false; + } + ob.observe(el); + }); + return () => ob.disconnect(); + }; + const hydrateOnMediaQuery = (query) => (hydrate) => { + if (query) { + const mql = matchMedia(query); + if (mql.matches) { + hydrate(); + } else { + mql.addEventListener("change", hydrate, { once: true }); + return () => mql.removeEventListener("change", hydrate); + } + } + }; + const hydrateOnInteraction = (interactions = []) => (hydrate, forEach) => { + if (isString(interactions)) interactions = [interactions]; + let hasHydrated = false; + const doHydrate = (e) => { + if (!hasHydrated) { + hasHydrated = true; + teardown(); + hydrate(); + e.target.dispatchEvent(new e.constructor(e.type, e)); + } + }; + const teardown = () => { + forEach((el) => { + for (const i of interactions) { + el.removeEventListener(i, doHydrate); + } + }); + }; + forEach((el) => { + for (const i of interactions) { + el.addEventListener(i, doHydrate, { once: true }); + } + }); + return teardown; + }; + function forEachElement(node, cb) { + if (isComment(node) && node.data === "[") { + let depth = 1; + let next = node.nextSibling; + while (next) { + if (next.nodeType === 1) { + const result = cb(next); + if (result === false) { + break; + } + } else if (isComment(next)) { + if (next.data === "]") { + if (--depth === 0) break; + } else if (next.data === "[") { + depth++; + } + } + next = next.nextSibling; + } + } else { + cb(node); + } + } + + const isAsyncWrapper = (i) => !!i.type.__asyncLoader; + // @__NO_SIDE_EFFECTS__ + function defineAsyncComponent(source) { + if (isFunction(source)) { + source = { loader: source }; + } + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + hydrate: hydrateStrategy, + timeout, + // undefined = never times out + suspensible = true, + onError: userOnError + } = source; + let pendingRequest = null; + let resolvedComp; + let retries = 0; + const retry = () => { + retries++; + pendingRequest = null; + return load(); + }; + const load = () => { + let thisRequest; + return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { + err = err instanceof Error ? err : new Error(String(err)); + if (userOnError) { + return new Promise((resolve, reject) => { + const userRetry = () => resolve(retry()); + const userFail = () => reject(err); + userOnError(err, userRetry, userFail, retries + 1); + }); + } else { + throw err; + } + }).then((comp) => { + if (thisRequest !== pendingRequest && pendingRequest) { + return pendingRequest; + } + if (!comp) { + warn$1( + `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` + ); + } + if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { + comp = comp.default; + } + if (comp && !isObject(comp) && !isFunction(comp)) { + throw new Error(`Invalid async component load result: ${comp}`); + } + resolvedComp = comp; + return comp; + })); + }; + return defineComponent({ + name: "AsyncComponentWrapper", + __asyncLoader: load, + __asyncHydrate(el, instance, hydrate) { + let patched = false; + (instance.bu || (instance.bu = [])).push(() => patched = true); + const performHydrate = () => { + if (patched) { + { + warn$1( + `Skipping lazy hydration for component '${getComponentName(resolvedComp) || resolvedComp.__file}': it was updated before lazy hydration performed.` + ); + } + return; + } + hydrate(); + }; + const doHydrate = hydrateStrategy ? () => { + const teardown = hydrateStrategy( + performHydrate, + (cb) => forEachElement(el, cb) + ); + if (teardown) { + (instance.bum || (instance.bum = [])).push(teardown); + } + } : performHydrate; + if (resolvedComp) { + doHydrate(); + } else { + load().then(() => !instance.isUnmounted && doHydrate()); + } + }, + get __asyncResolved() { + return resolvedComp; + }, + setup() { + const instance = currentInstance; + markAsyncBoundary(instance); + if (resolvedComp) { + return () => createInnerComp(resolvedComp, instance); + } + const onError = (err) => { + pendingRequest = null; + handleError( + err, + instance, + 13, + !errorComponent + ); + }; + if (suspensible && instance.suspense || false) { + return load().then((comp) => { + return () => createInnerComp(comp, instance); + }).catch((err) => { + onError(err); + return () => errorComponent ? createVNode(errorComponent, { + error: err + }) : null; + }); + } + const loaded = ref(false); + const error = ref(); + const delayed = ref(!!delay); + if (delay) { + setTimeout(() => { + delayed.value = false; + }, delay); + } + if (timeout != null) { + setTimeout(() => { + if (!loaded.value && !error.value) { + const err = new Error( + `Async component timed out after ${timeout}ms.` + ); + onError(err); + error.value = err; + } + }, timeout); + } + load().then(() => { + loaded.value = true; + if (instance.parent && isKeepAlive(instance.parent.vnode)) { + instance.parent.update(); + } + }).catch((err) => { + onError(err); + error.value = err; + }); + return () => { + if (loaded.value && resolvedComp) { + return createInnerComp(resolvedComp, instance); + } else if (error.value && errorComponent) { + return createVNode(errorComponent, { + error: error.value + }); + } else if (loadingComponent && !delayed.value) { + return createInnerComp( + loadingComponent, + instance + ); + } + }; + } + }); + } + function createInnerComp(comp, parent) { + const { ref: ref2, props, children, ce } = parent.vnode; + const vnode = createVNode(comp, props, children); + vnode.ref = ref2; + vnode.ce = ce; + delete parent.vnode.ce; + return vnode; + } + + const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; + const KeepAliveImpl = { + name: `KeepAlive`, + // Marker for special handling inside the renderer. We are not using a === + // check directly on KeepAlive in the renderer, because importing it directly + // would prevent it from being tree-shaken. + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + }, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const sharedContext = instance.ctx; + const cache = /* @__PURE__ */ new Map(); + const keys = /* @__PURE__ */ new Set(); + let current = null; + { + instance.__v_cache = cache; + } + const parentSuspense = instance.suspense; + const { + renderer: { + p: patch, + m: move, + um: _unmount, + o: { createElement } + } + } = sharedContext; + const storageContainer = createElement("div"); + sharedContext.activate = (vnode, container, anchor, namespace, optimized) => { + const instance2 = vnode.component; + move(vnode, container, anchor, 0, parentSuspense); + patch( + instance2.vnode, + vnode, + container, + anchor, + instance2, + parentSuspense, + namespace, + vnode.slotScopeIds, + optimized + ); + queuePostRenderEffect(() => { + instance2.isDeactivated = false; + if (instance2.a) { + invokeArrayFns(instance2.a); + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + sharedContext.deactivate = (vnode) => { + const instance2 = vnode.component; + invalidateMount(instance2.m); + invalidateMount(instance2.a); + move(vnode, storageContainer, null, 1, parentSuspense); + queuePostRenderEffect(() => { + if (instance2.da) { + invokeArrayFns(instance2.da); + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + instance2.isDeactivated = true; + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + { + instance2.__keepAliveStorageContainer = storageContainer; + } + }; + function unmount(vnode) { + resetShapeFlag(vnode); + _unmount(vnode, instance, parentSuspense, true); + } + function pruneCache(filter) { + cache.forEach((vnode, key) => { + const name = getComponentName(vnode.type); + if (name && !filter(name)) { + pruneCacheEntry(key); + } + }); + } + function pruneCacheEntry(key) { + const cached = cache.get(key); + if (cached && (!current || !isSameVNodeType(cached, current))) { + unmount(cached); + } else if (current) { + resetShapeFlag(current); + } + cache.delete(key); + keys.delete(key); + } + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache((name) => matches(include, name)); + exclude && pruneCache((name) => !matches(exclude, name)); + }, + // prune post-render after `current` has been updated + { flush: "post", deep: true } + ); + let pendingCacheKey = null; + const cacheSubtree = () => { + if (pendingCacheKey != null) { + if (isSuspense(instance.subTree.type)) { + queuePostRenderEffect(() => { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + }, instance.subTree.suspense); + } else { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + } + } + }; + onMounted(cacheSubtree); + onUpdated(cacheSubtree); + onBeforeUnmount(() => { + cache.forEach((cached) => { + const { subTree, suspense } = instance; + const vnode = getInnerChild(subTree); + if (cached.type === vnode.type && cached.key === vnode.key) { + resetShapeFlag(vnode); + const da = vnode.component.da; + da && queuePostRenderEffect(da, suspense); + return; + } + unmount(cached); + }); + }); + return () => { + pendingCacheKey = null; + if (!slots.default) { + return current = null; + } + const children = slots.default(); + const rawVNode = children[0]; + if (children.length > 1) { + { + warn$1(`KeepAlive should contain exactly one component child.`); + } + current = null; + return children; + } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { + current = null; + return rawVNode; + } + let vnode = getInnerChild(rawVNode); + if (vnode.type === Comment) { + current = null; + return vnode; + } + const comp = vnode.type; + const name = getComponentName( + isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp + ); + const { include, exclude, max } = props; + if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { + vnode.shapeFlag &= -257; + current = vnode; + return rawVNode; + } + const key = vnode.key == null ? comp : vnode.key; + const cachedVNode = cache.get(key); + if (vnode.el) { + vnode = cloneVNode(vnode); + if (rawVNode.shapeFlag & 128) { + rawVNode.ssContent = vnode; + } + } + pendingCacheKey = key; + if (cachedVNode) { + vnode.el = cachedVNode.el; + vnode.component = cachedVNode.component; + if (vnode.transition) { + setTransitionHooks(vnode, vnode.transition); + } + vnode.shapeFlag |= 512; + keys.delete(key); + keys.add(key); + } else { + keys.add(key); + if (max && keys.size > parseInt(max, 10)) { + pruneCacheEntry(keys.values().next().value); + } + } + vnode.shapeFlag |= 256; + current = vnode; + return isSuspense(rawVNode.type) ? rawVNode : vnode; + }; + } + }; + const KeepAlive = KeepAliveImpl; + function matches(pattern, name) { + if (isArray(pattern)) { + return pattern.some((p) => matches(p, name)); + } else if (isString(pattern)) { + return pattern.split(",").includes(name); + } else if (isRegExp(pattern)) { + pattern.lastIndex = 0; + return pattern.test(name); + } + return false; + } + function onActivated(hook, target) { + registerKeepAliveHook(hook, "a", target); + } + function onDeactivated(hook, target) { + registerKeepAliveHook(hook, "da", target); + } + function registerKeepAliveHook(hook, type, target = currentInstance) { + const wrappedHook = hook.__wdc || (hook.__wdc = () => { + let current = target; + while (current) { + if (current.isDeactivated) { + return; + } + current = current.parent; + } + return hook(); + }); + injectHook(type, wrappedHook, target); + if (target) { + let current = target.parent; + while (current && current.parent) { + if (isKeepAlive(current.parent.vnode)) { + injectToKeepAliveRoot(wrappedHook, type, target, current); + } + current = current.parent; + } + } + } + function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { + const injected = injectHook( + type, + hook, + keepAliveRoot, + true + /* prepend */ + ); + onUnmounted(() => { + remove(keepAliveRoot[type], injected); + }, target); + } + function resetShapeFlag(vnode) { + vnode.shapeFlag &= -257; + vnode.shapeFlag &= -513; + } + function getInnerChild(vnode) { + return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; + } + + function injectHook(type, hook, target = currentInstance, prepend = false) { + if (target) { + const hooks = target[type] || (target[type] = []); + const wrappedHook = hook.__weh || (hook.__weh = (...args) => { + pauseTracking(); + const reset = setCurrentInstance(target); + const res = callWithAsyncErrorHandling(hook, target, type, args); + reset(); + resetTracking(); + return res; + }); + if (prepend) { + hooks.unshift(wrappedHook); + } else { + hooks.push(wrappedHook); + } + return wrappedHook; + } else { + const apiName = toHandlerKey(ErrorTypeStrings$1[type].replace(/ hook$/, "")); + warn$1( + `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ) + ); + } + } + const createHook = (lifecycle) => (hook, target = currentInstance) => { + if (!isInSSRComponentSetup || lifecycle === "sp") { + injectHook(lifecycle, (...args) => hook(...args), target); + } + }; + const onBeforeMount = createHook("bm"); + const onMounted = createHook("m"); + const onBeforeUpdate = createHook( + "bu" + ); + const onUpdated = createHook("u"); + const onBeforeUnmount = createHook( + "bum" + ); + const onUnmounted = createHook("um"); + const onServerPrefetch = createHook( + "sp" + ); + const onRenderTriggered = createHook("rtg"); + const onRenderTracked = createHook("rtc"); + function onErrorCaptured(hook, target = currentInstance) { + injectHook("ec", hook, target); + } + + const COMPONENTS = "components"; + const DIRECTIVES = "directives"; + function resolveComponent(name, maybeSelfReference) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; + } + const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); + function resolveDynamicComponent(component) { + if (isString(component)) { + return resolveAsset(COMPONENTS, component, false) || component; + } else { + return component || NULL_DYNAMIC_COMPONENT; + } + } + function resolveDirective(name) { + return resolveAsset(DIRECTIVES, name); + } + function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { + const instance = currentRenderingInstance || currentInstance; + if (instance) { + const Component = instance.type; + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false + ); + if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { + return Component; + } + } + const res = ( + // local registration + // check instance[type] first which is resolved for options API + resolve(instance[type] || Component[type], name) || // global registration + resolve(instance.appContext[type], name) + ); + if (!res && maybeSelfReference) { + return Component; + } + if (warnMissing && !res) { + const extra = type === COMPONENTS ? ` +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; + warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); + } + return res; + } else { + warn$1( + `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` + ); + } + } + function resolve(registry, name) { + return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); + } + + function renderList(source, renderItem, cache, index) { + let ret; + const cached = cache && cache[index]; + const sourceIsArray = isArray(source); + if (sourceIsArray || isString(source)) { + const sourceIsReactiveArray = sourceIsArray && isReactive(source); + let needsWrap = false; + let isReadonlySource = false; + if (sourceIsReactiveArray) { + needsWrap = !isShallow(source); + isReadonlySource = isReadonly(source); + source = shallowReadArray(source); + } + ret = new Array(source.length); + for (let i = 0, l = source.length; i < l; i++) { + ret[i] = renderItem( + needsWrap ? isReadonlySource ? toReadonly(toReactive(source[i])) : toReactive(source[i]) : source[i], + i, + void 0, + cached && cached[i] + ); + } + } else if (typeof source === "number") { + if (!Number.isInteger(source)) { + warn$1(`The v-for range expect an integer value but got ${source}.`); + } + ret = new Array(source); + for (let i = 0; i < source; i++) { + ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); + } + } else if (isObject(source)) { + if (source[Symbol.iterator]) { + ret = Array.from( + source, + (item, i) => renderItem(item, i, void 0, cached && cached[i]) + ); + } else { + const keys = Object.keys(source); + ret = new Array(keys.length); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + ret[i] = renderItem(source[key], key, i, cached && cached[i]); + } + } + } else { + ret = []; + } + if (cache) { + cache[index] = ret; + } + return ret; + } + + function createSlots(slots, dynamicSlots) { + for (let i = 0; i < dynamicSlots.length; i++) { + const slot = dynamicSlots[i]; + if (isArray(slot)) { + for (let j = 0; j < slot.length; j++) { + slots[slot[j].name] = slot[j].fn; + } + } else if (slot) { + slots[slot.name] = slot.key ? (...args) => { + const res = slot.fn(...args); + if (res) res.key = slot.key; + return res; + } : slot.fn; + } + } + return slots; + } + + function renderSlot(slots, name, props = {}, fallback, noSlotted) { + if (currentRenderingInstance.ce || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.ce) { + const hasProps = Object.keys(props).length > 0; + if (name !== "default") props.name = name; + return openBlock(), createBlock( + Fragment, + null, + [createVNode("slot", props, fallback && fallback())], + hasProps ? -2 : 64 + ); + } + let slot = slots[name]; + if (slot && slot.length > 1) { + warn$1( + `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` + ); + slot = () => []; + } + if (slot && slot._c) { + slot._d = false; + } + openBlock(); + const validSlotContent = slot && ensureValidVNode(slot(props)); + const slotKey = props.key || // slot content array of a dynamic conditional slot may have a branch + // key attached in the `createSlots` helper, respect that + validSlotContent && validSlotContent.key; + const rendered = createBlock( + Fragment, + { + key: (slotKey && !isSymbol(slotKey) ? slotKey : `_${name}`) + // #7256 force differentiate fallback content from actual content + (!validSlotContent && fallback ? "_fb" : "") + }, + validSlotContent || (fallback ? fallback() : []), + validSlotContent && slots._ === 1 ? 64 : -2 + ); + if (!noSlotted && rendered.scopeId) { + rendered.slotScopeIds = [rendered.scopeId + "-s"]; + } + if (slot && slot._c) { + slot._d = true; + } + return rendered; + } + function ensureValidVNode(vnodes) { + return vnodes.some((child) => { + if (!isVNode(child)) return true; + if (child.type === Comment) return false; + if (child.type === Fragment && !ensureValidVNode(child.children)) + return false; + return true; + }) ? vnodes : null; + } + + function toHandlers(obj, preserveCaseIfNecessary) { + const ret = {}; + if (!isObject(obj)) { + warn$1(`v-on with no argument expects an object value.`); + return ret; + } + for (const key in obj) { + ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; + } + return ret; + } + + const getPublicInstance = (i) => { + if (!i) return null; + if (isStatefulComponent(i)) return getComponentPublicInstance(i); + return getPublicInstance(i.parent); + }; + const publicPropertiesMap = ( + // Move PURE marker to new line to workaround compiler discarding it + // due to type annotation + /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), { + $: (i) => i, + $el: (i) => i.vnode.el, + $data: (i) => i.data, + $props: (i) => shallowReadonly(i.props) , + $attrs: (i) => shallowReadonly(i.attrs) , + $slots: (i) => shallowReadonly(i.slots) , + $refs: (i) => shallowReadonly(i.refs) , + $parent: (i) => getPublicInstance(i.parent), + $root: (i) => getPublicInstance(i.root), + $host: (i) => i.ce, + $emit: (i) => i.emit, + $options: (i) => resolveMergedOptions(i) , + $forceUpdate: (i) => i.f || (i.f = () => { + queueJob(i.update); + }), + $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), + $watch: (i) => instanceWatch.bind(i) + }) + ); + const isReservedPrefix = (key) => key === "_" || key === "$"; + const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); + const PublicInstanceProxyHandlers = { + get({ _: instance }, key) { + if (key === "__v_skip") { + return true; + } + const { ctx, setupState, data, props, accessCache, type, appContext } = instance; + if (key === "__isVue") { + return true; + } + let normalizedProps; + if (key[0] !== "$") { + const n = accessCache[key]; + if (n !== void 0) { + switch (n) { + case 1 /* SETUP */: + return setupState[key]; + case 2 /* DATA */: + return data[key]; + case 4 /* CONTEXT */: + return ctx[key]; + case 3 /* PROPS */: + return props[key]; + } + } else if (hasSetupBinding(setupState, key)) { + accessCache[key] = 1 /* SETUP */; + return setupState[key]; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + accessCache[key] = 2 /* DATA */; + return data[key]; + } else if ( + // only cache other properties when instance has declared (thus stable) + // props + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) + ) { + accessCache[key] = 3 /* PROPS */; + return props[key]; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if (shouldCacheAccess) { + accessCache[key] = 0 /* OTHER */; + } + } + const publicGetter = publicPropertiesMap[key]; + let cssModule, globalProperties; + if (publicGetter) { + if (key === "$attrs") { + track(instance.attrs, "get", ""); + markAttrsAccessed(); + } else if (key === "$slots") { + track(instance, "get", key); + } + return publicGetter(instance); + } else if ( + // css module (injected by vue-loader) + (cssModule = type.__cssModules) && (cssModule = cssModule[key]) + ) { + return cssModule; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if ( + // global properties + globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) + ) { + { + return globalProperties[key]; + } + } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading + // to infinite warning loop + key.indexOf("__v") !== 0)) { + if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { + warn$1( + `Property ${JSON.stringify( + key + )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` + ); + } else if (instance === currentRenderingInstance) { + warn$1( + `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` + ); + } + } + }, + set({ _: instance }, key, value) { + const { data, setupState, ctx } = instance; + if (hasSetupBinding(setupState, key)) { + setupState[key] = value; + return true; + } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { + warn$1(`Cannot mutate + + diff --git a/frontend/share.html b/frontend/share.html new file mode 100644 index 0000000..920a5ac --- /dev/null +++ b/frontend/share.html @@ -0,0 +1,1134 @@ + + + + + + 文件分享 - 玩玩云 + + + + + + +
+
+
+
+ + + + 文件分享 +
+ + + + + +
+
{{ errorMessage }}
+
+ + +
+ +
+ + + + + +
+

+ 分享者: {{ shareInfo.username }} | + 创建时间: {{ formatDate(shareInfo.created_at) }} + | 到期时间: {{ formatExpireTime(shareInfo.expires_at) }} + | 有效期: 永久有效 +

+ + +
+ + +
+ +
+
+

加载中...

+
+ + +
+
+ +
{{ (viewingFile || files[0]).name }}
+
{{ (viewingFile || files[0]).sizeFormatted }}
+ +
+
+ + + +
+
+ +
{{ file.name }}
+
{{ file.sizeFormatted }}
+ +
+
+ + +
    +
  • +
    + +
    +
    {{ file.name }}
    +
    {{ file.sizeFormatted }}
    +
    +
    + +
  • +
+ +

+ 暂无文件 +

+
+ + +
+
+

加载中...

+
+
+
+
+ + + + + + diff --git a/frontend/verify.html b/frontend/verify.html new file mode 100644 index 0000000..3bed03f --- /dev/null +++ b/frontend/verify.html @@ -0,0 +1,288 @@ + + + + + + 邮箱验证 - 玩玩云 + + + + +
+
+ +

邮箱验证

+

玩玩云账号激活

+ +
+
+ +
+

正在验证您的邮箱...

+
+
+ + +
+ + + + diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..67f7f3a --- /dev/null +++ b/install.sh @@ -0,0 +1,4763 @@ +#!/bin/bash + +################################################################################ +# 玩玩云 (WanWanYun) - 一键部署/卸载/更新脚本 +# 项目地址: https://git.workyai.cn/237899745/vue-driven-cloud-storage +# 版本: v3.1.0 +################################################################################ + +set -e + +# 检查运行模式 +MODE="install" +if [[ "$1" == "--uninstall" ]] || [[ "$1" == "-u" ]] || [[ "$1" == "uninstall" ]]; then + MODE="uninstall" +elif [[ "$1" == "--update" ]] || [[ "$1" == "--upgrade" ]] || [[ "$1" == "update" ]]; then + MODE="update" +elif [[ "$1" == "--repair" ]] || [[ "$1" == "--fix" ]] || [[ "$1" == "repair" ]]; then + MODE="repair" +elif [[ "$1" == "--ssl" ]] || [[ "$1" == "--cert" ]] || [[ "$1" == "ssl" ]]; then + MODE="ssl" +fi + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +WHITE='\033[1;37m' +NC='\033[0m' # No Color + +# 全局变量 +PROJECT_NAME="wanwanyun" +PROJECT_DIR="/var/www/${PROJECT_NAME}" +REPO_URL="https://git.workyai.cn/237899745/vue-driven-cloud-storage.git" +NODE_VERSION="20" +ADMIN_USERNAME="" +ADMIN_PASSWORD="" +DOMAIN="" +USE_DOMAIN=false +SSL_METHOD="" +HTTP_PORT="80" +HTTPS_PORT="443" +BACKEND_PORT="40001" + +################################################################################ +# 工具函数 +################################################################################ + +print_banner() { + clear + echo -e "${CYAN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🌩️ 玩玩云 一键部署脚本 ║" + echo "║ ║" + echo "║ Cloud Storage Platform ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +print_step() { + echo -e "\n${BLUE}▶ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_info() { + echo -e "${CYAN}ℹ $1${NC}" +} + +# 检测操作系统 +detect_os() { + if [[ -f /etc/os-release ]]; then + . /etc/os-release + OS=$ID + OS_VERSION=$VERSION_ID + OS_NAME=$NAME + else + print_error "无法检测操作系统" + exit 1 + fi + + # 统一操作系统标识和包管理器检测 + case $OS in + ubuntu) + PKG_MANAGER="apt" + ;; + debian) + PKG_MANAGER="apt" + ;; + centos) + if [[ "${OS_VERSION%%.*}" -ge 8 ]]; then + PKG_MANAGER="dnf" + else + PKG_MANAGER="yum" + fi + ;; + rhel|redhat) + OS="rhel" + if [[ "${OS_VERSION%%.*}" -ge 8 ]]; then + PKG_MANAGER="dnf" + else + PKG_MANAGER="yum" + fi + ;; + rocky|rockylinux) + OS="rocky" + PKG_MANAGER="dnf" + ;; + almalinux|alma) + OS="almalinux" + PKG_MANAGER="dnf" + ;; + fedora) + PKG_MANAGER="dnf" + ;; + opensuse|opensuse-leap|opensuse-tumbleweed) + OS="opensuse" + PKG_MANAGER="zypper" + ;; + *) + # 自动检测包管理器作为后备方案 + print_warning "未识别的操作系统: $OS,尝试自动检测包管理器" + if command -v apt-get &> /dev/null; then + PKG_MANAGER="apt" + print_info "检测到APT包管理器" + elif command -v dnf &> /dev/null; then + PKG_MANAGER="dnf" + print_info "检测到DNF包管理器" + elif command -v yum &> /dev/null; then + PKG_MANAGER="yum" + print_info "检测到YUM包管理器" + elif command -v zypper &> /dev/null; then + PKG_MANAGER="zypper" + print_info "检测到Zypper包管理器" + else + print_error "无法检测到支持的包管理器" + exit 1 + fi + ;; + esac +} + +# 检测系统架构 +detect_arch() { + ARCH=$(uname -m) + case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64) + ARCH="arm64" + ;; + *) + print_error "不支持的系统架构: $ARCH" + exit 1 + ;; + esac +} + +# 检测root权限 +check_root() { + if [[ $EUID -ne 0 ]]; then + print_error "此脚本需要root权限运行" + print_info "请使用: sudo bash install.sh" + exit 1 + fi +} + +################################################################################ +# 环境检测 +################################################################################ + +system_check() { + print_step "正在检测系统环境..." + + # 检测操作系统 + detect_os + print_success "操作系统: $OS $OS_VERSION" + + # 检测架构 + detect_arch + print_success "系统架构: $ARCH" + + # 检测内存 + TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') + if [[ $TOTAL_MEM -lt 512 ]]; then + print_warning "内存不足512MB,可能影响性能" + else + print_success "可用内存: ${TOTAL_MEM}MB" + fi + + # 检测磁盘空间 + DISK_AVAIL=$(df -m / | awk 'NR==2 {print $4}') + if [[ $DISK_AVAIL -lt 2048 ]]; then + print_warning "磁盘空间不足2GB,可能影响运行" + else + print_success "可用磁盘: ${DISK_AVAIL}MB" + fi + + # 检测网络 + if ping -c 1 git.workyai.cn &> /dev/null; then + print_success "网络连接正常" + else + print_error "无法连接到网络" + exit 1 + fi + + # 检测公网IP + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") + print_info "公网IP: $PUBLIC_IP" + + echo "" +} + +################################################################################ +# 软件源配置 +################################################################################ + +choose_mirror() { + print_step "选择软件包安装源" + echo "" + echo "请选择软件源:" + echo -e "${GREEN}[1]${NC} 官方源 (国外服务器推荐)" + echo -e "${GREEN}[2]${NC} 阿里云镜像源 (国内服务器推荐,速度更快)" + echo "" + + while true; do + read -p "请输入选项 [1-2]: " mirror_choice < /dev/tty + case $mirror_choice in + 1) + print_info "使用官方源" + USE_ALIYUN_MIRROR=false + break + ;; + 2) + print_info "使用阿里云镜像源" + USE_ALIYUN_MIRROR=true + configure_aliyun_mirror + break + ;; + *) + print_error "无效选项,请重新选择" + ;; + esac + done + echo "" +} + +configure_aliyun_mirror() { + print_step "配置阿里云镜像源..." + + case $OS in + ubuntu) + # 备份原有源 + if [[ ! -f /etc/apt/sources.list.bak ]]; then + cp /etc/apt/sources.list /etc/apt/sources.list.bak + fi + + # 配置Ubuntu阿里云源 + cat > /etc/apt/sources.list << EOF +deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-updates main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-backports main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-security main restricted universe multiverse +EOF + print_success "阿里云源配置完成" + ;; + debian) + # 备份原有源 + if [[ ! -f /etc/apt/sources.list.bak ]]; then + cp /etc/apt/sources.list /etc/apt/sources.list.bak + fi + + # 配置Debian阿里云源 + cat > /etc/apt/sources.list << EOF +deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs) main contrib non-free non-free-firmware +deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-updates main contrib non-free non-free-firmware +deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-backports main contrib non-free non-free-firmware +deb http://mirrors.aliyun.com/debian-security $(lsb_release -cs)-security main contrib non-free non-free-firmware +EOF + print_success "阿里云源配置完成" + ;; + centos) + # 备份并配置CentOS阿里云源 + if [[ -f /etc/yum.repos.d/CentOS-Base.repo ]]; then + if [[ ! -f /etc/yum.repos.d/CentOS-Base.repo.bak ]]; then + cp /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak + fi + fi + + CENTOS_VERSION="${OS_VERSION%%.*}" + if [[ "$CENTOS_VERSION" == "7" ]]; then + curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo + elif [[ "$CENTOS_VERSION" == "8" ]]; then + curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-vault-8.5.2111.repo + sed -i 's/mirrors.cloud.aliyuncs.com/mirrors.aliyun.com/g' /etc/yum.repos.d/CentOS-Base.repo + fi + + yum clean all + yum makecache + print_success "阿里云源配置完成" + ;; + rhel) + # RHEL使用EPEL和阿里云镜像 + print_info "配置RHEL阿里云镜像源..." + yum install -y epel-release + print_success "阿里云源配置完成" + ;; + rocky) + # 备份并配置Rocky Linux阿里云源 + if [[ -d /etc/yum.repos.d ]]; then + mkdir -p /etc/yum.repos.d/backup + cp /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/ 2>/dev/null || true + fi + + sed -e 's|^mirrorlist=|#mirrorlist=|g' \ + -e 's|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.aliyun.com/rockylinux|g' \ + -i.bak /etc/yum.repos.d/rocky*.repo + + dnf clean all + dnf makecache + print_success "阿里云源配置完成" + ;; + almalinux) + # 备份并配置AlmaLinux阿里云源 + if [[ -d /etc/yum.repos.d ]]; then + mkdir -p /etc/yum.repos.d/backup + cp /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/ 2>/dev/null || true + fi + + sed -e 's|^mirrorlist=|#mirrorlist=|g' \ + -e 's|^# baseurl=https://repo.almalinux.org|baseurl=https://mirrors.aliyun.com|g' \ + -i.bak /etc/yum.repos.d/almalinux*.repo + + dnf clean all + dnf makecache + print_success "阿里云源配置完成" + ;; + fedora) + # 备份并配置Fedora阿里云源 + if [[ -d /etc/yum.repos.d ]]; then + mkdir -p /etc/yum.repos.d/backup + cp /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/ 2>/dev/null || true + fi + + sed -e 's|^metalink=|#metalink=|g' \ + -e 's|^#baseurl=http://download.example/pub/fedora/linux|baseurl=https://mirrors.aliyun.com/fedora|g' \ + -i.bak /etc/yum.repos.d/fedora*.repo /etc/yum.repos.d/fedora-updates*.repo + + dnf clean all + dnf makecache + print_success "阿里云源配置完成" + ;; + opensuse) + # 配置openSUSE阿里云源 + print_info "配置openSUSE阿里云镜像源..." + zypper mr -da + zypper ar -fcg https://mirrors.aliyun.com/opensuse/distribution/leap/\$releasever/repo/oss/ aliyun-oss + zypper ar -fcg https://mirrors.aliyun.com/opensuse/distribution/leap/\$releasever/repo/non-oss/ aliyun-non-oss + zypper ar -fcg https://mirrors.aliyun.com/opensuse/update/leap/\$releasever/oss/ aliyun-update-oss + zypper ar -fcg https://mirrors.aliyun.com/opensuse/update/leap/\$releasever/non-oss/ aliyun-update-non-oss + zypper ref + print_success "阿里云源配置完成" + ;; + *) + print_warning "当前系统($OS)暂不支持阿里云镜像源自动配置,使用官方源" + ;; + esac +} +################################################################################ +check_cpp_compiler() { + print_step "检查C++编译器版本..." + + # 检查g++是否已安装 + if ! command -v g++ &> /dev/null; then + print_warning "g++未安装,将在依赖安装时自动安装" + return + fi + + # 获取g++版本号 + GXX_VERSION=$(g++ --version | head -n1 | grep -oP '\d+\.\d+\.\d+' | head -1 | cut -d'.' -f1) + + if [[ -z "$GXX_VERSION" ]]; then + print_warning "无法检测g++版本,跳过版本检查" + return + fi + + print_info "当前g++版本: $GXX_VERSION.x" + + # better-sqlite3 v11+ 需要C++20支持(g++ 10+) + if [[ $GXX_VERSION -lt 10 ]]; then + print_warning "g++版本过低(需要10+以支持C++20),正在升级..." + echo "" + + case $PKG_MANAGER in + apt) + # Ubuntu/Debian: 使用toolchain PPA + print_info "添加Ubuntu Toolchain PPA..." + add-apt-repository ppa:ubuntu-toolchain-r/test -y || { + print_error "添加PPA失败" + return 1 + } + + apt-get update + + print_info "安装g++-11..." + apt-get install -y g++-11 gcc-11 || { + print_error "g++-11安装失败" + return 1 + } + + # 设置为默认编译器 + update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100 + update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 + + # 验证 + NEW_VERSION=$(g++ --version | head -n1 | grep -oP '\d+\.\d+\.\d+' | head -1 | cut -d'.' -f1) + print_success "g++已升级到版本: $NEW_VERSION.x" + ;; + + yum|dnf) + # CentOS/RHEL: 使用devtoolset或gcc-toolset + if [[ "$OS" == "centos" ]] && [[ "$OS_VERSION" == "7" ]]; then + # CentOS 7 使用devtoolset-11 + print_info "安装devtoolset-11..." + yum install -y centos-release-scl + yum install -y devtoolset-11-gcc devtoolset-11-gcc-c++ + + # 启用devtoolset-11 + echo "source /opt/rh/devtoolset-11/enable" >> /etc/profile + source /opt/rh/devtoolset-11/enable + + print_success "devtoolset-11安装完成" + else + # CentOS 8+ 使用gcc-toolset-11 + print_info "安装gcc-toolset-11..." + $PKG_MANAGER install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ + + # 启用gcc-toolset-11 + echo "source /opt/rh/gcc-toolset-11/enable" >> /etc/profile + source /opt/rh/gcc-toolset-11/enable + + print_success "gcc-toolset-11安装完成" + fi + ;; + + zypper) + # OpenSUSE + print_info "升级g++..." + zypper install -y gcc11-c++ || { + print_error "g++升级失败" + return 1 + } + + # 设置为默认 + update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100 + update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 100 + + print_success "g++已升级" + ;; + + *) + print_warning "不支持的包管理器,无法自动升级g++" + print_warning "请手动升级g++到10或更高版本" + return 1 + ;; + esac + + echo "" + else + print_success "g++版本满足要求(10+)" + echo "" + fi +} + +# 安装依赖环境 +################################################################################ + +install_dependencies() { + print_step "正在安装依赖环境..." + + case $PKG_MANAGER in + apt) + apt-get update + apt-get install -y curl wget git unzip lsb-release build-essential python3 + install_nodejs_apt + install_nginx_apt + ;; + yum) + yum install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 + install_nodejs_yum + install_nginx_yum + ;; + dnf) + dnf install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 + install_nodejs_dnf + install_nginx_dnf + ;; + zypper) + zypper install -y curl wget git unzip lsb-release gcc-c++ make python3 + install_nodejs_zypper + install_nginx_zypper + ;; + *) + print_error "不支持的包管理器: $PKG_MANAGER" + exit 1 + ;; + esac + + install_pm2 + print_success "依赖环境安装完成" + + # 检查并升级C++编译器(如果需要) + check_cpp_compiler + echo "" +} + +install_nodejs_apt() { + if command -v node &> /dev/null; then + NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [[ $NODE_VER -ge $NODE_VERSION ]]; then + print_success "Node.js 已安装: $(node -v)" + return + fi + fi + + print_info "正在安装 Node.js ${NODE_VERSION}.x..." + curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - + apt-get install -y nodejs + print_success "Node.js 安装完成: $(node -v)" +} + +install_nodejs_yum() { + if command -v node &> /dev/null; then + NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [[ $NODE_VER -ge $NODE_VERSION ]]; then + print_success "Node.js 已安装: $(node -v)" + return + fi + fi + + print_info "正在安装 Node.js ${NODE_VERSION}.x..." + curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - + yum install -y nodejs + print_success "Node.js 安装完成: $(node -v)" +} + +install_nginx_apt() { + if command -v nginx &> /dev/null; then + print_success "Nginx 已安装: $(nginx -v 2>&1 | cut -d'/' -f2)" + return + fi + + print_info "正在安装 Nginx..." + apt-get install -y nginx + systemctl enable nginx + print_success "Nginx 安装完成" +} + +install_nginx_yum() { + if command -v nginx &> /dev/null; then + print_success "Nginx 已安装: $(nginx -v 2>&1 | cut -d'/' -f2)" + return + fi + + print_info "正在安装 Nginx..." + yum install -y nginx + systemctl enable nginx + print_success "Nginx 安装完成" +} + +install_nodejs_dnf() { + if command -v node &> /dev/null; then + NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [[ $NODE_VER -ge $NODE_VERSION ]]; then + print_success "Node.js 已安装: $(node -v)" + return + fi + fi + + print_info "正在安装 Node.js ${NODE_VERSION}.x..." + curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - + dnf install -y nodejs + print_success "Node.js 安装完成: $(node -v)" +} + +install_nginx_dnf() { + if command -v nginx &> /dev/null; then + print_success "Nginx 已安装: $(nginx -v 2>&1 | cut -d'/' -f2)" + return + fi + + print_info "正在安装 Nginx..." + dnf install -y nginx + systemctl enable nginx + print_success "Nginx 安装完成" +} + +install_nodejs_zypper() { + if command -v node &> /dev/null; then + NODE_VER=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [[ $NODE_VER -ge $NODE_VERSION ]]; then + print_success "Node.js 已安装: $(node -v)" + return + fi + fi + + print_info "正在安装 Node.js ${NODE_VERSION}.x..." + # openSUSE使用官方仓库的Node.js + zypper install -y nodejs${NODE_VERSION} + print_success "Node.js 安装完成: $(node -v)" +} + +install_nginx_zypper() { + if command -v nginx &> /dev/null; then + print_success "Nginx 已安装: $(nginx -v 2>&1 | cut -d'/' -f2)" + return + fi + + print_info "正在安装 Nginx..." + zypper install -y nginx + systemctl enable nginx + print_success "Nginx 安装完成" +} + +install_pm2() { + if command -v pm2 &> /dev/null; then + print_success "PM2 已安装: $(pm2 -v)" + return + fi + + print_info "正在安装 PM2..." + npm install -g pm2 + pm2 startup + print_success "PM2 安装完成" +} + +################################################################################ +# 智能端口检测和配置 +################################################################################ + +# 检查端口是否可用(保留用于兼容性) +check_port_available() { + local port=$1 + if command -v netstat &> /dev/null; then + if netstat -tuln | grep -q ":${port} "; then + return 1 # 端口被占用 + fi + elif command -v ss &> /dev/null; then + if ss -tuln | grep -q ":${port} "; then + return 1 # 端口被占用 + fi + fi + return 0 # 端口可用 +} + +# 智能检测端口状态和占用进程 +check_port_status() { + local port=$1 + + # 1. 检查端口是否被监听 + if command -v netstat &> /dev/null; then + if ! netstat -tuln 2>/dev/null | grep -q ":${port} "; then + echo "available" + return 0 + fi + elif command -v ss &> /dev/null; then + if ! ss -tuln 2>/dev/null | grep -q ":${port} "; then + echo "available" + return 0 + fi + else + echo "available" + return 0 + fi + + # 2. 端口被占用,检查是什么进程 + local process="" + + if command -v netstat &> /dev/null; then + process=$(netstat -tulnp 2>/dev/null | grep ":${port} " | awk '{print $7}' | cut -d'/' -f2 | head -1) + fi + + if [[ -z "$process" ]] && command -v ss &> /dev/null; then + # 使用sed替代grep -oP以提高兼容性 + process=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | head -1) + fi + + # 3. 根据进程返回状态(始终返回0以避免set -e导致脚本退出) + if [[ -z "$process" ]]; then + # 无法获取进程名(可能权限不足) + echo "occupied" + elif [[ "$process" == "nginx" ]] || [[ "$process" =~ ^nginx: ]]; then + # Nginx占用 + echo "nginx" + elif [[ "$process" == "apache2" ]] || [[ "$process" == "httpd" ]] || [[ "$process" =~ apache ]]; then + # Apache占用 + echo "apache" + else + # 其他进程 + echo "other:$process" + fi + + # 始终返回0,避免set -e导致脚本退出 + return 0 +} + +# 改进的端口配置函数 +configure_ports() { + print_step "智能端口配置" + echo "" + + # 全局标志:是否共用Nginx端口 + SHARE_NGINX=false + + # ========== 检测80端口 ========== + port_80_status=$(check_port_status 80) + + case $port_80_status in + "available") + print_success "80 端口可用" + HTTP_PORT=80 + ;; + + "nginx") + print_info "检测到 Nginx 已占用 80 端口" + echo "" + echo "🎯 好消息:可以通过虚拟主机配置与现有Nginx共用此端口!" + echo "" + echo "请选择部署方式:" + echo "" + echo -e "${GREEN}[1]${NC} 共用80端口(推荐)" + echo " ✅ 需要配置不同的域名" + echo " ✅ 访问: http://your-domain.com" + echo " ✅ 不需要端口号" + echo "" + echo -e "${GREEN}[2]${NC} 使用其他HTTP端口" + echo " ℹ️ 独立端口" + echo " ℹ️ 访问: http://your-domain.com:8080" + echo "" + + while true; do + read -p "请选择 [1-2]: " choice < /dev/tty + + if [[ "$choice" == "1" ]]; then + HTTP_PORT=80 + SHARE_NGINX=true + print_success "将与现有Nginx共用80端口(虚拟主机模式)" + print_info "提示: 请确保使用不同的域名区分站点" + break + elif [[ "$choice" == "2" ]]; then + # 选择其他端口的逻辑 + while true; do + read -p "请输入HTTP端口 [建议: 8080]: " custom_port < /dev/tty + custom_port=${custom_port:-8080} + + if [[ ! "$custom_port" =~ ^[0-9]+$ ]] || [[ $custom_port -lt 1024 ]] || [[ $custom_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_port; then + print_error "端口 $custom_port 已被占用,请选择其他端口" + continue + fi + + HTTP_PORT=$custom_port + print_success "将使用 HTTP 端口: $HTTP_PORT" + break + done + break + else + print_error "无效选项,请重新选择" + fi + done + ;; + + "apache") + print_warning "检测到 Apache 已占用 80 端口" + echo "" + echo "⚠️ Apache和Nginx不能同时监听同一端口" + echo "" + echo "请选择解决方案:" + echo "" + echo -e "${GREEN}[1]${NC} 停止Apache,改用Nginx" + echo " ⚠️ 需要迁移Apache配置" + echo "" + echo -e "${GREEN}[2]${NC} 使用其他HTTP端口(推荐)" + echo " ✅ 不影响现有Apache服务" + echo "" + + while true; do + read -p "请选择 [1-2]: " choice < /dev/tty + + if [[ "$choice" == "1" ]]; then + print_info "正在停止Apache..." + systemctl stop apache2 2>/dev/null || systemctl stop httpd 2>/dev/null || true + systemctl disable apache2 2>/dev/null || systemctl disable httpd 2>/dev/null || true + HTTP_PORT=80 + print_success "Apache已停止,将使用80端口" + break + elif [[ "$choice" == "2" ]]; then + # 选择其他端口 + while true; do + read -p "请输入HTTP端口 [建议: 8080]: " custom_port < /dev/tty + custom_port=${custom_port:-8080} + + if [[ ! "$custom_port" =~ ^[0-9]+$ ]] || [[ $custom_port -lt 1024 ]] || [[ $custom_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_port; then + print_error "端口 $custom_port 已被占用,请选择其他端口" + continue + fi + + HTTP_PORT=$custom_port + print_success "将使用 HTTP 端口: $HTTP_PORT" + break + done + break + else + print_error "无效选项,请重新选择" + fi + done + ;; + + "occupied"|other:*) + process=${port_80_status#other:} + if [[ "$port_80_status" == "occupied" ]]; then + print_warning "80 端口已被占用(无法识别进程)" + else + print_warning "80 端口被进程 ${process} 占用" + fi + echo "" + echo "请选择其他HTTP端口" + + while true; do + read -p "请输入HTTP端口 [建议: 8080]: " custom_port < /dev/tty + custom_port=${custom_port:-8080} + + if [[ ! "$custom_port" =~ ^[0-9]+$ ]] || [[ $custom_port -lt 1024 ]] || [[ $custom_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_port; then + print_error "端口 $custom_port 已被占用,请选择其他端口" + continue + fi + + HTTP_PORT=$custom_port + print_success "将使用 HTTP 端口: $HTTP_PORT" + break + done + ;; + esac + + echo "" + + # ========== 检测443端口(仅在使用HTTPS时需要)========== + if [[ "$USE_DOMAIN" == "true" ]] && [[ "$SSL_METHOD" != "8" ]]; then + port_443_status=$(check_port_status 443) + + case $port_443_status in + "available") + print_success "443 端口可用" + HTTPS_PORT=443 + ;; + + "nginx") + print_info "检测到 Nginx 已占用 443 端口" + echo "" + + if [[ "$SHARE_NGINX" == "true" ]]; then + # 如果HTTP端口也是共用的,默认继续共用 + echo "🎯 将继续与现有Nginx共用443端口(虚拟主机模式)" + HTTPS_PORT=443 + print_success "将与现有Nginx共用443端口" + else + echo "请选择部署方式:" + echo "" + echo -e "${GREEN}[1]${NC} 共用443端口" + echo " ✅ 需要配置不同的域名" + echo "" + echo -e "${GREEN}[2]${NC} 使用其他HTTPS端口" + echo " ℹ️ 独立端口(如8443)" + echo "" + + while true; do + read -p "请选择 [1-2]: " choice < /dev/tty + + if [[ "$choice" == "1" ]]; then + HTTPS_PORT=443 + SHARE_NGINX=true + print_success "将与现有Nginx共用443端口" + break + elif [[ "$choice" == "2" ]]; then + # 选择其他端口 + while true; do + read -p "请输入HTTPS端口 [建议: 8443]: " custom_https_port < /dev/tty + custom_https_port=${custom_https_port:-8443} + + if [[ ! "$custom_https_port" =~ ^[0-9]+$ ]] || [[ $custom_https_port -lt 1024 ]] || [[ $custom_https_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_https_port; then + print_error "端口 $custom_https_port 已被占用,请选择其他端口" + continue + fi + + HTTPS_PORT=$custom_https_port + print_success "将使用 HTTPS 端口: $HTTPS_PORT" + break + done + break + else + print_error "无效选项,请重新选择" + fi + done + fi + ;; + + "apache"|"occupied"|other:*) + # Apache或其他进程占用443,需要换端口 + if [[ "$port_443_status" == "apache" ]]; then + print_warning "检测到 Apache 已占用 443 端口" + elif [[ "$port_443_status" == "occupied" ]]; then + print_warning "443 端口已被占用" + else + process=${port_443_status#other:} + print_warning "443 端口被进程 ${process} 占用" + fi + echo "" + + while true; do + read -p "请输入HTTPS端口 [建议: 8443]: " custom_https_port < /dev/tty + custom_https_port=${custom_https_port:-8443} + + if [[ ! "$custom_https_port" =~ ^[0-9]+$ ]] || [[ $custom_https_port -lt 1024 ]] || [[ $custom_https_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_https_port; then + print_error "端口 $custom_https_port 已被占用,请选择其他端口" + continue + fi + + HTTPS_PORT=$custom_https_port + print_success "将使用 HTTPS 端口: $HTTPS_PORT" + break + done + ;; + esac + + echo "" + fi + + # ========== 检测后端端口 ========== + if ! check_port_available 40001; then + print_warning "检测到 40001 端口已被占用" + echo "" + + while true; do + read -p "请输入后端服务端口 [建议: 40002]: " custom_backend_port < /dev/tty + custom_backend_port=${custom_backend_port:-40002} + + if [[ ! "$custom_backend_port" =~ ^[0-9]+$ ]] || [[ $custom_backend_port -lt 1024 ]] || [[ $custom_backend_port -gt 65535 ]]; then + print_error "端口范围: 1024-65535" + continue + fi + + if ! check_port_available $custom_backend_port; then + print_error "端口 $custom_backend_port 已被占用,请选择其他端口" + continue + fi + + BACKEND_PORT=$custom_backend_port + print_success "将使用后端端口: $BACKEND_PORT" + break + done + else + print_success "40001 端口可用" + fi + + echo "" + print_info "端口配置摘要:" + echo " - HTTP端口: $HTTP_PORT" + if [[ "$USE_DOMAIN" == "true" ]] && [[ "$SSL_METHOD" != "8" ]]; then + echo " - HTTPS端口: $HTTPS_PORT" + fi + echo " - 后端端口: $BACKEND_PORT" + if [[ "$SHARE_NGINX" == "true" ]]; then + echo " - 模式: 虚拟主机共用端口 ✅" + fi + echo "" +} + +################################################################################ +# 访问模式选择 +################################################################################ + +choose_access_mode() { + print_step "选择访问模式" + echo "" + echo "请选择访问模式:" + echo -e "${GREEN}[1]${NC} 域名模式 (推荐,支持HTTPS)" + echo -e "${GREEN}[2]${NC} IP模式 (仅HTTP,适合测试)" + echo "" + + while true; do + read -p "请输入选项 [1-2]: " mode_choice < /dev/tty + case $mode_choice in + 1) + USE_DOMAIN=true + configure_domain + break + ;; + 2) + USE_DOMAIN=false + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") + print_info "将使用 IP 模式访问: http://${PUBLIC_IP}" + echo "" + break + ;; + *) + print_error "无效选项,请重新选择" + ;; + esac + done +} + +configure_domain() { + echo "" + while true; do + read -p "请输入您的域名 (例如: wwy.example.com): " DOMAIN < /dev/tty + if [[ -z "$DOMAIN" ]]; then + print_error "域名不能为空" + continue + fi + + # 验证域名格式 + if [[ ! "$DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]; then + print_error "域名格式不正确" + continue + fi + + # 验证域名解析 + print_info "正在验证域名解析..." + DOMAIN_IP=$(dig +short "$DOMAIN" | tail -n1) + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com) + + if [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then + print_success "域名已正确解析到当前服务器IP" + break + else + print_warning "域名未解析到当前服务器IP" + print_info "域名解析IP: $DOMAIN_IP" + print_info "当前服务器IP: $PUBLIC_IP" + read -p "是否继续? (y/n): " continue_choice < /dev/tty + if [[ "$continue_choice" == "y" || "$continue_choice" == "Y" ]]; then + break + fi + fi + done + + choose_ssl_method +} + +################################################################################ +# SSL证书配置 +################################################################################ + +# 配置acme.sh自动续期 +setup_acme_auto_renew() { + echo "" + print_step "配置SSL证书自动续期..." + + # acme.sh安装时会自动创建cron任务,这里验证并确保其正常工作 + + # 1. 检查cron服务是否运行 + if systemctl is-active --quiet cron 2>/dev/null || systemctl is-active --quiet crond 2>/dev/null; then + print_success "Cron服务运行正常" + else + print_warning "Cron服务未运行,正在启动..." + systemctl start cron 2>/dev/null || systemctl start crond 2>/dev/null || true + systemctl enable cron 2>/dev/null || systemctl enable crond 2>/dev/null || true + fi + + # 2. 检查acme.sh cron任务 + if crontab -l 2>/dev/null | grep -q "acme.sh.*--cron"; then + print_success "acme.sh自动续期任务已配置" + else + print_warning "未检测到acme.sh cron任务,正在添加..." + # acme.sh会自动安装cron,这里手动触发一次 + ~/.acme.sh/acme.sh --install-cronjob 2>/dev/null || true + fi + + # 3. 显示续期信息 + echo "" + print_info "SSL证书自动续期已配置:" + echo " - 检查频率: 每天自动检查" + echo " - 续期时机: 证书到期前30天自动续期" + echo " - 续期后操作: 自动重载Nginx" + echo "" + + # 显示下次续期时间 + if [[ -f ~/.acme.sh/${DOMAIN}/${DOMAIN}.conf ]]; then + NEXT_RENEW=$(grep "Le_NextRenewTime=" ~/.acme.sh/${DOMAIN}/${DOMAIN}.conf 2>/dev/null | cut -d'=' -f2) + if [[ -n "$NEXT_RENEW" ]]; then + RENEW_DATE=$(date -d "@${NEXT_RENEW}" "+%Y年%m月%d日 %H:%M:%S" 2>/dev/null || date -r ${NEXT_RENEW} "+%Y年%m月%d日 %H:%M:%S" 2>/dev/null || echo "未知") + print_info "预计续期时间: ${RENEW_DATE}" + fi + fi + + # 4. 测试续期命令(不实际续期,只检查) + print_info "验证续期配置..." + if ~/.acme.sh/acme.sh --list 2>/dev/null | grep -q "$DOMAIN"; then + print_success "证书续期配置验证通过" + else + print_warning "证书列表中未找到域名,续期可能需要手动配置" + fi + + echo "" +} + +choose_ssl_method() { + echo "" + print_step "选择SSL证书部署方式" + echo "" + echo -e "${YELLOW}【推荐方案】${NC}" + echo -e "${GREEN}[1]${NC} acme.sh + Let's Encrypt" + echo " - 纯Shell脚本,轻量级稳定" + echo " - 自动续期,无需手动操作" + echo "" + echo -e "${YELLOW}【备选方案】${NC}" + echo -e "${GREEN}[2]${NC} acme.sh + ZeroSSL" + echo " - Let's Encrypt的免费替代品" + echo -e "${GREEN}[3]${NC} acme.sh + Buypass" + echo " - 挪威免费CA,有效期180天" + echo "" + echo -e "${YELLOW}【云服务商证书】${NC}" + echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)" + echo -e "${GREEN}[5]${NC} 腾讯云免费证书 (需提供SecretKey)" + echo "" + echo -e "${YELLOW}【其他选项】${NC}" + echo -e "${GREEN}[6]${NC} 使用已有证书 (手动上传)" + echo -e "${GREEN}[7]${NC} 暂不配置HTTPS (可后续配置)" + echo "" + + while true; do + read -p "请输入选项 [1-7]: " ssl_choice < /dev/tty + case $ssl_choice in + 1) + SSL_METHOD="2" # acme.sh + Let's Encrypt + break + ;; + 2) + SSL_METHOD="3" # acme.sh + ZeroSSL + break + ;; + 3) + SSL_METHOD="5" # acme.sh + Buypass + break + ;; + 4) + SSL_METHOD="4" # 阿里云 + break + ;; + 5) + SSL_METHOD="6" # 腾讯云 + break + ;; + 6) + SSL_METHOD="7" # 手动上传 + break + ;; + 7) + SSL_METHOD="8" # 不配置HTTPS + break + ;; + *) + print_error "无效选项,请重新选择" + ;; + esac + done + echo "" +} + +deploy_ssl() { + if [[ "$USE_DOMAIN" != "true" ]]; then + return 0 + fi + + case $SSL_METHOD in + 2) + deploy_acme_letsencrypt || ssl_fallback "2" + ;; + 3) + deploy_acme_zerossl || ssl_fallback "3" + ;; + 4) + deploy_aliyun_ssl || ssl_fallback "4" + ;; + 5) + deploy_acme_buypass || ssl_fallback "5" + ;; + 6) + deploy_tencent_ssl || ssl_fallback "6" + ;; + 7) + deploy_manual_ssl + ;; + 8) + print_info "跳过HTTPS配置" + return 0 + ;; + esac +} + +ssl_fallback() { + local failed_method=$1 # 接收失败的方案编号 + + print_error "SSL证书部署失败" + echo "" + print_warning "建议尝试备选方案:" + echo "" + + # 动态显示可用选项(排除已失败的) + local available_options=() + + # 方案2: acme.sh + Let's Encrypt + if [[ "$failed_method" != "2" ]]; then + echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt" + available_options+=("2") + fi + + # 方案3: acme.sh + ZeroSSL + if [[ "$failed_method" != "3" ]]; then + echo -e "${GREEN}[3]${NC} acme.sh + ZeroSSL" + available_options+=("3") + fi + + # 方案5: acme.sh + Buypass + if [[ "$failed_method" != "5" ]]; then + echo -e "${GREEN}[5]${NC} acme.sh + Buypass" + available_options+=("5") + fi + + # 方案8: 不配置HTTPS + echo -e "${GREEN}[8]${NC} 暂不配置HTTPS" + available_options+=("8") + + echo "" + echo -e "${YELLOW}提示: 方案 $failed_method 已失败,已从列表中移除${NC}" + echo "" + + while true; do + read -p "请选择备选方案: " retry_choice < /dev/tty + + # 检查输入是否在可用选项中 + if [[ ! " ${available_options[@]} " =~ " ${retry_choice} " ]]; then + print_error "无效选项或该方案已失败" + continue + fi + + case $retry_choice in + 2) + deploy_acme_letsencrypt && return 0 + # 如果再次失败,继续调用fallback但排除方案2 + ssl_fallback "2" + return $? + ;; + 3) + deploy_acme_zerossl && return 0 + ssl_fallback "3" + return $? + ;; + 5) + deploy_acme_buypass && return 0 + ssl_fallback "5" + return $? + ;; + 8) + print_info "跳过HTTPS配置" + SSL_METHOD=8 + return 0 + ;; + esac + done +} + +deploy_certbot() { + print_step "使用 Certbot 部署SSL证书..." + + # 检查certbot是否已安装 + if ! command -v certbot &> /dev/null; then + print_info "正在安装 Certbot..." + + # 安装certbot + case $PKG_MANAGER in + apt) + # Ubuntu/Debian: 优先使用snap(官方推荐,避免Python依赖冲突) + if command -v snap &> /dev/null; then + print_info "使用snap安装Certbot(官方推荐方式)..." + snap install --classic certbot 2>/dev/null || true + ln -sf /snap/bin/certbot /usr/bin/certbot 2>/dev/null || true + + # 验证snap安装是否成功 + if /snap/bin/certbot --version &> /dev/null; then + print_success "Certbot (snap版) 安装成功" + else + print_warning "snap安装失败,尝试apt安装..." + # 修复urllib3依赖问题 + apt-get remove -y python3-urllib3 2>/dev/null || true + apt-get install -y certbot python3-certbot-nginx + fi + else + print_info "snap不可用,使用apt安装..." + # 修复urllib3依赖问题 + apt-get remove -y python3-urllib3 2>/dev/null || true + apt-get install -y certbot python3-certbot-nginx + fi + ;; + yum) + # 修复urllib3依赖问题 + yum remove -y python3-urllib3 2>/dev/null || true + yum install -y certbot python3-certbot-nginx + ;; + dnf) + # 修复urllib3依赖问题 + dnf remove -y python3-urllib3 2>/dev/null || true + dnf install -y certbot python3-certbot-nginx + ;; + zypper) + zypper install -y certbot python3-certbot-nginx + ;; + esac + + # 最终验证certbot是否可用 + if ! command -v certbot &> /dev/null; then + print_error "Certbot安装失败" + return 1 + fi + else + print_success "Certbot 已安装: $(certbot --version 2>&1 | head -1)" + fi + + # 修复已安装certbot的urllib3依赖冲突 + if ! certbot --version &> /dev/null; then + print_warning "检测到Certbot依赖问题,正在修复..." + case $PKG_MANAGER in + apt) + apt-get remove -y python3-urllib3 2>/dev/null || true + apt-get install --reinstall -y certbot python3-certbot-nginx + ;; + yum|dnf) + $PKG_MANAGER remove -y python3-urllib3 2>/dev/null || true + $PKG_MANAGER reinstall -y certbot python3-certbot-nginx + ;; + esac + + # 再次验证 + if ! certbot --version &> /dev/null; then + print_error "Certbot依赖修复失败,建议尝试其他SSL方案" + return 1 + fi + print_success "Certbot依赖已修复" + fi + + # 申请证书(使用webroot模式,不自动修改Nginx配置) + echo "" + print_info "正在申请 Let's Encrypt 证书..." + + if certbot certonly --webroot -w "${PROJECT_DIR}/frontend" -d "$DOMAIN" --non-interactive --agree-tos --email "admin@${DOMAIN}"; then + # 将证书复制到Nginx SSL目录 + mkdir -p /etc/nginx/ssl + ln -sf "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "/etc/nginx/ssl/${DOMAIN}.crt" + ln -sf "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "/etc/nginx/ssl/${DOMAIN}.key" + + # 配置自动续期 + systemctl enable certbot.timer 2>/dev/null || true + + print_success "Certbot SSL证书申请成功" + return 0 + else + # 检查证书是否已存在 + if [[ -d "/etc/letsencrypt/live/${DOMAIN}" ]]; then + print_warning "检测到证书已存在,使用已有证书" + mkdir -p /etc/nginx/ssl + ln -sf "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "/etc/nginx/ssl/${DOMAIN}.crt" + ln -sf "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "/etc/nginx/ssl/${DOMAIN}.key" + print_success "已有证书已链接到Nginx目录" + return 0 + else + print_error "Certbot SSL证书申请失败" + echo "" + print_warning "常见失败原因:" + echo " 1. 域名未正确解析到此服务器" + echo " 2. 防火墙阻止了80端口" + echo " 3. Nginx未正确配置或未启动" + echo " 4. Let's Encrypt速率限制" + echo "" + return 1 + fi + fi +} + +deploy_acme_letsencrypt() { + print_step "使用 acme.sh + Let's Encrypt 部署SSL证书..." + + # 安装acme.sh + if [[ ! -d ~/.acme.sh ]] || [[ ! -f ~/.acme.sh/acme.sh ]]; then + echo "" + print_info "正在安装 acme.sh..." + + # 如果目录存在但文件不存在,先清理 + if [[ -d ~/.acme.sh ]] && [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_warning "检测到不完整的安装,正在清理..." + rm -rf ~/.acme.sh + fi + + print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" + + # 使用官方安装方法:直接通过curl管道执行 + print_info "正在下载并安装..." + + if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + install_result=$? + print_info "安装脚本执行完成,退出码: $install_result" + else + install_result=$? + print_error "安装脚本执行失败,退出码: $install_result" + fi + + # 重新加载环境变量 + source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true + + # 等待文件系统同步 + print_info "等待安装完成..." + sleep 3 + + # 验证安装是否真正成功 + if [[ -d ~/.acme.sh ]] && [[ -f ~/.acme.sh/acme.sh ]]; then + print_success "acme.sh 安装成功" + else + print_error "acme.sh 安装失败" + echo "" + print_warning "诊断信息:" + echo " - 安装命令退出码: $install_result" + echo " - 目录 ~/.acme.sh 存在: $([ -d ~/.acme.sh ] && echo '是' || echo '否')" + echo " - 文件 ~/.acme.sh/acme.sh 存在: $([ -f ~/.acme.sh/acme.sh ] && echo '是' || echo '否')" + echo " - HOME变量: $HOME" + echo " - 当前用户: $(whoami)" + echo "" + + if [[ -d ~/.acme.sh ]]; then + print_info "~/.acme.sh 目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" + echo "" + fi + + print_info "尝试查找acme.sh安装位置..." + find /root -name "acme.sh" -type f 2>/dev/null | head -5 || echo " 未找到" + echo "" + print_warning "可能的原因:" + echo " 1. 网络连接问题或下载超时" + echo " 2. GitHub访问受限(国内网络)" + echo " 3. curl版本过低或不支持某些功能" + echo "" + print_warning "建议尝试其他SSL方案:" + echo " 1. 返回选择 Certbot (推荐)" + echo " 2. 或选择 [8] 暂不配置HTTPS" + echo "" + return 1 + fi + fi + + # 确认acme.sh可用 + echo "" + print_info "验证 acme.sh 安装..." + + # 等待文件系统同步 + sleep 2 + + # 检查安装目录 + if [[ ! -d ~/.acme.sh ]]; then + print_error "安装目录不存在: ~/.acme.sh" + return 1 + fi + + # 检查主脚本文件 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "主脚本文件不存在: ~/.acme.sh/acme.sh" + print_info "目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -10 || echo "无法列出目录" + return 1 + fi + + # 检查脚本是否可执行 + if [[ ! -x ~/.acme.sh/acme.sh ]]; then + print_warning "脚本不可执行,正在添加执行权限..." + chmod +x ~/.acme.sh/acme.sh + fi + + # 测试脚本是否能运行 + if ! ~/.acme.sh/acme.sh --version &> /dev/null; then + print_error "acme.sh 无法运行" + return 1 + fi + + print_success "acme.sh 验证通过" + + # 申请证书 + echo "" + print_info "正在申请 Let's Encrypt 证书..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + # 使用webroot模式申请证书(更可靠) + # 先尝试正常申请,如果证书已存在则使用--force强制更新 + if ~/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then + print_success "证书申请成功" + else + # 检查是否是因为证书已存在 + if ~/.acme.sh/acme.sh --list | grep -q "$DOMAIN"; then + print_warning "检测到证书已存在,使用已有证书" + print_success "将直接安装现有证书" + else + print_error "证书申请失败" + echo "" + print_warning "常见失败原因:" + echo " 1. 域名未正确解析到此服务器" + echo " 2. Nginx未正确配置或未启动" + echo " 3. 80端口被占用或防火墙阻止" + echo " 4. 前端目录权限不足" + echo "" + return 1 + fi + fi + + # 安装证书 + echo "" + print_info "正在安装证书到Nginx..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + mkdir -p /etc/nginx/ssl + + # 确保nginx服务已启动(证书安装时需要reload) + if ! systemctl is-active --quiet nginx 2>/dev/null && ! pgrep -x nginx > /dev/null 2>&1; then + print_warning "Nginx未运行,正在启动..." + systemctl start nginx 2>/dev/null || /www/server/nginx/sbin/nginx 2>/dev/null || true + sleep 2 + fi + + # 先不带reload命令安装证书(避免nginx未启动导致失败) + if ~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \ + --key-file /etc/nginx/ssl/${DOMAIN}.key \ + --fullchain-file /etc/nginx/ssl/${DOMAIN}.crt; then + print_success "证书文件已安装到: /etc/nginx/ssl/" + + # 手动reload nginx + if systemctl is-active --quiet nginx 2>/dev/null; then + systemctl reload nginx && print_success "Nginx配置已重载" + elif pgrep -x nginx > /dev/null; then + nginx -s reload && print_success "Nginx配置已重载" + else + print_warning "Nginx未运行,将在后续步骤启动" + fi + + # 配置自动续期 + setup_acme_auto_renew + + return 0 + else + print_error "证书安装失败" + return 1 + fi +} + +deploy_acme_zerossl() { + print_step "使用 acme.sh + ZeroSSL 部署SSL证书..." + + # 安装acme.sh(使用改进的安装逻辑) + if [[ ! -d ~/.acme.sh ]] || [[ ! -f ~/.acme.sh/acme.sh ]]; then + echo "" + print_info "正在安装 acme.sh..." + + # 如果目录存在但文件不存在,先清理 + if [[ -d ~/.acme.sh ]] && [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_warning "检测到不完整的安装,正在清理..." + rm -rf ~/.acme.sh + fi + + print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" + + # 使用官方安装方法:直接通过curl管道执行 + print_info "正在下载并安装..." + + if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + install_result=$? + print_info "安装脚本执行完成,退出码: $install_result" + else + install_result=$? + print_error "安装脚本执行失败,退出码: $install_result" + fi + + # 重新加载环境变量 + source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true + + # 等待文件系统同步 + print_info "等待安装完成..." + sleep 3 + + # 验证安装 + if [[ -d ~/.acme.sh ]] && [[ -f ~/.acme.sh/acme.sh ]]; then + print_success "acme.sh 安装成功" + else + print_error "acme.sh 安装失败" + echo "" + print_warning "诊断信息:" + echo " - 安装命令退出码: $install_result" + echo " - 目录 ~/.acme.sh 存在: $([ -d ~/.acme.sh ] && echo '是' || echo '否')" + echo " - 文件 ~/.acme.sh/acme.sh 存在: $([ -f ~/.acme.sh/acme.sh ] && echo '是' || echo '否')" + echo "" + + if [[ -d ~/.acme.sh ]]; then + print_info "~/.acme.sh 目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" + echo "" + fi + + return 1 + fi + fi + + # 确认acme.sh可用 + echo "" + print_info "验证 acme.sh 安装..." + + # 等待文件系统同步 + sleep 2 + + # 检查安装目录 + if [[ ! -d ~/.acme.sh ]]; then + print_error "安装目录不存在: ~/.acme.sh" + return 1 + fi + + # 检查主脚本文件 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "主脚本文件不存在: ~/.acme.sh/acme.sh" + print_info "目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -10 || echo "无法列出目录" + return 1 + fi + + # 检查脚本是否可执行 + if [[ ! -x ~/.acme.sh/acme.sh ]]; then + print_warning "脚本不可执行,正在添加执行权限..." + chmod +x ~/.acme.sh/acme.sh + fi + + # 测试脚本是否能运行 + if ! ~/.acme.sh/acme.sh --version &> /dev/null; then + print_error "acme.sh 无法运行" + return 1 + fi + + print_success "acme.sh 验证通过" + + # 申请证书 + echo "" + print_info "正在申请 ZeroSSL 证书..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + # 使用webroot模式申请证书(更可靠) + if ~/.acme.sh/acme.sh --server zerossl --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then + print_success "证书申请成功" + else + # 检查是否是因为证书已存在 + if ~/.acme.sh/acme.sh --list | grep -q "$DOMAIN"; then + print_warning "检测到证书已存在,使用已有证书" + print_success "将直接安装现有证书" + else + print_error "证书申请失败" + return 1 + fi + fi + + # 安装证书 + echo "" + print_info "正在安装证书到Nginx..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + mkdir -p /etc/nginx/ssl + + # 确保nginx服务已启动(证书安装时需要reload) + if ! systemctl is-active --quiet nginx 2>/dev/null && ! pgrep -x nginx > /dev/null 2>&1; then + print_warning "Nginx未运行,正在启动..." + systemctl start nginx 2>/dev/null || /www/server/nginx/sbin/nginx 2>/dev/null || true + sleep 2 + fi + + # 先不带reload命令安装证书(避免nginx未启动导致失败) + if ~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \ + --key-file /etc/nginx/ssl/${DOMAIN}.key \ + --fullchain-file /etc/nginx/ssl/${DOMAIN}.crt; then + print_success "证书文件已安装到: /etc/nginx/ssl/" + + # 手动reload nginx + if systemctl is-active --quiet nginx 2>/dev/null; then + systemctl reload nginx && print_success "Nginx配置已重载" + elif pgrep -x nginx > /dev/null; then + nginx -s reload && print_success "Nginx配置已重载" + else + print_warning "Nginx未运行,将在后续步骤启动" + fi + + # 配置自动续期 + setup_acme_auto_renew + + return 0 + else + print_error "证书安装失败" + return 1 + fi +} + +deploy_acme_buypass() { + print_step "使用 acme.sh + Buypass 部署SSL证书..." + + # 安装acme.sh(使用改进的安装逻辑) + if [[ ! -d ~/.acme.sh ]] || [[ ! -f ~/.acme.sh/acme.sh ]]; then + echo "" + print_info "正在安装 acme.sh..." + + # 如果目录存在但文件不存在,先清理 + if [[ -d ~/.acme.sh ]] && [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_warning "检测到不完整的安装,正在清理..." + rm -rf ~/.acme.sh + fi + + print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" + + # 使用官方安装方法:直接通过curl管道执行 + print_info "正在下载并安装..." + + if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + install_result=$? + print_info "安装脚本执行完成,退出码: $install_result" + else + install_result=$? + print_error "安装脚本执行失败,退出码: $install_result" + fi + + # 重新加载环境变量 + source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true + + # 等待文件系统同步 + print_info "等待安装完成..." + sleep 3 + + # 验证安装 + if [[ -d ~/.acme.sh ]] && [[ -f ~/.acme.sh/acme.sh ]]; then + print_success "acme.sh 安装成功" + else + print_error "acme.sh 安装失败" + echo "" + print_warning "诊断信息:" + echo " - 安装命令退出码: $install_result" + echo " - 目录 ~/.acme.sh 存在: $([ -d ~/.acme.sh ] && echo '是' || echo '否')" + echo " - 文件 ~/.acme.sh/acme.sh 存在: $([ -f ~/.acme.sh/acme.sh ] && echo '是' || echo '否')" + echo "" + + if [[ -d ~/.acme.sh ]]; then + print_info "~/.acme.sh 目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" + echo "" + fi + + return 1 + fi + fi + + # 确认acme.sh可用 + echo "" + print_info "验证 acme.sh 安装..." + + # 等待文件系统同步 + sleep 2 + + # 检查安装目录 + if [[ ! -d ~/.acme.sh ]]; then + print_error "安装目录不存在: ~/.acme.sh" + return 1 + fi + + # 检查主脚本文件 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "主脚本文件不存在: ~/.acme.sh/acme.sh" + print_info "目录内容:" + ls -la ~/.acme.sh/ 2>&1 | head -10 || echo "无法列出目录" + return 1 + fi + + # 检查脚本是否可执行 + if [[ ! -x ~/.acme.sh/acme.sh ]]; then + print_warning "脚本不可执行,正在添加执行权限..." + chmod +x ~/.acme.sh/acme.sh + fi + + # 测试脚本是否能运行 + if ! ~/.acme.sh/acme.sh --version &> /dev/null; then + print_error "acme.sh 无法运行" + return 1 + fi + + print_success "acme.sh 验证通过" + + # 申请证书 + echo "" + print_info "正在申请 Buypass 证书..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + # 使用webroot模式申请证书(更可靠) + if ~/.acme.sh/acme.sh --server buypass --issue -d "$DOMAIN" --webroot "${PROJECT_DIR}/frontend"; then + print_success "证书申请成功" + else + # 检查是否是因为证书已存在 + if ~/.acme.sh/acme.sh --list | grep -q "$DOMAIN"; then + print_warning "检测到证书已存在,使用已有证书" + print_success "将直接安装现有证书" + else + print_error "证书申请失败" + return 1 + fi + fi + + # 安装证书 + echo "" + print_info "正在安装证书到Nginx..." + + # 再次确认acme.sh存在 + if [[ ! -f ~/.acme.sh/acme.sh ]]; then + print_error "acme.sh文件不存在: ~/.acme.sh/acme.sh" + return 1 + fi + + mkdir -p /etc/nginx/ssl + + # 确保nginx服务已启动(证书安装时需要reload) + if ! systemctl is-active --quiet nginx 2>/dev/null && ! pgrep -x nginx > /dev/null 2>&1; then + print_warning "Nginx未运行,正在启动..." + systemctl start nginx 2>/dev/null || /www/server/nginx/sbin/nginx 2>/dev/null || true + sleep 2 + fi + + # 先不带reload命令安装证书(避免nginx未启动导致失败) + if ~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \ + --key-file /etc/nginx/ssl/${DOMAIN}.key \ + --fullchain-file /etc/nginx/ssl/${DOMAIN}.crt; then + print_success "证书文件已安装到: /etc/nginx/ssl/" + + # 手动reload nginx + if systemctl is-active --quiet nginx 2>/dev/null; then + systemctl reload nginx && print_success "Nginx配置已重载" + elif pgrep -x nginx > /dev/null; then + nginx -s reload && print_success "Nginx配置已重载" + else + print_warning "Nginx未运行,将在后续步骤启动" + fi + + # 配置自动续期 + setup_acme_auto_renew + + return 0 + else + print_error "证书安装失败" + return 1 + fi +} + +deploy_aliyun_ssl() { + print_step "使用阿里云免费证书..." + + print_warning "此功能需要您提供阿里云AccessKey" + echo "" + read -p "阿里云AccessKey ID: " ALIYUN_ACCESS_KEY_ID < /dev/tty + read -p "阿里云AccessKey Secret: " ALIYUN_ACCESS_KEY_SECRET < /dev/tty + + # 这里需要调用阿里云API申请证书 + # 暂时返回失败,提示用户使用其他方案 + print_error "阿里云证书申请功能开发中,请选择其他方案" + return 1 +} + +deploy_tencent_ssl() { + print_step "使用腾讯云免费证书..." + + print_warning "此功能需要您提供腾讯云SecretKey" + echo "" + read -p "腾讯云SecretId: " TENCENT_SECRET_ID < /dev/tty + read -p "腾讯云SecretKey: " TENCENT_SECRET_KEY < /dev/tty + + # 这里需要调用腾讯云API申请证书 + # 暂时返回失败,提示用户使用其他方案 + print_error "腾讯云证书申请功能开发中,请选择其他方案" + return 1 +} + +deploy_manual_ssl() { + print_step "使用已有证书..." + + echo "" + print_info "请将以下文件上传到服务器:" + print_info "- 证书文件: /tmp/ssl_cert.crt" + print_info "- 私钥文件: /tmp/ssl_key.key" + echo "" + read -p "上传完成后按回车继续..." < /dev/tty + + if [[ -f /tmp/ssl_cert.crt ]] && [[ -f /tmp/ssl_key.key ]]; then + mkdir -p /etc/nginx/ssl + cp /tmp/ssl_cert.crt /etc/nginx/ssl/${DOMAIN}.crt + cp /tmp/ssl_key.key /etc/nginx/ssl/${DOMAIN}.key + chmod 600 /etc/nginx/ssl/${DOMAIN}.key + print_success "证书文件已复制" + return 0 + else + print_error "证书文件未找到" + return 1 + fi +} + +################################################################################ +# 项目部署 +################################################################################ + +create_project_directory() { + print_step "创建项目目录..." + + if [[ -d "$PROJECT_DIR" ]]; then + print_warning "项目目录已存在" + read -p "是否删除并重新创建? (y/n): " recreate < /dev/tty + if [[ "$recreate" == "y" || "$recreate" == "Y" ]]; then + rm -rf "$PROJECT_DIR" + else + print_error "部署已取消" + exit 1 + fi + fi + + mkdir -p "$PROJECT_DIR" + print_success "项目目录已创建: $PROJECT_DIR" + echo "" +} + +download_project() { + print_step "正在从仓库下载项目..." + + cd /tmp + if [[ -d "${PROJECT_NAME}" ]]; then + rm -rf "${PROJECT_NAME}" + fi + + git clone "$REPO_URL" "${PROJECT_NAME}" + + # 复制文件到项目目录 + cp -r "/tmp/${PROJECT_NAME}"/* "$PROJECT_DIR/" + + # 清理临时文件 + rm -rf "/tmp/${PROJECT_NAME}" + + print_success "项目下载完成" + echo "" +} + +configure_admin_account() { + print_step "配置管理员账号" + echo "" + + while true; do + read -p "管理员用户名 [默认: admin]: " ADMIN_USERNAME < /dev/tty + ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + + if [[ ${#ADMIN_USERNAME} -lt 3 ]]; then + print_error "用户名至少3个字符" + continue + fi + break + done + + while true; do + read -s -p "管理员密码(至少6位): " ADMIN_PASSWORD < /dev/tty + echo "" + if [[ ${#ADMIN_PASSWORD} -lt 6 ]]; then + print_error "密码至少6个字符" + continue + fi + + read -s -p "确认密码: " ADMIN_PASSWORD_CONFIRM < /dev/tty + echo "" + if [[ "$ADMIN_PASSWORD" != "$ADMIN_PASSWORD_CONFIRM" ]]; then + print_error "两次密码不一致" + continue + fi + break + done + + print_success "管理员账号配置完成" + echo "" +} + +install_backend_dependencies() { + print_step "安装后端依赖..." + + cd "${PROJECT_DIR}/backend" + + # 确保Python可用(node-gyp需要) + if ! command -v python &> /dev/null; then + if command -v python3 &> /dev/null; then + # 创建python软链接指向python3 + if [[ "$OS" == "ubuntu" ]] || [[ "$OS" == "debian" ]]; then + ln -sf /usr/bin/python3 /usr/bin/python || true + else + # CentOS/RHEL/其他系统 + alternatives --install /usr/bin/python python /usr/bin/python3 1 &> /dev/null || \ + ln -sf /usr/bin/python3 /usr/bin/python || true + fi + print_info "已配置Python环境(python -> python3)" + else + print_warning "未找到Python,某些依赖可能需要手动处理" + fi + fi + + # 使用国内镜像加速 + if [[ "$USE_ALIYUN_MIRROR" == "true" ]]; then + npm config set registry https://registry.npmmirror.com + fi + + + print_info "正在安装依赖包(包含数据库native模块,可能需要几分钟)..." + + # 安装依赖,捕获错误 + if PYTHON=python3 npm install --production; then + print_success "后端依赖安装完成" + else + print_error "依赖安装失败" + echo "" + print_warning "可能的解决方案:" + echo " 1. 检查网络连接" + echo " 2. 手动执行: cd ${PROJECT_DIR}/backend && npm install --production" + echo " 3. 查看详细错误日志: ~/.npm/_logs/" + echo "" + + # 询问是否继续 + read -p "是否忽略错误继续安装?(y/n): " continue_install < /dev/tty + if [[ "$continue_install" != "y" ]] && [[ "$continue_install" != "Y" ]]; then + exit 1 + fi + fi + + echo "" +} + +create_env_file() { + print_step "创建配置文件..." + + # 生成随机JWT密钥 + JWT_SECRET=$(openssl rand -base64 32) + + # 生成随机Session密钥 + SESSION_SECRET=$(openssl rand -hex 32) + + # ========== CORS 安全配置自动生成 ========== + # 根据部署模式自动配置 ALLOWED_ORIGINS 和 COOKIE_SECURE + + if [[ "$USE_DOMAIN" == "true" ]]; then + # 域名模式 + if [[ "$SSL_METHOD" == "8" || -z "$SSL_METHOD" ]]; then + # HTTP 模式 + PROTOCOL="http" + COOKIE_SECURE_VALUE="false" + PORT_VALUE=${HTTP_PORT:-80} + ENFORCE_HTTPS_VALUE="false" + else + # HTTPS 模式 + PROTOCOL="https" + COOKIE_SECURE_VALUE="true" + PORT_VALUE=${HTTPS_PORT:-443} + ENFORCE_HTTPS_VALUE="true" + fi + + # 生成 ALLOWED_ORIGINS (标准端口不需要显示端口号) + if [[ "$PORT_VALUE" == "80" ]] || [[ "$PORT_VALUE" == "443" ]]; then + ALLOWED_ORIGINS_VALUE="${PROTOCOL}://${DOMAIN}" + else + ALLOWED_ORIGINS_VALUE="${PROTOCOL}://${DOMAIN}:${PORT_VALUE}" + fi + + print_info "CORS 配置: ${ALLOWED_ORIGINS_VALUE}" + else + # IP 模式(开发/测试环境) + # 留空,后端默认允许所有来源(适合开发环境) + ALLOWED_ORIGINS_VALUE="" + COOKIE_SECURE_VALUE="false" + ENFORCE_HTTPS_VALUE="false" + print_warning "IP 模式下 CORS 将允许所有来源(仅适合开发环境)" + print_info "生产环境建议使用域名模式" + fi + + cat > "${PROJECT_DIR}/backend/.env" << EOF +# 管理员账号 +ADMIN_USERNAME=${ADMIN_USERNAME} +ADMIN_PASSWORD=${ADMIN_PASSWORD} + +# JWT密钥 +JWT_SECRET=${JWT_SECRET} + +# Session密钥(用于会话管理) +SESSION_SECRET=${SESSION_SECRET} + +# 数据库路径 +DATABASE_PATH=./data/database.db + +# 存储目录 +STORAGE_ROOT=./storage + +# 服务端口 +PORT=${BACKEND_PORT} + +# 环境 +NODE_ENV=production + +# 强制HTTPS(生产环境建议开启) +ENFORCE_HTTPS=${ENFORCE_HTTPS_VALUE} + +# CORS 跨域配置 +# 允许访问的前端域名(多个用逗号分隔) +# 生产环境必须配置具体域名,开发环境可留空 +ALLOWED_ORIGINS=${ALLOWED_ORIGINS_VALUE} + +# Cookie 安全配置 +# HTTPS 环境必须设置为 true +COOKIE_SECURE=${COOKIE_SECURE_VALUE} + +# 信任代理配置(重要安全配置) +# 在 Nginx/CDN 后部署时必须配置,否则无法正确识别客户端 IP 和协议 +# 配置选项: +# - false: 不信任代理(直接暴露,默认值) +# - 1: 信任前 1 跳代理(单层 Nginx,推荐) +# - 2: 信任前 2 跳代理(CDN + Nginx) +# - loopback: 仅信任本地回环地址 +# 警告:不要设置为 true,这会信任所有代理,存在 IP/协议伪造风险! +TRUST_PROXY=1 + +# 公开端口(nginx监听的端口,用于生成分享链接) +# 如果使用标准端口(80/443)或未配置,分享链接将不包含端口号 +PUBLIC_PORT=${HTTP_PORT} +EOF + + print_success "配置文件创建完成" + + # 显示安全提示 + if [[ -z "$ALLOWED_ORIGINS_VALUE" ]]; then + echo "" + print_warning "⚠️ 安全提示:" + print_info " 当前配置允许所有域名访问(CORS: *)" + print_info " 这仅适合开发环境,生产环境存在安全风险" + print_info " 建议在生产环境使用域名模式部署" + echo "" + fi + echo "" +} + +create_data_directories() { + print_step "创建数据目录..." + + mkdir -p "${PROJECT_DIR}/backend/data" + mkdir -p "${PROJECT_DIR}/backend/storage" + + print_success "数据目录创建完成" + echo "" +} + + +################################################################################ +# Nginx配置 - 分步骤执行 +################################################################################ + +# 安全重启/重载Nginx(带回退) +restart_nginx_safe() { + print_info "尝试重启/重载 Nginx..." + + # 如果有systemd并存在nginx服务,优先使用(不强制重启,避免80/443冲突) + if command -v systemctl &> /dev/null && systemctl list-unit-files | grep -q "^nginx.service"; then + if systemctl is-active --quiet nginx; then + if systemctl reload nginx 2>/dev/null; then + print_success "已通过 systemctl reload 重载 Nginx" + return 0 + fi + else + if systemctl start nginx 2>/dev/null; then + print_success "已通过 systemctl start 启动 Nginx" + return 0 + else + print_warning "systemctl 启动失败,可能端口被占用或已有其他反代在跑" + fi + fi + fi + + # 宝塔路径优先尝试 + if [[ -x /www/server/nginx/sbin/nginx ]]; then + if /www/server/nginx/sbin/nginx -t 2>/dev/null; then + if /www/server/nginx/sbin/nginx -s reload 2>/dev/null; then + print_success "已通过宝塔 nginx -s reload 重载" + return 0 + fi + if /www/server/nginx/sbin/nginx 2>/dev/null; then + print_success "已通过宝塔 nginx 启动" + return 0 + fi + else + print_error "宝塔 Nginx 配置测试失败" + /www/server/nginx/sbin/nginx -t 2>&1 || true + fi + fi + + # 直接使用nginx命令 + if command -v nginx &> /dev/null; then + if ! nginx -t 2>/dev/null; then + print_error "nginx -t 配置测试失败,请检查配置" + nginx -t 2>&1 || true + return 1 + fi + + if nginx -s reload 2>/dev/null; then + print_success "已使用 nginx -s reload 重载配置" + return 0 + fi + + # reload失败,尝试直接启动 + if nginx 2>/dev/null; then + print_success "已直接启动 Nginx" + return 0 + fi + fi + + print_error "未能成功启动/重载 Nginx,请手动检查(端口占用/安装状态)" + return 1 +} + +# 确保已安装Nginx(更新/修复模式下可能未安装) +ensure_nginx_installed() { + if command -v nginx &> /dev/null || [[ -x /www/server/nginx/sbin/nginx ]]; then + return 0 + fi + + print_warning "未检测到 Nginx。若你已有其它反向代理占用80/443,可跳过安装并手动配置;如需本脚本自动配置,请先安装Nginx后再运行。" + return 0 +} + +# 步骤1: 先配置HTTP Nginx(为SSL证书验证做准备) +configure_nginx_http_first() { + print_step "配置基础HTTP Nginx(用于SSL证书验证)..." + + # 确保已安装Nginx + ensure_nginx_installed || return 1 + + # 总是先配置HTTP模式 + local server_name="${DOMAIN:-_}" + + # 检测Nginx配置目录结构并创建必要的目录 + if [[ -d /www/server/nginx ]]; then + # 宝塔面板 (BT Panel) + NGINX_CONF_DIR="/www/server/panel/vhost/nginx" + NGINX_ENABLED_DIR="" + USE_SYMLINK=false + IS_BT_PANEL=true + + # 确保目录存在 + mkdir -p ${NGINX_CONF_DIR} + print_info "检测到宝塔面板,使用宝塔Nginx配置目录" + elif [[ -d /etc/nginx/sites-available ]] || [[ "$PKG_MANAGER" == "apt" ]]; then + # Debian/Ubuntu: 使用sites-available + NGINX_CONF_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + USE_SYMLINK=true + IS_BT_PANEL=false + + # 确保目录存在 + mkdir -p ${NGINX_CONF_DIR} + mkdir -p ${NGINX_ENABLED_DIR} + else + # CentOS/RHEL: 使用conf.d + NGINX_CONF_DIR="/etc/nginx/conf.d" + NGINX_ENABLED_DIR="" + USE_SYMLINK=false + IS_BT_PANEL=false + + # 确保目录存在 + mkdir -p ${NGINX_CONF_DIR} + fi + + cat > ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf << EOF +server { + listen ${HTTP_PORT}; + server_name ${server_name}; + + # 文件上传大小限制(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|sql|bak|backup|old|log)$ { + deny all; + return 404; + } + + # 前端静态文件 + location / { + root ${PROJECT_DIR}/frontend; + index index.html; + try_files \$uri \$uri/ /index.html; + } + + # 后端API + location /api { + proxy_pass http://localhost:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + 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; + + # Cookie传递配置(验证码session需要) + proxy_set_header Cookie \$http_cookie; + proxy_pass_header Set-Cookie; + + # 上传超时设置 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 300s; + } + + # 分享页面 + location /s/ { + proxy_pass http://localhost:${BACKEND_PORT}; + 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; + } + + # 静态资源 + location /libs { + alias ${PROJECT_DIR}/frontend/libs; + expires 30d; + } + + } +} +EOF + + # 根据系统类型处理配置文件 + if [[ "$USE_SYMLINK" == "true" ]]; then + # Debian/Ubuntu: 创建软链接 + ln -sf ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf ${NGINX_ENABLED_DIR}/${PROJECT_NAME}.conf + # 删除默认站点 + rm -f ${NGINX_ENABLED_DIR}/default + elif [[ "$IS_BT_PANEL" != "true" ]]; then + # CentOS/RHEL (非宝塔): conf.d中的.conf文件会自动加载 + rm -f /etc/nginx/conf.d/default.conf + fi + # 宝塔面板:配置文件已自动包含,无需额外操作 + + # 测试nginx配置 + if ! nginx -t; then + print_error "Nginx配置测试失败" + return 1 + fi + + # 启动或重载Nginx + if [[ "$IS_BT_PANEL" == "true" ]]; then + # 宝塔面板:尝试多种方式 + print_info "宝塔环境,尝试重载Nginx..." + + # 优先使用最可靠的方式: 直接使用nginx命令reload + if [[ -f /www/server/nginx/sbin/nginx ]]; then + # 先测试配置 + if /www/server/nginx/sbin/nginx -t 2>/dev/null; then + # 配置测试通过,尝试reload + if /www/server/nginx/sbin/nginx -s reload 2>/dev/null; then + print_success "已使用nginx -s reload重载配置" + else + # reload失败,尝试重启 + print_warning "reload失败,尝试重启Nginx..." + /www/server/nginx/sbin/nginx -s stop 2>/dev/null || true + sleep 2 + if /www/server/nginx/sbin/nginx 2>/dev/null; then + print_success "Nginx已重新启动" + else + print_error "Nginx启动失败,请手动检查" + # 不退出脚本,继续后续步骤 + fi + fi + else + print_error "Nginx配置测试失败" + # 显示配置错误但不退出脚本 + /www/server/nginx/sbin/nginx -t 2>&1 || true + fi + fi + + # 备用方式: 尝试systemctl(某些宝塔环境也支持) + if systemctl is-active --quiet nginx 2>/dev/null; then + systemctl reload nginx 2>/dev/null && print_info "已使用systemctl重载配置" || true + fi + else + # 标准Nginx:重启(带回退) + restart_nginx_safe || return 1 + fi + + + # 验证Nginx是否运行 + sleep 2 + if [[ "$IS_BT_PANEL" == "true" ]]; then + # 宝塔:检查进程 + if pgrep -x nginx > /dev/null; then + print_success "Nginx运行正常" + else + print_error "Nginx未运行" + print_warning "请在宝塔面板中手动启动Nginx,或运行:" + print_warning "/www/server/nginx/sbin/nginx" + return 1 + fi + else + # 标准Nginx:使用systemctl检查 + if command -v systemctl &> /dev/null && systemctl list-unit-files | grep -q "^nginx.service"; then + if ! systemctl is-active --quiet nginx; then + print_error "Nginx启动失败" + return 1 + fi + elif ! pgrep -x nginx > /dev/null; then + print_error "Nginx启动失败" + return 1 + fi + fi + + print_success "基础HTTP Nginx配置完成" + echo "" +} + +# 步骤2: 根据SSL结果配置最终Nginx +configure_nginx_final() { + print_step "配置最终Nginx..." + + # 检查SSL是否成功部署 + local ssl_deployed=false + if [[ "$USE_DOMAIN" == "true" ]] && [[ "$SSL_METHOD" != "8" ]]; then + # 检查SSL证书文件是否存在 + if [[ -f /etc/nginx/ssl/${DOMAIN}.crt ]] && [[ -f /etc/nginx/ssl/${DOMAIN}.key ]]; then + ssl_deployed=true + print_info "检测到SSL证书,配置HTTPS..." + else + print_warning "SSL证书不存在,保持HTTP配置" + fi + fi + + # 根据SSL状态配置 + if [[ "$ssl_deployed" == "true" ]]; then + # 配置HTTPS + configure_nginx_https + else + # 保持HTTP(已在第一步配置,这里只需确认) + print_info "使用HTTP配置" + fi + + # 测试nginx配置 + if ! nginx -t; then + print_error "Nginx配置测试失败" + return 1 + fi + + # 重载nginx - 兼容宝塔面板 + if [[ "$IS_BT_PANEL" == "true" ]]; then + # 宝塔面板:尝试多种方式 + print_info "宝塔环境,重载Nginx配置..." + + # 优先使用最可靠的方式: 直接使用nginx命令reload + if [[ -f /www/server/nginx/sbin/nginx ]]; then + if /www/server/nginx/sbin/nginx -s reload 2>/dev/null; then + print_success "已使用nginx -s reload重载配置" + else + # reload失败,尝试重启 + print_warning "reload失败,尝试重启Nginx..." + /www/server/nginx/sbin/nginx -s stop 2>/dev/null || true + sleep 2 + if /www/server/nginx/sbin/nginx 2>/dev/null; then + print_success "已启动Nginx" + else + print_warning "Nginx启动失败,请手动检查" + fi + fi + fi + + # 备用方式: 尝试systemctl + if systemctl is-active --quiet nginx 2>/dev/null; then + systemctl reload nginx 2>/dev/null && print_info "已使用systemctl重载配置" || true + fi + else + # 标准Nginx:重载(带回退) + restart_nginx_safe || return 1 + fi + + + print_success "Nginx最终配置完成" + echo "" +} + +configure_nginx() { + print_step "配置Nginx..." + + if [[ "$USE_DOMAIN" == "true" ]]; then + if [[ "$SSL_METHOD" == "8" ]]; then + # HTTP配置 + configure_nginx_http + else + # HTTPS配置 + configure_nginx_https + fi + else + # IP模式HTTP配置 + configure_nginx_http + fi + + # 测试nginx配置 + nginx -t + + # 重启nginx + restart_nginx_safe || return 1 + + print_success "Nginx配置完成" + echo "" +} + +configure_nginx_http() { + local server_name="${DOMAIN:-_}" + + # 确保已安装Nginx + ensure_nginx_installed || return 1 + + # 检测Nginx配置目录结构并创建必要的目录 + if [[ -d /www/server/nginx ]]; then + # 宝塔面板 + NGINX_CONF_DIR="/www/server/panel/vhost/nginx" + USE_SYMLINK=false + IS_BT_PANEL=true + mkdir -p ${NGINX_CONF_DIR} + elif [[ -d /etc/nginx/sites-available ]] || [[ "$PKG_MANAGER" == "apt" ]]; then + # Debian/Ubuntu + NGINX_CONF_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + USE_SYMLINK=true + IS_BT_PANEL=false + mkdir -p ${NGINX_CONF_DIR} + mkdir -p ${NGINX_ENABLED_DIR} + else + # CentOS/RHEL + NGINX_CONF_DIR="/etc/nginx/conf.d" + USE_SYMLINK=false + IS_BT_PANEL=false + mkdir -p ${NGINX_CONF_DIR} + fi + + cat > ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf << EOF +server { + listen ${HTTP_PORT}; + server_name ${server_name}; + + # 文件上传大小限制(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|sql|bak|backup|old|log)$ { + deny all; + return 404; + } + + # 前端静态文件 + location / { + root ${PROJECT_DIR}/frontend; + index index.html; + try_files \$uri \$uri/ /index.html; + } + + # 后端API + location /api { + proxy_pass http://localhost:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + 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; + + # Cookie传递配置(验证码session需要) + proxy_set_header Cookie \$http_cookie; + proxy_pass_header Set-Cookie; + + # 上传超时设置(大文件上传需要更长时间,设置为1小时) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 300s; + } + + # 分享页面(代理到后端处理重定向) + location /s/ { + proxy_pass http://localhost:${BACKEND_PORT}; + 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; + } + + # 静态资源 + location /libs { + alias ${PROJECT_DIR}/frontend/libs; + expires 30d; + } + + } +} +EOF + + # 根据系统类型处理配置文件 + if [[ "$USE_SYMLINK" == "true" ]]; then + # Debian/Ubuntu: 创建软链接 + ln -sf ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf ${NGINX_ENABLED_DIR}/${PROJECT_NAME}.conf + # 删除默认站点 + rm -f ${NGINX_ENABLED_DIR}/default + elif [[ "$IS_BT_PANEL" != "true" ]]; then + # CentOS/RHEL (非宝塔): conf.d中的.conf文件会自动加载 + rm -f /etc/nginx/conf.d/default.conf + fi +} + +configure_nginx_https() { + # 确保已安装Nginx + ensure_nginx_installed || return 1 + + # 检测Nginx配置目录结构并创建必要的目录 + if [[ -d /www/server/nginx ]]; then + # 宝塔面板 + NGINX_CONF_DIR="/www/server/panel/vhost/nginx" + USE_SYMLINK=false + IS_BT_PANEL=true + mkdir -p ${NGINX_CONF_DIR} + elif [[ -d /etc/nginx/sites-available ]] || [[ "$PKG_MANAGER" == "apt" ]]; then + # Debian/Ubuntu + NGINX_CONF_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + USE_SYMLINK=true + IS_BT_PANEL=false + mkdir -p ${NGINX_CONF_DIR} + mkdir -p ${NGINX_ENABLED_DIR} + else + # CentOS/RHEL + NGINX_CONF_DIR="/etc/nginx/conf.d" + USE_SYMLINK=false + IS_BT_PANEL=false + mkdir -p ${NGINX_CONF_DIR} + fi + + # 根据HTTPS端口生成正确的重定向URL + if [[ "$HTTPS_PORT" == "443" ]]; then + REDIRECT_URL="https://\$server_name\$request_uri" + else + REDIRECT_URL="https://\$server_name:${HTTPS_PORT}\$request_uri" + fi + + cat > ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf << EOF +server { + listen ${HTTP_PORT}; + server_name ${DOMAIN}; + return 301 ${REDIRECT_URL}; +} + +server { + listen ${HTTPS_PORT} ssl http2; + server_name ${DOMAIN}; + + ssl_certificate /etc/nginx/ssl/${DOMAIN}.crt; + ssl_certificate_key /etc/nginx/ssl/${DOMAIN}.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # 文件上传大小限制(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; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # 隐藏Nginx版本号 + server_tokens off; + + # ========== 禁止访问隐藏文件 ========== + location ~ /\\. { + deny all; + return 404; + } + + # ========== 禁止访问敏感文件 ========== + location ~ \\.(env|git|config|key|pem|crt|sql|bak|backup|old|log)$ { + deny all; + return 404; + } + + # 前端静态文件 + location / { + root ${PROJECT_DIR}/frontend; + index index.html; + try_files \$uri \$uri/ /index.html; + } + + # 后端API + location /api { + proxy_pass http://localhost:${BACKEND_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + 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; + + # Cookie传递配置(验证码session需要) + proxy_set_header Cookie \$http_cookie; + proxy_pass_header Set-Cookie; + + # 上传超时设置(大文件上传需要更长时间,设置为1小时) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 300s; + } + + # 分享页面(代理到后端处理重定向) + location /s/ { + proxy_pass http://localhost:${BACKEND_PORT}; + 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; + } + + # 静态资源 + location /libs { + alias ${PROJECT_DIR}/frontend/libs; + expires 30d; + } + + } +} +EOF + + # 根据系统类型处理配置文件 + if [[ "$USE_SYMLINK" == "true" ]]; then + # Debian/Ubuntu: 创建软链接 + ln -sf ${NGINX_CONF_DIR}/${PROJECT_NAME}.conf ${NGINX_ENABLED_DIR}/${PROJECT_NAME}.conf + # 删除默认站点 + rm -f ${NGINX_ENABLED_DIR}/default + elif [[ "$IS_BT_PANEL" != "true" ]]; then + # CentOS/RHEL (非宝塔): conf.d中的.conf文件会自动加载 + rm -f /etc/nginx/conf.d/default.conf + fi +} + +start_backend_service() { + print_step "启动后端服务..." + + cd "${PROJECT_DIR}/backend" + + # 使用PM2启动 + pm2 start server.js --name ${PROJECT_NAME}-backend + pm2 save + + print_success "后端服务已启动" + echo "" +} + +################################################################################ +# 健康检查 +################################################################################ + +health_check() { + print_step "正在进行健康检查..." + + sleep 3 + + # 检查后端服务 + if pm2 status | grep -q "${PROJECT_NAME}-backend.*online"; then + print_success "后端服务运行正常" + else + print_error "后端服务启动失败" + print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend" + return 1 + fi + + # 检查端口 + if netstat -tunlp 2>/dev/null | grep -q ":${BACKEND_PORT}" || ss -tunlp 2>/dev/null | grep -q ":${BACKEND_PORT}"; then + print_success "后端端口监听正常 (${BACKEND_PORT})" + else + print_error "后端端口监听异常" + return 1 + fi + + # 检查Nginx + if [[ -d /www/server/nginx ]]; then + # 宝塔面板:检查进程 + if pgrep -x nginx > /dev/null; then + print_success "Nginx服务运行正常" + else + print_error "Nginx服务异常" + return 1 + fi + else + # 标准Nginx:使用systemctl检查 + if systemctl is-active --quiet nginx; then + print_success "Nginx服务运行正常" + else + print_error "Nginx服务异常" + return 1 + fi + fi + + # 检查数据库 + if [[ -f "${PROJECT_DIR}/backend/data/database.db" ]]; then + print_success "数据库初始化成功" + else + print_warning "数据库文件不存在" + fi + + # 检查存储目录 + if [[ -d "${PROJECT_DIR}/backend/storage" ]]; then + print_success "文件存储目录就绪" + else + print_warning "存储目录不存在" + fi + + echo "" + return 0 +} + +################################################################################ +# 完成提示 +################################################################################ + +print_completion() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🎉 部署成功! ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo "" + + # 访问地址 + if [[ "$USE_DOMAIN" == "true" ]]; then + if [[ "$SSL_METHOD" == "8" ]]; then + if [[ "$HTTP_PORT" == "80" ]]; then + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}:${HTTP_PORT}" + fi + else + if [[ "$HTTPS_PORT" == "443" ]]; then + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}:${HTTPS_PORT}" + fi + fi + else + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") + if [[ "$HTTP_PORT" == "80" ]]; then + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" + else + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" + fi + fi + + echo -e "${CYAN}管理员账号:${NC} ${ADMIN_USERNAME}" + echo -e "${CYAN}管理员密码:${NC} ********" + echo "" + + # 端口信息 + if [[ "$HTTP_PORT" != "80" ]] || [[ "$HTTPS_PORT" != "443" ]] || [[ "$BACKEND_PORT" != "40001" ]]; then + echo -e "${YELLOW}端口配置:${NC}" + echo " HTTP端口: $HTTP_PORT" + if [[ "$USE_DOMAIN" == "true" ]] && [[ "$SSL_METHOD" != "8" ]]; then + echo " HTTPS端口: $HTTPS_PORT" + fi + echo " 后端端口: $BACKEND_PORT" + echo "" + fi + + # 常用命令 + echo -e "${YELLOW}常用命令:${NC}" + echo " 查看服务状态: pm2 status" + echo " 查看日志: pm2 logs ${PROJECT_NAME}-backend" + echo " 重启服务: pm2 restart ${PROJECT_NAME}-backend" + echo " 停止服务: pm2 stop ${PROJECT_NAME}-backend" + echo "" + + # 配置文件位置 + echo -e "${YELLOW}配置文件位置:${NC}" + echo " 后端配置: ${PROJECT_DIR}/backend/.env" + echo " Nginx配置: /etc/nginx/sites-enabled/${PROJECT_NAME}.conf" + echo " 数据库: ${PROJECT_DIR}/backend/data/database.db" + echo " 文件存储: ${PROJECT_DIR}/backend/storage" + echo "" + + # SSL续期提示 + if [[ "$USE_DOMAIN" == "true" ]] && [[ "$SSL_METHOD" != "8" ]]; then + echo -e "${YELLOW}SSL证书自动续期:${NC}" + echo " - 方式: acme.sh cron任务" + echo " - 频率: 每天自动检查" + echo " - 时机: 证书到期前30天自动续期" + echo " - 检查任务: crontab -l | grep acme" + echo " - 查看证书: ~/.acme.sh/acme.sh --list" + echo " - 手动续期: ~/.acme.sh/acme.sh --renew -d $DOMAIN --force" + echo "" + fi + + echo -e "${GREEN}祝您使用愉快!${NC}" + echo "" +} + +################################################################################ +# 卸载功能 +################################################################################ + +print_uninstall_banner() { + clear + echo -e "${RED}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ⚠️ 玩玩云 卸载模式 ║" + echo "║ ║" + echo "║ Uninstall Mode ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +confirm_uninstall() { + print_uninstall_banner + + echo -e "${YELLOW}" + echo "本脚本将执行以下操作:" + echo "" + echo "【将要删除】" + echo " ✓ PM2 进程: ${PROJECT_NAME}-backend" + echo " ✓ 项目目录: ${PROJECT_DIR}" + echo " ✓ Nginx 配置: /etc/nginx/sites-enabled/${PROJECT_NAME}.conf" + echo " ✓ 数据库文件: ${PROJECT_DIR}/backend/data/" + echo " ✓ 用户文件: ${PROJECT_DIR}/backend/storage/" + echo "" + echo "【将会保留】" + echo " ✓ Node.js" + echo " ✓ Nginx (程序本身)" + echo " ✓ PM2 (程序本身)" + echo " ✓ 编译工具 (build-essential等)" + echo -e "${NC}" + echo "" + + print_warning "此操作不可逆,所有数据将被永久删除!" + echo "" + + read -p "确定要卸载吗? (yes/no): " confirm < /dev/tty + + if [[ "$confirm" != "yes" ]]; then + print_info "已取消卸载" + exit 0 + fi + + echo "" + read -p "请再次确认 (yes/no): " confirm2 < /dev/tty + + if [[ "$confirm2" != "yes" ]]; then + print_info "已取消卸载" + exit 0 + fi + + echo "" +} + +uninstall_backup_data() { + print_step "备份用户数据..." + + if [[ ! -d "$PROJECT_DIR" ]]; then + print_info "项目目录不存在,跳过备份" + return + fi + + BACKUP_DIR="/root/${PROJECT_NAME}-backup-$(date +%Y%m%d-%H%M%S)" + + echo "" + read -p "是否备份用户数据? (y/n): " backup_choice < /dev/tty + + if [[ "$backup_choice" == "y" || "$backup_choice" == "Y" ]]; then + mkdir -p "$BACKUP_DIR" + + # 备份数据库 + if [[ -d "${PROJECT_DIR}/backend/data" ]]; then + cp -r "${PROJECT_DIR}/backend/data" "$BACKUP_DIR/" + print_success "数据库已备份" + fi + + # 备份用户文件 + if [[ -d "${PROJECT_DIR}/backend/storage" ]]; then + cp -r "${PROJECT_DIR}/backend/storage" "$BACKUP_DIR/" + print_success "用户文件已备份" + fi + + # 备份配置文件 + if [[ -f "${PROJECT_DIR}/backend/.env" ]]; then + cp "${PROJECT_DIR}/backend/.env" "$BACKUP_DIR/" + print_success "配置文件已备份" + fi + + print_success "备份已保存到: $BACKUP_DIR" + echo "" + else + print_warning "跳过备份,数据将被永久删除" + echo "" + fi +} + +uninstall_stop_pm2() { + print_step "停止PM2进程..." + + if command -v pm2 &> /dev/null; then + if pm2 list | grep -q "${PROJECT_NAME}-backend"; then + pm2 delete ${PROJECT_NAME}-backend + pm2 save --force + print_success "PM2进程已停止并删除" + else + print_info "PM2进程不存在,跳过" + fi + else + print_info "PM2未安装,跳过" + fi +} + +uninstall_nginx_config() { + print_step "删除Nginx配置..." + + local need_reload=false + + # 删除sites-enabled软链接 + if [[ -L /etc/nginx/sites-enabled/${PROJECT_NAME}.conf ]]; then + rm -f /etc/nginx/sites-enabled/${PROJECT_NAME}.conf + print_success "删除 sites-enabled 配置" + need_reload=true + fi + + # 删除sites-available配置文件 + if [[ -f /etc/nginx/sites-available/${PROJECT_NAME}.conf ]]; then + rm -f /etc/nginx/sites-available/${PROJECT_NAME}.conf + print_success "删除 sites-available 配置" + fi + + # 测试并重载nginx + if [[ "$need_reload" == true ]] && command -v nginx &> /dev/null; then + if nginx -t &> /dev/null; then + systemctl reload nginx + print_success "Nginx配置已重载" + else + print_warning "Nginx配置测试失败,请手动检查" + fi + fi +} + +uninstall_ssl_certificates() { + print_step "清理SSL证书..." + + # 删除nginx SSL证书目录下的项目证书 + local cert_removed=false + if [[ -d /etc/nginx/ssl ]]; then + # 使用find查找包含项目名或域名的证书 + find /etc/nginx/ssl -name "*${PROJECT_NAME}*" -type f -delete 2>/dev/null && cert_removed=true + fi + + if [[ "$cert_removed" == true ]]; then + print_success "已删除Nginx SSL证书" + else + print_info "未发现Nginx SSL证书" + fi + + # 提示acme.sh证书 + if [[ -d ~/.acme.sh ]]; then + if ~/.acme.sh/acme.sh --list 2>/dev/null | grep -q "Main_Domain"; then + print_info "检测到acme.sh证书" + print_warning "如需删除,请手动运行: ~/.acme.sh/acme.sh --remove -d " + fi + fi +} + +uninstall_project_directory() { + print_step "删除项目目录..." + + if [[ -d "$PROJECT_DIR" ]]; then + # 统计大小 + SIZE=$(du -sh "$PROJECT_DIR" 2>/dev/null | cut -f1 || echo "未知") + print_info "项目目录大小: $SIZE" + + # 删除目录 + rm -rf "$PROJECT_DIR" + print_success "项目目录已删除: $PROJECT_DIR" + else + print_info "项目目录不存在: $PROJECT_DIR" + fi +} + +uninstall_temp_files() { + print_step "清理临时文件..." + + local cleaned=false + + # 清理/tmp下的临时文件 + if [[ -d "/tmp/${PROJECT_NAME}" ]]; then + rm -rf "/tmp/${PROJECT_NAME}" + print_success "删除临时目录" + cleaned=true + fi + + # 清理npm缓存 + if command -v npm &> /dev/null; then + npm cache clean --force &> /dev/null || true + print_success "清理npm缓存" + cleaned=true + fi + + if [[ "$cleaned" == false ]]; then + print_info "无需清理" + fi +} + +uninstall_check_residual() { + print_step "检查残留文件..." + + local residual_found=false + + # 检查项目目录 + if [[ -d "$PROJECT_DIR" ]]; then + print_warning "残留: 项目目录 $PROJECT_DIR" + residual_found=true + fi + + # 检查Nginx配置 + if [[ -f /etc/nginx/sites-enabled/${PROJECT_NAME}.conf ]] || [[ -f /etc/nginx/sites-available/${PROJECT_NAME}.conf ]]; then + print_warning "残留: Nginx配置文件" + residual_found=true + fi + + # 检查PM2进程 + if command -v pm2 &> /dev/null; then + if pm2 list | grep -q "${PROJECT_NAME}"; then + print_warning "残留: PM2进程" + residual_found=true + fi + fi + + if [[ "$residual_found" == false ]]; then + print_success "未发现残留文件" + fi +} + +print_uninstall_completion() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ✓ 卸载完成! ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo "" + + echo -e "${CYAN}已删除内容:${NC}" + echo " ✓ PM2进程" + echo " ✓ 项目目录" + echo " ✓ Nginx配置" + echo " ✓ 数据库和用户文件" + echo "" + + echo -e "${CYAN}保留的环境:${NC}" + echo " ✓ Node.js $(node -v 2>/dev/null || echo '(未安装)')" + echo " ✓ Nginx $(nginx -v 2>&1 | cut -d'/' -f2 || echo '(未安装)')" + echo " ✓ PM2 $(pm2 -v 2>/dev/null || echo '(未安装)')" + echo " ✓ 编译工具 (build-essential 等)" + echo "" + + if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then + echo -e "${YELLOW}备份位置:${NC}" + echo " $BACKUP_DIR" + echo "" + fi + + echo -e "${GREEN}感谢使用玩玩云!${NC}" + echo "" +} + +################################################################################ +# 更新功能 +################################################################################ + +print_update_banner() { + clear + echo -e "${BLUE}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🔄 玩玩云 更新模式 ║" + echo "║ ║" + echo "║ Update Mode ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +confirm_update() { + print_update_banner + + echo -e "${YELLOW}" + echo "本脚本将执行以下操作:" + echo "" + echo "【将要更新】" + echo " ✓ 从仓库拉取最新代码" + echo " ✓ 更新后端依赖(npm install)" + echo " ✓ 重启后端服务" + echo "" + echo "【将会保留】" + echo " ✓ 数据库文件(用户数据)" + echo " ✓ 用户上传的文件" + echo " ✓ .env 配置文件" + echo " ✓ Nginx 配置" + echo -e "${NC}" + echo "" + + print_info "建议在更新前备份重要数据" + echo "" + + read -p "确定要更新吗? (y/n): " confirm < /dev/tty + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + print_info "已取消更新" + exit 0 + fi + + echo "" +} + +update_check_project() { + print_step "检查项目是否已安装..." + + if [[ ! -d "$PROJECT_DIR" ]]; then + print_error "项目未安装: $PROJECT_DIR" + print_info "请先运行安装命令" + exit 1 + fi + + if [[ ! -f "${PROJECT_DIR}/backend/server.js" ]]; then + print_error "项目目录不完整" + exit 1 + fi + + print_success "项目已安装: $PROJECT_DIR" +} + +update_backup_important_files() { + print_step "备份重要文件..." + + TEMP_BACKUP="/tmp/${PROJECT_NAME}-update-backup-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$TEMP_BACKUP" + + # 备份数据库 + if [[ -d "${PROJECT_DIR}/backend/data" ]]; then + cp -r "${PROJECT_DIR}/backend/data" "$TEMP_BACKUP/" + print_success "数据库已备份" + fi + + # 备份用户文件 + if [[ -d "${PROJECT_DIR}/backend/storage" ]]; then + cp -r "${PROJECT_DIR}/backend/storage" "$TEMP_BACKUP/" + print_success "用户文件已备份" + fi + + # 备份配置文件 + if [[ -f "${PROJECT_DIR}/backend/.env" ]]; then + cp "${PROJECT_DIR}/backend/.env" "$TEMP_BACKUP/" + print_success "配置文件已备份" + fi + + print_success "备份完成: $TEMP_BACKUP" + echo "" +} + +update_stop_services() { + print_step "停止服务..." + + if command -v pm2 &> /dev/null; then + if pm2 list | grep -q "${PROJECT_NAME}-backend"; then + pm2 stop ${PROJECT_NAME}-backend + print_success "后端服务已停止" + fi + fi +} + +update_pull_latest_code() { + print_step "正在从仓库拉取最新代码..." + + cd /tmp + if [[ -d "${PROJECT_NAME}-update" ]]; then + rm -rf "${PROJECT_NAME}-update" + fi + + # 克隆最新代码 + git clone "$REPO_URL" "${PROJECT_NAME}-update" + + # 更新前端文件 + print_info "更新前端文件..." + if [[ -d "/tmp/${PROJECT_NAME}-update/frontend" ]]; then + rm -rf "${PROJECT_DIR}/frontend" + cp -r "/tmp/${PROJECT_NAME}-update/frontend" "${PROJECT_DIR}/" + fi + + + # 更新后端代码文件(但不覆盖 data、storage、.env) + print_info "更新后端代码..." + if [[ -d "/tmp/${PROJECT_NAME}-update/backend" ]]; then + cd "/tmp/${PROJECT_NAME}-update/backend" + + # 复制后端文件,但排除 data、storage、.env、node_modules + for item in *; do + if [[ "$item" != "data" ]] && [[ "$item" != "storage" ]] && [[ "$item" != ".env" ]] && [[ "$item" != "node_modules" ]]; then + rm -rf "${PROJECT_DIR}/backend/$item" + cp -r "$item" "${PROJECT_DIR}/backend/" + fi + done + fi + + # 确保备份的重要文件存在 + print_info "确保数据完整性..." + + # 如果 data 目录不存在,从备份恢复 + if [[ ! -d "${PROJECT_DIR}/backend/data" ]] && [[ -d "$TEMP_BACKUP/data" ]]; then + print_warning "检测到 data 目录丢失,正在从备份恢复..." + cp -r "$TEMP_BACKUP/data" "${PROJECT_DIR}/backend/" + print_success "数据库已恢复" + fi + + # 如果 storage 目录不存在,从备份恢复 + if [[ ! -d "${PROJECT_DIR}/backend/storage" ]] && [[ -d "$TEMP_BACKUP/storage" ]]; then + print_warning "检测到 storage 目录丢失,正在从备份恢复..." + cp -r "$TEMP_BACKUP/storage" "${PROJECT_DIR}/backend/" + print_success "用户文件已恢复" + fi + + # 如果 .env 文件不存在,从备份恢复 + if [[ ! -f "${PROJECT_DIR}/backend/.env" ]] && [[ -f "$TEMP_BACKUP/.env" ]]; then + print_warning "检测到 .env 文件丢失,正在从备份恢复..." + cp "$TEMP_BACKUP/.env" "${PROJECT_DIR}/backend/" + print_success "配置文件已恢复" + fi + + # 清理临时文件 + rm -rf "/tmp/${PROJECT_NAME}-update" + rm -rf "$TEMP_BACKUP" + + print_success "代码更新完成" + echo "" +} + +update_install_dependencies() { + print_step "更新后端依赖..." + + cd "${PROJECT_DIR}/backend" + + # 确保Python可用(node-gyp需要) + if ! command -v python &> /dev/null; then + if command -v python3 &> /dev/null; then + if [[ "$OS" == "ubuntu" ]] || [[ "$OS" == "debian" ]]; then + ln -sf /usr/bin/python3 /usr/bin/python || true + else + alternatives --install /usr/bin/python python /usr/bin/python3 1 &> /dev/null || \ + ln -sf /usr/bin/python3 /usr/bin/python || true + fi + print_info "已配置Python环境" + fi + fi + + # 使用国内镜像加速(如果之前选择了) + if command -v npm &> /dev/null; then + current_registry=$(npm config get registry) + if [[ "$current_registry" =~ "npmmirror" ]] || [[ "$current_registry" =~ "taobao" ]]; then + print_info "检测到使用国内镜像源" + fi + fi + + + # 清理旧的node_modules + + # 检查并升级C++编译器(如果需要) + check_cpp_compiler + if [[ -d "node_modules" ]]; then + print_info "清理旧依赖..." + rm -rf node_modules package-lock.json + fi + + print_info "正在重新安装依赖(可能需要几分钟)..." + + if PYTHON=python3 npm install --production; then + print_success "依赖更新完成" + else + print_error "依赖更新失败" + print_warning "请检查错误日志: ~/.npm/_logs/" + fi + + echo "" +} +update_migrate_database() { + print_step "迁移数据库配置..." + + cd "${PROJECT_DIR}/backend" + + # 检查是否需要升级上传限制(从100MB升级到10GB) + if command -v sqlite3 &> /dev/null; then + if [[ -f "data/database.db" ]]; then + CURRENT_LIMIT=$(sqlite3 data/database.db "SELECT value FROM system_settings WHERE key = 'max_upload_size';" 2>/dev/null || echo "") + + if [[ "$CURRENT_LIMIT" == "104857600" ]]; then + print_info "检测到旧的上传限制(100MB),正在升级到10GB..." + sqlite3 data/database.db "UPDATE system_settings SET value = '10737418240' WHERE key = 'max_upload_size';" + print_success "上传限制已升级: 100MB → 10GB" + elif [[ "$CURRENT_LIMIT" == "10737418240" ]]; then + print_success "上传限制已是最新: 10GB" + elif [[ -n "$CURRENT_LIMIT" ]]; then + print_info "当前上传限制: ${CURRENT_LIMIT} 字节" + else + print_info "数据库配置正常" + fi + fi + else + print_warning "sqlite3未安装,跳过数据库迁移检查" + fi + + # ========== 安全配置迁移 ========== + print_step "检查安全配置..." + + if [[ -f ".env" ]]; then + # 检查 CORS 配置 + CURRENT_CORS=$(grep "^ALLOWED_ORIGINS=" .env | cut -d'=' -f2-) + + if [[ "$CURRENT_CORS" == "*" ]]; then + print_warning "⚠️ 检测到不安全的CORS配置: ALLOWED_ORIGINS=*" + echo "" + echo "这是一个严重的安全风险!攻击者可以从任何域名访问你的API。" + echo "" + + # 尝试从域名配置自动修复 + if [[ -f "/etc/nginx/sites-enabled/${PROJECT_NAME}" ]] || [[ -f "/etc/nginx/conf.d/${PROJECT_NAME}.conf" ]]; then + # 尝试从Nginx配置读取域名 + NGINX_DOMAIN=$(grep "server_name" /etc/nginx/sites-enabled/${PROJECT_NAME} 2>/dev/null | grep -v "_" | awk '{print $2}' | sed 's/;//g' | head -1) + + if [[ -z "$NGINX_DOMAIN" ]]; then + NGINX_DOMAIN=$(grep "server_name" /etc/nginx/conf.d/${PROJECT_NAME}.conf 2>/dev/null | grep -v "_" | awk '{print $2}' | sed 's/;//g' | head -1) + fi + + if [[ -n "$NGINX_DOMAIN" ]] && [[ "$NGINX_DOMAIN" != "localhost" ]]; then + # 检测是否使用HTTPS + if grep -q "listen.*443.*ssl" /etc/nginx/sites-enabled/${PROJECT_NAME} 2>/dev/null || \ + grep -q "listen.*443.*ssl" /etc/nginx/conf.d/${PROJECT_NAME}.conf 2>/dev/null; then + FIXED_CORS="https://${NGINX_DOMAIN}" + else + FIXED_CORS="http://${NGINX_DOMAIN}" + fi + + print_info "检测到域名: ${NGINX_DOMAIN}" + echo "" + print_warning "建议将CORS设置为: ${FIXED_CORS}" + echo "" + + read -p "是否自动修复CORS配置?[y/n]: " -n 1 -r < /dev/tty + echo "" + + if [[ $REPLY =~ ^[Yy]$ ]]; then + # 备份原配置 + cp .env .env.backup.$(date +%Y%m%d_%H%M%S) + + # 修复CORS配置 + sed -i "s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=${FIXED_CORS}|" .env + + print_success "✓ CORS配置已修复: ${FIXED_CORS}" + print_info "原配置已备份到: .env.backup.*" + else + print_warning "跳过自动修复,请手动编辑 .env 文件修改 ALLOWED_ORIGINS" + print_info "推荐值: ALLOWED_ORIGINS=${FIXED_CORS}" + fi + else + print_warning "无法自动修复,请手动编辑backend/.env文件" + print_info "将 ALLOWED_ORIGINS=* 改为你的实际域名" + print_info "示例: ALLOWED_ORIGINS=https://yourdomain.com" + fi + else + print_warning "无法自动修复,请手动编辑backend/.env文件" + print_info "将 ALLOWED_ORIGINS=* 改为你的实际域名" + print_info "示例: ALLOWED_ORIGINS=https://yourdomain.com" + fi + echo "" + elif [[ -z "$CURRENT_CORS" ]]; then + print_warning "⚠️ ALLOWED_ORIGINS未配置" + print_info "生产环境必须配置具体的域名" + else + print_success "✓ CORS配置安全: ${CURRENT_CORS}" + fi + + # 检查 NODE_ENV + CURRENT_ENV=$(grep "^NODE_ENV=" .env | cut -d'=' -f2-) + if [[ "$CURRENT_ENV" != "production" ]]; then + print_warning "⚠️ 当前环境: ${CURRENT_ENV:-未设置}" + print_info "生产环境建议设置为: NODE_ENV=production" + else + print_success "✓ 环境配置: production" + fi + else + print_error "❌ .env 文件不存在!" + fi + + echo "" +} + +update_restart_services() { + print_step "重启服务..." + + cd "${PROJECT_DIR}/backend" + + if command -v pm2 &> /dev/null; then + pm2 restart ${PROJECT_NAME}-backend + pm2 save + print_success "后端服务已重启" + fi + + # 重载Nginx(兼容宝塔/自管Nginx) + restart_nginx_safe || print_warning "Nginx重载失败,请检查端口占用或手动重载" + + echo "" +} + +update_check_version() { + print_step "检查更新后的版本..." + + # 检查package.json版本 + if [[ -f "${PROJECT_DIR}/backend/package.json" ]]; then + VERSION=$(grep '"version"' "${PROJECT_DIR}/backend/package.json" | head -1 | awk -F'"' '{print $4}') + if [[ -n "$VERSION" ]]; then + print_success "当前版本: v$VERSION" + fi + fi + + echo "" +} + +print_update_completion() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🎉 更新成功! ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo "" + + echo -e "${CYAN}更新内容:${NC}" + echo " ✓ 代码已更新到最新版本" + echo " ✓ 依赖已更新" + echo " ✓ 服务已重启" + echo "" + + echo -e "${CYAN}保留的数据:${NC}" + echo " ✓ 数据库(用户、分享链接等)" + echo " ✓ 用户文件(storage目录)" + echo " ✓ 配置文件(.env)" + echo "" + + echo -e "${YELLOW}常用命令:${NC}" + echo " 查看服务状态: pm2 status" + echo " 查看日志: pm2 logs ${PROJECT_NAME}-backend" + echo " 重启服务: pm2 restart ${PROJECT_NAME}-backend" + echo "" + + echo -e "${GREEN}更新完成,祝您使用愉快!${NC}" + echo "" +} + +update_patch_env() { + print_step "检查 .env 新增配置..." + if [[ -f "${PROJECT_DIR}/backend/.env" ]]; then + # 检查 ENFORCE_HTTPS + if ! grep -q "^ENFORCE_HTTPS=" "${PROJECT_DIR}/backend/.env"; then + # 基于现有配置做一个合理的默认值:如果已启用安全Cookie或公开端口为443,优先设为true + COOKIE_SECURE_CUR=$(grep "^COOKIE_SECURE=" "${PROJECT_DIR}/backend/.env" | cut -d'=' -f2- | tr '[:upper:]' '[:lower:]') + PUBLIC_PORT_CUR=$(grep "^PUBLIC_PORT=" "${PROJECT_DIR}/backend/.env" | cut -d'=' -f2-) + ALLOWED_CUR=$(grep "^ALLOWED_ORIGINS=" "${PROJECT_DIR}/backend/.env" | cut -d'=' -f2-) + + ENFORCE_DEFAULT="false" + if [[ "$COOKIE_SECURE_CUR" == "true" ]] || [[ "$PUBLIC_PORT_CUR" == "443" ]]; then + ENFORCE_DEFAULT="true" + elif echo "$ALLOWED_CUR" | grep -qi "https://"; then + ENFORCE_DEFAULT="true" + fi + + echo "ENFORCE_HTTPS=${ENFORCE_DEFAULT}" >> "${PROJECT_DIR}/backend/.env" + print_warning "已为现有 .env 补充 ENFORCE_HTTPS=${ENFORCE_DEFAULT}(如生产请确认设为 true 并重启)" + else + print_info ".env 已包含 ENFORCE_HTTPS,保持不变" + fi + + # 检查 TRUST_PROXY(反向代理配置,对于HTTPS强制模式非常重要) + if ! grep -q "^TRUST_PROXY=" "${PROJECT_DIR}/backend/.env"; then + # 默认设置为1,假设使用单层Nginx反向代理 + echo "TRUST_PROXY=1" >> "${PROJECT_DIR}/backend/.env" + print_warning "已为现有 .env 补充 TRUST_PROXY=1(Nginx反向代理场景必需)" + else + print_info ".env 已包含 TRUST_PROXY,保持不变" + fi + + # 检查 SESSION_SECRET(会话安全配置,生产环境必需) + if ! grep -q "^SESSION_SECRET=" "${PROJECT_DIR}/backend/.env"; then + # 自动生成随机 Session 密钥 + NEW_SESSION_SECRET=$(openssl rand -hex 32) + echo "SESSION_SECRET=${NEW_SESSION_SECRET}" >> "${PROJECT_DIR}/backend/.env" + print_warning "已为现有 .env 补充 SESSION_SECRET(已自动生成安全密钥)" + else + print_info ".env 已包含 SESSION_SECRET,保持不变" + fi + else + print_warning "未找到 ${PROJECT_DIR}/backend/.env,请手动确认配置" + fi + echo "" +} + +# 迁移旧数据库文件 +update_migrate_database() { + print_step "检查数据库迁移..." + + local OLD_DB="${PROJECT_DIR}/backend/data/database.db" + local OLD_DB_V2="${PROJECT_DIR}/backend/database.db" + + # 如果旧数据库存在且新数据库不存在,执行迁移 + if [[ -f "$OLD_DB_V2" ]]; then + if [[ ! -f "$OLD_DB" ]]; then + # 创建新目录 + mkdir -p "${PROJECT_DIR}/backend/data" + + # 迁移数据库 + mv "$OLD_DB_V2" "$OLD_DB" + print_success "数据库已迁移: database.db -> data/database.db" + + # 同时迁移 journal 文件(如果存在) + if [[ -f "${OLD_DB_V2}-journal" ]]; then + mv "${OLD_DB_V2}-journal" "${OLD_DB}-journal" + fi + if [[ -f "${OLD_DB_V2}-wal" ]]; then + mv "${OLD_DB_V2}-wal" "${OLD_DB}-wal" + fi + if [[ -f "${OLD_DB_V2}-shm" ]]; then + mv "${OLD_DB_V2}-shm" "${OLD_DB}-shm" + fi + else + # 新旧数据库都存在,警告用户 + print_warning "检测到新旧数据库同时存在!" + print_warning " 旧: ${OLD_DB_V2}" + print_warning " 新: ${OLD_DB}" + print_warning "请手动确认数据后删除旧数据库文件" + fi + else + print_info "无需迁移数据库" + fi + echo "" +} + +update_main() { + # 检查root权限 + check_root + + # 确认更新 + confirm_update + + # 检查项目 + update_check_project + + # 备份重要文件 + update_backup_important_files + + # 停止服务 + update_stop_services + + # 拉取最新代码 + update_pull_latest_code + + # 更新依赖 + + update_install_dependencies + + # 迁移数据库配置 + update_migrate_database + # 补充新配置项 + update_patch_env + + # 重启服务 + update_restart_services + + # 健康检查 + if ! health_check; then + print_error "健康检查未通过,请检查日志" + print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend" + exit 1 + fi + + # 检查版本 + update_check_version + + # 完成提示 + print_update_completion +} + +################################################################################ +# 主流程 +################################################################################ + +main() { + print_banner + + # 检查root权限 + check_root + + # 如果没有通过命令行参数指定模式,则显示交互式选择 + if [[ "$MODE" == "install" ]] && [[ "$1" != "--skip-mode-select" ]]; then + # 检测是否可以使用交互式输入 + if [[ -t 0 ]] || [[ -c /dev/tty ]]; then + print_step "请选择操作模式" + echo "" + echo -e "${GREEN}[1]${NC} 安装/部署 玩玩云" + echo -e "${BLUE}[2]${NC} 更新/升级 玩玩云" + echo -e "${YELLOW}[3]${NC} 修复/重新配置 玩玩云" + echo -e "${PURPLE}[4]${NC} SSL证书管理(安装/续签/更换证书)" + echo -e "${RED}[5]${NC} 卸载 玩玩云" + echo -e "${GRAY}[0]${NC} 退出脚本" + echo "" + + while true; do + read -p "请输入选项 [0-5]: " mode_choice < /dev/tty + case $mode_choice in + 1) + print_success "已选择: 安装模式" + echo "" + break + ;; + 2) + print_info "切换到更新模式..." + echo "" + update_main + exit 0 + ;; + 3) + print_info "切换到修复模式..." + echo "" + repair_main + exit 0 + ;; + 4) + print_info "切换到SSL证书管理模式..." + echo "" + ssl_main + exit 0 + ;; + 5) + print_info "切换到卸载模式..." + echo "" + uninstall_main + exit 0 + ;; + 0) + print_info "正在退出脚本..." + echo "" + exit 0 + ;; + *) + print_error "无效选项,请重新选择" + ;; + esac + done + else + # 管道执行时的提示 + print_info "检测到通过管道执行脚本" + print_info "默认进入安装模式" + print_warning "如需其他操作,请下载脚本后运行" + echo "" + echo -e "${YELLOW}提示:${NC}" + echo " 安装: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh" + echo " 更新: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --update" + echo " 修复: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --repair" + echo " SSL管理: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --ssl" + echo " 卸载: wget https://git.workyai.cn/237899745/vue-driven-cloud-storage/raw/master/install.sh && bash install.sh --uninstall" + echo "" + sleep 2 + fi + fi + + # 系统检测 + system_check + + # 选择软件源 + choose_mirror + + # 安装依赖 + install_dependencies + + # 选择访问模式 + choose_access_mode + + # 端口配置 + configure_ports + + # 配置管理员账号 + configure_admin_account + + # 创建项目目录 + create_project_directory + + # 下载项目 + download_project + + # 安装后端依赖 + install_backend_dependencies + + # 创建配置文件 + create_env_file + + # 创建数据目录 + create_data_directories + + + # 先配置基础HTTP Nginx(SSL证书申请需要) + configure_nginx_http_first + + # 部署SSL证书(需要HTTP server block进行验证) + deploy_ssl + + # 根据SSL结果配置最终Nginx + configure_nginx_final + + # 启动后端服务 + start_backend_service + + # 健康检查 + if ! health_check; then + print_error "健康检查未通过,请检查日志" + exit 1 + fi + + # 完成提示 + print_completion +} + +uninstall_main() { + # 检查root权限 + check_root + + # 确认卸载 + confirm_uninstall + + # 备份数据 + uninstall_backup_data + + # 停止PM2进程 + uninstall_stop_pm2 + + # 删除Nginx配置 + uninstall_nginx_config + + # 清理SSL证书 + uninstall_ssl_certificates + + # 删除项目目录 + uninstall_project_directory + + # 清理临时文件 + uninstall_temp_files + + # 检查残留 + uninstall_check_residual + + # 完成提示 + print_uninstall_completion +} + +################################################################################ +# 修复功能 +################################################################################ + +print_repair_banner() { + clear + echo -e "${BLUE}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🔧 玩玩云 修复模式 ║" + echo "║ ║" + echo "║ Repair Mode ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +confirm_repair() { + print_repair_banner + + echo -e "${YELLOW}" + echo "本脚本将执行以下操作:" + echo "" + echo "【将会重新配置】" + echo " ✓ 补充缺失的环境变量(TRUST_PROXY、ENFORCE_HTTPS等)" + echo " ✓ 重新生成Nginx配置(应用最新配置)" + echo " ✓ 重启后端服务" + echo " ✓ 重载Nginx服务" + echo "" + echo "【将会保留】" + echo " ✓ 数据库文件(用户数据)" + echo " ✓ 用户上传的文件" + echo " ✓ .env 配置文件(仅补充缺失项)" + echo " ✓ SSL证书" + echo -e "${NC}" + echo "" + + print_info "适用场景: 更新配置、修复nginx配置错误、修复HTTPS访问问题、重新应用设置" + echo "" + + read -p "确定要执行修复吗? (y/n): " confirm < /dev/tty + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + print_info "已取消修复" + exit 0 + fi + + echo "" +} + +repair_check_project() { + print_step "检查项目是否已安装..." + + if [[ ! -d "$PROJECT_DIR" ]]; then + print_error "项目未安装: $PROJECT_DIR" + print_info "请先运行安装命令: bash install.sh" + exit 1 + fi + + if [[ ! -f "${PROJECT_DIR}/backend/server.js" ]]; then + print_error "项目目录不完整" + exit 1 + fi + + print_success "项目已安装: $PROJECT_DIR" + echo "" +} + +repair_load_existing_config() { + print_step "读取现有配置..." + + # 从.env读取端口配置 + if [[ -f "${PROJECT_DIR}/backend/.env" ]]; then + BACKEND_PORT=$(grep "^PORT=" "${PROJECT_DIR}/backend/.env" | cut -d'=' -f2 || echo "40001") + print_success "后端端口: $BACKEND_PORT" + else + print_warning ".env文件不存在,使用默认端口40001" + BACKEND_PORT="40001" + fi + + # 检查现有nginx配置(兼容宝塔/标准) + local nginx_conf_path="" + if [[ -f "/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" ]]; then + nginx_conf_path="/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" + elif [[ -f "/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" ]]; then + nginx_conf_path="/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" + fi + + if [[ -n "$nginx_conf_path" ]]; then + # 尝试从现有配置读取端口 + EXISTING_HTTP_PORT=$(grep "listen" "$nginx_conf_path" | grep -v "ssl" | head -1 | awk '{print $2}' | tr -d ';' || echo "80") + HTTP_PORT=${EXISTING_HTTP_PORT:-80} + + # 检查是否有HTTPS配置 + if grep -q "listen.*ssl" "$nginx_conf_path"; then + EXISTING_HTTPS_PORT=$(grep "listen.*ssl" "$nginx_conf_path" | head -1 | awk '{print $2}' | tr -d ';' || echo "443") + HTTPS_PORT=${EXISTING_HTTPS_PORT:-443} + SSL_METHOD="existing" + print_success "检测到HTTPS配置,端口: $HTTPS_PORT" + else + SSL_METHOD="8" + print_info "未检测到HTTPS配置" + fi + + # 检查是否有域名 + SERVER_NAME=$(grep "server_name" "$nginx_conf_path" | head -1 | awk '{print $2}' | tr -d ';' || echo "_") + if [[ "$SERVER_NAME" != "_" ]] && [[ "$SERVER_NAME" != "localhost" ]]; then + DOMAIN="$SERVER_NAME" + USE_DOMAIN=true + print_success "检测到域名: $DOMAIN" + else + USE_DOMAIN=false + print_info "使用IP模式" + fi + + print_success "HTTP端口: $HTTP_PORT" + else + print_warning "未找到现有nginx配置,将使用默认配置" + HTTP_PORT="80" + HTTPS_PORT="443" + SSL_METHOD="8" + USE_DOMAIN=false + fi + + echo "" +} + +repair_regenerate_nginx_config() { + print_step "重新生成Nginx配置..." + + # 清理旧的备份文件(避免nginx读取到错误配置) + rm -f /etc/nginx/sites-enabled/${PROJECT_NAME}.conf.backup.* 2>/dev/null || true + rm -f /etc/nginx/sites-available/${PROJECT_NAME}.conf.backup.* 2>/dev/null || true + rm -f /www/server/panel/vhost/nginx/${PROJECT_NAME}.conf.backup.* 2>/dev/null || true + + # 备份当前配置到 /root/ + if [[ -f "/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" ]]; then + cp "/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" "/root/nginx-backup-${PROJECT_NAME}.conf.$(date +%Y%m%d%H%M%S)" + print_success "已备份现有配置到 /root/" + elif [[ -f "/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" ]]; then + cp "/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" "/root/nginx-backup-${PROJECT_NAME}.conf.$(date +%Y%m%d%H%M%S)" + print_success "已备份宝塔配置到 /root/" + fi + + # 调用现有的configure_nginx函数 + configure_nginx + + print_success "Nginx配置已重新生成" + echo "" +} +repair_restart_services() { + print_step "重启服务..." + + # 重启后端 + if command -v pm2 &> /dev/null; then + if pm2 list | grep -q "${PROJECT_NAME}-backend"; then + pm2 restart ${PROJECT_NAME}-backend + print_success "后端服务已重启" + else + print_warning "后端服务未运行,尝试启动..." + cd "${PROJECT_DIR}/backend" + pm2 start server.js --name ${PROJECT_NAME}-backend + pm2 save + print_success "后端服务已启动" + fi + fi + + # 重载Nginx(兼容宝塔/自管Nginx) + restart_nginx_safe || print_warning "Nginx未能启动/重载,请检查端口占用或手动重载" + + echo "" +} + +repair_verify_services() { + print_step "验证服务状态..." + # 等待服务启动 + sleep 3 + + + # 检查后端 + if pm2 status | grep -q "${PROJECT_NAME}-backend.*online"; then + print_success "后端服务运行正常" + else + print_error "后端服务状态异常" + print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend" + fi + + # 检查Nginx + if systemctl is-active --quiet nginx 2>/dev/null || pgrep -x nginx > /dev/null || [[ -x /www/server/nginx/sbin/nginx ]] && pgrep -f "/www/server/nginx/sbin/nginx" > /dev/null; then + print_success "Nginx服务运行正常" + else + print_error "Nginx服务异常" + fi + + # 检查端口 + if netstat -tunlp 2>/dev/null | grep -q ":${BACKEND_PORT}" || ss -tunlp 2>/dev/null | grep -q ":${BACKEND_PORT}"; then + print_success "后端端口监听正常 (${BACKEND_PORT})" + else + print_warning "后端端口监听异常" + fi + + echo "" +} + +print_repair_completion() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ✓ 修复完成! ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo "" + + echo -e "${CYAN}修复内容:${NC}" + echo " ✓ 环境配置已检查并补充缺失项(TRUST_PROXY、ENFORCE_HTTPS等)" + echo " ✓ Nginx配置已更新到最新版本" + echo " ✓ 服务已重启" + echo "" + + echo -e "${CYAN}保留的数据:${NC}" + echo " ✓ 数据库(用户、分享链接等)" + echo " ✓ 用户文件(storage目录)" + echo " ✓ 配置文件(.env,仅补充缺失项)" + echo "" + + # 显示访问地址 + if [[ "$USE_DOMAIN" == "true" ]]; then + if [[ "$SSL_METHOD" == "8" ]] || [[ "$SSL_METHOD" == "" ]]; then + if [[ "$HTTP_PORT" == "80" ]]; then + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}:${HTTP_PORT}" + fi + else + if [[ "$HTTPS_PORT" == "443" ]]; then + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}:${HTTPS_PORT}" + fi + fi + else + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") + if [[ "$HTTP_PORT" == "80" ]]; then + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" + else + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" + fi + fi + echo "" + + echo -e "${YELLOW}常用命令:${NC}" + echo " 查看服务状态: pm2 status" + echo " 查看日志: pm2 logs ${PROJECT_NAME}-backend" + echo " 重启服务: pm2 restart ${PROJECT_NAME}-backend" + echo "" + + echo -e "${GREEN}修复完成,请测试功能是否正常!${NC}" + echo "" +} + +repair_main() { + # 检查root权限 + check_root + + # 检测操作系统 + detect_os + + # 确认修复 + confirm_repair + + # 检查项目 + repair_check_project + + # 读取现有配置 + repair_load_existing_config + + # 迁移数据库(如果需要) + update_migrate_database + + # 补充缺失的环境配置(如 TRUST_PROXY, ENFORCE_HTTPS 等) + update_patch_env + + # 重新生成nginx配置 + repair_regenerate_nginx_config + + # 重启服务 + repair_restart_services + + # 验证服务 + repair_verify_services + + # 健康检查 + if ! health_check; then + print_warning "部分健康检查未通过,请查看日志" + print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend" + fi + + # 完成提示 + print_repair_completion +} + +################################################################################ +# SSL证书管理功能 +################################################################################ + +print_ssl_banner() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🔐 SSL证书管理模式 ║" + echo "║ ║" + echo "║ SSL Certificate Manager ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +confirm_ssl_operation() { + print_ssl_banner + + echo -e "${YELLOW}" + echo "本脚本将执行以下操作:" + echo "" + echo "【SSL证书管理】" + echo " ✓ 检测现有域名配置" + echo " ✓ 选择SSL证书部署方案" + echo " ✓ 申请/更换/续签证书" + echo " ✓ 更新Nginx HTTPS配置" + echo " ✓ 重载服务" + echo "" + echo "【将会保留】" + echo " ✓ 数据库文件(用户数据)" + echo " ✓ 用户上传的文件" + echo " ✓ 后端配置文件(.env)" + echo " ✓ 现有HTTP配置" + echo -e "${NC}" + echo "" + + print_info "适用场景: 初次配置HTTPS、更换证书方案、证书续签" + echo "" + + read -p "确定要继续吗? (y/n): " confirm < /dev/tty + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + print_info "已取消操作" + exit 0 + fi + + echo "" +} + +ssl_check_project() { + print_step "检查项目是否已安装..." + + if [[ ! -d "$PROJECT_DIR" ]]; then + print_error "项目未安装: $PROJECT_DIR" + print_info "请先运行安装命令: bash install.sh" + exit 1 + fi + + if [[ ! -f "${PROJECT_DIR}/backend/server.js" ]]; then + print_error "项目目录不完整" + exit 1 + fi + + print_success "项目已安装: $PROJECT_DIR" + echo "" +} + +ssl_load_existing_config() { + print_step "读取现有配置..." + + # 从.env读取后端端口 + if [[ -f "${PROJECT_DIR}/backend/.env" ]]; then + BACKEND_PORT=$(grep "^PORT=" "${PROJECT_DIR}/backend/.env" | cut -d'=' -f2 || echo "40001") + print_success "后端端口: $BACKEND_PORT" + else + BACKEND_PORT="40001" + print_warning ".env文件不存在,使用默认端口: $BACKEND_PORT" + fi + + # 检查现有nginx配置 + local nginx_conf="" + if [[ -f "/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" ]]; then + nginx_conf="/etc/nginx/sites-enabled/${PROJECT_NAME}.conf" + elif [[ -f "/etc/nginx/conf.d/${PROJECT_NAME}.conf" ]]; then + nginx_conf="/etc/nginx/conf.d/${PROJECT_NAME}.conf" + elif [[ -f "/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" ]]; then + nginx_conf="/www/server/panel/vhost/nginx/${PROJECT_NAME}.conf" + fi + + if [[ -n "$nginx_conf" ]]; then + # 读取HTTP端口 + EXISTING_HTTP_PORT=$(grep "listen" "$nginx_conf" | grep -v "ssl" | grep -v "#" | head -1 | awk '{print $2}' | tr -d ';' || echo "80") + HTTP_PORT=${EXISTING_HTTP_PORT:-80} + + # 检查是否有HTTPS配置 + if grep -q "listen.*ssl" "$nginx_conf"; then + EXISTING_HTTPS_PORT=$(grep "listen.*ssl" "$nginx_conf" | head -1 | awk '{print $2}' | tr -d ';' || echo "443") + HTTPS_PORT=${EXISTING_HTTPS_PORT:-443} + print_info "检测到现有HTTPS配置,端口: $HTTPS_PORT" + else + HTTPS_PORT="443" + print_info "未检测到HTTPS配置,将使用默认端口: 443" + fi + + # 读取域名 + SERVER_NAME=$(grep "server_name" "$nginx_conf" | head -1 | awk '{print $2}' | tr -d ';' || echo "") + if [[ -n "$SERVER_NAME" ]] && [[ "$SERVER_NAME" != "_" ]] && [[ "$SERVER_NAME" != "localhost" ]]; then + DOMAIN="$SERVER_NAME" + USE_DOMAIN=true + print_success "检测到域名: $DOMAIN" + else + USE_DOMAIN=false + print_warning "未检测到域名配置" + fi + + print_success "HTTP端口: $HTTP_PORT" + else + print_error "未找到Nginx配置文件" + exit 1 + fi + + echo "" +} + +ssl_configure_domain() { + print_step "配置域名" + echo "" + + # 如果已有域名,询问是否使用 + if [[ "$USE_DOMAIN" == "true" ]] && [[ -n "$DOMAIN" ]]; then + print_info "检测到现有域名: $DOMAIN" + read -p "是否使用此域名? (y/n): " use_existing < /dev/tty + + if [[ "$use_existing" == "y" || "$use_existing" == "Y" ]]; then + print_success "使用现有域名: $DOMAIN" + echo "" + return 0 + fi + fi + + # 输入新域名 + while true; do + read -p "请输入您的域名 (例如: wwy.example.com): " DOMAIN < /dev/tty + if [[ -z "$DOMAIN" ]]; then + print_error "域名不能为空" + continue + fi + + # 验证域名格式 + if [[ ! "$DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]; then + print_error "域名格式不正确" + continue + fi + + # 验证域名解析 + print_info "正在验证域名解析..." + DOMAIN_IP=$(dig +short "$DOMAIN" 2>/dev/null | tail -n1 || nslookup "$DOMAIN" 2>/dev/null | grep -A1 "Name:" | tail -1 | awk '{print $2}') + PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") + + if [[ -n "$DOMAIN_IP" ]] && [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then + print_success "域名已正确解析到当前服务器IP" + USE_DOMAIN=true + break + else + print_warning "域名未解析到当前服务器IP" + print_info "域名解析IP: ${DOMAIN_IP:-未解析}" + print_info "当前服务器IP: $PUBLIC_IP" + read -p "是否继续? (y/n): " continue_choice < /dev/tty + if [[ "$continue_choice" == "y" || "$continue_choice" == "Y" ]]; then + USE_DOMAIN=true + break + fi + fi + done + + echo "" +} + +ssl_choose_method() { + print_step "选择SSL证书部署方式" + echo "" + echo -e "${YELLOW}【推荐方案】${NC}" + echo -e "${GREEN}[1]${NC} Certbot (Let's Encrypt官方工具)" + echo " - 最稳定可靠,支持自动续期" + echo "" + echo -e "${YELLOW}【备选方案】${NC}" + echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt" + echo " - 纯Shell脚本,更轻量级" + echo -e "${GREEN}[3]${NC} acme.sh + ZeroSSL" + echo " - Let's Encrypt的免费替代品" + echo -e "${GREEN}[5]${NC} acme.sh + Buypass" + echo " - 挪威免费CA,有效期180天" + echo "" + echo -e "${YELLOW}【云服务商证书】${NC}" + echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)" + echo -e "${GREEN}[6]${NC} 腾讯云免费证书 (需提供SecretKey)" + echo "" + echo -e "${YELLOW}【其他选项】${NC}" + echo -e "${GREEN}[7]${NC} 使用已有证书 (手动上传)" + echo -e "${GREEN}[8]${NC} 移除HTTPS配置 (改回HTTP)" + echo -e "${GREEN}[0]${NC} 取消操作" + echo "" + + while true; do + read -p "请输入选项 [0-8]: " ssl_choice < /dev/tty + case $ssl_choice in + 1|2|3|4|5|6|7) + SSL_METHOD=$ssl_choice + break + ;; + 8) + SSL_METHOD=$ssl_choice + print_warning "将移除HTTPS配置,改回HTTP模式" + read -p "确定要继续吗? (y/n): " confirm_remove < /dev/tty + if [[ "$confirm_remove" == "y" || "$confirm_remove" == "Y" ]]; then + break + fi + ;; + 0) + print_info "已取消操作" + exit 0 + ;; + *) + print_error "无效选项,请重新选择" + ;; + esac + done + echo "" +} + +ssl_deploy_certificate() { + print_step "部署SSL证书..." + + # 如果选择移除HTTPS + if [[ "$SSL_METHOD" == "8" ]]; then + print_info "将移除HTTPS配置..." + # 配置为HTTP模式 + configure_nginx_http + return 0 + fi + + # 部署证书 + deploy_ssl + + # 检查证书是否部署成功 + if [[ -f "/etc/nginx/ssl/${DOMAIN}.crt" ]] && [[ -f "/etc/nginx/ssl/${DOMAIN}.key" ]]; then + print_success "证书文件已部署" + else + print_warning "证书文件未找到,将使用HTTP配置" + SSL_METHOD="8" + fi +} + +ssl_update_nginx_config() { + print_step "更新Nginx配置..." + + if [[ "$SSL_METHOD" == "8" ]]; then + # HTTP配置 + configure_nginx_http + else + # HTTPS配置 + configure_nginx_https + fi + + # 测试nginx配置 + if ! nginx -t 2>&1; then + print_error "Nginx配置测试失败" + print_info "请检查配置文件" + return 1 + fi + + print_success "Nginx配置已更新" + echo "" +} + +ssl_reload_services() { + print_step "重载服务..." + + # 重载Nginx + if [[ -d /www/server/nginx ]]; then + # 宝塔面板 + print_info "宝塔环境,重载Nginx..." + if [[ -f /www/server/nginx/sbin/nginx ]]; then + /www/server/nginx/sbin/nginx -s reload 2>/dev/null + if [[ $? -eq 0 ]]; then + print_success "Nginx已重载" + else + /www/server/nginx/sbin/nginx 2>/dev/null + print_success "Nginx已启动" + fi + fi + systemctl reload nginx 2>/dev/null || true + else + # 标准Nginx + systemctl reload nginx + print_success "Nginx已重载" + fi + + # 重启后端服务(更新PUBLIC_PORT配置) + if command -v pm2 &> /dev/null; then + if pm2 list | grep -q "${PROJECT_NAME}-backend"; then + pm2 restart ${PROJECT_NAME}-backend + print_success "后端服务已重启" + fi + fi + + echo "" +} + +ssl_verify_deployment() { + print_step "验证部署..." + + # 检查Nginx + if [[ -d /www/server/nginx ]]; then + if pgrep -x nginx > /dev/null; then + print_success "Nginx运行正常" + else + print_error "Nginx未运行" + fi + else + if systemctl is-active --quiet nginx; then + print_success "Nginx运行正常" + else + print_error "Nginx未运行" + fi + fi + + # 检查SSL证书 + if [[ "$SSL_METHOD" != "8" ]]; then + if [[ -f "/etc/nginx/ssl/${DOMAIN}.crt" ]]; then + print_success "SSL证书已部署: /etc/nginx/ssl/${DOMAIN}.crt" + + # 显示证书信息 + CERT_EXPIRY=$(openssl x509 -enddate -noout -in "/etc/nginx/ssl/${DOMAIN}.crt" 2>/dev/null | cut -d= -f2) + if [[ -n "$CERT_EXPIRY" ]]; then + print_info "证书有效期至: $CERT_EXPIRY" + fi + else + print_warning "SSL证书文件未找到" + fi + fi + + echo "" +} + +print_ssl_completion() { + clear + echo -e "${GREEN}" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ✓ SSL配置完成! ║" + echo "║ ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo -e "${NC}" + echo "" + + # 显示访问地址 + if [[ "$SSL_METHOD" == "8" ]]; then + if [[ "$HTTP_PORT" == "80" ]]; then + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} http://${DOMAIN}:${HTTP_PORT}" + fi + echo -e "${YELLOW}模式:${NC} HTTP" + else + if [[ "$HTTPS_PORT" == "443" ]]; then + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}" + else + echo -e "${CYAN}访问地址:${NC} https://${DOMAIN}:${HTTPS_PORT}" + fi + echo -e "${YELLOW}模式:${NC} HTTPS" + + # SSL信息 + echo "" + echo -e "${YELLOW}SSL证书:${NC}" + case $SSL_METHOD in + 1) + echo " 方案: Certbot (Let's Encrypt)" + echo " 续期: 自动续期已配置" + ;; + 2) + echo " 方案: acme.sh + Let's Encrypt" + echo " 续期: 自动续期已配置" + ;; + 3) + echo " 方案: acme.sh + ZeroSSL" + echo " 续期: 自动续期已配置" + ;; + 4) + echo " 方案: acme.sh + Buypass" + echo " 续期: 自动续期已配置" + ;; + 7) + echo " 方案: 手动上传证书" + echo " 续期: 需手动更新证书文件" + ;; + esac + fi + echo "" + + echo -e "${YELLOW}常用命令:${NC}" + echo " 查看证书信息: openssl x509 -text -noout -in /etc/nginx/ssl/${DOMAIN}.crt" + echo " 测试HTTPS: curl -I https://${DOMAIN}" + echo " 查看Nginx日志: tail -f /var/log/nginx/error.log" + echo " 重新配置SSL: bash install.sh --ssl" + echo "" + + echo -e "${GREEN}SSL配置完成!${NC}" + echo "" +} + +ssl_main() { + # 检查root权限 + check_root + + # 检测操作系统 + detect_os + + # 确认操作 + confirm_ssl_operation + + # 检查项目 + ssl_check_project + + # 读取现有配置 + ssl_load_existing_config + + # 配置域名 + if [[ "$USE_DOMAIN" != "true" ]] || [[ -z "$DOMAIN" ]]; then + ssl_configure_domain + fi + + # 选择SSL方案 + ssl_choose_method + + # 先配置基础HTTP Nginx(SSL验证需要) + configure_nginx_http_first + + # 部署SSL证书 + ssl_deploy_certificate + + # 更新Nginx配置 + ssl_update_nginx_config + + # 重载服务 + ssl_reload_services + + # 验证部署 + ssl_verify_deployment + + # 完成提示 + print_ssl_completion +} + +# 执行主流程 +if [[ "$MODE" == "uninstall" ]]; then + uninstall_main +elif [[ "$MODE" == "update" ]]; then + update_main +elif [[ "$MODE" == "repair" ]]; then + repair_main +elif [[ "$MODE" == "ssl" ]]; then + ssl_main +else + main +fi diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..740be5b --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } +} diff --git a/nginx/nginx.conf.example b/nginx/nginx.conf.example new file mode 100644 index 0000000..18f992e --- /dev/null +++ b/nginx/nginx.conf.example @@ -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; + } +}