From b7b00fff482241b3df185569c67dd1b78b13edf6 Mon Sep 17 00:00:00 2001 From: Dev Team Date: Tue, 20 Jan 2026 23:23:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0Vue=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E7=9A=84=E4=BA=91=E5=AD=98=E5=82=A8=E7=B3=BB=E7=BB=9F=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: Node.js + Express + SQLite架构 - 前端: Vue 3 + Axios实现 - 功能: 用户认证、文件上传/下载、分享链接、密码重置 - 安全: 密码加密、分享链接过期机制、缓存一致性 - 部署: Docker + Nginx容器化配置 - 测试: 完整的边界测试、并发测试和状态一致性测试 --- .gitignore | 136 + INSTALL_GUIDE.md | 327 + README.md | 516 + VERSION.txt | 131 + backend/.dockerignore | 46 + backend/.env.example | 168 + backend/Dockerfile | 28 + backend/auth.js | 314 + backend/backup.bat | 52 + backend/check_expire.sql | 19 + backend/data/.gitkeep | 0 backend/database.js | 1446 ++ backend/fix_expires_at_format.js | 34 + backend/package-lock.json | 4544 ++++ backend/package.json | 40 + backend/routes/health.js | 52 + backend/routes/index.js | 90 + backend/server.js | 6077 +++++ backend/start.bat | 10 + backend/storage.js | 1717 ++ backend/storage/.gitkeep | 0 backend/tests/boundary-tests.js | 934 + backend/tests/network-concurrent-tests.js | 838 + backend/tests/run-all-tests.js | 106 + backend/tests/state-consistency-tests.js | 896 + backend/utils/encryption.js | 271 + backend/utils/storage-cache.js | 352 + docker-compose.yml | 83 + frontend/app.html | 3508 +++ frontend/app.js | 3185 +++ frontend/favicon.ico | Bin 0 -> 916 bytes frontend/index.html | 669 + frontend/libs/axios.min.js | 3 + frontend/libs/fontawesome/css/all.min.css | 9 + .../fontawesome/webfonts/fa-brands-400.woff2 | Bin 0 -> 108020 bytes .../fontawesome/webfonts/fa-regular-400.woff2 | Bin 0 -> 24948 bytes .../fontawesome/webfonts/fa-solid-900.woff2 | Bin 0 -> 150124 bytes frontend/libs/vue.global.js | 18323 ++++++++++++++++ frontend/libs/vue.global.prod.js | 13 + frontend/reset-password.html | 434 + frontend/share.html | 1134 + frontend/verify.html | 288 + install.sh | 4763 ++++ nginx/nginx.conf | 73 + nginx/nginx.conf.example | 129 + 45 files changed, 51758 insertions(+) create mode 100644 .gitignore create mode 100644 INSTALL_GUIDE.md create mode 100644 README.md create mode 100644 VERSION.txt create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/auth.js create mode 100644 backend/backup.bat create mode 100644 backend/check_expire.sql create mode 100644 backend/data/.gitkeep create mode 100644 backend/database.js create mode 100644 backend/fix_expires_at_format.js create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/routes/health.js create mode 100644 backend/routes/index.js create mode 100644 backend/server.js create mode 100644 backend/start.bat create mode 100644 backend/storage.js create mode 100644 backend/storage/.gitkeep create mode 100644 backend/tests/boundary-tests.js create mode 100644 backend/tests/network-concurrent-tests.js create mode 100644 backend/tests/run-all-tests.js create mode 100644 backend/tests/state-consistency-tests.js create mode 100644 backend/utils/encryption.js create mode 100644 backend/utils/storage-cache.js create mode 100644 docker-compose.yml create mode 100644 frontend/app.html create mode 100644 frontend/app.js create mode 100644 frontend/favicon.ico create mode 100644 frontend/index.html create mode 100644 frontend/libs/axios.min.js create mode 100644 frontend/libs/fontawesome/css/all.min.css create mode 100644 frontend/libs/fontawesome/webfonts/fa-brands-400.woff2 create mode 100644 frontend/libs/fontawesome/webfonts/fa-regular-400.woff2 create mode 100644 frontend/libs/fontawesome/webfonts/fa-solid-900.woff2 create mode 100644 frontend/libs/vue.global.js create mode 100644 frontend/libs/vue.global.prod.js create mode 100644 frontend/reset-password.html create mode 100644 frontend/share.html create mode 100644 frontend/verify.html create mode 100644 install.sh create mode 100644 nginx/nginx.conf create mode 100644 nginx/nginx.conf.example 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 0000000000000000000000000000000000000000..f1c3b4f2c4a871f0e32e02968d4134a7fb2653d1 GIT binary patch literal 916 xcmZQzU<5(|0R|wcz>vYhz#zuJz@P!dKp~(AL>x#lFaYI7!DtAKh5)TY003+P0Yd-) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..71e31852689289b8d7b94ce0541953df40f76500 GIT binary patch literal 108020 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

gcuh(yM)5x$wXlMQz` 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 DW9(%e literal 0 HcmV?d00001 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; + } +}